diff --git a/pkg/apis/clientauthentication/types.go b/pkg/apis/clientauthentication/types.go index c1089979..8daaa3f8 100644 --- a/pkg/apis/clientauthentication/types.go +++ b/pkg/apis/clientauthentication/types.go @@ -47,7 +47,7 @@ type ExecCredentialSpec struct { Response *Response // Interactive is true when the transport detects the command is being called from an - // interactive prompt. + // interactive prompt, i.e., when stdin has been passed to this exec plugin. // +optional Interactive bool diff --git a/pkg/apis/clientauthentication/v1beta1/conversion.go b/pkg/apis/clientauthentication/v1beta1/conversion.go index 441b7c44..6741114d 100644 --- a/pkg/apis/clientauthentication/v1beta1/conversion.go +++ b/pkg/apis/clientauthentication/v1beta1/conversion.go @@ -22,7 +22,7 @@ import ( ) func Convert_clientauthentication_ExecCredentialSpec_To_v1beta1_ExecCredentialSpec(in *clientauthentication.ExecCredentialSpec, out *ExecCredentialSpec, s conversion.Scope) error { - // This conversion intentionally omits the Response and Interactive fields, which were only + // This conversion intentionally omits the Response field, which were only // supported in v1alpha1. return autoConvert_clientauthentication_ExecCredentialSpec_To_v1beta1_ExecCredentialSpec(in, out, s) } diff --git a/pkg/apis/clientauthentication/v1beta1/types.go b/pkg/apis/clientauthentication/v1beta1/types.go index fabc6f65..714b0273 100644 --- a/pkg/apis/clientauthentication/v1beta1/types.go +++ b/pkg/apis/clientauthentication/v1beta1/types.go @@ -46,6 +46,9 @@ type ExecCredentialSpec struct { // ExecConfig.ProvideClusterInfo). // +optional Cluster *Cluster `json:"cluster,omitempty"` + + // Interactive declares whether stdin has been passed to this exec plugin. + Interactive bool `json:"interactive"` } // ExecCredentialStatus holds credentials for the transport to use. diff --git a/pkg/apis/clientauthentication/v1beta1/zz_generated.conversion.go b/pkg/apis/clientauthentication/v1beta1/zz_generated.conversion.go index 90f7935f..8daed805 100644 --- a/pkg/apis/clientauthentication/v1beta1/zz_generated.conversion.go +++ b/pkg/apis/clientauthentication/v1beta1/zz_generated.conversion.go @@ -149,6 +149,7 @@ func autoConvert_v1beta1_ExecCredentialSpec_To_clientauthentication_ExecCredenti } else { out.Cluster = nil } + out.Interactive = in.Interactive return nil } @@ -159,7 +160,7 @@ func Convert_v1beta1_ExecCredentialSpec_To_clientauthentication_ExecCredentialSp func autoConvert_clientauthentication_ExecCredentialSpec_To_v1beta1_ExecCredentialSpec(in *clientauthentication.ExecCredentialSpec, out *ExecCredentialSpec, s conversion.Scope) error { // WARNING: in.Response requires manual conversion: does not exist in peer-type - // WARNING: in.Interactive requires manual conversion: does not exist in peer-type + out.Interactive = in.Interactive if in.Cluster != nil { in, out := &in.Cluster, &out.Cluster *out = new(Cluster) diff --git a/plugin/pkg/client/auth/exec/exec.go b/plugin/pkg/client/auth/exec/exec.go index ce24be17..0964f1d8 100644 --- a/plugin/pkg/client/auth/exec/exec.go +++ b/plugin/pkg/client/auth/exec/exec.go @@ -162,10 +162,10 @@ func (s *sometimes) Do(f func()) { // GetAuthenticator returns an exec-based plugin for providing client credentials. func GetAuthenticator(config *api.ExecConfig, cluster *clientauthentication.Cluster) (*Authenticator, error) { - return newAuthenticator(globalCache, config, cluster) + return newAuthenticator(globalCache, term.IsTerminal, config, cluster) } -func newAuthenticator(c *cache, config *api.ExecConfig, cluster *clientauthentication.Cluster) (*Authenticator, error) { +func newAuthenticator(c *cache, isTerminalFunc func(int) bool, config *api.ExecConfig, cluster *clientauthentication.Cluster) (*Authenticator, error) { key := cacheKey(config, cluster) if a, ok := c.get(key); ok { return a, nil @@ -196,11 +196,11 @@ func newAuthenticator(c *cache, config *api.ExecConfig, cluster *clientauthentic clock: clock.RealClock{}, }, - stdin: os.Stdin, - stderr: os.Stderr, - interactive: term.IsTerminal(int(os.Stdin.Fd())), - now: time.Now, - environ: os.Environ, + stdin: os.Stdin, + stderr: os.Stderr, + interactiveFunc: func() (bool, error) { return isInteractive(isTerminalFunc, config) }, + now: time.Now, + environ: os.Environ, defaultDialer: defaultDialer, connTracker: connTracker, @@ -213,6 +213,33 @@ func newAuthenticator(c *cache, config *api.ExecConfig, cluster *clientauthentic return c.put(key, a), nil } +func isInteractive(isTerminalFunc func(int) bool, config *api.ExecConfig) (bool, error) { + var shouldBeInteractive bool + switch config.InteractiveMode { + case api.NeverExecInteractiveMode: + shouldBeInteractive = false + case api.IfAvailableExecInteractiveMode: + shouldBeInteractive = !config.StdinUnavailable && isTerminalFunc(int(os.Stdin.Fd())) + case api.AlwaysExecInteractiveMode: + if !isTerminalFunc(int(os.Stdin.Fd())) { + return false, errors.New("standard input is not a terminal") + } + if config.StdinUnavailable { + suffix := "" + if len(config.StdinUnavailableMessage) > 0 { + // only print extra ": " if the user actually specified a message + suffix = fmt.Sprintf(": %s", config.StdinUnavailableMessage) + } + return false, fmt.Errorf("standard input is unavailable%s", suffix) + } + shouldBeInteractive = true + default: + return false, fmt.Errorf("unknown interactiveMode: %q", config.InteractiveMode) + } + + return shouldBeInteractive, nil +} + // Authenticator is a client credential provider that rotates credentials by executing a plugin. // The plugin input and output are defined by the API group client.authentication.k8s.io. type Authenticator struct { @@ -231,11 +258,11 @@ type Authenticator struct { installHint string // Stubbable for testing - stdin io.Reader - stderr io.Writer - interactive bool - now func() time.Time - environ func() []string + stdin io.Reader + stderr io.Writer + interactiveFunc func() (bool, error) + now func() time.Time + environ func() []string // defaultDialer is used for clients which don't specify a custom dialer defaultDialer *connrotation.Dialer @@ -376,10 +403,15 @@ func (a *Authenticator) maybeRefreshCreds(creds *credentials, r *clientauthentic // refreshCredsLocked executes the plugin and reads the credentials from // stdout. It must be called while holding the Authenticator's mutex. func (a *Authenticator) refreshCredsLocked(r *clientauthentication.Response) error { + interactive, err := a.interactiveFunc() + if err != nil { + return fmt.Errorf("exec plugin cannot support interactive mode: %w", err) + } + cred := &clientauthentication.ExecCredential{ Spec: clientauthentication.ExecCredentialSpec{ Response: r, - Interactive: a.interactive, + Interactive: interactive, }, } if a.provideClusterInfo { @@ -398,7 +430,7 @@ func (a *Authenticator) refreshCredsLocked(r *clientauthentication.Response) err cmd.Env = env cmd.Stderr = a.stderr cmd.Stdout = stdout - if a.interactive { + if interactive { cmd.Stdin = a.stdin } diff --git a/plugin/pkg/client/auth/exec/exec_test.go b/plugin/pkg/client/auth/exec/exec_test.go index d295391e..ca40eb0d 100644 --- a/plugin/pkg/client/auth/exec/exec_test.go +++ b/plugin/pkg/client/auth/exec/exec_test.go @@ -244,12 +244,25 @@ func TestCacheKey(t *testing.T) { APIVersion: "client.authentication.k8s.io/v1alpha1", } + // c7 should be the same as c6, except c7 has stdin marked as unavailable + c7 := &api.ExecConfig{ + Command: "foo-bar", + Args: []string{"1", "2"}, + Env: []api.ExecEnvVar{ + {Name: "3", Value: "4"}, + {Name: "5", Value: "6"}, + }, + APIVersion: "client.authentication.k8s.io/v1alpha1", + StdinUnavailable: true, + } + key1 := cacheKey(c1, c1c) key2 := cacheKey(c2, c2c) key3 := cacheKey(c3, c3c) key4 := cacheKey(c4, c4c) key5 := cacheKey(c5, c5c) key6 := cacheKey(c6, nil) + key7 := cacheKey(c7, nil) if key1 != key2 { t.Error("key1 and key2 didn't match") } @@ -268,6 +281,9 @@ func TestCacheKey(t *testing.T) { if key6 == key4 { t.Error("key6 and key4 matched") } + if key6 == key7 { + t.Error("key6 and key7 matched") + } } func compJSON(t *testing.T, got, want []byte) { @@ -290,23 +306,25 @@ func compJSON(t *testing.T, got, want []byte) { func TestRefreshCreds(t *testing.T) { tests := []struct { - name string - config api.ExecConfig - exitCode int - cluster *clientauthentication.Cluster - output string - interactive bool - response *clientauthentication.Response - wantInput string - wantCreds credentials - wantExpiry time.Time - wantErr bool - wantErrSubstr string + name string + config api.ExecConfig + stdinUnavailable bool + exitCode int + cluster *clientauthentication.Cluster + output string + isTerminal bool + response *clientauthentication.Response + wantInput string + wantCreds credentials + wantExpiry time.Time + wantErr bool + wantErrSubstr string }{ { name: "basic-request", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -325,9 +343,10 @@ func TestRefreshCreds(t *testing.T) { { name: "interactive", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, - interactive: true, + isTerminal: true, wantInput: `{ "kind":"ExecCredential", "apiVersion":"client.authentication.k8s.io/v1alpha1", @@ -347,7 +366,8 @@ func TestRefreshCreds(t *testing.T) { { name: "response", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, response: &clientauthentication.Response{ Header: map[string][]string{ @@ -381,7 +401,8 @@ func TestRefreshCreds(t *testing.T) { { name: "expiry", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -402,7 +423,8 @@ func TestRefreshCreds(t *testing.T) { { name: "no-group-version", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -420,7 +442,8 @@ func TestRefreshCreds(t *testing.T) { { name: "no-status", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -436,7 +459,8 @@ func TestRefreshCreds(t *testing.T) { { name: "no-creds", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -453,7 +477,8 @@ func TestRefreshCreds(t *testing.T) { { name: "TLS credentials", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -473,7 +498,8 @@ func TestRefreshCreds(t *testing.T) { { name: "bad TLS credentials", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -493,7 +519,8 @@ func TestRefreshCreds(t *testing.T) { { name: "cert but no key", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind":"ExecCredential", @@ -512,12 +539,207 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-basic-request", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind": "ExecCredential", "apiVersion": "client.authentication.k8s.io/v1beta1", - "spec": {} + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-never-interactive-mode", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.NeverExecInteractiveMode, + }, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-never-interactive-mode-and-stdin-unavailable", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.NeverExecInteractiveMode, + StdinUnavailable: true, + }, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-if-available-interactive-mode", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, + }, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-if-available-interactive-mode-and-stdin-unavailable", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, + StdinUnavailable: true, + }, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-if-available-interactive-mode-and-terminal", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, + }, + isTerminal: true, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": true + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-if-available-interactive-mode-and-terminal-and-stdin-unavailable", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, + StdinUnavailable: true, + }, + isTerminal: true, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": false + } + }`, + output: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "foo-bar" + } + }`, + wantCreds: credentials{token: "foo-bar"}, + }, + { + name: "beta-basic-request-with-always-interactive-mode", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.AlwaysExecInteractiveMode, + }, + wantErr: true, + wantErrSubstr: "exec plugin cannot support interactive mode: standard input is not a terminal", + }, + { + name: "beta-basic-request-with-always-interactive-mode-and-terminal-and-stdin-unavailable", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.AlwaysExecInteractiveMode, + StdinUnavailable: true, + }, + isTerminal: true, + wantErr: true, + wantErrSubstr: "exec plugin cannot support interactive mode: standard input is unavailable", + }, + { + name: "beta-basic-request-with-always-interactive-mode-and-terminal-and-stdin-unavailable-with-message", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.AlwaysExecInteractiveMode, + StdinUnavailable: true, + StdinUnavailableMessage: "some message", + }, + isTerminal: true, + wantErr: true, + wantErrSubstr: "exec plugin cannot support interactive mode: standard input is unavailable: some message", + }, + { + name: "beta-basic-request-with-always-interactive-mode-and-terminal", + config: api.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.AlwaysExecInteractiveMode, + }, + isTerminal: true, + wantInput: `{ + "kind": "ExecCredential", + "apiVersion": "client.authentication.k8s.io/v1beta1", + "spec": { + "interactive": true + } }`, output: `{ "kind": "ExecCredential", @@ -531,12 +753,15 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-expiry", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantInput: `{ "kind": "ExecCredential", "apiVersion": "client.authentication.k8s.io/v1beta1", - "spec": {} + "spec": { + "interactive": false + } }`, output: `{ "kind": "ExecCredential", @@ -552,7 +777,8 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-no-group-version", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, output: `{ "kind": "ExecCredential", @@ -565,7 +791,8 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-no-status", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, output: `{ "kind": "ExecCredential", @@ -576,7 +803,8 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-no-token", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, output: `{ "kind": "ExecCredential", @@ -588,9 +816,10 @@ func TestRefreshCreds(t *testing.T) { { name: "unknown-binary", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", - Command: "does not exist", - InstallHint: "some install hint", + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: "does not exist", + InstallHint: "some install hint", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, wantErr: true, wantErrSubstr: "some install hint", @@ -598,7 +827,8 @@ func TestRefreshCreds(t *testing.T) { { name: "binary-fails", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, exitCode: 73, wantErr: true, @@ -607,7 +837,8 @@ func TestRefreshCreds(t *testing.T) { { name: "alpha-with-cluster-is-ignored", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, cluster: &clientauthentication.Cluster{ Server: "foo", @@ -657,6 +888,7 @@ func TestRefreshCreds(t *testing.T) { config: api.ExecConfig{ APIVersion: "client.authentication.k8s.io/v1beta1", ProvideClusterInfo: true, + InteractiveMode: api.IfAvailableExecInteractiveMode, }, cluster: &clientauthentication.Cluster{ Server: "foo", @@ -693,7 +925,8 @@ func TestRefreshCreds(t *testing.T) { "audience": "snorlax" } } - } + }, + "interactive": false } }`, output: `{ @@ -708,7 +941,8 @@ func TestRefreshCreds(t *testing.T) { { name: "beta-with-cluster-and-without-provide-cluster-info-is-not-serialized", config: api.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, cluster: &clientauthentication.Cluster{ Server: "foo", @@ -733,7 +967,9 @@ func TestRefreshCreds(t *testing.T) { wantInput: `{ "kind":"ExecCredential", "apiVersion":"client.authentication.k8s.io/v1beta1", - "spec": {} + "spec": { + "interactive": false + } }`, output: `{ "kind": "ExecCredential", @@ -762,14 +998,13 @@ func TestRefreshCreds(t *testing.T) { }) } - a, err := newAuthenticator(newCache(), &c, test.cluster) + a, err := newAuthenticator(newCache(), func(_ int) bool { return test.isTerminal }, &c, test.cluster) if err != nil { t.Fatal(err) } stderr := &bytes.Buffer{} a.stderr = stderr - a.interactive = test.interactive a.environ = func() []string { return nil } if err := a.refreshCredsLocked(test.response); err != nil { @@ -837,10 +1072,11 @@ func TestRoundTripper(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(handler)) c := api.ExecConfig{ - Command: "./testdata/test-plugin.sh", - APIVersion: "client.authentication.k8s.io/v1alpha1", + Command: "./testdata/test-plugin.sh", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, } - a, err := newAuthenticator(newCache(), &c, nil) + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &c, nil) if err != nil { t.Fatal(err) } @@ -943,7 +1179,7 @@ func TestAuthorizationHeaderPresentCancelsExecAction(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - a, err := newAuthenticator(newCache(), &api.ExecConfig{ + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &api.ExecConfig{ Command: "./testdata/test-plugin.sh", APIVersion: "client.authentication.k8s.io/v1alpha1", }, nil) @@ -985,9 +1221,10 @@ func TestTLSCredentials(t *testing.T) { server.StartTLS() defer server.Close() - a, err := newAuthenticator(newCache(), &api.ExecConfig{ - Command: "./testdata/test-plugin.sh", - APIVersion: "client.authentication.k8s.io/v1alpha1", + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &api.ExecConfig{ + Command: "./testdata/test-plugin.sh", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: api.IfAvailableExecInteractiveMode, }, nil) if err != nil { t.Fatal(err) @@ -1078,7 +1315,7 @@ func TestConcurrentUpdateTransportConfig(t *testing.T) { Command: "./testdata/test-plugin.sh", APIVersion: "client.authentication.k8s.io/v1alpha1", } - a, err := newAuthenticator(newCache(), &c, nil) + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &c, nil) if err != nil { t.Fatal(err) } @@ -1141,11 +1378,12 @@ func TestInstallHintRateLimit(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { c := api.ExecConfig{ - Command: "does not exist", - APIVersion: "client.authentication.k8s.io/v1alpha1", - InstallHint: "some install hint", + Command: "does not exist", + APIVersion: "client.authentication.k8s.io/v1alpha1", + InstallHint: "some install hint", + InteractiveMode: api.IfAvailableExecInteractiveMode, } - a, err := newAuthenticator(newCache(), &c, nil) + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &c, nil) if err != nil { t.Fatal(err) } diff --git a/plugin/pkg/client/auth/exec/metrics_test.go b/plugin/pkg/client/auth/exec/metrics_test.go index 3c3c8f26..80d84480 100644 --- a/plugin/pkg/client/auth/exec/metrics_test.go +++ b/plugin/pkg/client/auth/exec/metrics_test.go @@ -140,9 +140,10 @@ func TestCallsMetric(t *testing.T) { {Name: "TEST_EXIT_CODE", Value: fmt.Sprintf("%d", exitCode)}, {Name: "TEST_OUTPUT", Value: goodOutput}, }, + InteractiveMode: api.IfAvailableExecInteractiveMode, } - a, err := newAuthenticator(newCache(), &c, nil) + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &c, nil) if err != nil { t.Fatal(err) } @@ -171,10 +172,11 @@ func TestCallsMetric(t *testing.T) { // metric values. refreshCreds := func(command string) { c := api.ExecConfig{ - Command: "does not exist", - APIVersion: "client.authentication.k8s.io/v1beta1", + Command: "does not exist", + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: api.IfAvailableExecInteractiveMode, } - a, err := newAuthenticator(newCache(), &c, nil) + a, err := newAuthenticator(newCache(), func(_ int) bool { return false }, &c, nil) if err != nil { t.Fatal(err) } diff --git a/rest/config_test.go b/rest/config_test.go index bd857e0a..7650928d 100644 --- a/rest/config_test.go +++ b/rest/config_test.go @@ -626,7 +626,7 @@ func TestConfigSprint(t *testing.T) { Proxy: fakeProxyFunc, } want := fmt.Sprintf( - `&rest.Config{Host:"localhost:8080", APIPath:"v1", ContentConfig:rest.ContentConfig{AcceptContentTypes:"application/json", ContentType:"application/json", GroupVersion:(*schema.GroupVersion)(nil), NegotiatedSerializer:runtime.NegotiatedSerializer(nil)}, Username:"gopher", Password:"--- REDACTED ---", BearerToken:"--- REDACTED ---", BearerTokenFile:"", Impersonate:rest.ImpersonationConfig{UserName:"gopher2", Groups:[]string(nil), Extra:map[string][]string(nil)}, AuthProvider:api.AuthProviderConfig{Name: "gopher", Config: map[string]string{--- REDACTED ---}}, AuthConfigPersister:rest.AuthProviderConfigPersister(--- REDACTED ---), ExecProvider:api.ExecConfig{Command: "sudo", Args: []string{"--- REDACTED ---"}, Env: []ExecEnvVar{--- REDACTED ---}, APIVersion: "", ProvideClusterInfo: true, Config: runtime.Object(--- REDACTED ---)}, TLSClientConfig:rest.sanitizedTLSClientConfig{Insecure:false, ServerName:"", CertFile:"a.crt", KeyFile:"a.key", CAFile:"", CertData:[]uint8{0x2d, 0x2d, 0x2d, 0x20, 0x54, 0x52, 0x55, 0x4e, 0x43, 0x41, 0x54, 0x45, 0x44, 0x20, 0x2d, 0x2d, 0x2d}, KeyData:[]uint8{0x2d, 0x2d, 0x2d, 0x20, 0x52, 0x45, 0x44, 0x41, 0x43, 0x54, 0x45, 0x44, 0x20, 0x2d, 0x2d, 0x2d}, CAData:[]uint8(nil), NextProtos:[]string{"h2", "http/1.1"}}, UserAgent:"gobot", DisableCompression:false, Transport:(*rest.fakeRoundTripper)(%p), WrapTransport:(transport.WrapperFunc)(%p), QPS:1, Burst:2, RateLimiter:(*rest.fakeLimiter)(%p), WarningHandler:rest.fakeWarningHandler{}, Timeout:3000000000, Dial:(func(context.Context, string, string) (net.Conn, error))(%p), Proxy:(func(*http.Request) (*url.URL, error))(%p)}`, + `&rest.Config{Host:"localhost:8080", APIPath:"v1", ContentConfig:rest.ContentConfig{AcceptContentTypes:"application/json", ContentType:"application/json", GroupVersion:(*schema.GroupVersion)(nil), NegotiatedSerializer:runtime.NegotiatedSerializer(nil)}, Username:"gopher", Password:"--- REDACTED ---", BearerToken:"--- REDACTED ---", BearerTokenFile:"", Impersonate:rest.ImpersonationConfig{UserName:"gopher2", Groups:[]string(nil), Extra:map[string][]string(nil)}, AuthProvider:api.AuthProviderConfig{Name: "gopher", Config: map[string]string{--- REDACTED ---}}, AuthConfigPersister:rest.AuthProviderConfigPersister(--- REDACTED ---), ExecProvider:api.ExecConfig{Command: "sudo", Args: []string{"--- REDACTED ---"}, Env: []ExecEnvVar{--- REDACTED ---}, APIVersion: "", ProvideClusterInfo: true, Config: runtime.Object(--- REDACTED ---), StdinUnavailable: false}, TLSClientConfig:rest.sanitizedTLSClientConfig{Insecure:false, ServerName:"", CertFile:"a.crt", KeyFile:"a.key", CAFile:"", CertData:[]uint8{0x2d, 0x2d, 0x2d, 0x20, 0x54, 0x52, 0x55, 0x4e, 0x43, 0x41, 0x54, 0x45, 0x44, 0x20, 0x2d, 0x2d, 0x2d}, KeyData:[]uint8{0x2d, 0x2d, 0x2d, 0x20, 0x52, 0x45, 0x44, 0x41, 0x43, 0x54, 0x45, 0x44, 0x20, 0x2d, 0x2d, 0x2d}, CAData:[]uint8(nil), NextProtos:[]string{"h2", "http/1.1"}}, UserAgent:"gobot", DisableCompression:false, Transport:(*rest.fakeRoundTripper)(%p), WrapTransport:(transport.WrapperFunc)(%p), QPS:1, Burst:2, RateLimiter:(*rest.fakeLimiter)(%p), WarningHandler:rest.fakeWarningHandler{}, Timeout:3000000000, Dial:(func(context.Context, string, string) (net.Conn, error))(%p), Proxy:(func(*http.Request) (*url.URL, error))(%p)}`, c.Transport, fakeWrapperFunc, c.RateLimiter, fakeDialFunc, fakeProxyFunc, ) diff --git a/tools/clientcmd/api/types.go b/tools/clientcmd/api/types.go index 24f46923..31716abf 100644 --- a/tools/clientcmd/api/types.go +++ b/tools/clientcmd/api/types.go @@ -245,6 +245,33 @@ type ExecConfig struct { // to be stored directly in the kubeconfig. // +k8s:conversion-gen=false Config runtime.Object + + // InteractiveMode determines this plugin's relationship with standard input. Valid + // values are "Never" (this exec plugin never uses standard input), "IfAvailable" (this + // exec plugin wants to use standard input if it is available), or "Always" (this exec + // plugin requires standard input to function). See ExecInteractiveMode values for more + // details. + // + // If APIVersion is client.authentication.k8s.io/v1alpha1 or + // client.authentication.k8s.io/v1beta1, then this field is optional and defaults + // to "IfAvailable" when unset. Otherwise, this field is required. + // +optional + InteractiveMode ExecInteractiveMode + + // StdinUnavailable indicates whether the exec authenticator can pass standard + // input through to this exec plugin. For example, a higher level entity might be using + // standard input for something else and therefore it would not be safe for the exec + // plugin to use standard input. This is kept here in order to keep all of the exec configuration + // together, but it is never serialized. + // +k8s:conversion-gen=false + StdinUnavailable bool + + // StdinUnavailableMessage is an optional message to be displayed when the exec authenticator + // cannot successfully run this exec plugin because it needs to use standard input and + // StdinUnavailable is true. For example, a process that is already using standard input to + // read user instructions might set this to "used by my-program to read user instructions". + // +k8s:conversion-gen=false + StdinUnavailableMessage string } var _ fmt.Stringer = new(ExecConfig) @@ -271,7 +298,7 @@ func (c ExecConfig) String() string { if c.Config != nil { config = "runtime.Object(--- REDACTED ---)" } - return fmt.Sprintf("api.ExecConfig{Command: %q, Args: %#v, Env: %s, APIVersion: %q, ProvideClusterInfo: %t, Config: %s}", c.Command, args, env, c.APIVersion, c.ProvideClusterInfo, config) + return fmt.Sprintf("api.ExecConfig{Command: %q, Args: %#v, Env: %s, APIVersion: %q, ProvideClusterInfo: %t, Config: %s, StdinUnavailable: %t}", c.Command, args, env, c.APIVersion, c.ProvideClusterInfo, config, c.StdinUnavailable) } // ExecEnvVar is used for setting environment variables when executing an exec-based @@ -281,6 +308,26 @@ type ExecEnvVar struct { Value string `json:"value"` } +// ExecInteractiveMode is a string that describes an exec plugin's relationship with standard input. +type ExecInteractiveMode string + +const ( + // NeverExecInteractiveMode declares that this exec plugin never needs to use standard + // input, and therefore the exec plugin will be run regardless of whether standard input is + // available for user input. + NeverExecInteractiveMode ExecInteractiveMode = "Never" + // IfAvailableExecInteractiveMode declares that this exec plugin would like to use standard input + // if it is available, but can still operate if standard input is not available. Therefore, the + // exec plugin will be run regardless of whether stdin is available for user input. If standard + // input is available for user input, then it will be provided to this exec plugin. + IfAvailableExecInteractiveMode ExecInteractiveMode = "IfAvailable" + // AlwaysExecInteractiveMode declares that this exec plugin requires standard input in order to + // run, and therefore the exec plugin will only be run if standard input is available for user + // input. If standard input is not available for user input, then the exec plugin will not be run + // and an error will be returned by the exec plugin runner. + AlwaysExecInteractiveMode ExecInteractiveMode = "Always" +) + // NewConfig is a convenience function that returns a new Config object with non-nil maps func NewConfig() *Config { return &Config{ diff --git a/tools/clientcmd/api/v1/defaults.go b/tools/clientcmd/api/v1/defaults.go new file mode 100644 index 00000000..bf513dc7 --- /dev/null +++ b/tools/clientcmd/api/v1/defaults.go @@ -0,0 +1,37 @@ +/* +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 v1 + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +func addDefaultingFuncs(scheme *runtime.Scheme) error { + return RegisterDefaults(scheme) +} + +func SetDefaults_ExecConfig(exec *ExecConfig) { + if len(exec.InteractiveMode) == 0 { + switch exec.APIVersion { + case "client.authentication.k8s.io/v1beta1", "client.authentication.k8s.io/v1alpha1": + // default to IfAvailableExecInteractiveMode for backwards compatibility + exec.InteractiveMode = IfAvailableExecInteractiveMode + default: + // require other versions to explicitly declare whether they want stdin or not + } + } +} diff --git a/tools/clientcmd/api/v1/defaults_test.go b/tools/clientcmd/api/v1/defaults_test.go new file mode 100644 index 00000000..03fdb042 --- /dev/null +++ b/tools/clientcmd/api/v1/defaults_test.go @@ -0,0 +1,84 @@ +/* +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 v1 + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSetDefaults_Config(t *testing.T) { + tests := []struct { + name string + in, wantOut *ExecConfig + }{ + { + name: "alpha exec API with empty interactive mode", + in: &ExecConfig{APIVersion: "client.authentication.k8s.io/v1alpha1"}, + wantOut: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: IfAvailableExecInteractiveMode, + }, + }, + { + name: "beta exec API with empty interactive mode", + in: &ExecConfig{APIVersion: "client.authentication.k8s.io/v1beta1"}, + wantOut: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: IfAvailableExecInteractiveMode, + }, + }, + { + name: "alpha exec API with set interactive mode", + in: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: NeverExecInteractiveMode, + }, + wantOut: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1alpha1", + InteractiveMode: NeverExecInteractiveMode, + }, + }, + { + name: "beta exec API with set interactive mode", + in: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: NeverExecInteractiveMode, + }, + wantOut: &ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + InteractiveMode: NeverExecInteractiveMode, + }, + }, + { + name: "v1 exec API with empty interactive mode", + in: &ExecConfig{APIVersion: "client.authentication.k8s.io/v1"}, + wantOut: &ExecConfig{APIVersion: "client.authentication.k8s.io/v1"}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + gotOut := test.in.DeepCopy() + SetDefaults_ExecConfig(gotOut) + if diff := cmp.Diff(test.wantOut, gotOut); diff != "" { + t.Errorf("unexpected defaulting; -want, +got:\n %s", diff) + } + }) + } +} diff --git a/tools/clientcmd/api/v1/doc.go b/tools/clientcmd/api/v1/doc.go index ba5572ab..3ccdebc1 100644 --- a/tools/clientcmd/api/v1/doc.go +++ b/tools/clientcmd/api/v1/doc.go @@ -16,5 +16,6 @@ limitations under the License. // +k8s:conversion-gen=k8s.io/client-go/tools/clientcmd/api // +k8s:deepcopy-gen=package +// +k8s:defaulter-gen=Kind package v1 diff --git a/tools/clientcmd/api/v1/register.go b/tools/clientcmd/api/v1/register.go index 24f6284c..4a4d4a55 100644 --- a/tools/clientcmd/api/v1/register.go +++ b/tools/clientcmd/api/v1/register.go @@ -37,7 +37,7 @@ func init() { // We only register manually written functions here. The registration of the // generated functions takes place in the generated files. The separation // makes the code compile even when the generated files are missing. - localSchemeBuilder.Register(addKnownTypes) + localSchemeBuilder.Register(addKnownTypes, addDefaultingFuncs) } func addKnownTypes(scheme *runtime.Scheme) error { diff --git a/tools/clientcmd/api/v1/types.go b/tools/clientcmd/api/v1/types.go index 8c29b39c..7e835103 100644 --- a/tools/clientcmd/api/v1/types.go +++ b/tools/clientcmd/api/v1/types.go @@ -221,6 +221,18 @@ type ExecConfig struct { // to false. Package k8s.io/client-go/tools/auth/exec provides helper methods for // reading this environment variable. ProvideClusterInfo bool `json:"provideClusterInfo"` + + // InteractiveMode determines this plugin's relationship with standard input. Valid + // values are "Never" (this exec plugin never uses standard input), "IfAvailable" (this + // exec plugin wants to use standard input if it is available), or "Always" (this exec + // plugin requires standard input to function). See ExecInteractiveMode values for more + // details. + // + // If APIVersion is client.authentication.k8s.io/v1alpha1 or + // client.authentication.k8s.io/v1beta1, then this field is optional and defaults + // to "IfAvailable" when unset. Otherwise, this field is required. + //+optional + InteractiveMode ExecInteractiveMode `json:"interactiveMode,omitempty"` } // ExecEnvVar is used for setting environment variables when executing an exec-based @@ -229,3 +241,23 @@ type ExecEnvVar struct { Name string `json:"name"` Value string `json:"value"` } + +// ExecInteractiveMode is a string that describes an exec plugin's relationship with standard input. +type ExecInteractiveMode string + +const ( + // NeverExecInteractiveMode declares that this exec plugin never needs to use standard + // input, and therefore the exec plugin will be run regardless of whether standard input is + // available for user input. + NeverExecInteractiveMode ExecInteractiveMode = "Never" + // IfAvailableExecInteractiveMode declares that this exec plugin would like to use standard input + // if it is available, but can still operate if standard input is not available. Therefore, the + // exec plugin will be run regardless of whether stdin is available for user input. If standard + // input is available for user input, then it will be provided to this exec plugin. + IfAvailableExecInteractiveMode ExecInteractiveMode = "IfAvailable" + // AlwaysExecInteractiveMode declares that this exec plugin requires standard input in order to + // run, and therefore the exec plugin will only be run if standard input is available for user + // input. If standard input is not available for user input, then the exec plugin will not be run + // and an error will be returned by the exec plugin runner. + AlwaysExecInteractiveMode ExecInteractiveMode = "Always" +) diff --git a/tools/clientcmd/api/v1/zz_generated.conversion.go b/tools/clientcmd/api/v1/zz_generated.conversion.go index 26e96529..13dfa903 100644 --- a/tools/clientcmd/api/v1/zz_generated.conversion.go +++ b/tools/clientcmd/api/v1/zz_generated.conversion.go @@ -376,6 +376,7 @@ func autoConvert_v1_ExecConfig_To_api_ExecConfig(in *ExecConfig, out *api.ExecCo out.APIVersion = in.APIVersion out.InstallHint = in.InstallHint out.ProvideClusterInfo = in.ProvideClusterInfo + out.InteractiveMode = api.ExecInteractiveMode(in.InteractiveMode) return nil } @@ -392,6 +393,9 @@ func autoConvert_api_ExecConfig_To_v1_ExecConfig(in *api.ExecConfig, out *ExecCo out.InstallHint = in.InstallHint out.ProvideClusterInfo = in.ProvideClusterInfo // INFO: in.Config opted out of conversion generation + out.InteractiveMode = ExecInteractiveMode(in.InteractiveMode) + // INFO: in.StdinUnavailable opted out of conversion generation + // INFO: in.StdinUnavailableMessage opted out of conversion generation return nil } diff --git a/tools/clientcmd/api/v1/zz_generated.defaults.go b/tools/clientcmd/api/v1/zz_generated.defaults.go new file mode 100644 index 00000000..aae46903 --- /dev/null +++ b/tools/clientcmd/api/v1/zz_generated.defaults.go @@ -0,0 +1,42 @@ +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by defaulter-gen. DO NOT EDIT. + +package v1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// RegisterDefaults adds defaulters functions to the given scheme. +// Public to allow building arbitrary schemes. +// All generated defaulters are covering - they call all nested defaulters. +func RegisterDefaults(scheme *runtime.Scheme) error { + scheme.AddTypeDefaultingFunc(&Config{}, func(obj interface{}) { SetObjectDefaults_Config(obj.(*Config)) }) + return nil +} + +func SetObjectDefaults_Config(in *Config) { + for i := range in.AuthInfos { + a := &in.AuthInfos[i] + if a.AuthInfo.Exec != nil { + SetDefaults_ExecConfig(a.AuthInfo.Exec) + } + } +} diff --git a/tools/clientcmd/client_config_test.go b/tools/clientcmd/client_config_test.go index 9d232f4f..a770dd61 100644 --- a/tools/clientcmd/client_config_test.go +++ b/tools/clientcmd/client_config_test.go @@ -630,9 +630,10 @@ func TestCreateAuthConfigExecInstallHintCleanup(t *testing.T) { clientBuilder := NewNonInteractiveClientConfig(*config, "clean", &ConfigOverrides{ AuthInfo: clientcmdapi.AuthInfo{ Exec: &clientcmdapi.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1alpha1", - Command: "some-command", - InstallHint: "some install hint with \x1b[1mcontrol chars\x1b[0m\nand a newline", + APIVersion: "client.authentication.k8s.io/v1alpha1", + Command: "some-command", + InstallHint: "some install hint with \x1b[1mcontrol chars\x1b[0m\nand a newline", + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, }, }, nil) diff --git a/tools/clientcmd/validation.go b/tools/clientcmd/validation.go index f77ef04f..8541a08b 100644 --- a/tools/clientcmd/validation.go +++ b/tools/clientcmd/validation.go @@ -308,6 +308,14 @@ func validateAuthInfo(authInfoName string, authInfo clientcmdapi.AuthInfo) []err validationErrors = append(validationErrors, fmt.Errorf("env variable name must be specified for %v to use exec authentication plugin", authInfoName)) } } + switch authInfo.Exec.InteractiveMode { + case "": + validationErrors = append(validationErrors, fmt.Errorf("interactiveMode must be specified for %v to use exec authentication plugin", authInfoName)) + case clientcmdapi.NeverExecInteractiveMode, clientcmdapi.IfAvailableExecInteractiveMode, clientcmdapi.AlwaysExecInteractiveMode: + // These are valid + default: + validationErrors = append(validationErrors, fmt.Errorf("invalid interactiveMode for %v: %q", authInfoName, authInfo.Exec.InteractiveMode)) + } } // authPath also provides information for the client to identify the server, so allow multiple auth methods in that case diff --git a/tools/clientcmd/validation_test.go b/tools/clientcmd/validation_test.go index caf67054..676d1989 100644 --- a/tools/clientcmd/validation_test.go +++ b/tools/clientcmd/validation_test.go @@ -377,6 +377,7 @@ func TestValidateAuthInfoExec(t *testing.T) { Env: []clientcmdapi.ExecEnvVar{ {Name: "foo", Value: "bar"}, }, + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, } test := configValidationTest{ @@ -391,7 +392,8 @@ func TestValidateAuthInfoExecNoVersion(t *testing.T) { config := clientcmdapi.NewConfig() config.AuthInfos["user"] = &clientcmdapi.AuthInfo{ Exec: &clientcmdapi.ExecConfig{ - Command: "/bin/example", + Command: "/bin/example", + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, } test := configValidationTest{ @@ -409,7 +411,8 @@ func TestValidateAuthInfoExecNoCommand(t *testing.T) { config := clientcmdapi.NewConfig() config.AuthInfos["user"] = &clientcmdapi.AuthInfo{ Exec: &clientcmdapi.ExecConfig{ - APIVersion: "clientauthentication.k8s.io/v1alpha1", + APIVersion: "clientauthentication.k8s.io/v1alpha1", + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, } test := configValidationTest{ @@ -430,8 +433,9 @@ func TestValidateAuthInfoExecWithAuthProvider(t *testing.T) { Name: "oidc", }, Exec: &clientcmdapi.ExecConfig{ - Command: "/bin/example", - APIVersion: "clientauthentication.k8s.io/v1alpha1", + Command: "/bin/example", + APIVersion: "clientauthentication.k8s.io/v1alpha1", + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, } test := configValidationTest{ @@ -454,6 +458,7 @@ func TestValidateAuthInfoExecNoEnv(t *testing.T) { Env: []clientcmdapi.ExecEnvVar{ {Name: "foo", Value: ""}, }, + InteractiveMode: clientcmdapi.IfAvailableExecInteractiveMode, }, } test := configValidationTest{ @@ -464,6 +469,45 @@ func TestValidateAuthInfoExecNoEnv(t *testing.T) { test.testConfig(t) } +func TestValidateAuthInfoExecInteractiveModeMissing(t *testing.T) { + config := clientcmdapi.NewConfig() + config.AuthInfos["user"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "/bin/example", + APIVersion: "clientauthentication.k8s.io/v1alpha1", + }, + } + test := configValidationTest{ + config: config, + expectedErrorSubstring: []string{ + "interactiveMode must be specified for user to use exec authentication plugin", + }, + } + + test.testAuthInfo("user", t) + test.testConfig(t) +} + +func TestValidateAuthInfoExecInteractiveModeInvalid(t *testing.T) { + config := clientcmdapi.NewConfig() + config.AuthInfos["user"] = &clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + Command: "/bin/example", + APIVersion: "clientauthentication.k8s.io/v1alpha1", + InteractiveMode: "invalid", + }, + } + test := configValidationTest{ + config: config, + expectedErrorSubstring: []string{ + `invalid interactiveMode for user: "invalid"`, + }, + } + + test.testAuthInfo("user", t) + test.testConfig(t) +} + type configValidationTest struct { config *clientcmdapi.Config expectedErrorSubstring []string