kubelet: implement image pull policies

This commit is contained in:
Stanislav Láznička 2024-10-15 14:07:00 +02:00
parent 64c0164cec
commit ad96b3aed5
No known key found for this signature in database
GPG Key ID: F8D8054395A1D157
2 changed files with 359 additions and 0 deletions

View File

@ -0,0 +1,171 @@
/*
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 images
import (
"fmt"
"strings"
dockerref "github.com/distribution/reference"
"k8s.io/apimachinery/pkg/util/sets"
kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config"
)
// ImagePullPolicyEnforcer defines a class of functions implementing a credential
// verification policies for image pulls. These function determines whether the
// implemented policy requires credential verification based on image name, local
// image presence and existence of records about previous image pulls.
//
// `image` is an image name from a Pod's container "image" field.
// `imagePresent` informs whether the `image` is present on the node.
// `imagePulledByKubelet` marks that ImagePulledRecord or ImagePullingIntent records
// for the `image` exist on the node, meaning it was pulled by the kubelet somewhere
// in the past.
type ImagePullPolicyEnforcer interface {
RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool
}
// ImagePullPolicyEnforcerFunc is a function type that implements the ImagePullPolicyEnforcer interface
type ImagePullPolicyEnforcerFunc func(image string, imagePulledByKubelet bool) bool
func (e ImagePullPolicyEnforcerFunc) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool {
return e(image, imagePulledByKubelet)
}
func NewImagePullCredentialVerificationPolicy(policy kubeletconfiginternal.ImagePullCredentialsVerificationPolicy, imageAllowList []string) (ImagePullPolicyEnforcer, error) {
switch policy {
case kubeletconfiginternal.NeverVerify:
return NeverVerifyImagePullPolicy(), nil
case kubeletconfiginternal.NeverVerifyPreloadedImages:
return NeverVerifyPreloadedPullPolicy(), nil
case kubeletconfiginternal.NeverVerifyAllowlistedImages:
return NewNeverVerifyAllowListedPullPolicy(imageAllowList)
case kubeletconfiginternal.AlwaysVerify:
return AlwaysVerifyImagePullPolicy(), nil
default:
return nil, fmt.Errorf("unknown image pull credential verification policy: %v", policy)
}
}
func NeverVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc {
return func(image string, imagePulledByKubelet bool) bool {
return false
}
}
func NeverVerifyPreloadedPullPolicy() ImagePullPolicyEnforcerFunc {
return func(image string, imagePulledByKubelet bool) bool {
return imagePulledByKubelet
}
}
func AlwaysVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc {
return func(image string, imagePulledByKubelet bool) bool {
return true
}
}
type NeverVerifyAllowlistedImages struct {
absoluteURLs sets.Set[string]
prefixes []string
}
func NewNeverVerifyAllowListedPullPolicy(allowList []string) (*NeverVerifyAllowlistedImages, error) {
policy := &NeverVerifyAllowlistedImages{
absoluteURLs: sets.New[string](),
}
for _, pattern := range allowList {
normalizedPattern, isWildcard, err := getAllowlistImagePattern(pattern)
if err != nil {
return nil, err
}
if isWildcard {
policy.prefixes = append(policy.prefixes, normalizedPattern)
} else {
policy.absoluteURLs.Insert(normalizedPattern)
}
}
return policy, nil
}
func (p *NeverVerifyAllowlistedImages) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool {
return !p.imageMatches(image)
}
func (p *NeverVerifyAllowlistedImages) imageMatches(image string) bool {
if p.absoluteURLs.Has(image) {
return true
}
for _, prefix := range p.prefixes {
if strings.HasPrefix(image, prefix) {
return true
}
}
return false
}
func ValidateAllowlistImagesPatterns(patterns []string) error {
for _, p := range patterns {
if _, _, err := getAllowlistImagePattern(p); err != nil {
return err
}
}
return nil
}
func getAllowlistImagePattern(pattern string) (string, bool, error) {
if pattern != strings.TrimSpace(pattern) {
return "", false, fmt.Errorf("leading/trailing spaces are not allowed: %s", pattern)
}
trimmedPattern := pattern
isWildcard := false
if strings.HasSuffix(pattern, "/*") {
isWildcard = true
trimmedPattern = strings.TrimSuffix(trimmedPattern, "*")
}
if len(trimmedPattern) == 0 {
return "", false, fmt.Errorf("the supplied pattern is too short: %s", pattern)
}
if strings.ContainsRune(trimmedPattern, '*') {
return "", false, fmt.Errorf("not a valid wildcard pattern, only patterns ending with '/*' are allowed: %s", pattern)
}
if isWildcard {
if len(trimmedPattern) == 1 {
return "", false, fmt.Errorf("at least registry hostname is required")
}
} else { // not a wildcard
image, err := dockerref.ParseNormalizedNamed(trimmedPattern)
if err != nil {
return "", false, fmt.Errorf("failed to parse as an image name: %w", err)
}
if trimmedPattern != image.Name() { // image.Name() returns the image name without tag/digest
return "", false, fmt.Errorf("neither tag nor digest is accepted in an image reference: %s", pattern)
}
return trimmedPattern, false, nil
}
return trimmedPattern, true, nil
}

View File

@ -0,0 +1,188 @@
/*
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 images
import (
"reflect"
"testing"
)
func TestNeverVerifyPreloadedPullPolicy(t *testing.T) {
tests := []struct {
name string
imageRecordsExist bool
want bool
}{
{
name: "there are no records about the image being pulled",
imageRecordsExist: false,
want: false,
},
{
name: "there are records about the image being pulled",
imageRecordsExist: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NeverVerifyPreloadedPullPolicy()("test-image", tt.imageRecordsExist); got != tt.want {
t.Errorf("NeverVerifyPreloadedPullPolicy() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewNeverVerifyAllowListedPullPolicy(t *testing.T) {
tests := []struct {
name string
imageRecordsExist bool
allowlist []string
expectedAbsolutes int
expectedWildcards int
want bool
wantErr bool
}{
{
name: "there are no records about the image being pulled, not in allowlist",
imageRecordsExist: false,
want: true,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/image3"},
expectedAbsolutes: 3,
},
{
name: "there are records about the image being pulled, not in allowlist",
imageRecordsExist: true,
want: true,
allowlist: []string{"test.io/test/image1", "test.io/test/image3", "test.io/test/image2", "test.io/test/image3"},
expectedAbsolutes: 3,
},
{
name: "there are no records about the image being pulled, appears in allowlist",
imageRecordsExist: false,
want: false,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"},
expectedAbsolutes: 4,
},
{
name: "there are records about the image being pulled, appears in allowlist",
imageRecordsExist: true,
want: false,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"},
expectedAbsolutes: 4,
},
{
name: "invalid allowlist pattern - wildcard in the middle",
wantErr: true,
allowlist: []string{"image.repo/pokus*/imagename"},
},
{
name: "invalid allowlist pattern - trailing non-segment wildcard middle",
wantErr: true,
allowlist: []string{"image.repo/pokus*"},
},
{
name: "invalid allowlist pattern - wildcard path segment in the middle",
wantErr: true,
allowlist: []string{"image.repo/*/imagename"},
},
{
name: "invalid allowlist pattern - only wildcard segment",
wantErr: true,
allowlist: []string{"/*"},
},
{
name: "invalid allowlist pattern - ends with a '/'",
wantErr: true,
allowlist: []string{"image.repo/"},
},
{
name: "invalid allowlist pattern - empty",
wantErr: true,
allowlist: []string{""},
},
{
name: "invalid allowlist pattern - asterisk",
wantErr: true,
allowlist: []string{"*"},
},
{
name: "invalid allowlist pattern - image with a tag",
wantErr: true,
allowlist: []string{"test.io/test/image1:tagged"},
},
{
name: "invalid allowlist pattern - image with a digest",
wantErr: true,
allowlist: []string{"test.io/test/image1@sha256:38a8906435c4dd5f4258899d46621bfd8eea3ad6ff494ee3c2f17ef0321625bd"},
},
{
name: "invalid allowlist pattern - trailing whitespace",
wantErr: true,
allowlist: []string{"test.io/test/image1 "},
},
{
name: "there are no records about the image being pulled, not in allowlist - different repo wildcard",
imageRecordsExist: false,
want: true,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "different.repo/test/*"},
expectedAbsolutes: 2,
expectedWildcards: 1,
},
{
name: "there are no records about the image being pulled, not in allowlist - matches org wildcard",
imageRecordsExist: false,
want: false,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/*"},
expectedAbsolutes: 2,
expectedWildcards: 1,
},
{
name: "there are no records about the image being pulled, not in allowlist - matches repo wildcard",
imageRecordsExist: false,
want: false,
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/*"},
expectedAbsolutes: 2,
expectedWildcards: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
policyEnforcer, err := NewNeverVerifyAllowListedPullPolicy(tt.allowlist)
if tt.wantErr != (err != nil) {
t.Fatalf("wanted error: %t, got: %v", tt.wantErr, err)
}
if err != nil {
return
}
if len(policyEnforcer.absoluteURLs) != tt.expectedAbsolutes {
t.Errorf("expected %d of absolute image URLs in the allowlist policy, got %d: %v", tt.expectedAbsolutes, len(policyEnforcer.absoluteURLs), policyEnforcer.absoluteURLs)
}
if len(policyEnforcer.prefixes) != tt.expectedWildcards {
t.Errorf("expected %d of wildcard image URLs in the allowlist policy, got %d: %v", tt.expectedWildcards, len(policyEnforcer.prefixes), policyEnforcer.prefixes)
}
got := policyEnforcer.RequireCredentialVerificationForImage("test.io/test/test-image", tt.imageRecordsExist)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewNeverVerifyAllowListedPullPolicy() = %v, want %v", got, tt.want)
}
})
}
}