pkg/credentialprovider: add initial exec-based credential provider plugin

Signed-off-by: Andrew Sy Kim <kim.andrewsy@gmail.com>
This commit is contained in:
Andrew Sy Kim 2020-11-10 13:44:07 -05:00
parent 2d0dd26252
commit 5344afd4fb
6 changed files with 1547 additions and 0 deletions

View File

@ -46,6 +46,7 @@ filegroup(
"//pkg/credentialprovider/aws:all-srcs",
"//pkg/credentialprovider/azure:all-srcs",
"//pkg/credentialprovider/gcp:all-srcs",
"//pkg/credentialprovider/plugin:all-srcs",
"//pkg/credentialprovider/secrets:all-srcs",
],
tags = ["automanaged"],

View File

@ -0,0 +1,58 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"config.go",
"plugin.go",
],
importpath = "k8s.io/kubernetes/pkg/credentialprovider/plugin",
visibility = ["//visibility:public"],
deps = [
"//pkg/credentialprovider:go_default_library",
"//pkg/kubelet/apis/config:go_default_library",
"//pkg/kubelet/apis/config/v1alpha1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/serializer:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/serializer/json:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/validation/field:go_default_library",
"//staging/src/k8s.io/client-go/tools/cache:go_default_library",
"//staging/src/k8s.io/kubelet/pkg/apis/credentialprovider:go_default_library",
"//staging/src/k8s.io/kubelet/pkg/apis/credentialprovider/install:go_default_library",
"//staging/src/k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1:go_default_library",
"//vendor/k8s.io/klog/v2:go_default_library",
],
)
filegroup(
name = "package-srcs",
srcs = glob(["**"]),
tags = ["automanaged"],
visibility = ["//visibility:private"],
)
filegroup(
name = "all-srcs",
srcs = [":package-srcs"],
tags = ["automanaged"],
visibility = ["//visibility:public"],
)
go_test(
name = "go_default_test",
srcs = [
"config_test.go",
"plugin_test.go",
],
embed = [":go_default_library"],
deps = [
"//pkg/credentialprovider:go_default_library",
"//pkg/kubelet/apis/config:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//staging/src/k8s.io/client-go/tools/cache:go_default_library",
"//staging/src/k8s.io/kubelet/pkg/apis/credentialprovider:go_default_library",
"//staging/src/k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1:go_default_library",
],
)

View File

@ -0,0 +1,128 @@
/*
Copyright 2020 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 plugin
import (
"fmt"
"io/ioutil"
"strings"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/kubernetes/pkg/credentialprovider"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
)
// readCredentialProviderConfigFile receives a path to a config file and decodes it
// into the internal CredentialProviderConfig type.
func readCredentialProviderConfigFile(configPath string) (*kubeletconfig.CredentialProviderConfig, error) {
if configPath == "" {
return nil, fmt.Errorf("credential provider config path is empty")
}
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("unable to read external registry credential provider configuration from %q: %v", configPath, err)
}
config, err := decode(data)
if err != nil {
return nil, fmt.Errorf("error decoding config %s: %v", configPath, err)
}
return config, nil
}
// decode decodes data into the internal CredentialProviderConfig type.
func decode(data []byte) (*kubeletconfig.CredentialProviderConfig, error) {
obj, gvk, err := codecs.UniversalDecoder().Decode(data, nil, nil)
if err != nil {
return nil, err
}
if gvk.Kind != "CredentialProviderConfig" {
return nil, fmt.Errorf("failed to decode %q (wrong Kind)", gvk.Kind)
}
if gvk.Group != kubeletconfig.GroupName {
return nil, fmt.Errorf("failed to decode CredentialProviderConfig, unexpected Group: %s", gvk.Group)
}
if internalConfig, ok := obj.(*kubeletconfig.CredentialProviderConfig); ok {
return internalConfig, nil
}
return nil, fmt.Errorf("unable to convert %T to *CredentialProviderConfig", obj)
}
// validateCredentialProviderConfig validates CredentialProviderConfig.
func validateCredentialProviderConfig(config *kubeletconfig.CredentialProviderConfig) field.ErrorList {
allErrs := field.ErrorList{}
if len(config.Providers) == 0 {
allErrs = append(allErrs, field.Required(field.NewPath("providers"), "at least 1 item in plugins is required"))
}
fieldPath := field.NewPath("providers")
for _, provider := range config.Providers {
if strings.Contains(provider.Name, "/") {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("name"), provider.Name, "provider name cannot contain '/'"))
}
if strings.Contains(provider.Name, " ") {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("name"), provider.Name, "provider name cannot contain spaces"))
}
if provider.Name == "." {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("name"), provider.Name, "provider name cannot be '.'"))
}
if provider.Name == ".." {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("name"), provider.Name, "provider name cannot be '..'"))
}
if provider.APIVersion == "" {
allErrs = append(allErrs, field.Required(fieldPath.Child("apiVersion"), "apiVersion is required"))
} else if _, ok := apiVersions[provider.APIVersion]; !ok {
validAPIVersions := []string{}
for apiVersion := range apiVersions {
validAPIVersions = append(validAPIVersions, apiVersion)
}
allErrs = append(allErrs, field.NotSupported(fieldPath.Child("apiVersion"), provider.APIVersion, validAPIVersions))
}
if len(provider.MatchImages) == 0 {
allErrs = append(allErrs, field.Required(fieldPath.Child("matchImages"), "at least 1 item in matchImages is required"))
}
for _, matchImage := range provider.MatchImages {
if _, err := credentialprovider.ParseSchemelessURL(matchImage); err != nil {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("matchImages"), matchImage, fmt.Sprintf("match image is invalid: %s", err.Error())))
}
}
if provider.DefaultCacheDuration == nil {
allErrs = append(allErrs, field.Required(fieldPath.Child("defaultCacheDuration"), "defaultCacheDuration is required"))
}
if provider.DefaultCacheDuration != nil && provider.DefaultCacheDuration.Duration < 0 {
allErrs = append(allErrs, field.Invalid(fieldPath.Child("defaultCacheDuration"), provider.DefaultCacheDuration.Duration, "defaultCacheDuration must be greater than or equal to 0"))
}
}
return allErrs
}

View File

@ -0,0 +1,435 @@
/*
Copyright 2020 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 plugin
import (
"io/ioutil"
"os"
"reflect"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
)
func Test_readCredentialProviderConfigFile(t *testing.T) {
testcases := []struct {
name string
configData string
config *kubeletconfig.CredentialProviderConfig
expectErr bool
}{
{
name: "config with 1 plugin and 1 image matcher",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/foobar"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "test",
MatchImages: []string{"registry.io/foobar"},
DefaultCacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
Args: []string{"--v=5"},
Env: []kubeletconfig.ExecEnvVar{
{
Name: "FOO",
Value: "BAR",
},
},
},
},
},
},
{
name: "config with 1 plugin and a wildcard image match",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/*"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "test",
MatchImages: []string{"registry.io/*"},
DefaultCacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
Args: []string{"--v=5"},
Env: []kubeletconfig.ExecEnvVar{
{
Name: "FOO",
Value: "BAR",
},
},
},
},
},
},
{
name: "config with 1 plugin and multiple image matchers",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/*"
- "foobar.registry.io/*"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "test",
MatchImages: []string{"registry.io/*", "foobar.registry.io/*"},
DefaultCacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
Args: []string{"--v=5"},
Env: []kubeletconfig.ExecEnvVar{
{
Name: "FOO",
Value: "BAR",
},
},
},
},
},
},
{
name: "config with multiple providers",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test1
matchImages:
- "registry.io/one"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
- name: test2
matchImages:
- "registry.io/two"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "test1",
MatchImages: []string{"registry.io/one"},
DefaultCacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
{
Name: "test2",
MatchImages: []string{"registry.io/two"},
DefaultCacheDuration: &metav1.Duration{Duration: 10 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
Args: []string{"--v=5"},
Env: []kubeletconfig.ExecEnvVar{
{
Name: "FOO",
Value: "BAR",
},
},
},
},
},
},
{
name: "config with wrong Kind",
configData: `---
kind: WrongKind
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/foobar"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: nil,
expectErr: true,
},
{
name: "config with wrong apiversion",
configData: `---
kind: CredentialProviderConfig
apiVersion: foobar/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/foobar"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: nil,
expectErr: true,
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
file, err := ioutil.TempFile("", "config.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(file.Name())
_, err = file.WriteString(testcase.configData)
if err != nil {
t.Fatal(err)
}
authConfig, err := readCredentialProviderConfigFile(file.Name())
if err != nil && !testcase.expectErr {
t.Fatal(err)
}
if err == nil && testcase.expectErr {
t.Error("expected error but got none")
}
if !reflect.DeepEqual(authConfig, testcase.config) {
t.Logf("actual auth config: %#v", authConfig)
t.Logf("expected auth config: %#v", testcase.config)
t.Error("credential provider config did not match")
}
})
}
}
func Test_validateCredentialProviderConfig(t *testing.T) {
testcases := []struct {
name string
config *kubeletconfig.CredentialProviderConfig
shouldErr bool
}{
{
name: "no providers provided",
config: &kubeletconfig.CredentialProviderConfig{},
shouldErr: true,
},
{
name: "no matchImages provided",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "no default cache duration provided",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "name contains '/'",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foo/../bar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "name is '.'",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: ".",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "name is '..'",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "..",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "name contains spaces",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foo bar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "no apiVersion",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "",
},
},
},
shouldErr: true,
},
{
name: "invalid apiVersion",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha0",
},
},
},
shouldErr: true,
},
{
name: "negative default cache duration",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: -1 * time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "invalid match image",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"%invalid%"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: true,
},
{
name: "valid config",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1alpha1",
},
},
},
shouldErr: false,
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
errs := validateCredentialProviderConfig(testcase.config)
if testcase.shouldErr && len(errs) == 0 {
t.Errorf("expected error but got none")
} else if !testcase.shouldErr && len(errs) > 0 {
t.Errorf("expected no error but received errors: %v", errs.ToAggregate())
}
})
}
}

View File

@ -0,0 +1,420 @@
/*
Copyright 2020 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 plugin
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/runtime/serializer/json"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
credentialproviderapi "k8s.io/kubelet/pkg/apis/credentialprovider"
"k8s.io/kubelet/pkg/apis/credentialprovider/install"
credentialproviderv1alpha1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
"k8s.io/kubernetes/pkg/credentialprovider"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
kubeletconfigv1alpha1 "k8s.io/kubernetes/pkg/kubelet/apis/config/v1alpha1"
)
const (
globalCacheKey = "global"
)
var (
scheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(scheme)
apiVersions = map[string]schema.GroupVersion{
credentialproviderv1alpha1.SchemeGroupVersion.String(): credentialproviderv1alpha1.SchemeGroupVersion,
}
)
func init() {
install.Install(scheme)
kubeletconfig.AddToScheme(scheme)
kubeletconfigv1alpha1.AddToScheme(scheme)
}
// RegisterCredentialProviderPlugins is called from kubelet to register external credential provider
// plugins according to the CredentialProviderConfig config file.
func RegisterCredentialProviderPlugins(pluginConfigFile, pluginBinDir string) error {
if _, err := os.Stat(pluginBinDir); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin binary directory %s did not exist", pluginBinDir)
}
return fmt.Errorf("error inspecting binary directory %s: %w", pluginBinDir, err)
}
credentialProviderConfig, err := readCredentialProviderConfigFile(pluginConfigFile)
if err != nil {
return err
}
errs := validateCredentialProviderConfig(credentialProviderConfig)
if len(errs) > 0 {
return fmt.Errorf("failed to validate credential provider config: %v", errs.ToAggregate())
}
for _, provider := range credentialProviderConfig.Providers {
pluginBin := filepath.Join(pluginBinDir, provider.Name)
if _, err := os.Stat(pluginBin); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin binary executable %s did not exist", pluginBin)
}
return fmt.Errorf("error inspecting binary executable %s: %w", pluginBin, err)
}
plugin, err := newPluginProvider(pluginBinDir, provider)
if err != nil {
return fmt.Errorf("error initializing plugin provider %s: %w", provider.Name, err)
}
credentialprovider.RegisterCredentialProvider(provider.Name, plugin)
}
return nil
}
// newPluginProvider returns a new pluginProvider based on the credential provider config.
func newPluginProvider(pluginBinDir string, provider kubeletconfig.CredentialProvider) (*pluginProvider, error) {
mediaType := "application/json"
info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
if !ok {
return nil, fmt.Errorf("unsupported media type %q", mediaType)
}
gv, ok := apiVersions[provider.APIVersion]
if !ok {
return nil, fmt.Errorf("invalid apiVersion: %q", provider.APIVersion)
}
return &pluginProvider{
matchImages: provider.MatchImages,
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
defaultCacheDuration: provider.DefaultCacheDuration.Duration,
plugin: &execPlugin{
name: provider.Name,
apiVersion: provider.APIVersion,
encoder: codecs.EncoderForVersion(info.Serializer, gv),
pluginBinDir: pluginBinDir,
args: provider.Args,
envVars: provider.Env,
},
}, nil
}
// pluginProvider is the plugin-based implementation of the DockerConfigProvider interface.
type pluginProvider struct {
sync.Mutex
// matchImages defines the matching image URLs this plugin should operate against.
// The plugin provider will not return any credentials for images that do not match
// against this list of match URLs.
matchImages []string
// cache stores DockerConfig entries with an expiration time based on the cache duration
// returned from the credential provider plugin.
cache cache.Store
// defaultCacheDuration is the default duration credentials are cached in-memory if the auth plugin
// response did not provide a cache duration for credentials.
defaultCacheDuration time.Duration
// plugin is the exec implementation of the credential providing plugin.
plugin Plugin
}
// cacheEntry is the cache object that will be stored in cache.Store.
type cacheEntry struct {
key string
credentials credentialprovider.DockerConfig
expiresAt time.Time
}
// cacheKeyFunc extracts AuthEntry.MatchKey as the cache key function for the plugin provider.
func cacheKeyFunc(obj interface{}) (string, error) {
key := obj.(*cacheEntry).key
return key, nil
}
// cacheExpirationPolicy defines implements cache.ExpirationPolicy, determining expiration based on the expiresAt timestamp.
type cacheExpirationPolicy struct{}
// IsExpired returns true if the current time is after cacheEntry.expiresAt, which is determined by the
// cache duration returned from the credential provider plugin response.
func (c *cacheExpirationPolicy) IsExpired(entry *cache.TimestampedEntry) bool {
return time.Now().After(entry.Obj.(*cacheEntry).expiresAt)
}
// Provide returns a credentialprovider.DockerConfig based on the credentials returned
// from cache or the exec plugin.
func (p *pluginProvider) Provide(image string) credentialprovider.DockerConfig {
if !p.isImageAllowed(image) {
return credentialprovider.DockerConfig{}
}
p.Lock()
defer p.Unlock()
cachedConfig, found, err := p.getCachedCredentials(image)
if err != nil {
klog.Errorf("Failed to get cached docker config: %v", err)
return credentialprovider.DockerConfig{}
}
if found {
return cachedConfig
}
response, err := p.plugin.ExecPlugin(context.Background(), image)
if err != nil {
klog.Errorf("Failed getting credential from external registry credential provider: %v", err)
return credentialprovider.DockerConfig{}
}
var cacheKey string
switch cacheKeyType := response.CacheKeyType; cacheKeyType {
case credentialproviderapi.ImagePluginCacheKeyType:
cacheKey = image
case credentialproviderapi.RegistryPluginCacheKeyType:
registry := parseRegistry(image)
cacheKey = registry
case credentialproviderapi.GlobalPluginCacheKeyType:
cacheKey = globalCacheKey
default:
klog.Errorf("credential provider plugin did not return a valid cacheKeyType: %q", cacheKeyType)
return credentialprovider.DockerConfig{}
}
dockerConfig := make(credentialprovider.DockerConfig, len(response.Auth))
for matchImage, authConfig := range response.Auth {
dockerConfig[matchImage] = credentialprovider.DockerConfigEntry{
Username: authConfig.Username,
Password: authConfig.Password,
}
}
// cache duration was explicitly 0 so don't cache this response at all.
if response.CacheDuration != nil && response.CacheDuration.Duration == 0 {
return dockerConfig
}
var expiresAt time.Time
// nil cache duration means use the default cache duration
if response.CacheDuration == nil {
if p.defaultCacheDuration == 0 {
return dockerConfig
}
expiresAt = time.Now().Add(p.defaultCacheDuration)
} else {
expiresAt = time.Now().Add(response.CacheDuration.Duration)
}
cachedEntry := &cacheEntry{
key: cacheKey,
credentials: dockerConfig,
expiresAt: expiresAt,
}
if err := p.cache.Add(cachedEntry); err != nil {
klog.Errorf("Error adding auth entry to cache: %v", err)
}
return dockerConfig
}
// Enabled always returns true since registration of the plugin via kubelet implies it should be enabled.
func (e *pluginProvider) Enabled() bool {
return true
}
// isImageAllowed returns true if the image matches against the list of allowed matches by the plugin.
func (p *pluginProvider) isImageAllowed(image string) bool {
for _, matchImage := range p.matchImages {
if matched, _ := credentialprovider.URLsMatchStr(matchImage, image); matched {
return true
}
}
return false
}
// getCachedCredentials returns a credentialprovider.DockerConfig if cached from the plugin.
func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.DockerConfig, bool, error) {
obj, found, err := p.cache.GetByKey(image)
if err != nil {
return nil, false, err
}
if found {
return obj.(*cacheEntry).credentials, true, nil
}
registry := parseRegistry(image)
obj, found, err = p.cache.GetByKey(registry)
if err != nil {
return nil, false, err
}
if found {
return obj.(*cacheEntry).credentials, true, nil
}
obj, found, err = p.cache.GetByKey(globalCacheKey)
if err != nil {
return nil, false, err
}
if found {
return obj.(*cacheEntry).credentials, true, nil
}
return nil, false, nil
}
// Plugin is the interface calling ExecPlugin. This is mainly for testability
// so tests don't have to actually exec any processes.
type Plugin interface {
ExecPlugin(ctx context.Context, image string) (*credentialproviderapi.CredentialProviderResponse, error)
}
// execPlugin is the implementation of the Plugin interface that execs a credential provider plugin based
// on it's name provided in CredentialProviderConfig. It is assumed that the executable is available in the
// plugin directory provided by the kubelet.
type execPlugin struct {
name string
apiVersion string
encoder runtime.Encoder
args []string
envVars []kubeletconfig.ExecEnvVar
pluginBinDir string
}
// ExecPlugin executes the plugin binary with arguments and environment variables specified in CredentialProviderConfig:
//
// $ ENV_NAME=ENV_VALUE <plugin-name> args[0] args[1] <<<request
//
// The plugin is expected to receive the CredentialProviderRequest API via stdin from the kubelet and
// return CredentialProviderResponse via stdout.
func (e *execPlugin) ExecPlugin(ctx context.Context, image string) (*credentialproviderapi.CredentialProviderResponse, error) {
authRequest := &credentialproviderapi.CredentialProviderRequest{Image: image}
data, err := e.encodeRequest(authRequest)
if err != nil {
return nil, fmt.Errorf("failed to encode auth request: %v", err)
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
stdin := bytes.NewBuffer(data)
// Use a catch-all timeout of 1 minute for all exec-based plugins, this should leave enough
// head room in case a plugin needs to retry a failed request while ensuring an exec plugin
// does not run forever. In the future we may want this timeout to be tweakable from the plugin
// config file.
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, filepath.Join(e.pluginBinDir, e.name), e.args...)
cmd.Stdout, cmd.Stderr, cmd.Stdin = stdout, stderr, stdin
cmd.Env = []string{}
for _, envVar := range e.envVars {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", envVar.Name, envVar.Value))
}
err = cmd.Run()
if ctx.Err() != nil {
return nil, fmt.Errorf("error execing credential provider plugin %s for image %s: %w", e.name, image, ctx.Err())
}
if err != nil {
klog.V(2).Infof("Error execing credential provider plugin, stderr: %v", stderr.String())
return nil, fmt.Errorf("error execing credential provider plugin %s for image %s: %w", e.name, image, err)
}
data = stdout.Bytes()
// check that the response apiVersion matches what is expected
gvk, err := json.DefaultMetaFactory.Interpret(data)
if err != nil {
return nil, fmt.Errorf("error reading GVK from response: %w", err)
}
if gvk.GroupVersion().String() != e.apiVersion {
return nil, errors.New("apiVersion from credential plugin response did not match")
}
response, err := e.decodeResponse(stdout.Bytes())
if err != nil {
// err is explicitly not wrapped since it may contain credentials in the response.
return nil, errors.New("error decoding credential provider plugin response from stdout")
}
return response, nil
}
// encodeRequest encodes the internal CredentialProviderRequest type into the v1alpha1 version in json
func (e *execPlugin) encodeRequest(request *credentialproviderapi.CredentialProviderRequest) ([]byte, error) {
data, err := runtime.Encode(e.encoder, request)
if err != nil {
return nil, fmt.Errorf("error encoding request: %v", err)
}
return data, nil
}
// decodeResponse decodes data into the internal CredentialProviderResponse type
func (e *execPlugin) decodeResponse(data []byte) (*credentialproviderapi.CredentialProviderResponse, error) {
obj, gvk, err := codecs.UniversalDecoder().Decode(data, nil, nil)
if err != nil {
return nil, err
}
if gvk.Kind != "CredentialProviderResponse" {
return nil, fmt.Errorf("failed to decode CredentialProviderResponse, unexpected Kind: %q", gvk.Kind)
}
if gvk.Group != credentialproviderapi.GroupName {
return nil, fmt.Errorf("failed to decode CredentialProviderResponse, unexpected Group: %s", gvk.Group)
}
if internalResponse, ok := obj.(*credentialproviderapi.CredentialProviderResponse); ok {
return internalResponse, nil
}
return nil, fmt.Errorf("unable to convert %T to *CredentialProviderResponse", obj)
}
// parseRegistry extracts the registry hostname of an image (including port if specified).
func parseRegistry(image string) string {
imageParts := strings.Split(image, "/")
return imageParts[0]
}

View File

@ -0,0 +1,505 @@
/*
Copyright 2020 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 plugin
import (
"context"
"reflect"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/cache"
credentialproviderapi "k8s.io/kubelet/pkg/apis/credentialprovider"
credentialproviderv1alpha1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
"k8s.io/kubernetes/pkg/credentialprovider"
)
type fakeExecPlugin struct {
cacheKeyType credentialproviderapi.PluginCacheKeyType
cacheDuration time.Duration
auth map[string]credentialproviderapi.AuthConfig
}
func (f *fakeExecPlugin) ExecPlugin(ctx context.Context, image string) (*credentialproviderapi.CredentialProviderResponse, error) {
return &credentialproviderapi.CredentialProviderResponse{
CacheKeyType: f.cacheKeyType,
CacheDuration: &metav1.Duration{
Duration: f.cacheDuration,
},
Auth: f.auth,
}, nil
}
func Test_Provide(t *testing.T) {
testcases := []struct {
name string
pluginProvider *pluginProvider
image string
dockerconfig credentialprovider.DockerConfig
}{
{
name: "exact image match, with Registry cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"test.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.RegistryPluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"test.registry.io": {
Username: "user",
Password: "password",
},
},
},
},
image: "test.registry.io/foo/bar",
dockerconfig: credentialprovider.DockerConfig{
"test.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
{
name: "exact image match, with Image cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"test.registry.io/foo/bar"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.ImagePluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"test.registry.io/foo/bar": {
Username: "user",
Password: "password",
},
},
},
},
image: "test.registry.io/foo/bar",
dockerconfig: credentialprovider.DockerConfig{
"test.registry.io/foo/bar": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
{
name: "exact image match, with Global cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"test.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.GlobalPluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"test.registry.io": {
Username: "user",
Password: "password",
},
},
},
},
image: "test.registry.io",
dockerconfig: credentialprovider.DockerConfig{
"test.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
{
name: "wild card image match, with Registry cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"*.registry.io:8080"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.RegistryPluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io:8080": {
Username: "user",
Password: "password",
},
},
},
},
image: "test.registry.io:8080/foo",
dockerconfig: credentialprovider.DockerConfig{
"*.registry.io:8080": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
{
name: "wild card image match, with Image cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"*.*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.ImagePluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"*.*.registry.io": {
Username: "user",
Password: "password",
},
},
},
},
image: "foo.bar.registry.io/foo/bar",
dockerconfig: credentialprovider.DockerConfig{
"*.*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
{
name: "wild card image match, with Global cache key",
pluginProvider: &pluginProvider{
matchImages: []string{"*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.GlobalPluginCacheKeyType,
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
},
image: "test.registry.io",
dockerconfig: credentialprovider.DockerConfig{
"*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
},
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
dockerconfig := testcase.pluginProvider.Provide(testcase.image)
if !reflect.DeepEqual(dockerconfig, testcase.dockerconfig) {
t.Logf("actual docker config: %v", dockerconfig)
t.Logf("expected docker config: %v", testcase.dockerconfig)
t.Error("unexpected docker config")
}
})
}
}
func Test_encodeRequest(t *testing.T) {
testcases := []struct {
name string
apiVersion string
request *credentialproviderapi.CredentialProviderRequest
expectedData []byte
expectedErr bool
}{
{
name: "successful",
request: &credentialproviderapi.CredentialProviderRequest{
Image: "test.registry.io/foobar",
},
expectedData: []byte(`{"kind":"CredentialProviderRequest","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","image":"test.registry.io/foobar"}
`),
expectedErr: false,
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
mediaType := "application/json"
info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
if !ok {
t.Fatalf("unsupported media type: %s", mediaType)
}
e := &execPlugin{
encoder: codecs.EncoderForVersion(info.Serializer, credentialproviderv1alpha1.SchemeGroupVersion),
}
data, err := e.encodeRequest(testcase.request)
if err != nil && !testcase.expectedErr {
t.Fatalf("unexpected error: %v", err)
}
if err == nil && testcase.expectedErr {
t.Fatalf("expected error %v but got nil", testcase.expectedErr)
}
if !reflect.DeepEqual(data, testcase.expectedData) {
t.Errorf("actual encoded data: %v", string(data))
t.Errorf("expected encoded data: %v", string(testcase.expectedData))
t.Errorf("unexpected encoded response")
}
})
}
}
func Test_decodeResponse(t *testing.T) {
testcases := []struct {
name string
data []byte
expectedResponse *credentialproviderapi.CredentialProviderResponse
expectedErr bool
}{
{
name: "success",
data: []byte(`{"kind":"CredentialProviderResponse","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","cacheKeyType":"Registry","cacheDuration":"1m","auth":{"*.registry.io":{"username":"user","password":"password"}}}`),
expectedResponse: &credentialproviderapi.CredentialProviderResponse{
CacheKeyType: credentialproviderapi.RegistryPluginCacheKeyType,
CacheDuration: &metav1.Duration{
Duration: time.Minute,
},
Auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
expectedErr: false,
},
{
name: "wrong Kind",
data: []byte(`{"kind":"WrongKind","apiVersion":"credentialprovider.kubelet.k8s.io/v1alpha1","cacheKeyType":"Registry","cacheDuration":"1m","auth":{"*.registry.io":{"username":"user","password":"password"}}}`),
expectedResponse: nil,
expectedErr: true,
},
{
name: "wrong Group",
data: []byte(`{"kind":"CredentialProviderResponse","apiVersion":"foobar.kubelet.k8s.io/v1alpha1","cacheKeyType":"Registry","cacheDuration":"1m","auth":{"*.registry.io":{"username":"user","password":"password"}}}`),
expectedResponse: nil,
expectedErr: true,
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
e := &execPlugin{}
decodedResponse, err := e.decodeResponse(testcase.data)
if err != nil && !testcase.expectedErr {
t.Fatalf("unexpected error: %v", err)
}
if err == nil && testcase.expectedErr {
t.Fatalf("expected error %v but not nil", testcase.expectedErr)
}
if !reflect.DeepEqual(decodedResponse, testcase.expectedResponse) {
t.Logf("actual decoded response: %#v", decodedResponse)
t.Logf("expected decoded response: %#v", testcase.expectedResponse)
t.Errorf("unexpected decoded response")
}
})
}
}
func Test_RegistryCacheKeyType(t *testing.T) {
pluginProvider := &pluginProvider{
matchImages: []string{"*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.RegistryPluginCacheKeyType,
cacheDuration: time.Hour,
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
}
expectedDockerConfig := credentialprovider.DockerConfig{
"*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
}
dockerConfig := pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
expectedCacheKeys := []string{"test.registry.io"}
cacheKeys := pluginProvider.cache.ListKeys()
if !reflect.DeepEqual(cacheKeys, expectedCacheKeys) {
t.Logf("actual cache keys: %v", cacheKeys)
t.Logf("expected cache keys: %v", expectedCacheKeys)
t.Error("unexpected cache keys")
}
// nil out the exec plugin, this will test whether credentialproviderapi are fetched
// from cache, otherwise Provider should panic
pluginProvider.plugin = nil
dockerConfig = pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
}
func Test_ImageCacheKeyType(t *testing.T) {
pluginProvider := &pluginProvider{
matchImages: []string{"*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.ImagePluginCacheKeyType,
cacheDuration: time.Hour,
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
}
expectedDockerConfig := credentialprovider.DockerConfig{
"*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
}
dockerConfig := pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
expectedCacheKeys := []string{"test.registry.io/foo/bar"}
cacheKeys := pluginProvider.cache.ListKeys()
if !reflect.DeepEqual(cacheKeys, expectedCacheKeys) {
t.Logf("actual cache keys: %v", cacheKeys)
t.Logf("expected cache keys: %v", expectedCacheKeys)
t.Error("unexpected cache keys")
}
// nil out the exec plugin, this will test whether credentialproviderapi are fetched
// from cache, otherwise Provider should panic
pluginProvider.plugin = nil
dockerConfig = pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
}
func Test_GlobalCacheKeyType(t *testing.T) {
pluginProvider := &pluginProvider{
matchImages: []string{"*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.GlobalPluginCacheKeyType,
cacheDuration: time.Hour,
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
}
expectedDockerConfig := credentialprovider.DockerConfig{
"*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
}
dockerConfig := pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
expectedCacheKeys := []string{"global"}
cacheKeys := pluginProvider.cache.ListKeys()
if !reflect.DeepEqual(cacheKeys, expectedCacheKeys) {
t.Logf("actual cache keys: %v", cacheKeys)
t.Logf("expected cache keys: %v", expectedCacheKeys)
t.Error("unexpected cache keys")
}
// nil out the exec plugin, this will test whether credentialproviderapi are fetched
// from cache, otherwise Provider should panic
pluginProvider.plugin = nil
dockerConfig = pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
}
func Test_NoCacheResponse(t *testing.T) {
pluginProvider := &pluginProvider{
matchImages: []string{"*.registry.io"},
cache: cache.NewExpirationStore(cacheKeyFunc, &cacheExpirationPolicy{}),
plugin: &fakeExecPlugin{
cacheKeyType: credentialproviderapi.GlobalPluginCacheKeyType,
cacheDuration: 0, // no cache
auth: map[string]credentialproviderapi.AuthConfig{
"*.registry.io": {
Username: "user",
Password: "password",
},
},
},
}
expectedDockerConfig := credentialprovider.DockerConfig{
"*.registry.io": credentialprovider.DockerConfigEntry{
Username: "user",
Password: "password",
},
}
dockerConfig := pluginProvider.Provide("test.registry.io/foo/bar")
if !reflect.DeepEqual(dockerConfig, expectedDockerConfig) {
t.Logf("actual docker config: %v", dockerConfig)
t.Logf("expected docker config: %v", expectedDockerConfig)
t.Fatal("unexpected docker config")
}
expectedCacheKeys := []string{}
cacheKeys := pluginProvider.cache.ListKeys()
if !reflect.DeepEqual(cacheKeys, expectedCacheKeys) {
t.Logf("actual cache keys: %v", cacheKeys)
t.Logf("expected cache keys: %v", expectedCacheKeys)
t.Error("unexpected cache keys")
}
}