From 5a43a5d53a57260792dfbe7f9e90daa9769cbbb4 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 17 Mar 2021 11:08:01 -0500 Subject: [PATCH 1/6] Remove library.AssertNoRestartsDuringTest and make that assertion implicit in library.IntegrationEnv. This means we (hopefully) can't forget to include these assertions in any integration test. Signed-off-by: Matt Moyer --- test/integration/cli_test.go | 2 -- .../concierge_api_serving_certs_test.go | 2 -- test/integration/concierge_client_test.go | 2 -- .../concierge_credentialissuer_test.go | 2 -- .../concierge_credentialrequest_test.go | 18 ++++-------------- .../concierge_kubecertagent_test.go | 2 -- test/integration/e2e_test.go | 3 --- test/integration/supervisor_discovery_test.go | 6 ------ test/integration/supervisor_healthz_test.go | 2 -- test/integration/supervisor_login_test.go | 2 -- test/integration/supervisor_secrets_test.go | 2 -- test/integration/supervisor_upstream_test.go | 2 -- test/library/assertions.go | 4 ++-- test/library/env.go | 4 ++++ 14 files changed, 10 insertions(+), 43 deletions(-) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 910d1ac4..ca994ac0 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -35,8 +35,6 @@ import ( func TestCLIGetKubeconfigStaticToken(t *testing.T) { env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - // Create a test webhook configuration to use with the CLI. ctx, cancelFunc := context.WithTimeout(context.Background(), 4*time.Minute) defer cancelFunc() diff --git a/test/integration/concierge_api_serving_certs_test.go b/test/integration/concierge_api_serving_certs_test.go index a6d3cd36..af6666d5 100644 --- a/test/integration/concierge_api_serving_certs_test.go +++ b/test/integration/concierge_api_serving_certs_test.go @@ -23,8 +23,6 @@ func TestAPIServingCertificateAutoCreationAndRotation(t *testing.T) { env := library.IntegrationEnv(t) defaultServingCertResourceName := env.ConciergeAppName + "-api-tls-serving-certificate" - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - tests := []struct { name string forceRotation func(context.Context, kubernetes.Interface, string) error diff --git a/test/integration/concierge_client_test.go b/test/integration/concierge_client_test.go index 1cad0de5..1bf1524e 100644 --- a/test/integration/concierge_client_test.go +++ b/test/integration/concierge_client_test.go @@ -57,8 +57,6 @@ var maskKey = func(s string) string { return strings.ReplaceAll(s, "TESTING KEY" func TestClient(t *testing.T) { env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/test/integration/concierge_credentialissuer_test.go b/test/integration/concierge_credentialissuer_test.go index c7ed6e66..172022c6 100644 --- a/test/integration/concierge_credentialissuer_test.go +++ b/test/integration/concierge_credentialissuer_test.go @@ -23,8 +23,6 @@ func TestCredentialIssuer(t *testing.T) { client := library.NewConciergeClientset(t) aggregatedClientset := library.NewAggregatedClientset(t) - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 00fe5d0f..81984095 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -23,9 +23,7 @@ import ( ) func TestUnsuccessfulCredentialRequest(t *testing.T) { - env := library.IntegrationEnv(t).WithCapability(library.AnonymousAuthenticationSupported) - - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") + _ = library.IntegrationEnv(t).WithCapability(library.AnonymousAuthenticationSupported) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -44,8 +42,6 @@ func TestUnsuccessfulCredentialRequest(t *testing.T) { func TestSuccessfulCredentialRequest(t *testing.T) { env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) defer cancel() @@ -131,9 +127,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) { } func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) { - env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") + _ = library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) // Create a testWebhook so we have a legitimate authenticator to pass to the // TokenCredentialRequest API. @@ -154,9 +148,7 @@ func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthentic } func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T) { - env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") + _ = library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) // Create a testWebhook so we have a legitimate authenticator to pass to the // TokenCredentialRequest API. @@ -184,9 +176,7 @@ func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken(t *testing.T } func TestCredentialRequest_OtherwiseValidRequestWithRealTokenShouldFailWhenTheClusterIsNotCapable(t *testing.T) { - env := library.IntegrationEnv(t).WithoutCapability(library.ClusterSigningKeyIsAvailable).WithCapability(library.AnonymousAuthenticationSupported) - - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") + _ = library.IntegrationEnv(t).WithoutCapability(library.ClusterSigningKeyIsAvailable).WithCapability(library.AnonymousAuthenticationSupported) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() diff --git a/test/integration/concierge_kubecertagent_test.go b/test/integration/concierge_kubecertagent_test.go index 37392013..5ce1cff4 100644 --- a/test/integration/concierge_kubecertagent_test.go +++ b/test/integration/concierge_kubecertagent_test.go @@ -28,8 +28,6 @@ const ( func TestKubeCertAgent(t *testing.T) { env := library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) defer cancel() diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 6ffdf0b9..275d0b8a 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -46,9 +46,6 @@ func TestE2EFullIntegration(t *testing.T) { defer library.DumpLogs(t, env.SupervisorNamespace, "") defer library.DumpLogs(t, "dex", "app=proxy") - library.AssertNoRestartsDuringTest(t, env.ConciergeNamespace, "") - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute) defer cancelFunc() diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index 8712a316..c164cb7b 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -45,8 +45,6 @@ func TestSupervisorOIDCDiscovery(t *testing.T) { env := library.IntegrationEnv(t) client := library.NewSupervisorClientset(t) - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ns := env.SupervisorNamespace ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) @@ -152,8 +150,6 @@ func TestSupervisorTLSTerminationWithSNI(t *testing.T) { pinnipedClient := library.NewSupervisorClientset(t) kubeClient := library.NewKubernetesClientset(t) - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ns := env.SupervisorNamespace ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() @@ -225,8 +221,6 @@ func TestSupervisorTLSTerminationWithDefaultCerts(t *testing.T) { pinnipedClient := library.NewSupervisorClientset(t) kubeClient := library.NewKubernetesClientset(t) - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ns := env.SupervisorNamespace ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() diff --git a/test/integration/supervisor_healthz_test.go b/test/integration/supervisor_healthz_test.go index 20323aca..dafe1d08 100644 --- a/test/integration/supervisor_healthz_test.go +++ b/test/integration/supervisor_healthz_test.go @@ -29,8 +29,6 @@ func TestSupervisorHealthz(t *testing.T) { t.Skip("PINNIPED_TEST_SUPERVISOR_HTTP_ADDRESS not defined") } - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 84fa8efa..b3e4760c 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -44,8 +44,6 @@ func TestSupervisorLogin(t *testing.T) { defer library.DumpLogs(t, env.SupervisorNamespace, "") defer library.DumpLogs(t, "dex", "app=proxy") - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() diff --git a/test/integration/supervisor_secrets_test.go b/test/integration/supervisor_secrets_test.go index d8498955..2cf44128 100644 --- a/test/integration/supervisor_secrets_test.go +++ b/test/integration/supervisor_secrets_test.go @@ -24,8 +24,6 @@ func TestSupervisorSecrets(t *testing.T) { kubeClient := library.NewKubernetesClientset(t) supervisorClient := library.NewSupervisorClientset(t) - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/test/integration/supervisor_upstream_test.go b/test/integration/supervisor_upstream_test.go index 22a92ff1..b43735a6 100644 --- a/test/integration/supervisor_upstream_test.go +++ b/test/integration/supervisor_upstream_test.go @@ -17,8 +17,6 @@ import ( func TestSupervisorUpstreamOIDCDiscovery(t *testing.T) { env := library.IntegrationEnv(t) - library.AssertNoRestartsDuringTest(t, env.SupervisorNamespace, "") - t.Run("invalid missing secret and bad issuer", func(t *testing.T) { t.Parallel() spec := v1alpha1.OIDCIdentityProviderSpec{ diff --git a/test/library/assertions.go b/test/library/assertions.go index 4f42f5a7..71dd6bc5 100644 --- a/test/library/assertions.go +++ b/test/library/assertions.go @@ -29,9 +29,9 @@ func RequireEventuallyWithoutError( require.NoError(t, wait.PollImmediate(tick, waitFor, f), msgAndArgs...) } -// NewRestartAssertion allows a caller to assert that there were no restarts for a Pod in the +// assertNoRestartsDuringTest allows a caller to assert that there were no restarts for a Pod in the // provided namespace with the provided labelSelector during the lifetime of a test. -func AssertNoRestartsDuringTest(t *testing.T, namespace, labelSelector string) { +func assertNoRestartsDuringTest(t *testing.T, namespace, labelSelector string) { t.Helper() previousRestartCounts := getRestartCounts(t, namespace, labelSelector) diff --git a/test/library/env.go b/test/library/env.go index 3f96b9a0..28caf52f 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -97,6 +97,10 @@ func IntegrationEnv(t *testing.T) *TestEnv { loadEnvVars(t, &result) + // In every integration test, assert that no pods in our namespaces restart during the test. + assertNoRestartsDuringTest(t, result.ConciergeNamespace, "") + assertNoRestartsDuringTest(t, result.SupervisorNamespace, "") + result.t = t return &result } From 6520c5a3a1852808b5f99c054f03f9fdef24da68 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 17 Mar 2021 11:24:59 -0500 Subject: [PATCH 2/6] Extend library.DumpLogs() to dump logs from the previous container, if one exists. This is important in case the container has crashed and has been restarted. Signed-off-by: Matt Moyer --- test/library/dumplogs.go | 41 +++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/test/library/dumplogs.go b/test/library/dumplogs.go index dca0d276..86dfd252 100644 --- a/test/library/dumplogs.go +++ b/test/library/dumplogs.go @@ -6,12 +6,15 @@ package library import ( "bufio" "context" + "fmt" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) // DumpLogs is meant to be called in a `defer` to dump the logs of components in the cluster on a test failure. @@ -25,25 +28,37 @@ func DumpLogs(t *testing.T, namespace string, labelSelector string) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - logTailLines := int64(40) pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) require.NoError(t, err) for _, pod := range pods.Items { for _, container := range pod.Status.ContainerStatuses { - t.Logf("pod %s/%s container %s restarted %d times:", pod.Namespace, pod.Name, container.Name, container.RestartCount) - req := kubeClient.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ - Container: container.Name, - TailLines: &logTailLines, - }) - logReader, err := req.Stream(ctx) - require.NoError(t, err) - - scanner := bufio.NewScanner(logReader) - for scanner.Scan() { - t.Logf("%s/%s/%s > %s", pod.Namespace, pod.Name, container.Name, scanner.Text()) + if container.RestartCount > 0 { + dumpContainerLogs(ctx, t, kubeClient, pod.Namespace, pod.Name, container.Name, true) } - require.NoError(t, scanner.Err()) + dumpContainerLogs(ctx, t, kubeClient, pod.Namespace, pod.Name, container.Name, false) } } } + +func dumpContainerLogs(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface, namespace, pod, container string, prev bool) { + logTailLines := int64(40) + shortName := fmt.Sprintf("%s/%s/%s", namespace, pod, container) + logReader, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pod, &corev1.PodLogOptions{ + Container: container, + TailLines: &logTailLines, + Previous: prev, + }).Stream(ctx) + if !assert.NoErrorf(t, err, "failed to stream logs for container %s", shortName) { + return + } + scanner := bufio.NewScanner(logReader) + for scanner.Scan() { + prefix := shortName + if prev { + prefix += " (previous)" + } + t.Logf("%s > %s", prefix, scanner.Text()) + } + assert.NoError(t, scanner.Err(), "failed to read logs from container %s", shortName) +} From 0dd2b358fbb01d28d4a06aa69f08946c62244052 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 17 Mar 2021 11:46:55 -0500 Subject: [PATCH 3/6] Extend assertNoRestartsDuringTest to dump logs from containers that restarted. Signed-off-by: Matt Moyer --- test/library/assertions.go | 68 ++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/test/library/assertions.go b/test/library/assertions.go index 71dd6bc5..c74cdd95 100644 --- a/test/library/assertions.go +++ b/test/library/assertions.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" ) // RequireEventuallyWithoutError is a wrapper around require.Eventually() that allows the caller to @@ -33,48 +34,73 @@ func RequireEventuallyWithoutError( // provided namespace with the provided labelSelector during the lifetime of a test. func assertNoRestartsDuringTest(t *testing.T, namespace, labelSelector string) { t.Helper() + kubeClient := NewKubernetesClientset(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() - previousRestartCounts := getRestartCounts(t, namespace, labelSelector) + previousRestartCounts := getRestartCounts(ctx, t, kubeClient, namespace, labelSelector) t.Cleanup(func() { - currentRestartCounts := getRestartCounts(t, namespace, labelSelector) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + currentRestartCounts := getRestartCounts(ctx, t, kubeClient, namespace, labelSelector) for key, previousRestartCount := range previousRestartCounts { currentRestartCount, ok := currentRestartCounts[key] - if assert.Truef( + + // If the container no longer exists, that's a test failure. + if !assert.Truef( t, ok, - "pod namespace/name/container %s existed at beginning of the test, but not the end", - key, + "container %s existed at beginning of the test, but not the end", + key.String(), ) { - assert.Equal( - t, - previousRestartCount, - currentRestartCount, - "pod namespace/name/container %s has restarted %d times (original count was %d)", - key, - currentRestartCount, - previousRestartCount, - ) + continue + } + + // Expect the restart count to be the same as it was before the test. + if !assert.Equal( + t, + previousRestartCount, + currentRestartCount, + "container %s has restarted %d times (original count was %d)", + key.String(), + currentRestartCount, + previousRestartCount, + ) { + // Attempt to dump the logs from the previous container that crashed. + dumpContainerLogs(ctx, t, kubeClient, key.namespace, key.pod, key.container, true) } } }) } -func getRestartCounts(t *testing.T, namespace, labelSelector string) map[string]int32 { - t.Helper() +type containerRestartKey struct { + namespace string + pod string + container string +} - kubeClient := NewKubernetesClientset(t) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() +func (k containerRestartKey) String() string { + return fmt.Sprintf("%s/%s/%s", k.namespace, k.pod, k.container) +} + +type containerRestartMap map[containerRestartKey]int32 + +func getRestartCounts(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface, namespace, labelSelector string) containerRestartMap { + t.Helper() pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) require.NoError(t, err) - restartCounts := make(map[string]int32) + restartCounts := make(containerRestartMap) for _, pod := range pods.Items { for _, container := range pod.Status.ContainerStatuses { - key := fmt.Sprintf("%s/%s/%s", pod.Namespace, pod.Name, container.Name) + key := containerRestartKey{ + namespace: pod.Namespace, + pod: pod.Name, + container: container.Name, + } restartCounts[key] = container.RestartCount } } From 74df6d138b87fa53218dc0fea7a12f0a51bda7ba Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Wed, 17 Mar 2021 12:47:38 -0500 Subject: [PATCH 4/6] Memoize library.IntegrationEnv so it's only constructed once per test. This is probably a good idea regardless, but it also avoids an infinite recursion from IntegrationEnv() -> assertNoRestartsDuringTest() -> NewKubeclient() -> IntegrationEnv() -> ... Signed-off-by: Matt Moyer --- test/library/env.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/library/env.go b/test/library/env.go index 28caf52f..97b24c22 100644 --- a/test/library/env.go +++ b/test/library/env.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "os" "strings" + "sync" "testing" "github.com/stretchr/testify/require" @@ -73,9 +74,17 @@ func (e *TestEnv) ProxyEnv() []string { return []string{"http_proxy=" + e.Proxy, "https_proxy=" + e.Proxy, "no_proxy=127.0.0.1"} } +// memoizedTestEnvsByTest maps *testing.T pointers to *TestEnv. It exists so that we don't do all the +// environment parsing N times per test and so that any implicit assertions happen only once. +var memoizedTestEnvsByTest sync.Map //nolint: gochecknoglobals + // IntegrationEnv gets the integration test environment from OS environment variables. This // method also implies SkipUnlessIntegration(). func IntegrationEnv(t *testing.T) *TestEnv { + if existing, exists := memoizedTestEnvsByTest.Load(t); exists { + return existing.(*TestEnv) + } + t.Helper() SkipUnlessIntegration(t) @@ -96,12 +105,12 @@ func IntegrationEnv(t *testing.T) *TestEnv { require.NoErrorf(t, err, "capabilities specification was invalid YAML") loadEnvVars(t, &result) + result.t = t + memoizedTestEnvsByTest.Store(t, &result) // In every integration test, assert that no pods in our namespaces restart during the test. assertNoRestartsDuringTest(t, result.ConciergeNamespace, "") assertNoRestartsDuringTest(t, result.SupervisorNamespace, "") - - result.t = t return &result } From de6837226e043cc152219adb57ed52d025ecb5ed Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Thu, 4 Mar 2021 13:34:59 -0500 Subject: [PATCH 5/6] cmd/pinniped: add whoami command Signed-off-by: Andrew Keesler --- cmd/pinniped/cmd/kube_util.go | 43 +++++ cmd/pinniped/cmd/kubeconfig.go | 27 +-- cmd/pinniped/cmd/whoami.go | 298 ++++++++++++++++++++++++++++++++ cmd/pinniped/cmd/whoami_test.go | 273 +++++++++++++++++++++++++++++ 4 files changed, 616 insertions(+), 25 deletions(-) create mode 100644 cmd/pinniped/cmd/kube_util.go create mode 100644 cmd/pinniped/cmd/whoami.go create mode 100644 cmd/pinniped/cmd/whoami_test.go diff --git a/cmd/pinniped/cmd/kube_util.go b/cmd/pinniped/cmd/kube_util.go new file mode 100644 index 00000000..678824df --- /dev/null +++ b/cmd/pinniped/cmd/kube_util.go @@ -0,0 +1,43 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "k8s.io/client-go/tools/clientcmd" + + conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" + "go.pinniped.dev/internal/groupsuffix" + "go.pinniped.dev/internal/kubeclient" +) + +// getConciergeClientsetFunc is a function that can return a clientset for the Concierge API given a +// clientConfig and the apiGroupSuffix with which the API is running. +type getConciergeClientsetFunc func(clientConfig clientcmd.ClientConfig, apiGroupSuffix string) (conciergeclientset.Interface, error) + +// getRealConciergeClientset returns a real implementation of a conciergeclientset.Interface. +func getRealConciergeClientset(clientConfig clientcmd.ClientConfig, apiGroupSuffix string) (conciergeclientset.Interface, error) { + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + client, err := kubeclient.New( + kubeclient.WithConfig(restConfig), + kubeclient.WithMiddleware(groupsuffix.New(apiGroupSuffix)), + ) + if err != nil { + return nil, err + } + return client.PinnipedConcierge, nil +} + +// newClientConfig returns a clientcmd.ClientConfig given an optional kubeconfig path override and +// an optional context override. +func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigPathOverride + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ + CurrentContext: currentContextName, + }) + return clientConfig +} diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 90c72f8f..0e9e2b6a 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -27,31 +27,17 @@ import ( conciergev1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" "go.pinniped.dev/internal/groupsuffix" - "go.pinniped.dev/internal/kubeclient" ) type kubeconfigDeps struct { getPathToSelf func() (string, error) - getClientset func(clientConfig clientcmd.ClientConfig, apiGroupSuffix string) (conciergeclientset.Interface, error) + getClientset getConciergeClientsetFunc } func kubeconfigRealDeps() kubeconfigDeps { return kubeconfigDeps{ getPathToSelf: os.Executable, - getClientset: func(clientConfig clientcmd.ClientConfig, apiGroupSuffix string) (conciergeclientset.Interface, error) { - restConfig, err := clientConfig.ClientConfig() - if err != nil { - return nil, err - } - client, err := kubeclient.New( - kubeclient.WithConfig(restConfig), - kubeclient.WithMiddleware(groupsuffix.New(apiGroupSuffix)), - ) - if err != nil { - return nil, err - } - return client.PinnipedConcierge, nil - }, + getClientset: getRealConciergeClientset, } } @@ -350,15 +336,6 @@ func lookupAuthenticator(clientset conciergeclientset.Interface, authType, authN return results[0], nil } -func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - loadingRules.ExplicitPath = kubeconfigPathOverride - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ - CurrentContext: currentContextName, - }) - return clientConfig -} - func writeConfigAsYAML(out io.Writer, config clientcmdapi.Config) error { output, err := clientcmd.Write(config) if err != nil { diff --git a/cmd/pinniped/cmd/whoami.go b/cmd/pinniped/cmd/whoami.go new file mode 100644 index 00000000..bf973981 --- /dev/null +++ b/cmd/pinniped/cmd/whoami.go @@ -0,0 +1,298 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/clientcmd" + + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" + loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" + loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/groupsuffix" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/plog" +) + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(newWhoamiCommand(getRealConciergeClientset)) +} + +type whoamiFlags struct { + outputFormat string // e.g., yaml, json, text + + kubeconfigPath string + kubeconfigContextOverride string + + apiGroupSuffix string +} + +type clusterInfo struct { + name string + url string +} + +func newWhoamiCommand(getClientset getConciergeClientsetFunc) *cobra.Command { + cmd := &cobra.Command{ + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "whoami", + Short: "Print information about the current user", + SilenceUsage: true, + } + flags := &whoamiFlags{} + + // flags + f := cmd.Flags() + f.StringVarP(&flags.outputFormat, "output", "o", "text", "Output format (e.g., 'yaml', 'json', 'text')") + f.StringVar(&flags.kubeconfigPath, "kubeconfig", os.Getenv("KUBECONFIG"), "Path to kubeconfig file") + f.StringVar(&flags.kubeconfigContextOverride, "kubeconfig-context", "", "Kubeconfig context name (default: current active context)") + f.StringVar(&flags.apiGroupSuffix, "api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") + + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + return runWhoami(cmd.OutOrStdout(), getClientset, flags) + } + + return cmd +} + +func runWhoami(output io.Writer, getClientset getConciergeClientsetFunc, flags *whoamiFlags) error { + clientConfig := newClientConfig(flags.kubeconfigPath, flags.kubeconfigContextOverride) + clientset, err := getClientset(clientConfig, flags.apiGroupSuffix) + if err != nil { + return fmt.Errorf("could not configure Kubernetes client: %w", err) + } + + clusterInfo, err := getCurrentCluster(clientConfig) + if err != nil { + return fmt.Errorf("could not get current cluster info: %w", err) + } + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) + defer cancelFunc() + whoAmI, err := clientset.IdentityV1alpha1().WhoAmIRequests().Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + if err != nil { + hint := "" + if errors.IsNotFound(err) { + hint = " (is the Pinniped WhoAmI API running and healthy?)" + } + return fmt.Errorf("could not complete WhoAmIRequest%s: %w", hint, err) + } + + if err := writeWhoamiOutput(output, flags, clusterInfo, whoAmI); err != nil { + return fmt.Errorf("could not write output: %w", err) + } + + return nil +} + +func getCurrentCluster(clientConfig clientcmd.ClientConfig) (*clusterInfo, error) { + currentKubeconfig, err := clientConfig.RawConfig() + if err != nil { + return nil, err + } + + unknownClusterInfo := &clusterInfo{name: "???", url: "???"} + context, ok := currentKubeconfig.Contexts[currentKubeconfig.CurrentContext] + if !ok { + return unknownClusterInfo, nil + } + + cluster, ok := currentKubeconfig.Clusters[context.Cluster] + if !ok { + return unknownClusterInfo, nil + } + + return &clusterInfo{name: context.Cluster, url: cluster.Server}, nil +} + +func writeWhoamiOutput(output io.Writer, flags *whoamiFlags, cInfo *clusterInfo, whoAmI *identityv1alpha1.WhoAmIRequest) error { + switch flags.outputFormat { + case "text": + return writeWhoamiOutputText(output, cInfo, whoAmI) + case "json": + return writeWhoamiOutputJSON(output, flags.apiGroupSuffix, whoAmI) + case "yaml": + return writeWhoamiOutputYAML(output, flags.apiGroupSuffix, whoAmI) + default: + return fmt.Errorf("unknown output format: %q", flags.outputFormat) + } +} + +func writeWhoamiOutputText(output io.Writer, clusterInfo *clusterInfo, whoAmI *identityv1alpha1.WhoAmIRequest) error { + fmt.Fprint(output, here.Docf(` + Current cluster info: + + Name: %s + URL: %s + + Current user info: + + Username: %s + Groups: %s +`, clusterInfo.name, clusterInfo.url, whoAmI.Status.KubernetesUserInfo.User.Username, prettyStrings(whoAmI.Status.KubernetesUserInfo.User.Groups))) + return nil +} + +func writeWhoamiOutputJSON(output io.Writer, apiGroupSuffix string, whoAmI *identityv1alpha1.WhoAmIRequest) error { + return serialize(output, apiGroupSuffix, whoAmI, runtime.ContentTypeJSON) +} + +func writeWhoamiOutputYAML(output io.Writer, apiGroupSuffix string, whoAmI *identityv1alpha1.WhoAmIRequest) error { + return serialize(output, apiGroupSuffix, whoAmI, runtime.ContentTypeYAML) +} + +func serialize(output io.Writer, apiGroupSuffix string, whoAmI *identityv1alpha1.WhoAmIRequest, contentType string) error { + scheme, _, identityGV := conciergeschemeNew(apiGroupSuffix) + codecs := serializer.NewCodecFactory(scheme) + respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), contentType) + if !ok { + return fmt.Errorf("unknown content type: %q", contentType) + } + + // I have seen the pretty serializer be nil before, so this will hopefully protect against that + // corner. + serializer := respInfo.PrettySerializer + if serializer == nil { + serializer = respInfo.Serializer + } + + // Ensure that these fields are set so that the JSON/YAML output tells the full story. + whoAmI.APIVersion = identityGV.String() + whoAmI.Kind = "WhoAmIRequest" + + return serializer.Encode(whoAmI, output) +} + +func prettyStrings(ss []string) string { + b := &strings.Builder{} + for i, s := range ss { + if i != 0 { + b.WriteString(", ") + } + b.WriteString(s) + } + return b.String() +} + +// conciergeschemeNew is a temporary private function to stand in place for +// "go.pinniped.dev/internal/concierge/scheme".New until the later function is merged to main. +func conciergeschemeNew(apiGroupSuffix string) (_ *runtime.Scheme, login, identity schema.GroupVersion) { + // standard set up of the server side scheme + scheme := runtime.NewScheme() + + // add the options to empty v1 + metav1.AddToGroupVersion(scheme, metav1.Unversioned) + + // nothing fancy is required if using the standard group suffix + if apiGroupSuffix == groupsuffix.PinnipedDefaultSuffix { + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.AddToScheme, + loginapi.AddToScheme, + identityv1alpha1.AddToScheme, + identityapi.AddToScheme, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) + return scheme, loginv1alpha1.SchemeGroupVersion, identityv1alpha1.SchemeGroupVersion + } + + loginConciergeGroupData, identityConciergeGroupData := groupsuffix.ConciergeAggregatedGroups(apiGroupSuffix) + + addToSchemeAtNewGroup(scheme, loginv1alpha1.GroupName, loginConciergeGroupData.Group, loginv1alpha1.AddToScheme, loginapi.AddToScheme) + addToSchemeAtNewGroup(scheme, identityv1alpha1.GroupName, identityConciergeGroupData.Group, identityv1alpha1.AddToScheme, identityapi.AddToScheme) + + // manually register conversions and defaulting into the correct scheme since we cannot directly call AddToScheme + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.RegisterConversions, + loginv1alpha1.RegisterDefaults, + identityv1alpha1.RegisterConversions, + identityv1alpha1.RegisterDefaults, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) + + // we do not want to return errors from the scheme and instead would prefer to defer + // to the REST storage layer for consistency. The simplest way to do this is to force + // a cache miss from the authenticator cache. Kube API groups are validated via the + // IsDNS1123Subdomain func thus we can easily create a group that is guaranteed never + // to be in the authenticator cache. Add a timestamp just to be extra sure. + const authenticatorCacheMissPrefix = "_INVALID_API_GROUP_" + authenticatorCacheMiss := authenticatorCacheMissPrefix + time.Now().UTC().String() + + // we do not have any defaulting functions for *loginv1alpha1.TokenCredentialRequest + // today, but we may have some in the future. Calling AddTypeDefaultingFunc overwrites + // any previously registered defaulting function. Thus to make sure that we catch + // a situation where we add a defaulting func, we attempt to call it here with a nil + // *loginv1alpha1.TokenCredentialRequest. This will do nothing when there is no + // defaulting func registered, but it will almost certainly panic if one is added. + scheme.Default((*loginv1alpha1.TokenCredentialRequest)(nil)) + + // on incoming requests, restore the authenticator API group to the standard group + // note that we are responsible for duplicating this logic for every external API version + scheme.AddTypeDefaultingFunc(&loginv1alpha1.TokenCredentialRequest{}, func(obj interface{}) { + credentialRequest := obj.(*loginv1alpha1.TokenCredentialRequest) + + if credentialRequest.Spec.Authenticator.APIGroup == nil { + // force a cache miss because this is an invalid request + plog.Debug("invalid token credential request, nil group", "authenticator", credentialRequest.Spec.Authenticator) + credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss + return + } + + restoredGroup, ok := groupsuffix.Unreplace(*credentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix) + if !ok { + // force a cache miss because this is an invalid request + plog.Debug("invalid token credential request, wrong group", "authenticator", credentialRequest.Spec.Authenticator) + credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss + return + } + + credentialRequest.Spec.Authenticator.APIGroup = &restoredGroup + }) + + return scheme, schema.GroupVersion(loginConciergeGroupData), schema.GroupVersion(identityConciergeGroupData) +} + +func addToSchemeAtNewGroup(scheme *runtime.Scheme, oldGroup, newGroup string, funcs ...func(*runtime.Scheme) error) { + // we need a temporary place to register our types to avoid double registering them + tmpScheme := runtime.NewScheme() + schemeBuilder := runtime.NewSchemeBuilder(funcs...) + utilruntime.Must(schemeBuilder.AddToScheme(tmpScheme)) + + for gvk := range tmpScheme.AllKnownTypes() { + if gvk.GroupVersion() == metav1.Unversioned { + continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore + } + + if gvk.Group != oldGroup { + panic(fmt.Errorf("tmp scheme has type not in the old aggregated API group %s: %s", oldGroup, gvk)) // programmer error + } + + obj, err := tmpScheme.New(gvk) + if err != nil { + panic(err) // programmer error, scheme internal code is broken + } + newGVK := schema.GroupVersionKind{ + Group: newGroup, + Version: gvk.Version, + Kind: gvk.Kind, + } + + // register the existing type but with the new group in the correct scheme + scheme.AddKnownTypeWithName(newGVK, obj) + } +} diff --git a/cmd/pinniped/cmd/whoami_test.go b/cmd/pinniped/cmd/whoami_test.go new file mode 100644 index 00000000..38c7f99c --- /dev/null +++ b/cmd/pinniped/cmd/whoami_test.go @@ -0,0 +1,273 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + kubetesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" + + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" + fakeconciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake" + "go.pinniped.dev/internal/constable" + "go.pinniped.dev/internal/here" +) + +func TestWhoami(t *testing.T) { + tests := []struct { + name string + args []string + groupsOverride []string + gettingClientsetErr error + callingAPIErr error + wantError bool + wantStdout, wantStderr string + }{ + { + name: "help flag", + args: []string{"--help"}, + wantStdout: here.Doc(` + Print information about the current user + + Usage: + whoami [flags] + + Flags: + --api-group-suffix string Concierge API group suffix (default "pinniped.dev") + -h, --help help for whoami + --kubeconfig string Path to kubeconfig file + --kubeconfig-context string Kubeconfig context name (default: current active context) + -o, --output string Output format (e.g., 'yaml', 'json', 'text') (default "text") + `), + }, + { + name: "text output", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml"}, + wantStdout: here.Doc(` + Current cluster info: + + Name: kind-kind + URL: https://fake-server-url-value + + Current user info: + + Username: some-username + Groups: some-group-0, some-group-1 + `), + }, + { + name: "text output with long output flag", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "--output", "text"}, + wantStdout: here.Doc(` + Current cluster info: + + Name: kind-kind + URL: https://fake-server-url-value + + Current user info: + + Username: some-username + Groups: some-group-0, some-group-1 + `), + }, + { + name: "text output with 1 group", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "--output", "text"}, + groupsOverride: []string{"some-group-0"}, + wantStdout: here.Doc(` + Current cluster info: + + Name: kind-kind + URL: https://fake-server-url-value + + Current user info: + + Username: some-username + Groups: some-group-0 + `), + }, + { + name: "text output with no groups", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "--output", "text"}, + groupsOverride: []string{}, + wantStdout: here.Doc(` + Current cluster info: + + Name: kind-kind + URL: https://fake-server-url-value + + Current user info: + + Username: some-username + Groups: + `), + }, + { + name: "json output", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "-o", "json"}, + wantStdout: here.Doc(` + { + "kind": "WhoAmIRequest", + "apiVersion": "identity.concierge.pinniped.dev/v1alpha1", + "metadata": { + "creationTimestamp": null + }, + "spec": {}, + "status": { + "kubernetesUserInfo": { + "user": { + "username": "some-username", + "groups": [ + "some-group-0", + "some-group-1" + ] + } + } + } + }`), + }, + { + name: "json output with api group suffix flag", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "-o", "json", "--api-group-suffix", "tuna.io"}, + wantStdout: here.Doc(` + { + "kind": "WhoAmIRequest", + "apiVersion": "identity.concierge.tuna.io/v1alpha1", + "metadata": { + "creationTimestamp": null + }, + "spec": {}, + "status": { + "kubernetesUserInfo": { + "user": { + "username": "some-username", + "groups": [ + "some-group-0", + "some-group-1" + ] + } + } + } + }`), + }, + { + name: "yaml output", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "-o", "yaml"}, + wantStdout: here.Doc(` + apiVersion: identity.concierge.pinniped.dev/v1alpha1 + kind: WhoAmIRequest + metadata: + creationTimestamp: null + spec: {} + status: + kubernetesUserInfo: + user: + groups: + - some-group-0 + - some-group-1 + username: some-username + `), + }, + { + name: "yaml output with api group suffix", + args: []string{"--kubeconfig", "testdata/kubeconfig.yaml", "-o", "yaml", "--api-group-suffix", "tuna.io"}, + wantStdout: here.Doc(` + apiVersion: identity.concierge.tuna.io/v1alpha1 + kind: WhoAmIRequest + metadata: + creationTimestamp: null + spec: {} + status: + kubernetesUserInfo: + user: + groups: + - some-group-0 + - some-group-1 + username: some-username + `), + }, + { + name: "extra args", + args: []string{"extra-arg"}, + wantError: true, + wantStderr: "Error: unknown command \"extra-arg\" for \"whoami\"\n", + }, + { + name: "cannot get cluster info", + args: []string{"--kubeconfig", "this-file-does-not-exist"}, + wantError: true, + wantStderr: "Error: could not get current cluster info: stat this-file-does-not-exist: no such file or directory\n", + }, + { + name: "getting clientset fails", + gettingClientsetErr: constable.Error("some get clientset error"), + wantError: true, + wantStderr: "Error: could not configure Kubernetes client: some get clientset error\n", + }, + { + name: "calling API fails", + callingAPIErr: constable.Error("some API error"), + wantError: true, + wantStderr: "Error: could not complete WhoAmIRequest: some API error\n", + }, + { + name: "calling API fails because WhoAmI API is not installed", + callingAPIErr: errors.NewNotFound(identityv1alpha1.SchemeGroupVersion.WithResource("whoamirequests").GroupResource(), "whatever"), + wantError: true, + wantStderr: "Error: could not complete WhoAmIRequest (is the Pinniped WhoAmI API running and healthy?): whoamirequests.identity.concierge.pinniped.dev \"whatever\" not found\n", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + getClientset := func(clientConfig clientcmd.ClientConfig, apiGroupSuffix string) (conciergeclientset.Interface, error) { + if test.gettingClientsetErr != nil { + return nil, test.gettingClientsetErr + } + clientset := fakeconciergeclientset.NewSimpleClientset() + clientset.PrependReactor("create", "whoamirequests", func(_ kubetesting.Action) (bool, runtime.Object, error) { + if test.callingAPIErr != nil { + return true, nil, test.callingAPIErr + } + groups := []string{"some-group-0", "some-group-1"} + if test.groupsOverride != nil { + groups = test.groupsOverride + } + return true, &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "some-username", + Groups: groups, + }, + }, + }, + }, nil + }) + return clientset, nil + } + cmd := newWhoamiCommand(getClientset) + + stdout, stderr := bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(test.args) + + err := cmd.Execute() + if test.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, test.wantStdout, stdout.String()) + require.Equal(t, test.wantStderr, stderr.String()) + }) + } +} From cc8f0b623cf4ed2fd5be23dfc75eaff9cac9ca16 Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Thu, 4 Mar 2021 14:46:18 -0500 Subject: [PATCH 6/6] test/integration: add pinniped whoami tests Signed-off-by: Andrew Keesler --- test/integration/cli_test.go | 174 +++++++++++++++++++++++++++++++++++ test/integration/e2e_test.go | 11 +++ 2 files changed, 185 insertions(+) diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index ca994ac0..bdc4a044 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -23,8 +23,19 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "gopkg.in/square/go-jose.v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" + loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" + loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" + "go.pinniped.dev/internal/groupsuffix" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" @@ -93,6 +104,19 @@ func TestCLIGetKubeconfigStaticToken(t *testing.T) { group := group t.Run("access as group "+group+" with client-go", library.AccessAsGroupTest(ctx, group, kubeClient)) } + + // Validate that `pinniped whoami` returns the correct identity. + kubeconfigPath := filepath.Join(testutil.TempDir(t), "whoami-kubeconfig") + require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(stdout), 0600)) + assertWhoami( + ctx, + t, + false, + pinnipedExe, + kubeconfigPath, + env.TestUser.ExpectedUsername, + append(env.TestUser.ExpectedGroups, "system:authenticated"), + ) }) } } @@ -107,6 +131,49 @@ func runPinnipedCLI(t *testing.T, pinnipedExe string, args ...string) (string, s return stdout.String(), stderr.String() } +func assertWhoami(ctx context.Context, t *testing.T, useProxy bool, pinnipedExe, kubeconfigPath, wantUsername string, wantGroups []string) { + t.Helper() + + apiGroupSuffix := library.IntegrationEnv(t).APIGroupSuffix + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext( + ctx, + pinnipedExe, + "whoami", + "--kubeconfig", + kubeconfigPath, + "--output", + "yaml", + "--api-group-suffix", + apiGroupSuffix, + ) + if useProxy { + cmd.Env = append(os.Environ(), library.IntegrationEnv(t).ProxyEnv()...) + } + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "stderr:\n%s\n\nstdout:\n%s\n\n", stderr.String(), stdout.String()) + + whoAmI := deserializeWhoAmIRequest(t, stdout.String(), apiGroupSuffix) + require.Equal(t, wantUsername, whoAmI.Status.KubernetesUserInfo.User.Username) + require.ElementsMatch(t, wantGroups, whoAmI.Status.KubernetesUserInfo.User.Groups) +} + +func deserializeWhoAmIRequest(t *testing.T, data string, apiGroupSuffix string) *identityv1alpha1.WhoAmIRequest { + t.Helper() + + scheme, _, _ := conciergeschemeNew(apiGroupSuffix) + codecs := serializer.NewCodecFactory(scheme) + respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeYAML) + require.True(t, ok) + + obj, err := runtime.Decode(respInfo.Serializer, []byte(data)) + require.NoError(t, err) + + return obj.(*identityv1alpha1.WhoAmIRequest) +} + func TestCLILoginOIDC(t *testing.T) { env := library.IntegrationEnv(t) @@ -343,3 +410,110 @@ func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, ses cmd.Env = append(os.Environ(), env.ProxyEnv()...) return cmd } + +// conciergeschemeNew is a temporary private function to stand in place for +// "go.pinniped.dev/internal/concierge/scheme".New until the later function is merged to main. +func conciergeschemeNew(apiGroupSuffix string) (_ *runtime.Scheme, login, identity schema.GroupVersion) { + // standard set up of the server side scheme + scheme := runtime.NewScheme() + + // add the options to empty v1 + metav1.AddToGroupVersion(scheme, metav1.Unversioned) + + // nothing fancy is required if using the standard group suffix + if apiGroupSuffix == groupsuffix.PinnipedDefaultSuffix { + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.AddToScheme, + loginapi.AddToScheme, + identityv1alpha1.AddToScheme, + identityapi.AddToScheme, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) + return scheme, loginv1alpha1.SchemeGroupVersion, identityv1alpha1.SchemeGroupVersion + } + + loginConciergeGroupData, identityConciergeGroupData := groupsuffix.ConciergeAggregatedGroups(apiGroupSuffix) + + addToSchemeAtNewGroup(scheme, loginv1alpha1.GroupName, loginConciergeGroupData.Group, loginv1alpha1.AddToScheme, loginapi.AddToScheme) + addToSchemeAtNewGroup(scheme, identityv1alpha1.GroupName, identityConciergeGroupData.Group, identityv1alpha1.AddToScheme, identityapi.AddToScheme) + + // manually register conversions and defaulting into the correct scheme since we cannot directly call AddToScheme + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.RegisterConversions, + loginv1alpha1.RegisterDefaults, + identityv1alpha1.RegisterConversions, + identityv1alpha1.RegisterDefaults, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) + + // we do not want to return errors from the scheme and instead would prefer to defer + // to the REST storage layer for consistency. The simplest way to do this is to force + // a cache miss from the authenticator cache. Kube API groups are validated via the + // IsDNS1123Subdomain func thus we can easily create a group that is guaranteed never + // to be in the authenticator cache. Add a timestamp just to be extra sure. + const authenticatorCacheMissPrefix = "_INVALID_API_GROUP_" + authenticatorCacheMiss := authenticatorCacheMissPrefix + time.Now().UTC().String() + + // we do not have any defaulting functions for *loginv1alpha1.TokenCredentialRequest + // today, but we may have some in the future. Calling AddTypeDefaultingFunc overwrites + // any previously registered defaulting function. Thus to make sure that we catch + // a situation where we add a defaulting func, we attempt to call it here with a nil + // *loginv1alpha1.TokenCredentialRequest. This will do nothing when there is no + // defaulting func registered, but it will almost certainly panic if one is added. + scheme.Default((*loginv1alpha1.TokenCredentialRequest)(nil)) + + // on incoming requests, restore the authenticator API group to the standard group + // note that we are responsible for duplicating this logic for every external API version + scheme.AddTypeDefaultingFunc(&loginv1alpha1.TokenCredentialRequest{}, func(obj interface{}) { + credentialRequest := obj.(*loginv1alpha1.TokenCredentialRequest) + + if credentialRequest.Spec.Authenticator.APIGroup == nil { + // force a cache miss because this is an invalid request + plog.Debug("invalid token credential request, nil group", "authenticator", credentialRequest.Spec.Authenticator) + credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss + return + } + + restoredGroup, ok := groupsuffix.Unreplace(*credentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix) + if !ok { + // force a cache miss because this is an invalid request + plog.Debug("invalid token credential request, wrong group", "authenticator", credentialRequest.Spec.Authenticator) + credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss + return + } + + credentialRequest.Spec.Authenticator.APIGroup = &restoredGroup + }) + + return scheme, schema.GroupVersion(loginConciergeGroupData), schema.GroupVersion(identityConciergeGroupData) +} + +func addToSchemeAtNewGroup(scheme *runtime.Scheme, oldGroup, newGroup string, funcs ...func(*runtime.Scheme) error) { + // we need a temporary place to register our types to avoid double registering them + tmpScheme := runtime.NewScheme() + schemeBuilder := runtime.NewSchemeBuilder(funcs...) + utilruntime.Must(schemeBuilder.AddToScheme(tmpScheme)) + + for gvk := range tmpScheme.AllKnownTypes() { + if gvk.GroupVersion() == metav1.Unversioned { + continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore + } + + if gvk.Group != oldGroup { + panic(fmt.Errorf("tmp scheme has type not in the old aggregated API group %s: %s", oldGroup, gvk)) // programmer error + } + + obj, err := tmpScheme.New(gvk) + if err != nil { + panic(err) // programmer error, scheme internal code is broken + } + newGVK := schema.GroupVersionKind{ + Group: newGroup, + Version: gvk.Version, + Kind: gvk.Kind, + } + + // register the existing type but with the new group in the correct scheme + scheme.AddKnownTypeWithName(newGVK, obj) + } +} diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 275d0b8a..572b1955 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -333,4 +333,15 @@ status: username: `+env.SupervisorTestUpstream.Username+` `, string(kubectlOutput3)) + + // Validate that `pinniped whoami` returns the correct identity. + assertWhoami( + ctx, + t, + true, + pinnipedExe, + kubeconfigPath, + env.SupervisorTestUpstream.Username, + append(env.SupervisorTestUpstream.ExpectedGroups, "system:authenticated"), + ) }