Merge pull request #128372 from aramase/aramase/f/kep_4412_alpha_impl

KSA token for Kubelet image credential providers alpha
This commit is contained in:
Kubernetes Prow Robot 2025-03-12 00:39:50 -07:00 committed by GitHub
commit e0ab1a16ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2619 additions and 491 deletions

View File

@ -22,7 +22,9 @@ import (
"strings"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
credentialproviderv1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1"
"k8s.io/kubernetes/pkg/credentialprovider"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
)
@ -70,7 +72,7 @@ func decode(data []byte) (*kubeletconfig.CredentialProviderConfig, error) {
}
// validateCredentialProviderConfig validates CredentialProviderConfig.
func validateCredentialProviderConfig(config *kubeletconfig.CredentialProviderConfig) field.ErrorList {
func validateCredentialProviderConfig(config *kubeletconfig.CredentialProviderConfig, saTokenForCredentialProviders bool) field.ErrorList {
allErrs := field.ErrorList{}
if len(config.Providers) == 0 {
@ -125,7 +127,56 @@ func validateCredentialProviderConfig(config *kubeletconfig.CredentialProviderCo
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"))
}
if provider.TokenAttributes != nil {
fldPath := fieldPath.Child("tokenAttributes")
if !saTokenForCredentialProviders {
allErrs = append(allErrs, field.Forbidden(fldPath, "tokenAttributes is not supported when KubeletServiceAccountTokenForCredentialProviders feature gate is disabled"))
}
if len(provider.TokenAttributes.ServiceAccountTokenAudience) == 0 {
allErrs = append(allErrs, field.Required(fldPath.Child("serviceAccountTokenAudience"), "serviceAccountTokenAudience is required"))
}
if provider.TokenAttributes.RequireServiceAccount == nil {
allErrs = append(allErrs, field.Required(fldPath.Child("requireServiceAccount"), "requireServiceAccount is required"))
}
if provider.APIVersion != credentialproviderv1.SchemeGroupVersion.String() {
allErrs = append(allErrs, field.Forbidden(fldPath, fmt.Sprintf("tokenAttributes is only supported for %s API version", credentialproviderv1.SchemeGroupVersion.String())))
}
if provider.TokenAttributes.RequireServiceAccount != nil && !*provider.TokenAttributes.RequireServiceAccount && len(provider.TokenAttributes.RequiredServiceAccountAnnotationKeys) > 0 {
allErrs = append(allErrs, field.Forbidden(fldPath.Child("requiredServiceAccountAnnotationKeys"), "requireServiceAccount cannot be false when requiredServiceAccountAnnotationKeys is set"))
}
allErrs = append(allErrs, validateServiceAccountAnnotationKeys(fldPath.Child("requiredServiceAccountAnnotationKeys"), provider.TokenAttributes.RequiredServiceAccountAnnotationKeys)...)
allErrs = append(allErrs, validateServiceAccountAnnotationKeys(fldPath.Child("optionalServiceAccountAnnotationKeys"), provider.TokenAttributes.OptionalServiceAccountAnnotationKeys)...)
requiredServiceAccountAnnotationKeys := sets.New[string](provider.TokenAttributes.RequiredServiceAccountAnnotationKeys...)
optionalServiceAccountAnnotationKeys := sets.New[string](provider.TokenAttributes.OptionalServiceAccountAnnotationKeys...)
duplicateAnnotationKeys := requiredServiceAccountAnnotationKeys.Intersection(optionalServiceAccountAnnotationKeys)
if duplicateAnnotationKeys.Len() > 0 {
allErrs = append(allErrs, field.Invalid(fldPath, sets.List(duplicateAnnotationKeys), "annotation keys cannot be both required and optional"))
}
}
}
return allErrs
}
// validateServiceAccountAnnotationKeys validates the service account annotation keys.
func validateServiceAccountAnnotationKeys(fldPath *field.Path, keys []string) field.ErrorList {
allErrs := field.ErrorList{}
seenAnnotationKeys := sets.New[string]()
// Using the validation logic for keys from https://github.com/kubernetes/kubernetes/blob/69dbc74417304328a9fd3c161643dc4f0a057f41/staging/src/k8s.io/apimachinery/pkg/api/validation/objectmeta.go#L46-L51
for _, k := range keys {
// The rule is QualifiedName except that case doesn't matter, so convert to lowercase before checking.
for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) {
allErrs = append(allErrs, field.Invalid(fldPath, k, msg))
}
if seenAnnotationKeys.Has(k) {
allErrs = append(allErrs, field.Duplicate(fldPath, k))
}
seenAnnotationKeys.Insert(k)
}
return allErrs
}

View File

@ -19,16 +19,17 @@ package plugin
import (
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/errors"
utiltesting "k8s.io/client-go/util/testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
"k8s.io/utils/ptr"
)
func Test_readCredentialProviderConfigFile(t *testing.T) {
@ -337,6 +338,48 @@ providers:
config: nil,
expectErr: `strict decoding error: unknown field "providers[0].unknownField"`,
},
{
name: "v1alpha1 config with token attributes should fail",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1alpha1
providers:
- name: test
matchImages:
- "registry.io/foobar"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1alpha1
tokenAttributes:
serviceAccountTokenAudience: audience
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: nil,
expectErr: `strict decoding error: unknown field "providers[0].tokenAttributes"`,
},
{
name: "v1beta1 config with token attributes should fail",
configData: `---
kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1beta1
providers:
- name: test
matchImages:
- "registry.io/foobar"
defaultCacheDuration: 10m
apiVersion: credentialprovider.kubelet.k8s.io/v1beta1
tokenAttributes:
serviceAccountTokenAudience: audience
args:
- --v=5
env:
- name: FOO
value: BAR`,
config: nil,
expectErr: `strict decoding error: unknown field "providers[0].tokenAttributes"`,
},
}
for _, testcase := range testcases {
@ -347,17 +390,19 @@ providers:
}
defer utiltesting.CloseAndRemove(t, file)
_, err = file.WriteString(testcase.configData)
if err != nil {
if _, err = file.WriteString(testcase.configData); err != nil {
t.Fatal(err)
}
authConfig, err := readCredentialProviderConfigFile(file.Name())
if err != nil && len(testcase.expectErr) == 0 {
t.Fatal(err)
}
if err == nil && len(testcase.expectErr) > 0 {
if err != nil {
if len(testcase.expectErr) == 0 {
t.Fatal(err)
}
if !strings.Contains(err.Error(), testcase.expectErr) {
t.Fatalf("expected error %q but got %q", testcase.expectErr, err.Error())
}
} else if len(testcase.expectErr) > 0 {
t.Fatalf("expected error %q but got none", testcase.expectErr)
}
@ -554,11 +599,248 @@ func Test_validateCredentialProviderConfig(t *testing.T) {
},
},
},
{
name: "token attributes set without KubeletServiceAccountTokenForCredentialProviders feature gate enabled",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
},
},
},
},
expectErr: `providers.tokenAttributes: Forbidden: tokenAttributes is not supported when KubeletServiceAccountTokenForCredentialProviders feature gate is disabled`,
},
{
name: "token attributes not nil but empty ServiceAccountTokenAudience",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
RequiredServiceAccountAnnotationKeys: []string{"prefix.io/annotation-1", "prefix.io/annotation-2"},
RequireServiceAccount: ptr.To(true),
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.serviceAccountTokenAudience: Required value: serviceAccountTokenAudience is required`,
},
{
name: "token attributes not nil but empty ServiceAccountTokenRequired",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequiredServiceAccountAnnotationKeys: []string{"prefix.io/annotation-1", "prefix.io/annotation-2"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.requireServiceAccount: Required value: requireServiceAccount is required`,
},
{
name: "required service account annotation keys not qualified name (same validation as metav1.ObjectMeta)",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"cantendwithadash-", "now-with-dashes/simple"}, // first key is invalid
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.requiredServiceAccountAnnotationKeys: Invalid value: "cantendwithadash-": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')`,
},
{
name: "optional service account annotation keys not qualified name (same validation as metav1.ObjectMeta)",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
OptionalServiceAccountAnnotationKeys: []string{"cantendwithadash-", "now-with-dashes/simple"}, // first key is invalid
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.optionalServiceAccountAnnotationKeys: Invalid value: "cantendwithadash-": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')`,
},
{
name: "duplicate required service account annotation keys",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple", "now-with-dashes/simple"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.requiredServiceAccountAnnotationKeys: Duplicate value: "now-with-dashes/simple"`,
},
{
name: "duplicate optional service account annotation keys",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
OptionalServiceAccountAnnotationKeys: []string{"now-with-dashes/simple", "now-with-dashes/simple"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.optionalServiceAccountAnnotationKeys: Duplicate value: "now-with-dashes/simple"`,
},
{
name: "annotation key in required and optional keys",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple-1", "now-with-dashes/simple-2"},
OptionalServiceAccountAnnotationKeys: []string{"now-with-dashes/simple-2", "now-with-dashes/simple-3"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes: Invalid value: []string{"now-with-dashes/simple-2"}: annotation keys cannot be both required and optional`,
},
{
name: "required annotation keys set when requireServiceAccount is false",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(false),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple-1", "now-with-dashes/simple-2"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes.requiredServiceAccountAnnotationKeys: Forbidden: requireServiceAccount cannot be false when requiredServiceAccountAnnotationKeys is set`,
},
{
name: "valid config with KubeletServiceAccountTokenForCredentialProviders feature gate enabled",
config: &kubeletconfig.CredentialProviderConfig{
Providers: []kubeletconfig.CredentialProvider{
{
Name: "foobar",
MatchImages: []string{"foobar.registry.io"},
DefaultCacheDuration: &metav1.Duration{Duration: time.Minute},
APIVersion: "credentialprovider.kubelet.k8s.io/v1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple-1", "now-with-dashes/simple-2"},
OptionalServiceAccountAnnotationKeys: []string{"now-with-dashes/simple-3"},
},
},
},
},
saTokenForCredentialProviders: true,
},
{
name: "tokenAttributes set with credentialprovider.kubelet.k8s.io/v1alpha1 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/v1alpha1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes: Forbidden: tokenAttributes is only supported for credentialprovider.kubelet.k8s.io/v1 API version`,
},
{
name: "tokenAttributes set with credentialprovider.kubelet.k8s.io/v1beta1 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/v1beta1",
TokenAttributes: &kubeletconfig.ServiceAccountTokenAttributes{
ServiceAccountTokenAudience: "audience",
RequireServiceAccount: ptr.To(true),
RequiredServiceAccountAnnotationKeys: []string{"now-with-dashes/simple"},
},
},
},
},
saTokenForCredentialProviders: true,
expectErr: `providers.tokenAttributes: Forbidden: tokenAttributes is only supported for credentialprovider.kubelet.k8s.io/v1 API version`,
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
errs := validateCredentialProviderConfig(testcase.config).ToAggregate()
errs := validateCredentialProviderConfig(testcase.config, testcase.saTokenForCredentialProviders).ToAggregate()
if d := cmp.Diff(testcase.expectErr, errString(errs)); d != "" {
t.Fatalf("CredentialProviderConfig validation mismatch (-want +got):\n%s", d)
}

View File

@ -19,6 +19,7 @@ package plugin
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
@ -28,12 +29,18 @@ import (
"sync"
"time"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/sync/singleflight"
authenticationv1 "k8s.io/api/authentication/v1"
v1 "k8s.io/api/core/v1"
"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/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
credentialproviderapi "k8s.io/kubelet/pkg/apis/credentialprovider"
@ -42,6 +49,7 @@ import (
credentialproviderv1alpha1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1alpha1"
credentialproviderv1beta1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1beta1"
"k8s.io/kubernetes/pkg/credentialprovider"
"k8s.io/kubernetes/pkg/features"
kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
kubeletconfigv1 "k8s.io/kubernetes/pkg/kubelet/apis/config/v1"
kubeletconfigv1alpha1 "k8s.io/kubernetes/pkg/kubelet/apis/config/v1alpha1"
@ -75,7 +83,10 @@ func init() {
// RegisterCredentialProviderPlugins is called from kubelet to register external credential provider
// plugins according to the CredentialProviderConfig config file.
func RegisterCredentialProviderPlugins(pluginConfigFile, pluginBinDir string) error {
func RegisterCredentialProviderPlugins(pluginConfigFile, pluginBinDir string,
getServiceAccountToken func(namespace, name string, tr *authenticationv1.TokenRequest) (*authenticationv1.TokenRequest, error),
getServiceAccount func(namespace, name string) (*v1.ServiceAccount, error),
) error {
if _, err := os.Stat(pluginBinDir); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin binary directory %s did not exist", pluginBinDir)
@ -89,8 +100,8 @@ func RegisterCredentialProviderPlugins(pluginConfigFile, pluginBinDir string) er
return err
}
errs := validateCredentialProviderConfig(credentialProviderConfig)
if len(errs) > 0 {
saTokenForCredentialProvidersFeatureEnabled := utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders)
if errs := validateCredentialProviderConfig(credentialProviderConfig, saTokenForCredentialProvidersFeatureEnabled); len(errs) > 0 {
return fmt.Errorf("failed to validate credential provider config: %v", errs.ToAggregate())
}
@ -109,19 +120,22 @@ func RegisterCredentialProviderPlugins(pluginConfigFile, pluginBinDir string) er
return fmt.Errorf("error inspecting binary executable %s: %w", pluginBin, err)
}
plugin, err := newPluginProvider(pluginBinDir, provider)
plugin, err := newPluginProvider(pluginBinDir, provider, getServiceAccountToken, getServiceAccount)
if err != nil {
return fmt.Errorf("error initializing plugin provider %s: %w", provider.Name, err)
}
credentialprovider.RegisterCredentialProvider(provider.Name, plugin)
registerCredentialProviderPlugin(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) {
func newPluginProvider(pluginBinDir string, provider kubeletconfig.CredentialProvider,
getServiceAccountToken func(namespace, name string, tr *authenticationv1.TokenRequest) (*authenticationv1.TokenRequest, error),
getServiceAccount func(namespace, name string) (*v1.ServiceAccount, error),
) (*pluginProvider, error) {
mediaType := "application/json"
info, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), mediaType)
if !ok {
@ -134,7 +148,6 @@ func newPluginProvider(pluginBinDir string, provider kubeletconfig.CredentialPro
}
clock := clock.RealClock{}
return &pluginProvider{
clock: clock,
matchImages: provider.MatchImages,
@ -150,6 +163,7 @@ func newPluginProvider(pluginBinDir string, provider kubeletconfig.CredentialPro
envVars: provider.Env,
environ: os.Environ,
},
serviceAccountProvider: newServiceAccountProvider(provider, getServiceAccount, getServiceAccountToken),
}, nil
}
@ -178,6 +192,101 @@ type pluginProvider struct {
// lastCachePurge is the last time cache is cleaned for expired entries.
lastCachePurge time.Time
// serviceAccountProvider holds the logic for handling service account tokens when needed.
serviceAccountProvider *serviceAccountProvider
}
type serviceAccountProvider struct {
audience string
requireServiceAccount bool
getServiceAccountFunc func(namespace, name string) (*v1.ServiceAccount, error)
getServiceAccountTokenFunc func(podNamespace, serviceAccountName string, tr *authenticationv1.TokenRequest) (*authenticationv1.TokenRequest, error)
requiredServiceAccountAnnotationKeys []string
optionalServiceAccountAnnotationKeys []string
}
func newServiceAccountProvider(
provider kubeletconfig.CredentialProvider,
getServiceAccount func(namespace, name string) (*v1.ServiceAccount, error),
getServiceAccountToken func(namespace, name string, tr *authenticationv1.TokenRequest) (*authenticationv1.TokenRequest, error),
) *serviceAccountProvider {
featureGateEnabled := utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders)
serviceAccountTokenAudienceSet := provider.TokenAttributes != nil && len(provider.TokenAttributes.ServiceAccountTokenAudience) > 0
if !featureGateEnabled || !serviceAccountTokenAudienceSet {
return nil
}
return &serviceAccountProvider{
audience: provider.TokenAttributes.ServiceAccountTokenAudience,
requireServiceAccount: *provider.TokenAttributes.RequireServiceAccount,
getServiceAccountFunc: getServiceAccount,
getServiceAccountTokenFunc: getServiceAccountToken,
requiredServiceAccountAnnotationKeys: provider.TokenAttributes.RequiredServiceAccountAnnotationKeys,
optionalServiceAccountAnnotationKeys: provider.TokenAttributes.OptionalServiceAccountAnnotationKeys,
}
}
type requiredAnnotationNotFoundError string
func (e requiredAnnotationNotFoundError) Error() string {
return fmt.Sprintf("required annotation %s not found", string(e))
}
// getServiceAccountData returns the service account UID and required annotations for the service account.
// If the service account does not exist, an error is returned.
// saAnnotations is a map of annotation keys and values that the plugin requires to generate credentials
// that's defined in the tokenAttributes in the credential provider config.
// requiredServiceAccountAnnotationKeys are the keys that are required to be present in the service account.
// If any of the keys defined in this list are not present in the service account, kubelet will not invoke the plugin
// and will return an error.
// optionalServiceAccountAnnotationKeys are the keys that are optional to be present in the service account.
// If present, they will be added to the saAnnotations map.
func (s *serviceAccountProvider) getServiceAccountData(namespace, name string) (types.UID, map[string]string, error) {
sa, err := s.getServiceAccountFunc(namespace, name)
if err != nil {
return "", nil, err
}
saAnnotations := make(map[string]string, len(s.requiredServiceAccountAnnotationKeys)+len(s.optionalServiceAccountAnnotationKeys))
for _, k := range s.requiredServiceAccountAnnotationKeys {
val, ok := sa.Annotations[k]
if !ok {
return "", nil, requiredAnnotationNotFoundError(k)
}
saAnnotations[k] = val
}
for _, k := range s.optionalServiceAccountAnnotationKeys {
if val, ok := sa.Annotations[k]; ok {
saAnnotations[k] = val
}
}
return sa.UID, saAnnotations, nil
}
// getServiceAccountToken returns a service account token for the service account.
func (s *serviceAccountProvider) getServiceAccountToken(podNamespace, podName, serviceAccountName string, podUID types.UID) (string, error) {
tr, err := s.getServiceAccountTokenFunc(podNamespace, serviceAccountName, &authenticationv1.TokenRequest{
Spec: authenticationv1.TokenRequestSpec{
Audiences: []string{s.audience},
// expirationSeconds is not set explicitly here. It has the same default value of "ExpirationSeconds" in the TokenRequestSpec.
BoundObjectRef: &authenticationv1.BoundObjectReference{
APIVersion: "v1",
Kind: "Pod",
Name: podName,
UID: podUID,
},
},
})
if err != nil {
return "", err
}
return tr.Status.Token, nil
}
// cacheEntry is the cache object that will be stored in cache.Store.
@ -204,15 +313,86 @@ func (c *cacheExpirationPolicy) IsExpired(entry *cache.TimestampedEntry) bool {
return c.clock.Now().After(entry.Obj.(*cacheEntry).expiresAt)
}
// Provide returns a credentialprovider.DockerConfig based on the credentials returned
// perPluginProvider holds the shared pluginProvider and the per-request information
// like podName, podNamespace, podUID and serviceAccountName.
// This is used to provide the per-request information to the pluginProvider.provide method, so
// that the plugin can use this information to get the pod's service account and generate bound service account tokens
// for plugins running in service account token mode.
type perPodPluginProvider struct {
name string
provider *pluginProvider
podNamespace string
podName string
podUID types.UID
serviceAccountName string
}
// Enabled always returns true since registration of the plugin via kubelet implies it should be enabled.
func (p *perPodPluginProvider) Enabled() bool {
return true
}
func (p *perPodPluginProvider) Provide(image string) credentialprovider.DockerConfig {
return p.provider.provide(image, p.podNamespace, p.podName, p.podUID, p.serviceAccountName)
}
// provide returns a credentialprovider.DockerConfig based on the credentials returned
// from cache or the exec plugin.
func (p *pluginProvider) Provide(image string) credentialprovider.DockerConfig {
func (p *pluginProvider) provide(image, podNamespace, podName string, podUID types.UID, serviceAccountName string) credentialprovider.DockerConfig {
if !p.isImageAllowed(image) {
return credentialprovider.DockerConfig{}
}
cachedConfig, found, err := p.getCachedCredentials(image)
if err != nil {
var serviceAccountUID types.UID
var serviceAccountToken string
var saAnnotations map[string]string
var err error
var serviceAccountCacheKey string
if p.serviceAccountProvider != nil {
if len(serviceAccountName) == 0 && p.serviceAccountProvider.requireServiceAccount {
klog.V(5).Infof("Service account name is empty for pod %s/%s", podNamespace, podName)
return credentialprovider.DockerConfig{}
}
// If the service account name is empty and the plugin has indicated that invoking the plugin
// without a service account is allowed, we will continue without generating a service account token.
// This is useful for plugins that are running in service account token mode and are also used
// to pull images for pods without service accounts (e.g., static pods).
if len(serviceAccountName) > 0 {
if serviceAccountUID, saAnnotations, err = p.serviceAccountProvider.getServiceAccountData(podNamespace, serviceAccountName); err != nil {
var requiredAnnotationNotFoundErr requiredAnnotationNotFoundError
if errors.As(err, &requiredAnnotationNotFoundErr) {
// The required annotation could be a mechanism for individual workloads to opt in to using service account tokens
// for image pull. If any of the required annotation is missing, we will not invoke the plugin. We will log the error
// at higher verbosity level as it could be noisy.
klog.V(5).Infof("Failed to get service account data %s/%s: %v", podNamespace, serviceAccountName, err)
return credentialprovider.DockerConfig{}
}
klog.Errorf("Failed to get service account %s/%s: %v", podNamespace, serviceAccountName, err)
return credentialprovider.DockerConfig{}
}
if serviceAccountToken, err = p.serviceAccountProvider.getServiceAccountToken(podNamespace, podName, serviceAccountName, podUID); err != nil {
klog.Errorf("Error getting service account token %s/%s: %v", podNamespace, serviceAccountName, err)
return credentialprovider.DockerConfig{}
}
serviceAccountCacheKey, err = generateServiceAccountCacheKey(podNamespace, serviceAccountName, serviceAccountUID, saAnnotations)
if err != nil {
klog.Errorf("Error generating service account cache key: %v", err)
return credentialprovider.DockerConfig{}
}
}
}
// Check if the credentials are cached and return them if found.
cachedConfig, found, errCache := p.getCachedCredentials(image, serviceAccountCacheKey)
if errCache != nil {
klog.Errorf("Failed to get cached docker config: %v", err)
return credentialprovider.DockerConfig{}
}
@ -227,8 +407,23 @@ func (p *pluginProvider) Provide(image string) credentialprovider.DockerConfig {
// foo.bar.registry
// foo.bar.registry/image1
// foo.bar.registry/image2
res, err, _ := p.group.Do(image, func() (interface{}, error) {
return p.plugin.ExecPlugin(context.Background(), image)
// When the plugin is operating in the service account token mode, the singleflight key is the image plus the serviceAccountCacheKey
// which is generated from the service account namespace, name, uid and the annotations passed to the plugin.
singleFlightKey := image
if p.serviceAccountProvider != nil && len(serviceAccountName) > 0 {
// When the plugin is operating in the service account token mode, the singleflight key is the
// image + sa annotations + sa token.
// This does mean the singleflight key is different for each image pull request (even if the image is the same)
// and the workload is using the same service account.
// In the future, when we support caching of the service account token for pod-sa pairs, this will be singleflighted
// for different containers in the same pod using the same image.
if singleFlightKey, err = generateSingleFlightKey(image, getHashIfNotEmpty(serviceAccountToken), saAnnotations); err != nil {
klog.Errorf("Error generating singleflight key: %v", err)
return credentialprovider.DockerConfig{}
}
}
res, err, _ := p.group.Do(singleFlightKey, func() (interface{}, error) {
return p.plugin.ExecPlugin(context.Background(), image, serviceAccountToken, saAnnotations)
})
if err != nil {
@ -280,6 +475,12 @@ func (p *pluginProvider) Provide(image string) credentialprovider.DockerConfig {
expiresAt = p.clock.Now().Add(response.CacheDuration.Duration)
}
cacheKey, err = generateCacheKey(cacheKey, serviceAccountCacheKey)
if err != nil {
klog.Errorf("Error generating cache key: %v", err)
return credentialprovider.DockerConfig{}
}
cachedEntry := &cacheEntry{
key: cacheKey,
credentials: dockerConfig,
@ -310,7 +511,7 @@ func (p *pluginProvider) isImageAllowed(image string) bool {
}
// getCachedCredentials returns a credentialprovider.DockerConfig if cached from the plugin.
func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.DockerConfig, bool, error) {
func (p *pluginProvider) getCachedCredentials(image, serviceAccountCacheKey string) (credentialprovider.DockerConfig, bool, error) {
p.Lock()
if p.clock.Now().After(p.lastCachePurge.Add(cachePurgeInterval)) {
// NewExpirationCache purges expired entries when List() is called
@ -321,7 +522,12 @@ func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.
}
p.Unlock()
obj, found, err := p.cache.GetByKey(image)
cacheKey, err := generateCacheKey(image, serviceAccountCacheKey)
if err != nil {
return nil, false, fmt.Errorf("error generating cache key: %w", err)
}
obj, found, err := p.cache.GetByKey(cacheKey)
if err != nil {
return nil, false, err
}
@ -331,7 +537,13 @@ func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.
}
registry := parseRegistry(image)
obj, found, err = p.cache.GetByKey(registry)
cacheKey, err = generateCacheKey(registry, serviceAccountCacheKey)
if err != nil {
return nil, false, fmt.Errorf("error generating cache key: %w", err)
}
obj, found, err = p.cache.GetByKey(cacheKey)
if err != nil {
return nil, false, err
}
@ -340,7 +552,12 @@ func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.
return obj.(*cacheEntry).credentials, true, nil
}
obj, found, err = p.cache.GetByKey(globalCacheKey)
cacheKey, err = generateCacheKey(globalCacheKey, serviceAccountCacheKey)
if err != nil {
return nil, false, fmt.Errorf("error generating cache key: %w", err)
}
obj, found, err = p.cache.GetByKey(cacheKey)
if err != nil {
return nil, false, err
}
@ -355,7 +572,7 @@ func (p *pluginProvider) getCachedCredentials(image string) (credentialprovider.
// 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(ctx context.Context, image, serviceAccountToken string, serviceAccountAnnotations map[string]string) (*credentialproviderapi.CredentialProviderResponse, error)
}
// execPlugin is the implementation of the Plugin interface that execs a credential provider plugin based
@ -377,10 +594,10 @@ type execPlugin struct {
//
// 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) {
func (e *execPlugin) ExecPlugin(ctx context.Context, image, serviceAccountToken string, serviceAccountAnnotations map[string]string) (*credentialproviderapi.CredentialProviderResponse, error) {
klog.V(5).Infof("Getting image %s credentials from external exec plugin %s", image, e.name)
authRequest := &credentialproviderapi.CredentialProviderRequest{Image: image}
authRequest := &credentialproviderapi.CredentialProviderRequest{Image: image, ServiceAccountToken: serviceAccountToken, ServiceAccountAnnotations: serviceAccountAnnotations}
data, err := e.encodeRequest(authRequest)
if err != nil {
return nil, fmt.Errorf("failed to encode auth request: %w", err)
@ -499,3 +716,96 @@ func mergeEnvVars(sysEnvVars, credProviderVars []string) []string {
mergedEnvVars = append(mergedEnvVars, credProviderVars...)
return mergedEnvVars
}
// generateServiceAccountCacheKey generates the serviceaccount cache key to be used for
// 1. constructing the cache key for the service account token based plugin in addition to the actual cache key (image, registry, global).
// 2. the unique key to use singleflight for the plugin in addition to the image.
func generateServiceAccountCacheKey(serviceAccountNamespace, serviceAccountName string, serviceAccountUID types.UID, saAnnotations map[string]string) (string, error) {
b := cryptobyte.NewBuilder(nil)
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(serviceAccountNamespace))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(serviceAccountName))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(serviceAccountUID))
})
// add the length of annotations to the cache key
b.AddUint32(uint32(len(saAnnotations)))
// Sort the annotations by key to ensure the cache key is deterministic
keys := sets.StringKeySet(saAnnotations).List()
for _, k := range keys {
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(k))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(saAnnotations[k]))
})
}
keyBytes, err := b.Bytes()
if err != nil {
return "", err
}
return string(keyBytes), nil
}
func generateCacheKey(baseKey, serviceAccountCacheKey string) (string, error) {
b := cryptobyte.NewBuilder(nil)
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(baseKey))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(serviceAccountCacheKey))
})
keyBytes, err := b.Bytes()
if err != nil {
return "", err
}
return string(keyBytes), nil
}
func generateSingleFlightKey(image, saTokenHash string, saAnnotations map[string]string) (string, error) {
b := cryptobyte.NewBuilder(nil)
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(image))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(saTokenHash))
})
// add the length of annotations to the cache key
b.AddUint32(uint32(len(saAnnotations)))
// Sort the annotations by key to ensure the cache key is deterministic
keys := sets.StringKeySet(saAnnotations).List()
for _, k := range keys {
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(k))
})
b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
b.AddBytes([]byte(saAnnotations[k]))
})
}
keyBytes, err := b.Bytes()
if err != nil {
return "", err
}
return string(keyBytes), nil
}
// getHashIfNotEmpty returns the sha256 hash of the data if it is not empty.
func getHashIfNotEmpty(data string) string {
if len(data) > 0 {
return fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(data)))
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
/*
Copyright 2024 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 (
"sync"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/klog/v2"
"k8s.io/kubernetes/pkg/credentialprovider"
"k8s.io/kubernetes/pkg/features"
)
type provider struct {
name string
impl *pluginProvider
}
var providersMutex sync.RWMutex
var providers = make([]provider, 0)
var seenProviderNames = sets.NewString()
func registerCredentialProviderPlugin(name string, p *pluginProvider) {
providersMutex.Lock()
defer providersMutex.Unlock()
if seenProviderNames.Has(name) {
klog.Fatalf("Credential provider %q was registered twice", name)
}
seenProviderNames.Insert(name)
providers = append(providers, provider{name, p})
klog.V(4).Infof("Registered credential provider %q", name)
}
type externalCredentialProviderKeyring struct {
providers []credentialprovider.DockerConfigProvider
}
func NewExternalCredentialProviderDockerKeyring(podNamespace, podName, podUID, serviceAccountName string) credentialprovider.DockerKeyring {
providersMutex.RLock()
defer providersMutex.RUnlock()
keyring := &externalCredentialProviderKeyring{
providers: make([]credentialprovider.DockerConfigProvider, 0, len(providers)),
}
for _, p := range providers {
if !p.impl.Enabled() {
continue
}
pp := &perPodPluginProvider{
name: p.name,
provider: p.impl,
}
if utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders) {
klog.V(4).InfoS("Generating per pod credential provider", "provider", p.name, "podName", podName, "podNamespace", podNamespace, "podUID", podUID, "serviceAccountName", serviceAccountName)
pp.podNamespace = podNamespace
pp.podName = podName
pp.podUID = types.UID(podUID)
pp.serviceAccountName = serviceAccountName
} else {
klog.V(4).InfoS("Generating credential provider", "provider", p.name)
}
keyring.providers = append(keyring.providers, pp)
}
return keyring
}
func (k *externalCredentialProviderKeyring) Lookup(image string) ([]credentialprovider.AuthConfig, bool) {
keyring := &credentialprovider.BasicDockerKeyring{}
for _, p := range k.providers {
keyring.Add(p.Provide(image))
}
return keyring.Lookup(image)
}

View File

@ -1,70 +0,0 @@
/*
Copyright 2014 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 credentialprovider
import (
"sync"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/klog/v2"
)
type provider struct {
name string
impl DockerConfigProvider
}
// All registered credential providers.
var providersMutex sync.Mutex
var providers = make([]provider, 0)
var seenProviderNames = sets.NewString()
// RegisterCredentialProvider is called by provider implementations on
// initialization to register themselves, like so:
//
// func init() {
// RegisterCredentialProvider("name", &myProvider{...})
// }
func RegisterCredentialProvider(name string, p DockerConfigProvider) {
providersMutex.Lock()
defer providersMutex.Unlock()
if seenProviderNames.Has(name) {
klog.Fatalf("Credential provider %q was registered twice", name)
}
seenProviderNames.Insert(name)
providers = append(providers, provider{name, p})
klog.V(4).Infof("Registered credential provider %q", name)
}
// NewDockerKeyring creates a DockerKeyring to use for resolving credentials,
// which draws from the set of registered credential providers.
func NewDockerKeyring() DockerKeyring {
keyring := &providersDockerKeyring{
Providers: make([]DockerConfigProvider, 0),
}
for _, p := range providers {
if p.impl.Enabled() {
klog.V(4).Infof("Registering credential provider: %v", p.name)
keyring.Providers = append(keyring.Providers, p.impl)
}
}
return keyring
}

View File

@ -42,15 +42,6 @@ type DockerConfigProvider interface {
// A DockerConfigProvider that simply reads the .dockercfg file
type defaultDockerConfigProvider struct{}
// init registers our default provider, which simply reads the .dockercfg file.
func init() {
RegisterCredentialProvider(".dockercfg",
&CachingDockerConfigProvider{
Provider: &defaultDockerConfigProvider{},
Lifetime: 5 * time.Minute,
})
}
// CachingDockerConfigProvider implements DockerConfigProvider by composing
// with another DockerConfigProvider and caching the DockerConfig it provides
// for a pre-specified lifetime.
@ -107,3 +98,16 @@ func (d *CachingDockerConfigProvider) Provide(image string) DockerConfig {
}
return config
}
// NewDefaultDockerKeyring creates a DockerKeyring to use for resolving credentials,
// which returns the default credentials from the .dockercfg file.
func NewDefaultDockerKeyring() DockerKeyring {
return &providersDockerKeyring{
Providers: []DockerConfigProvider{
&CachingDockerConfigProvider{
Provider: &defaultDockerConfigProvider{},
Lifetime: 5 * time.Minute,
},
},
}
}

View File

@ -377,6 +377,13 @@ const (
// separate filesystems.
KubeletSeparateDiskGC featuregate.Feature = "KubeletSeparateDiskGC"
// owner: @aramase
// kep: http://kep.k8s.io/4412
//
// Enable kubelet to send the service account token bound to the pod for which the image
// is being pulled to the credential provider plugin.
KubeletServiceAccountTokenForCredentialProviders featuregate.Feature = "KubeletServiceAccountTokenForCredentialProviders"
// owner: @sallyom
// kep: https://kep.k8s.io/2832
//

View File

@ -456,6 +456,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate
{Version: version.MustParse("1.31"), Default: true, PreRelease: featuregate.Beta},
},
KubeletServiceAccountTokenForCredentialProviders: {
{Version: version.MustParse("1.33"), Default: false, PreRelease: featuregate.Alpha},
},
KubeletTracing: {
{Version: version.MustParse("1.25"), Default: false, PreRelease: featuregate.Alpha},
{Version: version.MustParse("1.27"), Default: true, PreRelease: featuregate.Beta},

View File

@ -1253,6 +1253,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"k8s.io/kubelet/config/v1.CredentialProvider": schema_k8sio_kubelet_config_v1_CredentialProvider(ref),
"k8s.io/kubelet/config/v1.CredentialProviderConfig": schema_k8sio_kubelet_config_v1_CredentialProviderConfig(ref),
"k8s.io/kubelet/config/v1.ExecEnvVar": schema_k8sio_kubelet_config_v1_ExecEnvVar(ref),
"k8s.io/kubelet/config/v1.ServiceAccountTokenAttributes": schema_k8sio_kubelet_config_v1_ServiceAccountTokenAttributes(ref),
"k8s.io/kubelet/config/v1alpha1.CredentialProvider": schema_k8sio_kubelet_config_v1alpha1_CredentialProvider(ref),
"k8s.io/kubelet/config/v1alpha1.CredentialProviderConfig": schema_k8sio_kubelet_config_v1alpha1_CredentialProviderConfig(ref),
"k8s.io/kubelet/config/v1alpha1.ExecEnvVar": schema_k8sio_kubelet_config_v1alpha1_ExecEnvVar(ref),
@ -64169,12 +64170,18 @@ func schema_k8sio_kubelet_config_v1_CredentialProvider(ref common.ReferenceCallb
},
},
},
"tokenAttributes": {
SchemaProps: spec.SchemaProps{
Description: "tokenAttributes is the configuration for the service account token that will be passed to the plugin. The credential provider opts in to using service account tokens for image pull by setting this field. When this field is set, kubelet will generate a service account token bound to the pod for which the image is being pulled and pass to the plugin as part of CredentialProviderRequest along with other attributes required by the plugin.\n\nThe service account metadata and token attributes will be used as a dimension to cache the credentials in kubelet. The cache key is generated by combining the service account metadata (namespace, name, UID, and annotations key+value for the keys defined in serviceAccountTokenAttribute.requiredServiceAccountAnnotationKeys and serviceAccountTokenAttribute.optionalServiceAccountAnnotationKeys). The pod metadata (namespace, name, UID) that are in the service account token are not used as a dimension to cache the credentials in kubelet. This means workloads that are using the same service account could end up using the same credentials for image pull. For plugins that don't want this behavior, or plugins that operate in pass-through mode; i.e., they return the service account token as-is, they can set the credentialProviderResponse.cacheDuration to 0. This will disable the caching of credentials in kubelet and the plugin will be invoked for every image pull. This does result in token generation overhead for every image pull, but it is the only way to ensure that the credentials are not shared across pods (even if they are using the same service account).",
Ref: ref("k8s.io/kubelet/config/v1.ServiceAccountTokenAttributes"),
},
},
},
Required: []string{"name", "matchImages", "defaultCacheDuration", "apiVersion"},
},
},
Dependencies: []string{
"k8s.io/apimachinery/pkg/apis/meta/v1.Duration", "k8s.io/kubelet/config/v1.ExecEnvVar"},
"k8s.io/apimachinery/pkg/apis/meta/v1.Duration", "k8s.io/kubelet/config/v1.ExecEnvVar", "k8s.io/kubelet/config/v1.ServiceAccountTokenAttributes"},
}
}
@ -64250,6 +64257,75 @@ func schema_k8sio_kubelet_config_v1_ExecEnvVar(ref common.ReferenceCallback) com
}
}
func schema_k8sio_kubelet_config_v1_ServiceAccountTokenAttributes(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "ServiceAccountTokenAttributes is the configuration for the service account token that will be passed to the plugin.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"serviceAccountTokenAudience": {
SchemaProps: spec.SchemaProps{
Description: "serviceAccountTokenAudience is the intended audience for the projected service account token.",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"requireServiceAccount": {
SchemaProps: spec.SchemaProps{
Description: "requireServiceAccount indicates whether the plugin requires the pod to have a service account. If set to true, kubelet will only invoke the plugin if the pod has a service account. If set to false, kubelet will invoke the plugin even if the pod does not have a service account and will not include a token in the CredentialProviderRequest in that scenario. This is useful for plugins that are used to pull images for pods without service accounts (e.g., static pods).",
Type: []string{"boolean"},
Format: "",
},
},
"requiredServiceAccountAnnotationKeys": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "set",
},
},
SchemaProps: spec.SchemaProps{
Description: "requiredServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in and that are required to be present in the service account. The keys defined in this list will be extracted from the corresponding service account and passed to the plugin as part of the CredentialProviderRequest. If any of the keys defined in this list are not present in the service account, kubelet will not invoke the plugin and will return an error. This field is optional and may be empty. Plugins may use this field to extract additional information required to fetch credentials or allow workloads to opt in to using service account tokens for image pull. If non-empty, requireServiceAccount must be set to true.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"optionalServiceAccountAnnotationKeys": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "set",
},
},
SchemaProps: spec.SchemaProps{
Description: "optionalServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in and that are optional to be present in the service account. The keys defined in this list will be extracted from the corresponding service account and passed to the plugin as part of the CredentialProviderRequest. The plugin is responsible for validating the existence of annotations and their values. This field is optional and may be empty. Plugins may use this field to extract additional information required to fetch credentials.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"serviceAccountTokenAudience", "requireServiceAccount"},
},
},
}
}
func schema_k8sio_kubelet_config_v1alpha1_CredentialProvider(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{

View File

@ -127,5 +127,11 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
"AllBeta": true,
}
},
// tokenAttributes field is only supported in v1 CredentialProvider
func(obj *kubeletconfig.CredentialProvider, c randfill.Continue) {
c.FillNoCustom(obj)
obj.TokenAttributes = nil
},
}
}

View File

@ -670,6 +670,64 @@ type CredentialProvider struct {
// to pass argument to the plugin.
// +optional
Env []ExecEnvVar
// tokenAttributes is the configuration for the service account token that will be passed to the plugin.
// The credential provider opts in to using service account tokens for image pull by setting this field.
// When this field is set, kubelet will generate a service account token bound to the pod for which the
// image is being pulled and pass to the plugin as part of CredentialProviderRequest along with other
// attributes required by the plugin.
//
// The service account metadata and token attributes will be used as a dimension to cache
// the credentials in kubelet. The cache key is generated by combining the service account metadata
// (namespace, name, UID, and annotations key+value for the keys defined in
// serviceAccountTokenAttribute.requiredServiceAccountAnnotationKeys and serviceAccountTokenAttribute.optionalServiceAccountAnnotationKeys).
// The pod metadata (namespace, name, UID) that are in the service account token are not used as a dimension
// to cache the credentials in kubelet. This means workloads that are using the same service account
// could end up using the same credentials for image pull. For plugins that don't want this behavior, or
// plugins that operate in pass-through mode; i.e., they return the service account token as-is, they
// can set the credentialProviderResponse.cacheDuration to 0. This will disable the caching of
// credentials in kubelet and the plugin will be invoked for every image pull. This does result in
// token generation overhead for every image pull, but it is the only way to ensure that the
// credentials are not shared across pods (even if they are using the same service account).
// +optional
TokenAttributes *ServiceAccountTokenAttributes
}
// ServiceAccountTokenAttributes is the configuration for the service account token that will be passed to the plugin.
type ServiceAccountTokenAttributes struct {
// serviceAccountTokenAudience is the intended audience for the projected service account token.
// +required
ServiceAccountTokenAudience string
// requireServiceAccount indicates whether the plugin requires the pod to have a service account.
// If set to true, kubelet will only invoke the plugin if the pod has a service account.
// If set to false, kubelet will invoke the plugin even if the pod does not have a service account
// and will not include a token in the CredentialProviderRequest in that scenario. This is useful for plugins that
// are used to pull images for pods without service accounts (e.g., static pods).
// +required
RequireServiceAccount *bool
// requiredServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in
// and that are required to be present in the service account.
// The keys defined in this list will be extracted from the corresponding service account and passed
// to the plugin as part of the CredentialProviderRequest. If any of the keys defined in this list
// are not present in the service account, kubelet will not invoke the plugin and will return an error.
// This field is optional and may be empty. Plugins may use this field to extract
// additional information required to fetch credentials or allow workloads to opt in to
// using service account tokens for image pull.
// If non-empty, requireServiceAccount must be set to true.
// +optional
RequiredServiceAccountAnnotationKeys []string
// optionalServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in
// and that are optional to be present in the service account.
// The keys defined in this list will be extracted from the corresponding service account and passed
// to the plugin as part of the CredentialProviderRequest. The plugin is responsible for validating
// the existence of annotations and their values.
// This field is optional and may be empty. Plugins may use this field to extract
// additional information required to fetch credentials.
// +optional
OptionalServiceAccountAnnotationKeys []string
}
// ExecEnvVar is used for setting environment variables when executing an exec-based

View File

@ -68,6 +68,16 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*configv1.ServiceAccountTokenAttributes)(nil), (*config.ServiceAccountTokenAttributes)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1_ServiceAccountTokenAttributes_To_config_ServiceAccountTokenAttributes(a.(*configv1.ServiceAccountTokenAttributes), b.(*config.ServiceAccountTokenAttributes), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*config.ServiceAccountTokenAttributes)(nil), (*configv1.ServiceAccountTokenAttributes)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_config_ServiceAccountTokenAttributes_To_v1_ServiceAccountTokenAttributes(a.(*config.ServiceAccountTokenAttributes), b.(*configv1.ServiceAccountTokenAttributes), scope)
}); err != nil {
return err
}
return nil
}
@ -78,6 +88,7 @@ func autoConvert_v1_CredentialProvider_To_config_CredentialProvider(in *configv1
out.APIVersion = in.APIVersion
out.Args = *(*[]string)(unsafe.Pointer(&in.Args))
out.Env = *(*[]config.ExecEnvVar)(unsafe.Pointer(&in.Env))
out.TokenAttributes = (*config.ServiceAccountTokenAttributes)(unsafe.Pointer(in.TokenAttributes))
return nil
}
@ -93,6 +104,7 @@ func autoConvert_config_CredentialProvider_To_v1_CredentialProvider(in *config.C
out.APIVersion = in.APIVersion
out.Args = *(*[]string)(unsafe.Pointer(&in.Args))
out.Env = *(*[]configv1.ExecEnvVar)(unsafe.Pointer(&in.Env))
out.TokenAttributes = (*configv1.ServiceAccountTokenAttributes)(unsafe.Pointer(in.TokenAttributes))
return nil
}
@ -142,3 +154,29 @@ func autoConvert_config_ExecEnvVar_To_v1_ExecEnvVar(in *config.ExecEnvVar, out *
func Convert_config_ExecEnvVar_To_v1_ExecEnvVar(in *config.ExecEnvVar, out *configv1.ExecEnvVar, s conversion.Scope) error {
return autoConvert_config_ExecEnvVar_To_v1_ExecEnvVar(in, out, s)
}
func autoConvert_v1_ServiceAccountTokenAttributes_To_config_ServiceAccountTokenAttributes(in *configv1.ServiceAccountTokenAttributes, out *config.ServiceAccountTokenAttributes, s conversion.Scope) error {
out.ServiceAccountTokenAudience = in.ServiceAccountTokenAudience
out.RequireServiceAccount = (*bool)(unsafe.Pointer(in.RequireServiceAccount))
out.RequiredServiceAccountAnnotationKeys = *(*[]string)(unsafe.Pointer(&in.RequiredServiceAccountAnnotationKeys))
out.OptionalServiceAccountAnnotationKeys = *(*[]string)(unsafe.Pointer(&in.OptionalServiceAccountAnnotationKeys))
return nil
}
// Convert_v1_ServiceAccountTokenAttributes_To_config_ServiceAccountTokenAttributes is an autogenerated conversion function.
func Convert_v1_ServiceAccountTokenAttributes_To_config_ServiceAccountTokenAttributes(in *configv1.ServiceAccountTokenAttributes, out *config.ServiceAccountTokenAttributes, s conversion.Scope) error {
return autoConvert_v1_ServiceAccountTokenAttributes_To_config_ServiceAccountTokenAttributes(in, out, s)
}
func autoConvert_config_ServiceAccountTokenAttributes_To_v1_ServiceAccountTokenAttributes(in *config.ServiceAccountTokenAttributes, out *configv1.ServiceAccountTokenAttributes, s conversion.Scope) error {
out.ServiceAccountTokenAudience = in.ServiceAccountTokenAudience
out.RequireServiceAccount = (*bool)(unsafe.Pointer(in.RequireServiceAccount))
out.RequiredServiceAccountAnnotationKeys = *(*[]string)(unsafe.Pointer(&in.RequiredServiceAccountAnnotationKeys))
out.OptionalServiceAccountAnnotationKeys = *(*[]string)(unsafe.Pointer(&in.OptionalServiceAccountAnnotationKeys))
return nil
}
// Convert_config_ServiceAccountTokenAttributes_To_v1_ServiceAccountTokenAttributes is an autogenerated conversion function.
func Convert_config_ServiceAccountTokenAttributes_To_v1_ServiceAccountTokenAttributes(in *config.ServiceAccountTokenAttributes, out *configv1.ServiceAccountTokenAttributes, s conversion.Scope) error {
return autoConvert_config_ServiceAccountTokenAttributes_To_v1_ServiceAccountTokenAttributes(in, out, s)
}

View File

@ -0,0 +1,28 @@
/*
Copyright 2025 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 v1alpha1
import (
"k8s.io/apimachinery/pkg/conversion"
configv1alpha1 "k8s.io/kubelet/config/v1alpha1"
"k8s.io/kubernetes/pkg/kubelet/apis/config"
)
func Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider(in *config.CredentialProvider, out *configv1alpha1.CredentialProvider, s conversion.Scope) error {
// This conversion intentionally omits the tokenAttributes field which is only supported in v1 CredentialProvider.
return autoConvert_config_CredentialProvider_To_v1alpha1_CredentialProvider(in, out, s)
}

View File

@ -43,11 +43,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*config.CredentialProvider)(nil), (*configv1alpha1.CredentialProvider)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider(a.(*config.CredentialProvider), b.(*configv1alpha1.CredentialProvider), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*configv1alpha1.CredentialProviderConfig)(nil), (*config.CredentialProviderConfig)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_CredentialProviderConfig_To_config_CredentialProviderConfig(a.(*configv1alpha1.CredentialProviderConfig), b.(*config.CredentialProviderConfig), scope)
}); err != nil {
@ -68,6 +63,11 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*config.CredentialProvider)(nil), (*configv1alpha1.CredentialProvider)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider(a.(*config.CredentialProvider), b.(*configv1alpha1.CredentialProvider), scope)
}); err != nil {
return err
}
return nil
}
@ -93,16 +93,22 @@ func autoConvert_config_CredentialProvider_To_v1alpha1_CredentialProvider(in *co
out.APIVersion = in.APIVersion
out.Args = *(*[]string)(unsafe.Pointer(&in.Args))
out.Env = *(*[]configv1alpha1.ExecEnvVar)(unsafe.Pointer(&in.Env))
// WARNING: in.TokenAttributes requires manual conversion: does not exist in peer-type
return nil
}
// Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider is an autogenerated conversion function.
func Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider(in *config.CredentialProvider, out *configv1alpha1.CredentialProvider, s conversion.Scope) error {
return autoConvert_config_CredentialProvider_To_v1alpha1_CredentialProvider(in, out, s)
}
func autoConvert_v1alpha1_CredentialProviderConfig_To_config_CredentialProviderConfig(in *configv1alpha1.CredentialProviderConfig, out *config.CredentialProviderConfig, s conversion.Scope) error {
out.Providers = *(*[]config.CredentialProvider)(unsafe.Pointer(&in.Providers))
if in.Providers != nil {
in, out := &in.Providers, &out.Providers
*out = make([]config.CredentialProvider, len(*in))
for i := range *in {
if err := Convert_v1alpha1_CredentialProvider_To_config_CredentialProvider(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Providers = nil
}
return nil
}
@ -112,7 +118,17 @@ func Convert_v1alpha1_CredentialProviderConfig_To_config_CredentialProviderConfi
}
func autoConvert_config_CredentialProviderConfig_To_v1alpha1_CredentialProviderConfig(in *config.CredentialProviderConfig, out *configv1alpha1.CredentialProviderConfig, s conversion.Scope) error {
out.Providers = *(*[]configv1alpha1.CredentialProvider)(unsafe.Pointer(&in.Providers))
if in.Providers != nil {
in, out := &in.Providers, &out.Providers
*out = make([]configv1alpha1.CredentialProvider, len(*in))
for i := range *in {
if err := Convert_config_CredentialProvider_To_v1alpha1_CredentialProvider(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Providers = nil
}
return nil
}

View File

@ -0,0 +1,28 @@
/*
Copyright 2025 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 v1beta1
import (
"k8s.io/apimachinery/pkg/conversion"
configv1beta1 "k8s.io/kubelet/config/v1beta1"
"k8s.io/kubernetes/pkg/kubelet/apis/config"
)
func Convert_config_CredentialProvider_To_v1beta1_CredentialProvider(in *config.CredentialProvider, out *configv1beta1.CredentialProvider, s conversion.Scope) error {
// This conversion intentionally omits the tokenAttributes field which is only supported in v1 CredentialProvider.
return autoConvert_config_CredentialProvider_To_v1beta1_CredentialProvider(in, out, s)
}

View File

@ -55,11 +55,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*config.CredentialProvider)(nil), (*configv1beta1.CredentialProvider)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_config_CredentialProvider_To_v1beta1_CredentialProvider(a.(*config.CredentialProvider), b.(*configv1beta1.CredentialProvider), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*configv1beta1.CredentialProviderConfig)(nil), (*config.CredentialProviderConfig)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1beta1_CredentialProviderConfig_To_config_CredentialProviderConfig(a.(*configv1beta1.CredentialProviderConfig), b.(*config.CredentialProviderConfig), scope)
}); err != nil {
@ -190,6 +185,11 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*config.CredentialProvider)(nil), (*configv1beta1.CredentialProvider)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_config_CredentialProvider_To_v1beta1_CredentialProvider(a.(*config.CredentialProvider), b.(*configv1beta1.CredentialProvider), scope)
}); err != nil {
return err
}
return nil
}
@ -235,16 +235,22 @@ func autoConvert_config_CredentialProvider_To_v1beta1_CredentialProvider(in *con
out.APIVersion = in.APIVersion
out.Args = *(*[]string)(unsafe.Pointer(&in.Args))
out.Env = *(*[]configv1beta1.ExecEnvVar)(unsafe.Pointer(&in.Env))
// WARNING: in.TokenAttributes requires manual conversion: does not exist in peer-type
return nil
}
// Convert_config_CredentialProvider_To_v1beta1_CredentialProvider is an autogenerated conversion function.
func Convert_config_CredentialProvider_To_v1beta1_CredentialProvider(in *config.CredentialProvider, out *configv1beta1.CredentialProvider, s conversion.Scope) error {
return autoConvert_config_CredentialProvider_To_v1beta1_CredentialProvider(in, out, s)
}
func autoConvert_v1beta1_CredentialProviderConfig_To_config_CredentialProviderConfig(in *configv1beta1.CredentialProviderConfig, out *config.CredentialProviderConfig, s conversion.Scope) error {
out.Providers = *(*[]config.CredentialProvider)(unsafe.Pointer(&in.Providers))
if in.Providers != nil {
in, out := &in.Providers, &out.Providers
*out = make([]config.CredentialProvider, len(*in))
for i := range *in {
if err := Convert_v1beta1_CredentialProvider_To_config_CredentialProvider(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Providers = nil
}
return nil
}
@ -254,7 +260,17 @@ func Convert_v1beta1_CredentialProviderConfig_To_config_CredentialProviderConfig
}
func autoConvert_config_CredentialProviderConfig_To_v1beta1_CredentialProviderConfig(in *config.CredentialProviderConfig, out *configv1beta1.CredentialProviderConfig, s conversion.Scope) error {
out.Providers = *(*[]configv1beta1.CredentialProvider)(unsafe.Pointer(&in.Providers))
if in.Providers != nil {
in, out := &in.Providers, &out.Providers
*out = make([]configv1beta1.CredentialProvider, len(*in))
for i := range *in {
if err := Convert_config_CredentialProvider_To_v1beta1_CredentialProvider(&(*in)[i], &(*out)[i], s); err != nil {
return err
}
}
} else {
out.Providers = nil
}
return nil
}

View File

@ -72,6 +72,11 @@ func (in *CredentialProvider) DeepCopyInto(out *CredentialProvider) {
*out = make([]ExecEnvVar, len(*in))
copy(*out, *in)
}
if in.TokenAttributes != nil {
in, out := &in.TokenAttributes, &out.TokenAttributes
*out = new(ServiceAccountTokenAttributes)
(*in).DeepCopyInto(*out)
}
return
}
@ -491,6 +496,37 @@ func (in *SerializedNodeConfigSource) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceAccountTokenAttributes) DeepCopyInto(out *ServiceAccountTokenAttributes) {
*out = *in
if in.RequireServiceAccount != nil {
in, out := &in.RequireServiceAccount, &out.RequireServiceAccount
*out = new(bool)
**out = **in
}
if in.RequiredServiceAccountAnnotationKeys != nil {
in, out := &in.RequiredServiceAccountAnnotationKeys, &out.RequiredServiceAccountAnnotationKeys
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.OptionalServiceAccountAnnotationKeys != nil {
in, out := &in.OptionalServiceAccountAnnotationKeys, &out.OptionalServiceAccountAnnotationKeys
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountTokenAttributes.
func (in *ServiceAccountTokenAttributes) DeepCopy() *ServiceAccountTokenAttributes {
if in == nil {
return nil
}
out := new(ServiceAccountTokenAttributes)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ShutdownGracePeriodByPodPriority) DeepCopyInto(out *ShutdownGracePeriodByPodPriority) {
*out = *in

View File

@ -152,7 +152,7 @@ type StreamingRuntime interface {
type ImageService interface {
// PullImage pulls an image from the network to local storage using the supplied
// secrets if necessary. It returns a reference (digest or ID) to the pulled image.
PullImage(ctx context.Context, image ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error)
PullImage(ctx context.Context, image ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) (string, error)
// GetImageRef gets the reference (digest or ID) of the image which has already been in
// the local storage. It returns ("", nil) if the image isn't in the local storage.
GetImageRef(ctx context.Context, image ImageSpec) (string, error)

View File

@ -308,7 +308,7 @@ func (f *FakeRuntime) GetContainerLogs(_ context.Context, pod *v1.Pod, container
return f.Err
}
func (f *FakeRuntime) PullImage(ctx context.Context, image kubecontainer.ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error) {
func (f *FakeRuntime) PullImage(ctx context.Context, image kubecontainer.ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) (string, error) {
f.Lock()
f.CalledFunctions = append(f.CalledFunctions, "PullImage")
if f.Err == nil {

View File

@ -990,9 +990,9 @@ func (_c *MockRuntime_ListPodSandboxMetrics_Call) RunAndReturn(run func(context.
return _c
}
// PullImage provides a mock function with given fields: ctx, image, pullSecrets, podSandboxConfig
func (_m *MockRuntime) PullImage(ctx context.Context, image container.ImageSpec, pullSecrets []corev1.Secret, podSandboxConfig *v1.PodSandboxConfig) (string, error) {
ret := _m.Called(ctx, image, pullSecrets, podSandboxConfig)
// PullImage provides a mock function with given fields: ctx, image, pullSecrets, podSandboxConfig, serviceAccountName
func (_m *MockRuntime) PullImage(ctx context.Context, image container.ImageSpec, pullSecrets []corev1.Secret, podSandboxConfig *v1.PodSandboxConfig, serviceAccountName string) (string, error) {
ret := _m.Called(ctx, image, pullSecrets, podSandboxConfig, serviceAccountName)
if len(ret) == 0 {
panic("no return value specified for PullImage")
@ -1000,17 +1000,17 @@ func (_m *MockRuntime) PullImage(ctx context.Context, image container.ImageSpec,
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig) (string, error)); ok {
return rf(ctx, image, pullSecrets, podSandboxConfig)
if rf, ok := ret.Get(0).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig, string) (string, error)); ok {
return rf(ctx, image, pullSecrets, podSandboxConfig, serviceAccountName)
}
if rf, ok := ret.Get(0).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig) string); ok {
r0 = rf(ctx, image, pullSecrets, podSandboxConfig)
if rf, ok := ret.Get(0).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig, string) string); ok {
r0 = rf(ctx, image, pullSecrets, podSandboxConfig, serviceAccountName)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig) error); ok {
r1 = rf(ctx, image, pullSecrets, podSandboxConfig)
if rf, ok := ret.Get(1).(func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig, string) error); ok {
r1 = rf(ctx, image, pullSecrets, podSandboxConfig, serviceAccountName)
} else {
r1 = ret.Error(1)
}
@ -1028,13 +1028,14 @@ type MockRuntime_PullImage_Call struct {
// - image container.ImageSpec
// - pullSecrets []corev1.Secret
// - podSandboxConfig *v1.PodSandboxConfig
func (_e *MockRuntime_Expecter) PullImage(ctx interface{}, image interface{}, pullSecrets interface{}, podSandboxConfig interface{}) *MockRuntime_PullImage_Call {
return &MockRuntime_PullImage_Call{Call: _e.mock.On("PullImage", ctx, image, pullSecrets, podSandboxConfig)}
// - serviceAccountName string
func (_e *MockRuntime_Expecter) PullImage(ctx interface{}, image interface{}, pullSecrets interface{}, podSandboxConfig interface{}, serviceAccountName interface{}) *MockRuntime_PullImage_Call {
return &MockRuntime_PullImage_Call{Call: _e.mock.On("PullImage", ctx, image, pullSecrets, podSandboxConfig, serviceAccountName)}
}
func (_c *MockRuntime_PullImage_Call) Run(run func(ctx context.Context, image container.ImageSpec, pullSecrets []corev1.Secret, podSandboxConfig *v1.PodSandboxConfig)) *MockRuntime_PullImage_Call {
func (_c *MockRuntime_PullImage_Call) Run(run func(ctx context.Context, image container.ImageSpec, pullSecrets []corev1.Secret, podSandboxConfig *v1.PodSandboxConfig, serviceAccountName string)) *MockRuntime_PullImage_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(container.ImageSpec), args[2].([]corev1.Secret), args[3].(*v1.PodSandboxConfig))
run(args[0].(context.Context), args[1].(container.ImageSpec), args[2].([]corev1.Secret), args[3].(*v1.PodSandboxConfig), args[4].(string))
})
return _c
}
@ -1044,7 +1045,7 @@ func (_c *MockRuntime_PullImage_Call) Return(_a0 string, _a1 error) *MockRuntime
return _c
}
func (_c *MockRuntime_PullImage_Call) RunAndReturn(run func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig) (string, error)) *MockRuntime_PullImage_Call {
func (_c *MockRuntime_PullImage_Call) RunAndReturn(run func(context.Context, container.ImageSpec, []corev1.Secret, *v1.PodSandboxConfig, string) (string, error)) *MockRuntime_PullImage_Call {
_c.Call.Return(run)
return _c
}

View File

@ -44,9 +44,9 @@ type throttledImageService struct {
limiter flowcontrol.RateLimiter
}
func (ts throttledImageService) PullImage(ctx context.Context, image kubecontainer.ImageSpec, secrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error) {
func (ts throttledImageService) PullImage(ctx context.Context, image kubecontainer.ImageSpec, secrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) (string, error) {
if ts.limiter.TryAccept() {
return ts.ImageService.PullImage(ctx, image, secrets, podSandboxConfig)
return ts.ImageService.PullImage(ctx, image, secrets, podSandboxConfig, serviceAccountName)
}
return "", fmt.Errorf("pull QPS exceeded")
}

View File

@ -175,7 +175,7 @@ func (m *imageManager) EnsureImageExists(ctx context.Context, objRef *v1.ObjectR
m.logIt(objRef, v1.EventTypeNormal, events.PullingImage, logPrefix, fmt.Sprintf("Pulling image %q", imgRef), klog.Info)
startTime := time.Now()
pullChan := make(chan pullResult)
m.puller.pullImage(ctx, spec, pullSecrets, pullChan, podSandboxConfig)
m.puller.pullImage(ctx, spec, pullSecrets, pullChan, podSandboxConfig, pod.Spec.ServiceAccountName)
imagePullResult := <-pullChan
if imagePullResult.err != nil {
m.logIt(objRef, v1.EventTypeWarning, events.FailedToPullImage, logPrefix, fmt.Sprintf("Failed to pull image %q: %v", imgRef, imagePullResult.err), klog.Warning)

View File

@ -34,7 +34,7 @@ type pullResult struct {
}
type imagePuller interface {
pullImage(context.Context, kubecontainer.ImageSpec, []v1.Secret, chan<- pullResult, *runtimeapi.PodSandboxConfig)
pullImage(context.Context, kubecontainer.ImageSpec, []v1.Secret, chan<- pullResult, *runtimeapi.PodSandboxConfig, string)
}
var _, _ imagePuller = &parallelImagePuller{}, &serialImagePuller{}
@ -51,14 +51,14 @@ func newParallelImagePuller(imageService kubecontainer.ImageService, maxParallel
return &parallelImagePuller{imageService, make(chan struct{}, *maxParallelImagePulls)}
}
func (pip *parallelImagePuller) pullImage(ctx context.Context, spec kubecontainer.ImageSpec, pullSecrets []v1.Secret, pullChan chan<- pullResult, podSandboxConfig *runtimeapi.PodSandboxConfig) {
func (pip *parallelImagePuller) pullImage(ctx context.Context, spec kubecontainer.ImageSpec, pullSecrets []v1.Secret, pullChan chan<- pullResult, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) {
go func() {
if pip.tokens != nil {
pip.tokens <- struct{}{}
defer func() { <-pip.tokens }()
}
startTime := time.Now()
imageRef, err := pip.imageService.PullImage(ctx, spec, pullSecrets, podSandboxConfig)
imageRef, err := pip.imageService.PullImage(ctx, spec, pullSecrets, podSandboxConfig, serviceAccountName)
var size uint64
if err == nil && imageRef != "" {
// Getting the image size with best effort, ignoring the error.
@ -88,27 +88,29 @@ func newSerialImagePuller(imageService kubecontainer.ImageService) imagePuller {
}
type imagePullRequest struct {
ctx context.Context
spec kubecontainer.ImageSpec
pullSecrets []v1.Secret
pullChan chan<- pullResult
podSandboxConfig *runtimeapi.PodSandboxConfig
ctx context.Context
spec kubecontainer.ImageSpec
pullSecrets []v1.Secret
pullChan chan<- pullResult
podSandboxConfig *runtimeapi.PodSandboxConfig
serviceAccountName string
}
func (sip *serialImagePuller) pullImage(ctx context.Context, spec kubecontainer.ImageSpec, pullSecrets []v1.Secret, pullChan chan<- pullResult, podSandboxConfig *runtimeapi.PodSandboxConfig) {
func (sip *serialImagePuller) pullImage(ctx context.Context, spec kubecontainer.ImageSpec, pullSecrets []v1.Secret, pullChan chan<- pullResult, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) {
sip.pullRequests <- &imagePullRequest{
ctx: ctx,
spec: spec,
pullSecrets: pullSecrets,
pullChan: pullChan,
podSandboxConfig: podSandboxConfig,
ctx: ctx,
spec: spec,
pullSecrets: pullSecrets,
pullChan: pullChan,
podSandboxConfig: podSandboxConfig,
serviceAccountName: serviceAccountName,
}
}
func (sip *serialImagePuller) processImagePullRequests() {
for pullRequest := range sip.pullRequests {
startTime := time.Now()
imageRef, err := sip.imageService.PullImage(pullRequest.ctx, pullRequest.spec, pullRequest.pullSecrets, pullRequest.podSandboxConfig)
imageRef, err := sip.imageService.PullImage(pullRequest.ctx, pullRequest.spec, pullRequest.pullSecrets, pullRequest.podSandboxConfig, pullRequest.serviceAccountName)
var size uint64
if err == nil && imageRef != "" {
// Getting the image size with best effort, ignoring the error.

View File

@ -41,6 +41,7 @@ import (
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
"go.opentelemetry.io/otel/trace"
"k8s.io/client-go/informers"
"k8s.io/mount-utils"
@ -713,6 +714,19 @@ func NewMainKubelet(kubeCfg *kubeletconfiginternal.KubeletConfiguration,
}
}
tokenManager := token.NewManager(kubeDeps.KubeClient)
getServiceAccount := func(namespace, name string) (*v1.ServiceAccount, error) {
return nil, fmt.Errorf("get service account is not implemented")
}
if utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders) {
getServiceAccount = func(namespace, name string) (*v1.ServiceAccount, error) {
if klet.kubeClient == nil {
return nil, errors.New("cannot get ServiceAccounts when kubelet is in standalone mode")
}
return klet.kubeClient.CoreV1().ServiceAccounts(namespace).Get(ctx, name, metav1.GetOptions{})
}
}
runtime, err := kuberuntime.NewKubeGenericRuntimeManager(
kubecontainer.FilterEventRecorder(kubeDeps.Recorder),
klet.livenessManager,
@ -747,6 +761,8 @@ func NewMainKubelet(kubeCfg *kubeletconfiginternal.KubeletConfiguration,
*kubeCfg.MemoryThrottlingFactor,
kubeDeps.PodStartupLatencyTracker,
kubeDeps.TracerProvider,
tokenManager,
getServiceAccount,
)
if err != nil {
return nil, err
@ -876,8 +892,6 @@ func NewMainKubelet(kubeCfg *kubeletconfiginternal.KubeletConfiguration,
kubeDeps.Recorder)
}
tokenManager := token.NewManager(kubeDeps.KubeClient)
var clusterTrustBundleManager clustertrustbundle.Manager = &clustertrustbundle.NoopManager{}
if kubeDeps.KubeClient != nil && utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundleProjection) {
clusterTrustBundleManager = clustertrustbundle.NewLazyInformerManager(ctx, kubeDeps.KubeClient, 2*int(kubeCfg.MaxPods))

View File

@ -40,6 +40,7 @@ import (
cadvisorapiv2 "github.com/google/cadvisor/info/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
core "k8s.io/client-go/testing"
"k8s.io/mount-utils"
@ -3424,6 +3425,8 @@ func TestSyncPodSpans(t *testing.T) {
*kubeCfg.MemoryThrottlingFactor,
kubeletutil.NewPodStartupLatencyTracker(),
tp,
token.NewManager(kubelet.kubeClient),
func(string, string) (*v1.ServiceAccount, error) { return nil, nil },
)
assert.NoError(t, err)

View File

@ -24,6 +24,8 @@ import (
utilfeature "k8s.io/apiserver/pkg/util/feature"
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
"k8s.io/klog/v2"
"k8s.io/kubernetes/pkg/credentialprovider"
credentialproviderplugin "k8s.io/kubernetes/pkg/credentialprovider/plugin"
credentialprovidersecrets "k8s.io/kubernetes/pkg/credentialprovider/secrets"
"k8s.io/kubernetes/pkg/features"
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
@ -32,14 +34,26 @@ import (
// PullImage pulls an image from the network to local storage using the supplied
// secrets if necessary.
func (m *kubeGenericRuntimeManager) PullImage(ctx context.Context, image kubecontainer.ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig) (string, error) {
func (m *kubeGenericRuntimeManager) PullImage(ctx context.Context, image kubecontainer.ImageSpec, pullSecrets []v1.Secret, podSandboxConfig *runtimeapi.PodSandboxConfig, serviceAccountName string) (string, error) {
img := image.Image
repoToPull, _, _, err := parsers.ParseImageName(img)
if err != nil {
return "", err
}
keyring, err := credentialprovidersecrets.MakeDockerKeyring(pullSecrets, m.keyring)
// construct the dynamic keyring using the providers we have in the kubelet
var podName, podNamespace, podUID string
if utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders) {
sandboxMetadata := podSandboxConfig.GetMetadata()
podName = sandboxMetadata.Name
podNamespace = sandboxMetadata.Namespace
podUID = sandboxMetadata.Uid
}
externalCredentialProviderKeyring := credentialproviderplugin.NewExternalCredentialProviderDockerKeyring(podNamespace, podName, podUID, serviceAccountName)
keyring, err := credentialprovidersecrets.MakeDockerKeyring(pullSecrets, credentialprovider.UnionDockerKeyring{m.keyring, externalCredentialProviderKeyring})
if err != nil {
return "", err
}

View File

@ -37,7 +37,7 @@ func TestPullImage(t *testing.T) {
_, _, fakeManager, err := createTestRuntimeManager()
assert.NoError(t, err)
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil)
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil, "")
assert.NoError(t, err)
assert.Equal(t, "busybox", imageRef)
@ -53,12 +53,12 @@ func TestPullImageWithError(t *testing.T) {
assert.NoError(t, err)
// trying to pull an image with an invalid name should return an error
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: ":invalid"}, nil, nil)
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: ":invalid"}, nil, nil, "")
assert.Error(t, err)
assert.Equal(t, "", imageRef)
fakeImageService.InjectError("PullImage", fmt.Errorf("test-error"))
imageRef, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil)
imageRef, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil, "")
assert.Error(t, err)
assert.Equal(t, "", imageRef)
@ -75,7 +75,7 @@ func TestPullImageWithInvalidImageName(t *testing.T) {
fakeImageService.SetFakeImages(imageList)
for _, val := range imageList {
ctx := context.Background()
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: val}, nil, nil)
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: val}, nil, nil, "")
assert.Error(t, err)
assert.Equal(t, "", imageRef)
@ -196,7 +196,7 @@ func TestRemoveImage(t *testing.T) {
_, fakeImageService, fakeManager, err := createTestRuntimeManager()
assert.NoError(t, err)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil, "")
assert.NoError(t, err)
assert.Len(t, fakeImageService.Images, 1)
@ -219,7 +219,7 @@ func TestRemoveImageWithError(t *testing.T) {
_, fakeImageService, fakeManager, err := createTestRuntimeManager()
assert.NoError(t, err)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: "busybox"}, nil, nil, "")
assert.NoError(t, err)
assert.Len(t, fakeImageService.Images, 1)
@ -324,7 +324,7 @@ func TestPullWithSecrets(t *testing.T) {
_, fakeImageService, fakeManager, err := customTestRuntimeManager(builtInKeyRing)
require.NoError(t, err)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: test.imageName}, test.passedSecrets, nil)
_, err = fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: test.imageName}, test.passedSecrets, nil, "")
require.NoError(t, err)
fakeImageService.AssertImagePulledWithAuth(t, &runtimeapi.ImageSpec{Image: test.imageName, Annotations: make(map[string]string)}, test.expectedAuth, description)
}
@ -375,7 +375,7 @@ func TestPullWithSecretsWithError(t *testing.T) {
fakeImageService.InjectError("PullImage", fmt.Errorf("test-error"))
}
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: test.imageName}, test.passedSecrets, nil)
imageRef, err := fakeManager.PullImage(ctx, kubecontainer.ImageSpec{Image: test.imageName}, test.passedSecrets, nil, "")
assert.Error(t, err)
assert.Equal(t, "", imageRef)
@ -398,7 +398,7 @@ func TestPullThenListWithAnnotations(t *testing.T) {
},
}
_, err = fakeManager.PullImage(ctx, imageSpec, nil, nil)
_, err = fakeManager.PullImage(ctx, imageSpec, nil, nil, "")
assert.NoError(t, err)
images, err := fakeManager.ListImages(ctx)

View File

@ -28,8 +28,6 @@ import (
cadvisorapi "github.com/google/cadvisor/info/v1"
"go.opentelemetry.io/otel/trace"
grpcstatus "google.golang.org/grpc/status"
crierror "k8s.io/cri-api/pkg/errors"
"k8s.io/klog/v2"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
@ -44,7 +42,8 @@ import (
"k8s.io/component-base/logs/logreduction"
internalapi "k8s.io/cri-api/pkg/apis"
runtimeapi "k8s.io/cri-api/pkg/apis/runtime/v1"
crierror "k8s.io/cri-api/pkg/errors"
"k8s.io/klog/v2"
"k8s.io/kubernetes/pkg/api/legacyscheme"
podutil "k8s.io/kubernetes/pkg/api/v1/pod"
"k8s.io/kubernetes/pkg/credentialprovider"
@ -62,6 +61,7 @@ import (
proberesults "k8s.io/kubernetes/pkg/kubelet/prober/results"
"k8s.io/kubernetes/pkg/kubelet/runtimeclass"
"k8s.io/kubernetes/pkg/kubelet/sysctl"
"k8s.io/kubernetes/pkg/kubelet/token"
"k8s.io/kubernetes/pkg/kubelet/types"
"k8s.io/kubernetes/pkg/kubelet/util/cache"
"k8s.io/kubernetes/pkg/kubelet/util/format"
@ -223,6 +223,8 @@ func NewKubeGenericRuntimeManager(
memoryThrottlingFactor float64,
podPullingTimeRecorder images.ImagePodPullingTimeRecorder,
tracerProvider trace.TracerProvider,
tokenManager *token.Manager,
getServiceAccount func(string, string) (*v1.ServiceAccount, error),
) (KubeGenericRuntime, error) {
ctx := context.Background()
runtimeService = newInstrumentedRuntimeService(runtimeService)
@ -277,12 +279,12 @@ func NewKubeGenericRuntimeManager(
"apiVersion", typedVersion.RuntimeApiVersion)
if imageCredentialProviderConfigFile != "" || imageCredentialProviderBinDir != "" {
if err := plugin.RegisterCredentialProviderPlugins(imageCredentialProviderConfigFile, imageCredentialProviderBinDir); err != nil {
if err := plugin.RegisterCredentialProviderPlugins(imageCredentialProviderConfigFile, imageCredentialProviderBinDir, tokenManager.GetServiceAccountToken, getServiceAccount); err != nil {
klog.ErrorS(err, "Failed to register CRI auth plugins")
os.Exit(1)
}
}
kubeRuntimeManager.keyring = credentialprovider.NewDockerKeyring()
kubeRuntimeManager.keyring = credentialprovider.NewDefaultDockerKeyring()
kubeRuntimeManager.imagePuller = images.NewImageManager(
kubecontainer.FilterEventRecorder(recorder),

View File

@ -27,14 +27,13 @@ import (
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
cadvisorapi "github.com/google/cadvisor/info/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
noopoteltrace "go.opentelemetry.io/otel/trace/noop"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"

View File

@ -135,7 +135,7 @@ func (r *NodeAuthorizer) Authorize(ctx context.Context, attrs authorizer.Attribu
case vaResource:
return r.authorizeGet(nodeName, vaVertexType, attrs)
case svcAcctResource:
return r.authorizeCreateToken(nodeName, serviceAccountVertexType, attrs)
return r.authorizeServiceAccount(nodeName, attrs)
case leaseResource:
return r.authorizeLease(nodeName, attrs)
case csiNodeResource:
@ -196,7 +196,7 @@ func (r *NodeAuthorizer) authorizeGet(nodeName string, startingType vertexType,
func (r *NodeAuthorizer) authorizeReadNamespacedObject(nodeName string, startingType vertexType, attrs authorizer.Attributes) (authorizer.Decision, string, error) {
switch attrs.GetVerb() {
case "get", "list", "watch":
//ok
// ok
default:
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "can only read resources of this type", nil
@ -231,6 +231,23 @@ func (r *NodeAuthorizer) authorize(nodeName string, startingType vertexType, att
return authorizer.DecisionAllow, "", nil
}
// authorizeServiceAccount authorizes
// - "get" requests to serviceaccounts when KubeletServiceAccountTokenForCredentialProviders feature is enabled
// - "create" requests to serviceaccounts 'token' subresource of pods running on a node
func (r *NodeAuthorizer) authorizeServiceAccount(nodeName string, attrs authorizer.Attributes) (authorizer.Decision, string, error) {
verb := attrs.GetVerb()
if verb == "get" && attrs.GetSubresource() == "" {
if !r.features.Enabled(features.KubeletServiceAccountTokenForCredentialProviders) {
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "not allowed to get service accounts", nil
}
return r.authorizeReadNamespacedObject(nodeName, serviceAccountVertexType, attrs)
}
return r.authorizeCreateToken(nodeName, serviceAccountVertexType, attrs)
}
// authorizeCreateToken authorizes "create" requests to serviceaccounts 'token'
// subresource of pods running on a node
func (r *NodeAuthorizer) authorizeCreateToken(nodeName string, startingType vertexType, attrs authorizer.Attributes) (authorizer.Decision, string, error) {
@ -262,7 +279,7 @@ func (r *NodeAuthorizer) authorizeLease(nodeName string, attrs authorizer.Attrib
verb := attrs.GetVerb()
switch verb {
case "get", "create", "update", "patch", "delete":
//ok
// ok
default:
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "can only get, create, update, patch, or delete a node lease", nil
@ -291,7 +308,7 @@ func (r *NodeAuthorizer) authorizeCSINode(nodeName string, attrs authorizer.Attr
verb := attrs.GetVerb()
switch verb {
case "get", "create", "update", "patch", "delete":
//ok
// ok
default:
klog.V(2).Infof("NODE DENY: '%s' %#v", nodeName, attrs)
return authorizer.DecisionNoOpinion, "can only get, create, update, patch, or delete a CSINode", nil

View File

@ -80,6 +80,12 @@ func TestNodeAuthorizer(t *testing.T) {
featuregatetesting.SetFeatureGateDuringTest(t, selectorAuthzEnabled, genericfeatures.AuthorizeWithSelectors, true)
featuregatetesting.SetFeatureGateDuringTest(t, selectorAuthzEnabled, features.AuthorizeNodeWithSelectors, true)
serviceAccountTokenForCredentialProvidersDisabled := utilfeature.DefaultFeatureGate.DeepCopy()
featuregatetesting.SetFeatureGateDuringTest(t, serviceAccountTokenForCredentialProvidersDisabled, features.KubeletServiceAccountTokenForCredentialProviders, false)
serviceAccountTokenForCredentialProvidersEnabled := utilfeature.DefaultFeatureGate.DeepCopy()
featuregatetesting.SetFeatureGateDuringTest(t, serviceAccountTokenForCredentialProvidersEnabled, features.KubeletServiceAccountTokenForCredentialProviders, true)
featureVariants := []struct {
suffix string
features featuregate.FeatureGate
@ -89,10 +95,11 @@ func TestNodeAuthorizer(t *testing.T) {
}
tests := []struct {
name string
attrs authorizer.AttributesRecord
expect authorizer.Decision
features featuregate.FeatureGate
name string
attrs authorizer.AttributesRecord
expect authorizer.Decision
expectReason string
features featuregate.FeatureGate
}{
{
name: "allowed configmap",
@ -115,19 +122,22 @@ func TestNodeAuthorizer(t *testing.T) {
expect: authorizer.DecisionAllow,
},
{
name: "disallowed list many secrets",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "secrets", Name: "", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
name: "disallowed list many secrets",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "secrets", Name: "", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
expectReason: "No Object name found,",
},
{
name: "disallowed watch many secrets",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "secrets", Name: "", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
name: "disallowed watch many secrets",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "secrets", Name: "", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
expectReason: "No Object name found,",
},
{
name: "disallowed list secrets from all namespaces with name",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "secrets", Name: "secret0-pod0-node0", Namespace: ""},
expect: authorizer.DecisionNoOpinion,
name: "disallowed list secrets from all namespaces with name",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "secrets", Name: "secret0-pod0-node0", Namespace: ""},
expect: authorizer.DecisionNoOpinion,
expectReason: "can only read namespaced object of this type",
},
{
name: "allowed shared secret via pod",
@ -219,6 +229,33 @@ func TestNodeAuthorizer(t *testing.T) {
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "update", Resource: "serviceaccounts", Subresource: "token", Name: "svcacct0-node0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
},
{
name: "get allowed svcacct via pod - feature enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "serviceaccounts", Name: "svcacct0-node0", Namespace: "ns0"},
expect: authorizer.DecisionAllow,
features: serviceAccountTokenForCredentialProvidersEnabled,
},
{
name: "disallowed get svcacct via pod - feature disabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "serviceaccounts", Name: "svcacct0-node0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
features: serviceAccountTokenForCredentialProvidersDisabled,
expectReason: "not allowed to get service accounts",
},
{
name: "disallowed list svcacct via pod - feature disabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "serviceaccounts", Name: "svcacct0-node0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
features: serviceAccountTokenForCredentialProvidersDisabled,
expectReason: "can only create tokens for individual service accounts",
},
{
name: "disallowed watch svcacct via pod - feature disabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "serviceaccounts", Name: "svcacct0-node0", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion,
features: serviceAccountTokenForCredentialProvidersDisabled,
expectReason: "can only create tokens for individual service accounts",
},
{
name: "disallowed get lease in namespace other than kube-node-lease - feature enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "leases", APIGroup: "coordination.k8s.io", Name: "node0", Namespace: "foo"},
@ -398,10 +435,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "disallowed unfiltered list ResourceSlices - selector authz enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "resourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionNoOpinion,
features: selectorAuthzEnabled,
name: "disallowed unfiltered list ResourceSlices - selector authz enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "resourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionNoOpinion,
features: selectorAuthzEnabled,
expectReason: "can only list/watch/deletecollection resourceslices with nodeName field selector",
},
{
name: "allowed filtered watch ResourceSlices",
@ -415,10 +453,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "disallowed unfiltered watch ResourceSlices - selector authz enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "resourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionNoOpinion,
features: selectorAuthzEnabled,
name: "disallowed unfiltered watch ResourceSlices - selector authz enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "resourceslices", APIGroup: "resource.k8s.io"},
expect: authorizer.DecisionNoOpinion,
features: selectorAuthzEnabled,
expectReason: "can only list/watch/deletecollection resourceslices with nodeName field selector",
},
{
name: "allowed get ResourceSlice",
@ -460,10 +499,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "get unrelated pod - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "pods", APIGroup: "", Name: "pod0-node1", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "get unrelated pod - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "pods", APIGroup: "", Name: "pod0-node1", Namespace: "ns0"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "no relationship found between node 'node0' and this object",
},
// list pods
{
@ -488,10 +528,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "list unrelated pods - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "pods", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "list unrelated pods - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "pods", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "can only list/watch pods with spec.nodeName field selector",
},
// watch pods
{
@ -516,10 +557,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "watch unrelated pods - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "pods", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "watch unrelated pods - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "pods", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "can only list/watch pods with spec.nodeName field selector",
},
// create, delete pods
{
@ -604,10 +646,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "get unrelated pod - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "get unrelated pod - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "get", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "node 'node0' cannot read 'node1', only its own Node object",
},
// list nodes
{
@ -622,10 +665,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "list single unrelated node - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "list single unrelated node - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "node 'node0' cannot read 'node1', only its own Node object",
},
{
name: "list all nodes - selector disabled",
@ -634,10 +678,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "list all nodes - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "nodes", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "list all nodes - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "list", Resource: "nodes", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "node 'node0' cannot read all nodes, only its own Node object",
},
// watch nodes
{
@ -652,10 +697,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "watch single unrelated node - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "watch single unrelated node - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "nodes", APIGroup: "", Name: "node1"},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "node 'node0' cannot read 'node1', only its own Node object",
},
{
name: "watch all nodes - selector disabled",
@ -664,10 +710,11 @@ func TestNodeAuthorizer(t *testing.T) {
features: selectorAuthzDisabled,
},
{
name: "watch all nodes - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "nodes", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
name: "watch all nodes - selector enabled",
attrs: authorizer.AttributesRecord{User: node0, ResourceRequest: true, Verb: "watch", Resource: "nodes", APIGroup: ""},
expect: authorizer.DecisionNoOpinion, // stricter with selector authz enabled
features: selectorAuthzEnabled,
expectReason: "node 'node0' cannot read all nodes, only its own Node object",
},
// create nodes
{
@ -737,6 +784,9 @@ func TestNodeAuthorizer(t *testing.T) {
if decision != tc.expect {
t.Errorf("expected %v, got %v (%s)", tc.expect, decision, reason)
}
if reason != tc.expectReason {
t.Errorf("expected reason %q, got %q", tc.expectReason, reason)
}
})
}
}

View File

@ -189,6 +189,11 @@ func NodeRules() []rbacv1.PolicyRule {
if utilfeature.DefaultFeatureGate.Enabled(features.ClusterTrustBundle) {
nodePolicyRules = append(nodePolicyRules, rbacv1helpers.NewRule("get", "list", "watch").Groups(certificatesGroup).Resources("clustertrustbundles").RuleOrDie())
}
// Kubelet needs access to ServiceAccounts to support sending service account tokens to the credential provider.
// Use the Node authorizer to limit get to service accounts related to the node.
if utilfeature.DefaultFeatureGate.Enabled(features.KubeletServiceAccountTokenForCredentialProviders) {
nodePolicyRules = append(nodePolicyRules, rbacv1helpers.NewRule("get").Groups(legacyGroup).Resources("serviceaccounts").RuleOrDie())
}
return nodePolicyRules
}

View File

@ -88,6 +88,66 @@ type CredentialProvider struct {
// to pass argument to the plugin.
// +optional
Env []ExecEnvVar `json:"env,omitempty"`
// tokenAttributes is the configuration for the service account token that will be passed to the plugin.
// The credential provider opts in to using service account tokens for image pull by setting this field.
// When this field is set, kubelet will generate a service account token bound to the pod for which the
// image is being pulled and pass to the plugin as part of CredentialProviderRequest along with other
// attributes required by the plugin.
//
// The service account metadata and token attributes will be used as a dimension to cache
// the credentials in kubelet. The cache key is generated by combining the service account metadata
// (namespace, name, UID, and annotations key+value for the keys defined in
// serviceAccountTokenAttribute.requiredServiceAccountAnnotationKeys and serviceAccountTokenAttribute.optionalServiceAccountAnnotationKeys).
// The pod metadata (namespace, name, UID) that are in the service account token are not used as a dimension
// to cache the credentials in kubelet. This means workloads that are using the same service account
// could end up using the same credentials for image pull. For plugins that don't want this behavior, or
// plugins that operate in pass-through mode; i.e., they return the service account token as-is, they
// can set the credentialProviderResponse.cacheDuration to 0. This will disable the caching of
// credentials in kubelet and the plugin will be invoked for every image pull. This does result in
// token generation overhead for every image pull, but it is the only way to ensure that the
// credentials are not shared across pods (even if they are using the same service account).
// +optional
TokenAttributes *ServiceAccountTokenAttributes `json:"tokenAttributes,omitempty"`
}
// ServiceAccountTokenAttributes is the configuration for the service account token that will be passed to the plugin.
type ServiceAccountTokenAttributes struct {
// serviceAccountTokenAudience is the intended audience for the projected service account token.
// +required
ServiceAccountTokenAudience string `json:"serviceAccountTokenAudience"`
// requireServiceAccount indicates whether the plugin requires the pod to have a service account.
// If set to true, kubelet will only invoke the plugin if the pod has a service account.
// If set to false, kubelet will invoke the plugin even if the pod does not have a service account
// and will not include a token in the CredentialProviderRequest in that scenario. This is useful for plugins that
// are used to pull images for pods without service accounts (e.g., static pods).
// +required
RequireServiceAccount *bool `json:"requireServiceAccount"`
// requiredServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in
// and that are required to be present in the service account.
// The keys defined in this list will be extracted from the corresponding service account and passed
// to the plugin as part of the CredentialProviderRequest. If any of the keys defined in this list
// are not present in the service account, kubelet will not invoke the plugin and will return an error.
// This field is optional and may be empty. Plugins may use this field to extract
// additional information required to fetch credentials or allow workloads to opt in to
// using service account tokens for image pull.
// If non-empty, requireServiceAccount must be set to true.
// +optional
// +listType=set
RequiredServiceAccountAnnotationKeys []string `json:"requiredServiceAccountAnnotationKeys,omitempty"`
// optionalServiceAccountAnnotationKeys is the list of annotation keys that the plugin is interested in
// and that are optional to be present in the service account.
// The keys defined in this list will be extracted from the corresponding service account and passed
// to the plugin as part of the CredentialProviderRequest. The plugin is responsible for validating
// the existence of annotations and their values.
// This field is optional and may be empty. Plugins may use this field to extract
// additional information required to fetch credentials.
// +optional
// +listType=set
OptionalServiceAccountAnnotationKeys []string `json:"optionalServiceAccountAnnotationKeys,omitempty"`
}
// ExecEnvVar is used for setting environment variables when executing an exec-based

View File

@ -49,6 +49,11 @@ func (in *CredentialProvider) DeepCopyInto(out *CredentialProvider) {
*out = make([]ExecEnvVar, len(*in))
copy(*out, *in)
}
if in.TokenAttributes != nil {
in, out := &in.TokenAttributes, &out.TokenAttributes
*out = new(ServiceAccountTokenAttributes)
(*in).DeepCopyInto(*out)
}
return
}
@ -109,3 +114,34 @@ func (in *ExecEnvVar) DeepCopy() *ExecEnvVar {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServiceAccountTokenAttributes) DeepCopyInto(out *ServiceAccountTokenAttributes) {
*out = *in
if in.RequireServiceAccount != nil {
in, out := &in.RequireServiceAccount, &out.RequireServiceAccount
*out = new(bool)
**out = **in
}
if in.RequiredServiceAccountAnnotationKeys != nil {
in, out := &in.RequiredServiceAccountAnnotationKeys, &out.RequiredServiceAccountAnnotationKeys
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.OptionalServiceAccountAnnotationKeys != nil {
in, out := &in.OptionalServiceAccountAnnotationKeys, &out.OptionalServiceAccountAnnotationKeys
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountTokenAttributes.
func (in *ServiceAccountTokenAttributes) DeepCopy() *ServiceAccountTokenAttributes {
if in == nil {
return nil
}
out := new(ServiceAccountTokenAttributes)
in.DeepCopyInto(out)
return out
}

View File

@ -32,6 +32,17 @@ type CredentialProviderRequest struct {
// credential provider plugin request. Plugins may optionally parse the image
// to extract any information required to fetch credentials.
Image string
// serviceAccountToken is the service account token bound to the pod for which
// the image is being pulled. This token is only sent to the plugin if the
// tokenAttributes.serviceAccountTokenAudience field is configured in the kubelet's credential provider configuration.
ServiceAccountToken string
// serviceAccountAnnotations is a map of annotations on the service account bound to the
// pod for which the image is being pulled. The list of annotations in the service account
// that need to be passed to the plugin is configured in the kubelet's credential provider
// configuration.
ServiceAccountAnnotations map[string]string
}
type PluginCacheKeyType string

View File

@ -32,6 +32,18 @@ type CredentialProviderRequest struct {
// credential provider plugin request. Plugins may optionally parse the image
// to extract any information required to fetch credentials.
Image string `json:"image"`
// serviceAccountToken is the service account token bound to the pod for which
// the image is being pulled. This token is only sent to the plugin if the
// tokenAttributes.serviceAccountTokenAudience field is configured in the kubelet's credential
// provider configuration.
ServiceAccountToken string `json:"serviceAccountToken,omitempty" datapolicy:"token"`
// serviceAccountAnnotations is a map of annotations on the service account bound to the
// pod for which the image is being pulled. The list of annotations in the service account
// that need to be passed to the plugin is configured in the kubelet's credential provider
// configuration.
ServiceAccountAnnotations map[string]string `json:"serviceAccountAnnotations,omitempty"`
}
type PluginCacheKeyType string

View File

@ -94,6 +94,8 @@ func Convert_credentialprovider_AuthConfig_To_v1_AuthConfig(in *credentialprovid
func autoConvert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProviderRequest(in *CredentialProviderRequest, out *credentialprovider.CredentialProviderRequest, s conversion.Scope) error {
out.Image = in.Image
out.ServiceAccountToken = in.ServiceAccountToken
out.ServiceAccountAnnotations = *(*map[string]string)(unsafe.Pointer(&in.ServiceAccountAnnotations))
return nil
}
@ -104,6 +106,8 @@ func Convert_v1_CredentialProviderRequest_To_credentialprovider_CredentialProvid
func autoConvert_credentialprovider_CredentialProviderRequest_To_v1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
out.Image = in.Image
out.ServiceAccountToken = in.ServiceAccountToken
out.ServiceAccountAnnotations = *(*map[string]string)(unsafe.Pointer(&in.ServiceAccountAnnotations))
return nil
}

View File

@ -46,6 +46,13 @@ func (in *AuthConfig) DeepCopy() *AuthConfig {
func (in *CredentialProviderRequest) DeepCopyInto(out *CredentialProviderRequest) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.ServiceAccountAnnotations != nil {
in, out := &in.ServiceAccountAnnotations, &out.ServiceAccountAnnotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}

View File

@ -0,0 +1,27 @@
/*
Copyright 2025 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 v1alpha1
import (
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/kubelet/pkg/apis/credentialprovider"
)
func Convert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
// This conversion intentionally omits the serviceAccountToken and serviceAccountAnnotations fields which are only supported in v1 CredentialProviderRequest.
return autoConvert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(in, out, s)
}

View File

@ -52,11 +52,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*credentialprovider.CredentialProviderRequest)(nil), (*CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(a.(*credentialprovider.CredentialProviderRequest), b.(*CredentialProviderRequest), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*CredentialProviderResponse)(nil), (*credentialprovider.CredentialProviderResponse)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(a.(*CredentialProviderResponse), b.(*credentialprovider.CredentialProviderResponse), scope)
}); err != nil {
@ -67,6 +62,11 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*credentialprovider.CredentialProviderRequest)(nil), (*CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(a.(*credentialprovider.CredentialProviderRequest), b.(*CredentialProviderRequest), scope)
}); err != nil {
return err
}
return nil
}
@ -104,14 +104,11 @@ func Convert_v1alpha1_CredentialProviderRequest_To_credentialprovider_Credential
func autoConvert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
out.Image = in.Image
// WARNING: in.ServiceAccountToken requires manual conversion: does not exist in peer-type
// WARNING: in.ServiceAccountAnnotations requires manual conversion: does not exist in peer-type
return nil
}
// Convert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest is an autogenerated conversion function.
func Convert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
return autoConvert_credentialprovider_CredentialProviderRequest_To_v1alpha1_CredentialProviderRequest(in, out, s)
}
func autoConvert_v1alpha1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(in *CredentialProviderResponse, out *credentialprovider.CredentialProviderResponse, s conversion.Scope) error {
out.CacheKeyType = credentialprovider.PluginCacheKeyType(in.CacheKeyType)
out.CacheDuration = (*v1.Duration)(unsafe.Pointer(in.CacheDuration))

View File

@ -0,0 +1,27 @@
/*
Copyright 2025 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 v1beta1
import (
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/kubelet/pkg/apis/credentialprovider"
)
func Convert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
// This conversion intentionally omits the serviceAccountToken and serviceAccountAnnotations fields which are only supported in v1 CredentialProviderRequest.
return autoConvert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(in, out, s)
}

View File

@ -52,11 +52,6 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*credentialprovider.CredentialProviderRequest)(nil), (*CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(a.(*credentialprovider.CredentialProviderRequest), b.(*CredentialProviderRequest), scope)
}); err != nil {
return err
}
if err := s.AddGeneratedConversionFunc((*CredentialProviderResponse)(nil), (*credentialprovider.CredentialProviderResponse)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1beta1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(a.(*CredentialProviderResponse), b.(*credentialprovider.CredentialProviderResponse), scope)
}); err != nil {
@ -67,6 +62,11 @@ func RegisterConversions(s *runtime.Scheme) error {
}); err != nil {
return err
}
if err := s.AddConversionFunc((*credentialprovider.CredentialProviderRequest)(nil), (*CredentialProviderRequest)(nil), func(a, b interface{}, scope conversion.Scope) error {
return Convert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(a.(*credentialprovider.CredentialProviderRequest), b.(*CredentialProviderRequest), scope)
}); err != nil {
return err
}
return nil
}
@ -104,14 +104,11 @@ func Convert_v1beta1_CredentialProviderRequest_To_credentialprovider_CredentialP
func autoConvert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
out.Image = in.Image
// WARNING: in.ServiceAccountToken requires manual conversion: does not exist in peer-type
// WARNING: in.ServiceAccountAnnotations requires manual conversion: does not exist in peer-type
return nil
}
// Convert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest is an autogenerated conversion function.
func Convert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(in *credentialprovider.CredentialProviderRequest, out *CredentialProviderRequest, s conversion.Scope) error {
return autoConvert_credentialprovider_CredentialProviderRequest_To_v1beta1_CredentialProviderRequest(in, out, s)
}
func autoConvert_v1beta1_CredentialProviderResponse_To_credentialprovider_CredentialProviderResponse(in *CredentialProviderResponse, out *credentialprovider.CredentialProviderResponse, s conversion.Scope) error {
out.CacheKeyType = credentialprovider.PluginCacheKeyType(in.CacheKeyType)
out.CacheDuration = (*v1.Duration)(unsafe.Pointer(in.CacheDuration))

View File

@ -46,6 +46,13 @@ func (in *AuthConfig) DeepCopy() *AuthConfig {
func (in *CredentialProviderRequest) DeepCopyInto(out *CredentialProviderRequest) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.ServiceAccountAnnotations != nil {
in, out := &in.ServiceAccountAnnotations, &out.ServiceAccountAnnotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}

View File

@ -24,6 +24,7 @@ import (
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/uuid"
typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/kubernetes/test/e2e/feature"
"k8s.io/kubernetes/test/e2e/framework"
e2epod "k8s.io/kubernetes/test/e2e/framework/pod"
@ -34,10 +35,12 @@ import (
var _ = SIGDescribe("ImageCredentialProvider", feature.KubeletCredentialProviders, func() {
f := framework.NewDefaultFramework("image-credential-provider")
f.NamespacePodSecurityLevel = admissionapi.LevelPrivileged
var serviceAccountClient typedcorev1.ServiceAccountInterface
var podClient *e2epod.PodClient
ginkgo.BeforeEach(func() {
podClient = e2epod.NewPodClient(f)
serviceAccountClient = f.ClientSet.CoreV1().ServiceAccounts(f.Namespace.Name)
})
/*
@ -48,11 +51,28 @@ var _ = SIGDescribe("ImageCredentialProvider", feature.KubeletCredentialProvider
ginkgo.It("should be able to create pod with image credentials fetched from external credential provider ", func(ctx context.Context) {
privateimage := imageutils.GetConfig(imageutils.AgnhostPrivate)
name := "pod-auth-image-" + string(uuid.NewUUID())
// The service account is required to exist for the credential provider plugin that's configured to use service account token.
serviceAccount := &v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-service-account",
// these annotations are validated by the test gcp-credential-provider-with-sa plugin
// that runs in service account token mode.
Annotations: map[string]string{
"domain.io/identity-id": "123456",
"domain.io/identity-type": "serviceaccount",
},
},
}
_, err := serviceAccountClient.Create(ctx, serviceAccount, metav1.CreateOptions{})
framework.ExpectNoError(err)
pod := &v1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Spec: v1.PodSpec{
ServiceAccountName: "test-service-account",
Containers: []v1.Container{
{
Name: "container-auth-image",

View File

@ -17,19 +17,29 @@ limitations under the License.
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"reflect"
"strings"
"time"
"gopkg.in/go-jose/go-jose.v2/jwt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/klog/v2"
credentialproviderv1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1"
)
const metadataTokenEndpoint = "http://metadata.google.internal./computeMetadata/v1/instance/service-accounts/default/token"
const (
metadataTokenEndpoint = "http://metadata.google.internal./computeMetadata/v1/instance/service-accounts/default/token"
pluginModeEnvVar = "PLUGIN_MODE"
)
func main() {
if err := getCredentials(metadataTokenEndpoint, os.Stdin, os.Stdout); err != nil {
@ -56,6 +66,40 @@ func getCredentials(tokenEndpoint string, r io.Reader, w io.Writer) error {
return err
}
pluginUsingServiceAccount := os.Getenv(pluginModeEnvVar) == "serviceaccount"
if pluginUsingServiceAccount {
if len(authRequest.ServiceAccountToken) == 0 {
return errors.New("service account token is empty")
}
expectedAnnotations := map[string]string{
"domain.io/identity-id": "123456",
"domain.io/identity-type": "serviceaccount",
}
if !reflect.DeepEqual(authRequest.ServiceAccountAnnotations, expectedAnnotations) {
return fmt.Errorf("unexpected service account annotations, want: %v, got: %v", expectedAnnotations, authRequest.ServiceAccountAnnotations)
}
// The service account token is not actually used for authentication by this test plugin.
// We extract the claims from the token to validate the audience.
// This is solely for testing assertions and is not an actual security layer.
// Post validation in this block, we proceed with the default flow for fetching credentials.
c, err := getClaims(authRequest.ServiceAccountToken)
if err != nil {
return err
}
// The audience in the token should match the audience configured in tokenAttributes.serviceAccountTokenAudience
// in CredentialProviderConfig.
if len(c.Audience) != 1 || c.Audience[0] != "test-audience" {
return fmt.Errorf("unexpected audience: %v", c.Audience)
}
} else {
if len(authRequest.ServiceAccountToken) > 0 {
return errors.New("service account token is not expected")
}
if len(authRequest.ServiceAccountAnnotations) > 0 {
return errors.New("service account annotations are not expected")
}
}
auth, err := provider.Provide(authRequest.Image)
if err != nil {
return err
@ -70,6 +114,10 @@ func getCredentials(tokenEndpoint string, r io.Reader, w io.Writer) error {
Auth: auth,
}
if pluginUsingServiceAccount {
response.CacheKeyType = credentialproviderv1.GlobalPluginCacheKeyType
}
if err := json.NewEncoder(w).Encode(response); err != nil {
// The error from json.Marshal is intentionally not included so as to not leak credentials into the logs
return errors.New("error marshaling response")
@ -77,3 +125,55 @@ func getCredentials(tokenEndpoint string, r io.Reader, w io.Writer) error {
return nil
}
// getClaims is used to extract claims from the service account token when the plugin is running in service account mode
// This is solely for testing assertions and is not an actual security layer.
// We get claims and validate the audience of the token (audience in the token matches the audience configured
// in tokenAttributes.serviceAccountTokenAudience in CredentialProviderConfig).
func getClaims(tokenData string) (claims, error) {
if strings.HasPrefix(strings.TrimSpace(tokenData), "{") {
return claims{}, errors.New("token is not a JWS")
}
parts := strings.Split(tokenData, ".")
if len(parts) != 3 {
return claims{}, errors.New("token is not a JWS")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return claims{}, fmt.Errorf("error decoding token payload: %w", err)
}
var c claims
d := json.NewDecoder(strings.NewReader(string(payload)))
d.DisallowUnknownFields()
if err := d.Decode(&c); err != nil {
return claims{}, fmt.Errorf("error decoding token payload: %w", err)
}
return c, nil
}
type claims struct {
jwt.Claims
privateClaims
}
// copied from https://github.com/kubernetes/kubernetes/blob/60c4c2b2521fb454ce69dee737e3eb91a25e0535/pkg/serviceaccount/claims.go#L51-L67
type privateClaims struct {
Kubernetes kubernetes `json:"kubernetes.io,omitempty"`
}
type kubernetes struct {
Namespace string `json:"namespace,omitempty"`
Svcacct ref `json:"serviceaccount,omitempty"`
Pod *ref `json:"pod,omitempty"`
Secret *ref `json:"secret,omitempty"`
Node *ref `json:"node,omitempty"`
WarnAfter *jwt.NumericDate `json:"warnafter,omitempty"`
}
type ref struct {
Name string `json:"name,omitempty"`
UID string `json:"uid,omitempty"`
}

View File

@ -72,6 +72,21 @@ func (n *NodeE2ERemote) SetupTestPackage(tardir, systemSpecName string) error {
}
}
// create a symlink of gcp-credential-provider binary to use for testing
// service account token for credential providers.
// feature-gate: KubeletServiceAccountTokenForCredentialProviders=true
binary := "gcp-credential-provider" // Use relative path instead of full path
symlink := filepath.Join(tardir, "gcp-credential-provider-with-sa")
if _, err := os.Lstat(symlink); err == nil {
if err := os.Remove(symlink); err != nil {
return fmt.Errorf("failed to remove symlink %q: %w", symlink, err)
}
}
klog.V(2).Infof("Creating symlink %s -> %s", symlink, binary)
if err := os.Symlink(binary, symlink); err != nil {
return fmt.Errorf("failed to create symlink %q: %w", symlink, err)
}
if systemSpecName != "" {
// Copy system spec file
source := filepath.Join(rootDir, system.SystemSpecPath, systemSpecName+".yaml")
@ -97,9 +112,10 @@ func prependMemcgNotificationFlag(args string) string {
// a credential provider plugin.
func prependCredentialProviderFlag(args, workspace string) string {
credentialProviderConfig := filepath.Join(workspace, "credential-provider.yaml")
featureGateFlag := "--kubelet-flags=--feature-gates=KubeletServiceAccountTokenForCredentialProviders=true"
configFlag := fmt.Sprintf("--kubelet-flags=--image-credential-provider-config=%s", credentialProviderConfig)
binFlag := fmt.Sprintf("--kubelet-flags=--image-credential-provider-bin-dir=%s", workspace)
return fmt.Sprintf("%s %s %s", configFlag, binFlag, args)
return fmt.Sprintf("%s %s %s %s", featureGateFlag, configFlag, binFlag, args)
}
// osSpecificActions takes OS specific actions required for the node tests

View File

@ -53,14 +53,31 @@ const cniConfig = `{
const credentialGCPProviderConfig = `kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1
providers:
- name: gcp-credential-provider
apiVersion: credentialprovider.kubelet.k8s.io/v1
matchImages:
- "gcr.io"
- "*.gcr.io"
- "container.cloud.google.com"
- "*.pkg.dev"
defaultCacheDuration: 1m`
- name: gcp-credential-provider
apiVersion: credentialprovider.kubelet.k8s.io/v1
matchImages:
- "gcr.io"
- "*.gcr.io"
- "container.cloud.google.com"
- "*.pkg.dev"
defaultCacheDuration: 1m
- name: gcp-credential-provider-with-sa
apiVersion: credentialprovider.kubelet.k8s.io/v1
matchImages:
- "gcr.io"
- "*.gcr.io"
- "container.cloud.google.com"
- "*.pkg.dev"
defaultCacheDuration: 1m
tokenAttributes:
serviceAccountTokenAudience: test-audience
requireServiceAccount: true
requiredServiceAccountAnnotationKeys:
- "domain.io/identity-id"
- "domain.io/identity-type"
env:
- name: PLUGIN_MODE
value: "serviceaccount"`
const credentialAWSProviderConfig = `kind: CredentialProviderConfig
apiVersion: kubelet.config.k8s.io/v1

View File

@ -670,6 +670,12 @@
lockToDefault: false
preRelease: Beta
version: "1.31"
- name: KubeletServiceAccountTokenForCredentialProviders
versionedSpecs:
- default: false
lockToDefault: false
preRelease: Alpha
version: "1.33"
- name: KubeletTracing
versionedSpecs:
- default: false