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()) + }) + } +}