From 211fc12b67ea2d04a46b24ef245b3513f2cfeb03 Mon Sep 17 00:00:00 2001 From: Sean Sullivan Date: Tue, 9 Feb 2021 23:09:22 -0800 Subject: [PATCH] Kubectl command headers in requests: KEP 859 --- staging/src/k8s.io/cli-runtime/go.mod | 1 + staging/src/k8s.io/cli-runtime/go.sum | 1 + .../pkg/genericclioptions/command_headers.go | 79 +++++++++++++ .../genericclioptions/command_headers_test.go | 105 ++++++++++++++++++ .../pkg/genericclioptions/config_flags.go | 15 ++- staging/src/k8s.io/kubectl/pkg/cmd/cmd.go | 46 +++++++- staging/src/k8s.io/sample-cli-plugin/go.sum | 1 + 7 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers.go create mode 100644 staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers_test.go diff --git a/staging/src/k8s.io/cli-runtime/go.mod b/staging/src/k8s.io/cli-runtime/go.mod index 5acf6ac02a1..60653316514 100644 --- a/staging/src/k8s.io/cli-runtime/go.mod +++ b/staging/src/k8s.io/cli-runtime/go.mod @@ -8,6 +8,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/emicklei/go-restful v2.9.5+incompatible // indirect github.com/evanphx/json-patch v4.9.0+incompatible + github.com/google/uuid v1.1.2 github.com/googleapis/gnostic v0.4.1 github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de github.com/mailru/easyjson v0.7.0 // indirect diff --git a/staging/src/k8s.io/cli-runtime/go.sum b/staging/src/k8s.io/cli-runtime/go.sum index 6ffd4a0bfbe..ea23c73d5bd 100644 --- a/staging/src/k8s.io/cli-runtime/go.sum +++ b/staging/src/k8s.io/cli-runtime/go.sum @@ -148,6 +148,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= diff --git a/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers.go b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers.go new file mode 100644 index 00000000000..8f9e774752a --- /dev/null +++ b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers.go @@ -0,0 +1,79 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package genericclioptions + +import ( + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +const ( + kubectlCommandHeader = "X-Kubectl-Command" + kubectlSessionHeader = "X-Kubectl-Session" +) + +// CommandHeaderRoundTripper adds a layer around the standard +// round tripper to add Request headers before delegation. Implements +// the go standard library "http.RoundTripper" interface. +type CommandHeaderRoundTripper struct { + Delegate http.RoundTripper + Headers map[string]string +} + +// CommandHeaderRoundTripper adds Request headers before delegating to standard +// round tripper. These headers are kubectl command headers which +// detail the kubectl command. See SIG CLI KEP 859: +// https://github.com/kubernetes/enhancements/tree/master/keps/sig-cli/859-kubectl-headers +func (c *CommandHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + for header, value := range c.Headers { + req.Header.Set(header, value) + } + return c.Delegate.RoundTrip(req) +} + +// ParseCommandHeaders fills in a map of X-Headers into the CommandHeaderRoundTripper. These +// headers are then filled into each request. For details on X-Headers see: +// https://github.com/kubernetes/enhancements/tree/master/keps/sig-cli/859-kubectl-headers +// Each call overwrites the previously parsed command headers (not additive). +// TODO(seans3): Parse/add flags removing PII from flag values. +func (c *CommandHeaderRoundTripper) ParseCommandHeaders(cmd *cobra.Command, args []string) { + if cmd == nil { + return + } + // Overwrites previously parsed command headers (headers not additive). + c.Headers = map[string]string{} + // Session identifier to aggregate multiple Requests from single kubectl command. + uid := uuid.New().String() + c.Headers[kubectlSessionHeader] = uid + // Iterate up the hierarchy of commands from the leaf command to create + // the full command string. Example: kubectl create secret generic + cmdStrs := []string{} + for cmd.HasParent() { + parent := cmd.Parent() + currName := strings.TrimSpace(cmd.Name()) + cmdStrs = append([]string{currName}, cmdStrs...) + cmd = parent + } + currName := strings.TrimSpace(cmd.Name()) + cmdStrs = append([]string{currName}, cmdStrs...) + if len(cmdStrs) > 0 { + c.Headers[kubectlCommandHeader] = strings.Join(cmdStrs, " ") + } +} diff --git a/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers_test.go b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers_test.go new file mode 100644 index 00000000000..b95f8cacc95 --- /dev/null +++ b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/command_headers_test.go @@ -0,0 +1,105 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package genericclioptions + +import ( + "testing" + + "github.com/spf13/cobra" +) + +var kubectlCmd = &cobra.Command{Use: "kubectl"} +var applyCmd = &cobra.Command{Use: "apply"} +var createCmd = &cobra.Command{Use: "create"} +var secretCmd = &cobra.Command{Use: "secret"} +var genericCmd = &cobra.Command{Use: "generic"} +var authCmd = &cobra.Command{Use: "auth"} +var reconcileCmd = &cobra.Command{Use: "reconcile"} + +func TestParseCommandHeaders(t *testing.T) { + tests := map[string]struct { + // Ordering is important; each subsequent command is added as a subcommand + // of the previous command. + commands []*cobra.Command + // Headers which should be present; but other headers may exist + expectedHeaders map[string]string + }{ + "Single kubectl command example": { + commands: []*cobra.Command{kubectlCmd}, + expectedHeaders: map[string]string{ + kubectlCommandHeader: "kubectl", + }, + }, + "Simple kubectl apply example": { + commands: []*cobra.Command{kubectlCmd, applyCmd}, + expectedHeaders: map[string]string{ + kubectlCommandHeader: "kubectl apply", + }, + }, + "Kubectl auth reconcile example": { + commands: []*cobra.Command{kubectlCmd, authCmd, reconcileCmd}, + expectedHeaders: map[string]string{ + kubectlCommandHeader: "kubectl auth reconcile", + }, + }, + "Long kubectl create secret generic example": { + commands: []*cobra.Command{kubectlCmd, createCmd, secretCmd, genericCmd}, + expectedHeaders: map[string]string{ + kubectlCommandHeader: "kubectl create secret generic", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + rootCmd := buildCommandChain(tc.commands) + ch := &CommandHeaderRoundTripper{} + ch.ParseCommandHeaders(rootCmd, []string{}) + // Unique session ID header should always be present. + if _, found := ch.Headers[kubectlSessionHeader]; !found { + t.Errorf("expected kubectl session header (%s) is missing", kubectlSessionHeader) + } + // All expected headers must be present; but there may be extras. + for key, expectedValue := range tc.expectedHeaders { + actualValue, found := ch.Headers[key] + if found { + if expectedValue != actualValue { + t.Errorf("expected header value (%s), got (%s)", expectedValue, actualValue) + } + } else { + t.Errorf("expected header (%s) not found", key) + } + } + }) + } +} + +// Builds a hierarchy of commands in order from the passed slice of commands, +// by adding each subsequent command as a child of the previous command, +// returning the last leaf command. +func buildCommandChain(commands []*cobra.Command) *cobra.Command { + var currCmd *cobra.Command + if len(commands) > 0 { + currCmd = commands[0] + } + for i := 1; i < len(commands); i++ { + cmd := commands[i] + currCmd.AddCommand(cmd) + currCmd = cmd + } + return currCmd +} diff --git a/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go index 1b053113417..86a601a0ded 100644 --- a/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go +++ b/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go @@ -98,6 +98,9 @@ type ConfigFlags struct { Username *string Password *string Timeout *string + // If non-nil, wrap config function can transform the Config + // before it is returned in ToRESTConfig function. + WrapConfigFn func(*rest.Config) *rest.Config clientConfig clientcmd.ClientConfig lock sync.Mutex @@ -113,9 +116,17 @@ type ConfigFlags struct { // ToRESTConfig implements RESTClientGetter. // Returns a REST client configuration based on a provided path // to a .kubeconfig file, loading rules, and config flag overrides. -// Expects the AddFlags method to have been called. +// Expects the AddFlags method to have been called. If WrapConfigFn +// is non-nil this function can transform config before return. func (f *ConfigFlags) ToRESTConfig() (*rest.Config, error) { - return f.ToRawKubeConfigLoader().ClientConfig() + c, err := f.ToRawKubeConfigLoader().ClientConfig() + if err != nil { + return nil, err + } + if f.WrapConfigFn != nil { + return f.WrapConfigFn(c), nil + } + return c, nil } // ToRawKubeConfigLoader binds config flag values to config overrides diff --git a/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go b/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go index 94bf8239692..1f94844720c 100644 --- a/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go +++ b/staging/src/k8s.io/kubectl/pkg/cmd/cmd.go @@ -20,6 +20,7 @@ import ( "flag" "fmt" "io" + "net/http" "os" "os/exec" "runtime" @@ -322,6 +323,8 @@ __kubectl_custom_func() { ` ) +const kubectlCmdHeaders = "KUBECTL_COMMAND_HEADERS" + var ( bashCompletionFlags = map[string]string{ "namespace": "__kubectl_get_resource_namespace", @@ -469,7 +472,6 @@ func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string) error { func NewKubectlCommand(in io.Reader, out, err io.Writer) *cobra.Command { warningHandler := rest.NewWarningWriter(err, rest.WarningWriterOptions{Deduplicate: true, Color: term.AllowsColorOutput(err)}) warningsAsErrors := false - // Parent command to which all subcommands are added. cmds := &cobra.Command{ Use: "kubectl", @@ -521,6 +523,8 @@ func NewKubectlCommand(in io.Reader, out, err io.Writer) *cobra.Command { kubeConfigFlags.AddFlags(flags) matchVersionKubeConfigFlags := cmdutil.NewMatchVersionFlags(kubeConfigFlags) matchVersionKubeConfigFlags.AddFlags(cmds.PersistentFlags()) + // Updates hooks to add kubectl command headers: SIG CLI KEP 859. + addCmdHeaderHooks(cmds, kubeConfigFlags) cmds.PersistentFlags().AddGoFlagSet(flag.CommandLine) @@ -538,6 +542,12 @@ func NewKubectlCommand(in io.Reader, out, err io.Writer) *cobra.Command { ioStreams := genericclioptions.IOStreams{In: in, Out: out, ErrOut: err} + // Proxy command is incompatible with CommandHeaderRoundTripper, so + // clear the WrapConfigFn before running proxy command. + proxyCmd := proxy.NewCmdProxy(f, ioStreams) + proxyCmd.PreRun = func(cmd *cobra.Command, args []string) { + kubeConfigFlags.WrapConfigFn = nil + } groups := templates.CommandGroups{ { Message: "Basic Commands (Beginner):", @@ -585,7 +595,7 @@ func NewKubectlCommand(in io.Reader, out, err io.Writer) *cobra.Command { attach.NewCmdAttach(f, ioStreams), cmdexec.NewCmdExec(f, ioStreams), portforward.NewCmdPortForward(f, ioStreams), - proxy.NewCmdProxy(f, ioStreams), + proxyCmd, cp.NewCmdCp(f, ioStreams), auth.NewCmdAuth(f, ioStreams), debug.NewCmdDebug(f, ioStreams), @@ -646,6 +656,38 @@ func NewKubectlCommand(in io.Reader, out, err io.Writer) *cobra.Command { return cmds } +// addCmdHeaderHooks performs updates on two hooks: +// 1) Modifies the passed "cmds" persistent pre-run function to parse command headers. +// These headers will be subsequently added as X-headers to every +// REST call. +// 2) Adds CommandHeaderRoundTripper as a wrapper around the standard +// RoundTripper. CommandHeaderRoundTripper adds X-Headers then delegates +// to standard RoundTripper. +// For alpha, these hooks are only updated if the KUBECTL_COMMAND_HEADERS +// environment variable is set. +// See SIG CLI KEP 859 for more information: +// https://github.com/kubernetes/enhancements/tree/master/keps/sig-cli/859-kubectl-headers +func addCmdHeaderHooks(cmds *cobra.Command, kubeConfigFlags *genericclioptions.ConfigFlags) { + if _, exists := os.LookupEnv(kubectlCmdHeaders); !exists { + return + } + crt := &genericclioptions.CommandHeaderRoundTripper{} + existingPreRunE := cmds.PersistentPreRunE + // Add command parsing to the existing persistent pre-run function. + cmds.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + crt.ParseCommandHeaders(cmd, args) + return existingPreRunE(cmd, args) + } + // Wraps CommandHeaderRoundTripper around standard RoundTripper. + kubeConfigFlags.WrapConfigFn = func(c *rest.Config) *rest.Config { + c.Wrap(func(rt http.RoundTripper) http.RoundTripper { + crt.Delegate = rt + return crt + }) + return c + } +} + func runHelp(cmd *cobra.Command, args []string) { cmd.Help() } diff --git a/staging/src/k8s.io/sample-cli-plugin/go.sum b/staging/src/k8s.io/sample-cli-plugin/go.sum index 6ffd4a0bfbe..ea23c73d5bd 100644 --- a/staging/src/k8s.io/sample-cli-plugin/go.sum +++ b/staging/src/k8s.io/sample-cli-plugin/go.sum @@ -148,6 +148,7 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=