Infer apiVersion from KUBERNETES_EXEC_INFO environment variable (#1162)

* Infer apiVersion from KUBERNETES_EXEC_INFO

* Test client.authentication.k8s.io/v1

* Set --exec-interactive-mode

* Set --exec-interactive-mode=Never

* Fix comments
This commit is contained in:
Hidetake Iwata
2024-11-03 17:21:25 +09:00
committed by GitHub
parent f1f2a37adc
commit 0e9a39a571
10 changed files with 304 additions and 57 deletions

View File

@@ -0,0 +1,90 @@
// Code generated by mockery v2.46.3. DO NOT EDIT.
package reader_mock
import (
credentialplugin "github.com/int128/kubelogin/pkg/credentialplugin"
mock "github.com/stretchr/testify/mock"
)
// MockInterface is an autogenerated mock type for the Interface type
type MockInterface struct {
mock.Mock
}
type MockInterface_Expecter struct {
mock *mock.Mock
}
func (_m *MockInterface) EXPECT() *MockInterface_Expecter {
return &MockInterface_Expecter{mock: &_m.Mock}
}
// Read provides a mock function with given fields:
func (_m *MockInterface) Read() (credentialplugin.Input, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 credentialplugin.Input
var r1 error
if rf, ok := ret.Get(0).(func() (credentialplugin.Input, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() credentialplugin.Input); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(credentialplugin.Input)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockInterface_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type MockInterface_Read_Call struct {
*mock.Call
}
// Read is a helper method to define mock.On call
func (_e *MockInterface_Expecter) Read() *MockInterface_Read_Call {
return &MockInterface_Read_Call{Call: _e.mock.On("Read")}
}
func (_c *MockInterface_Read_Call) Run(run func()) *MockInterface_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockInterface_Read_Call) Return(_a0 credentialplugin.Input, _a1 error) *MockInterface_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockInterface_Read_Call) RunAndReturn(run func() (credentialplugin.Input, error)) *MockInterface_Read_Call {
_c.Call.Return(run)
return _c
}
// NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockInterface(t interface {
mock.TestingT
Cleanup(func())
}) *MockInterface {
mock := &MockInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,39 @@
// Package reader provides a loader for the credential plugin.
package reader
import (
"encoding/json"
"fmt"
"os"
"github.com/google/wire"
"github.com/int128/kubelogin/pkg/credentialplugin"
"k8s.io/client-go/pkg/apis/clientauthentication"
)
var Set = wire.NewSet(
wire.Struct(new(Reader), "*"),
wire.Bind(new(Interface), new(*Reader)),
)
type Interface interface {
Read() (credentialplugin.Input, error)
}
type Reader struct{}
// Read parses the environment variable KUBERNETES_EXEC_INFO.
// If the environment variable is not given by kubectl, Read returns a zero value.
func (r Reader) Read() (credentialplugin.Input, error) {
execInfo := os.Getenv("KUBERNETES_EXEC_INFO")
if execInfo == "" {
return credentialplugin.Input{}, nil
}
var execCredential clientauthentication.ExecCredential
if err := json.Unmarshal([]byte(execInfo), &execCredential); err != nil {
return credentialplugin.Input{}, fmt.Errorf("invalid KUBERNETES_EXEC_INFO: %w", err)
}
return credentialplugin.Input{
ClientAuthenticationAPIVersion: execCredential.APIVersion,
}, nil
}

View File

@@ -0,0 +1,44 @@
package reader
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/int128/kubelogin/pkg/credentialplugin"
)
func TestReader_Read(t *testing.T) {
var reader Reader
t.Run("KUBERNETES_EXEC_INFO is empty", func(t *testing.T) {
input, err := reader.Read()
if err != nil {
t.Errorf("Read returned error: %v", err)
}
want := credentialplugin.Input{}
if diff := cmp.Diff(want, input); diff != "" {
t.Errorf("input mismatch (-want +got):\n%s", diff)
}
})
t.Run("KUBERNETES_EXEC_INFO is invalid JSON", func(t *testing.T) {
t.Setenv("KUBERNETES_EXEC_INFO", "invalid")
_, err := reader.Read()
if err == nil {
t.Errorf("Read wants error but no error")
}
})
t.Run("KUBERNETES_EXEC_INFO is v1", func(t *testing.T) {
t.Setenv(
"KUBERNETES_EXEC_INFO",
`{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1","spec":{"interactive":true}}`,
)
input, err := reader.Read()
if err != nil {
t.Errorf("Read returned error: %v", err)
}
want := credentialplugin.Input{ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1"}
if diff := cmp.Diff(want, input); diff != "" {
t.Errorf("input mismatch (-want +got):\n%s", diff)
}
})
}

View File

@@ -3,8 +3,15 @@ package credentialplugin
import "time"
// Input represents an input object of the credential plugin.
// This may be a zero value if the input is not available.
type Input struct {
ClientAuthenticationAPIVersion string
}
// Output represents an output object of the credential plugin.
type Output struct {
Token string
Expiry time.Time
Token string
Expiry time.Time
ClientAuthenticationAPIVersion string
}

View File

@@ -1,4 +1,4 @@
// Package writer provides a writer for a credential plugin.
// Package writer provides a writer for the credential plugin.
package writer
import (
@@ -9,6 +9,7 @@ import (
"github.com/int128/kubelogin/pkg/credentialplugin"
"github.com/int128/kubelogin/pkg/infrastructure/stdio"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
)
@@ -27,19 +28,44 @@ type Writer struct {
// Write writes the ExecCredential to standard output for kubectl.
func (w *Writer) Write(out credentialplugin.Output) error {
ec := &clientauthenticationv1beta1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: "client.authentication.k8s.io/v1beta1",
Kind: "ExecCredential",
},
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
Token: out.Token,
ExpirationTimestamp: &metav1.Time{Time: out.Expiry},
},
execCredential, err := generateExecCredential(out)
if err != nil {
return fmt.Errorf("generate ExecCredential: %w", err)
}
e := json.NewEncoder(w.Stdout)
if err := e.Encode(ec); err != nil {
return fmt.Errorf("could not write the ExecCredential: %w", err)
if err := json.NewEncoder(w.Stdout).Encode(execCredential); err != nil {
return fmt.Errorf("write ExecCredential: %w", err)
}
return nil
}
func generateExecCredential(out credentialplugin.Output) (any, error) {
switch out.ClientAuthenticationAPIVersion {
// If the API version is not available, fall back to v1beta1.
case clientauthenticationv1beta1.SchemeGroupVersion.String(), "":
return &clientauthenticationv1beta1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(),
Kind: "ExecCredential",
},
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
Token: out.Token,
ExpirationTimestamp: &metav1.Time{Time: out.Expiry},
},
}, nil
case clientauthenticationv1.SchemeGroupVersion.String():
return &clientauthenticationv1.ExecCredential{
TypeMeta: metav1.TypeMeta{
APIVersion: clientauthenticationv1.SchemeGroupVersion.String(),
Kind: "ExecCredential",
},
Status: &clientauthenticationv1.ExecCredentialStatus{
Token: out.Token,
ExpirationTimestamp: &metav1.Time{Time: out.Expiry},
},
}, nil
default:
return nil, fmt.Errorf("unknown apiVersion: %s", out.ClientAuthenticationAPIVersion)
}
}

View File

@@ -7,7 +7,8 @@ package di
import (
"github.com/google/wire"
"github.com/int128/kubelogin/pkg/cmd"
"github.com/int128/kubelogin/pkg/credentialplugin/writer"
credentialpluginreader "github.com/int128/kubelogin/pkg/credentialplugin/reader"
credentialpluginwriter "github.com/int128/kubelogin/pkg/credentialplugin/writer"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/infrastructure/clock"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
@@ -55,7 +56,8 @@ func NewCmdForHeadless(clock.Interface, stdio.Stdin, stdio.Stdout, logger.Interf
repository.Set,
client.Set,
loader.Set,
writer.Set,
credentialpluginreader.Set,
credentialpluginwriter.Set,
)
return nil
}

View File

@@ -8,6 +8,7 @@ package di
import (
"github.com/int128/kubelogin/pkg/cmd"
reader2 "github.com/int128/kubelogin/pkg/credentialplugin/reader"
writer2 "github.com/int128/kubelogin/pkg/credentialplugin/writer"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/infrastructure/clock"
@@ -96,15 +97,17 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout
Logger: loggerInterface,
}
repositoryRepository := &repository.Repository{}
reader3 := &reader2.Reader{}
writer3 := &writer2.Writer{
Stdout: stdout,
}
getToken := &credentialplugin.GetToken{
Authentication: authenticationAuthentication,
TokenCacheRepository: repositoryRepository,
Writer: writer3,
Logger: loggerInterface,
Clock: clockInterface,
Authentication: authenticationAuthentication,
TokenCacheRepository: repositoryRepository,
CredentialPluginReader: reader3,
CredentialPluginWriter: writer3,
Logger: loggerInterface,
Clock: clockInterface,
}
cmdGetToken := &cmd.GetToken{
GetToken: getToken,

View File

@@ -9,7 +9,8 @@ import (
"github.com/google/wire"
"github.com/int128/kubelogin/pkg/credentialplugin"
"github.com/int128/kubelogin/pkg/credentialplugin/writer"
credentialpluginreader "github.com/int128/kubelogin/pkg/credentialplugin/reader"
credentialpluginwriter "github.com/int128/kubelogin/pkg/credentialplugin/writer"
"github.com/int128/kubelogin/pkg/infrastructure/clock"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
@@ -38,16 +39,23 @@ type Input struct {
}
type GetToken struct {
Authentication authentication.Interface
TokenCacheRepository repository.Interface
Writer writer.Interface
Logger logger.Interface
Clock clock.Interface
Authentication authentication.Interface
TokenCacheRepository repository.Interface
CredentialPluginReader credentialpluginreader.Interface
CredentialPluginWriter credentialpluginwriter.Interface
Logger logger.Interface
Clock clock.Interface
}
func (u *GetToken) Do(ctx context.Context, in Input) error {
u.Logger.V(1).Infof("WARNING: log may contain your secrets such as token or password")
credentialPluginInput, err := u.CredentialPluginReader.Read()
if err != nil {
return fmt.Errorf("could not read the input of credential plugin: %w", err)
}
u.Logger.V(1).Infof("credential plugin is called with apiVersion: %s", credentialPluginInput.ClientAuthenticationAPIVersion)
u.Logger.V(1).Infof("finding a token from cache directory %s", in.TokenCacheDir)
tokenCacheKey := tokencache.Key{
Provider: in.Provider,
@@ -88,10 +96,11 @@ func (u *GetToken) Do(ctx context.Context, in Input) error {
if !claims.IsExpired(u.Clock) {
u.Logger.V(1).Infof("you already have a valid token until %s", claims.Expiry)
out := credentialplugin.Output{
Token: cachedTokenSet.IDToken,
Expiry: claims.Expiry,
Token: cachedTokenSet.IDToken,
Expiry: claims.Expiry,
ClientAuthenticationAPIVersion: credentialPluginInput.ClientAuthenticationAPIVersion,
}
if err := u.Writer.Write(out); err != nil {
if err := u.CredentialPluginWriter.Write(out); err != nil {
return fmt.Errorf("could not write the token to client-go: %w", err)
}
return nil
@@ -122,10 +131,11 @@ func (u *GetToken) Do(ctx context.Context, in Input) error {
}
u.Logger.V(1).Infof("writing the token to client-go")
out := credentialplugin.Output{
Token: authenticationOutput.TokenSet.IDToken,
Expiry: idTokenClaims.Expiry,
Token: authenticationOutput.TokenSet.IDToken,
Expiry: idTokenClaims.Expiry,
ClientAuthenticationAPIVersion: credentialPluginInput.ClientAuthenticationAPIVersion,
}
if err := u.Writer.Write(out); err != nil {
if err := u.CredentialPluginWriter.Write(out); err != nil {
return fmt.Errorf("could not write the token to client-go: %w", err)
}
return nil

View File

@@ -7,6 +7,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/credentialplugin/reader_mock"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/credentialplugin/writer_mock"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/tokencache/repository_mock"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/authentication_mock"
@@ -40,8 +41,12 @@ func TestGetToken_Do(t *testing.T) {
RefreshToken: "YOUR_REFRESH_TOKEN",
}
issuedOutput := credentialplugin.Output{
Token: issuedIDToken,
Expiry: expiryTime,
Token: issuedIDToken,
Expiry: expiryTime,
ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1",
}
credentialpluginInput := credentialplugin.Input{
ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1",
}
grantOptionSet := authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
@@ -84,16 +89,21 @@ func TestGetToken_Do(t *testing.T) {
mockRepository.EXPECT().
Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet).
Return(nil)
mockReader := reader_mock.NewMockInterface(t)
mockReader.EXPECT().
Read().
Return(credentialpluginInput, nil)
mockWriter := writer_mock.NewMockInterface(t)
mockWriter.EXPECT().
Write(issuedOutput).
Return(nil)
u := GetToken{
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
Writer: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
CredentialPluginReader: mockReader,
CredentialPluginWriter: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
}
if err := u.Do(ctx, in); err != nil {
t.Errorf("Do returned error: %+v", err)
@@ -140,16 +150,21 @@ func TestGetToken_Do(t *testing.T) {
mockRepository.EXPECT().
Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet).
Return(nil)
mockReader := reader_mock.NewMockInterface(t)
mockReader.EXPECT().
Read().
Return(credentialplugin.Input{ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1"}, nil)
mockWriter := writer_mock.NewMockInterface(t)
mockWriter.EXPECT().
Write(issuedOutput).
Return(nil)
u := GetToken{
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
Writer: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
CredentialPluginReader: mockReader,
CredentialPluginWriter: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
}
if err := u.Do(ctx, in); err != nil {
t.Errorf("Do returned error: %+v", err)
@@ -188,16 +203,21 @@ func TestGetToken_Do(t *testing.T) {
},
}).
Return(&issuedTokenSet, nil)
mockReader := reader_mock.NewMockInterface(t)
mockReader.EXPECT().
Read().
Return(credentialpluginInput, nil)
mockWriter := writer_mock.NewMockInterface(t)
mockWriter.EXPECT().
Write(issuedOutput).
Return(nil)
u := GetToken{
Authentication: authentication_mock.NewMockInterface(t),
TokenCacheRepository: mockRepository,
Writer: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
Authentication: authentication_mock.NewMockInterface(t),
TokenCacheRepository: mockRepository,
CredentialPluginReader: mockReader,
CredentialPluginWriter: mockWriter,
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
}
if err := u.Do(ctx, in); err != nil {
t.Errorf("Do returned error: %+v", err)
@@ -242,12 +262,17 @@ func TestGetToken_Do(t *testing.T) {
},
}).
Return(nil, errors.New("file not found"))
mockReader := reader_mock.NewMockInterface(t)
mockReader.EXPECT().
Read().
Return(credentialpluginInput, nil)
u := GetToken{
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
Writer: writer_mock.NewMockInterface(t),
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
Authentication: mockAuthentication,
TokenCacheRepository: mockRepository,
CredentialPluginReader: mockReader,
CredentialPluginWriter: writer_mock.NewMockInterface(t),
Logger: logger.New(t),
Clock: clock.Fake(expiryTime.Add(-time.Hour)),
}
if err := u.Do(ctx, in); err == nil {
t.Errorf("err wants non-nil but nil")

View File

@@ -19,7 +19,8 @@ test: build
--browser-command=$(BIN_DIR)/chromelogin
# set up the kubeconfig
kubectl config set-credentials oidc \
--exec-api-version=client.authentication.k8s.io/v1beta1 \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-interactive-mode=Never \
--exec-command=kubectl \
--exec-arg=oidc-login \
--exec-arg=get-token \