Skip to content

Commit ad96b3a

Browse files
committed
kubelet: implement image pull policies
1 parent 64c0164 commit ad96b3a

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package images
18+
19+
import (
20+
"fmt"
21+
"strings"
22+
23+
dockerref "github.com/distribution/reference"
24+
25+
"k8s.io/apimachinery/pkg/util/sets"
26+
kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config"
27+
)
28+
29+
// ImagePullPolicyEnforcer defines a class of functions implementing a credential
30+
// verification policies for image pulls. These function determines whether the
31+
// implemented policy requires credential verification based on image name, local
32+
// image presence and existence of records about previous image pulls.
33+
//
34+
// `image` is an image name from a Pod's container "image" field.
35+
// `imagePresent` informs whether the `image` is present on the node.
36+
// `imagePulledByKubelet` marks that ImagePulledRecord or ImagePullingIntent records
37+
// for the `image` exist on the node, meaning it was pulled by the kubelet somewhere
38+
// in the past.
39+
type ImagePullPolicyEnforcer interface {
40+
RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool
41+
}
42+
43+
// ImagePullPolicyEnforcerFunc is a function type that implements the ImagePullPolicyEnforcer interface
44+
type ImagePullPolicyEnforcerFunc func(image string, imagePulledByKubelet bool) bool
45+
46+
func (e ImagePullPolicyEnforcerFunc) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool {
47+
return e(image, imagePulledByKubelet)
48+
}
49+
50+
func NewImagePullCredentialVerificationPolicy(policy kubeletconfiginternal.ImagePullCredentialsVerificationPolicy, imageAllowList []string) (ImagePullPolicyEnforcer, error) {
51+
switch policy {
52+
case kubeletconfiginternal.NeverVerify:
53+
return NeverVerifyImagePullPolicy(), nil
54+
case kubeletconfiginternal.NeverVerifyPreloadedImages:
55+
return NeverVerifyPreloadedPullPolicy(), nil
56+
case kubeletconfiginternal.NeverVerifyAllowlistedImages:
57+
return NewNeverVerifyAllowListedPullPolicy(imageAllowList)
58+
case kubeletconfiginternal.AlwaysVerify:
59+
return AlwaysVerifyImagePullPolicy(), nil
60+
default:
61+
return nil, fmt.Errorf("unknown image pull credential verification policy: %v", policy)
62+
}
63+
}
64+
65+
func NeverVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc {
66+
return func(image string, imagePulledByKubelet bool) bool {
67+
return false
68+
}
69+
}
70+
71+
func NeverVerifyPreloadedPullPolicy() ImagePullPolicyEnforcerFunc {
72+
return func(image string, imagePulledByKubelet bool) bool {
73+
return imagePulledByKubelet
74+
}
75+
}
76+
77+
func AlwaysVerifyImagePullPolicy() ImagePullPolicyEnforcerFunc {
78+
return func(image string, imagePulledByKubelet bool) bool {
79+
return true
80+
}
81+
}
82+
83+
type NeverVerifyAllowlistedImages struct {
84+
absoluteURLs sets.Set[string]
85+
prefixes []string
86+
}
87+
88+
func NewNeverVerifyAllowListedPullPolicy(allowList []string) (*NeverVerifyAllowlistedImages, error) {
89+
policy := &NeverVerifyAllowlistedImages{
90+
absoluteURLs: sets.New[string](),
91+
}
92+
for _, pattern := range allowList {
93+
normalizedPattern, isWildcard, err := getAllowlistImagePattern(pattern)
94+
if err != nil {
95+
return nil, err
96+
}
97+
98+
if isWildcard {
99+
policy.prefixes = append(policy.prefixes, normalizedPattern)
100+
} else {
101+
policy.absoluteURLs.Insert(normalizedPattern)
102+
}
103+
}
104+
105+
return policy, nil
106+
}
107+
108+
func (p *NeverVerifyAllowlistedImages) RequireCredentialVerificationForImage(image string, imagePulledByKubelet bool) bool {
109+
return !p.imageMatches(image)
110+
}
111+
112+
func (p *NeverVerifyAllowlistedImages) imageMatches(image string) bool {
113+
if p.absoluteURLs.Has(image) {
114+
return true
115+
}
116+
for _, prefix := range p.prefixes {
117+
if strings.HasPrefix(image, prefix) {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
124+
func ValidateAllowlistImagesPatterns(patterns []string) error {
125+
for _, p := range patterns {
126+
if _, _, err := getAllowlistImagePattern(p); err != nil {
127+
return err
128+
}
129+
}
130+
return nil
131+
}
132+
133+
func getAllowlistImagePattern(pattern string) (string, bool, error) {
134+
if pattern != strings.TrimSpace(pattern) {
135+
return "", false, fmt.Errorf("leading/trailing spaces are not allowed: %s", pattern)
136+
}
137+
138+
trimmedPattern := pattern
139+
isWildcard := false
140+
if strings.HasSuffix(pattern, "/*") {
141+
isWildcard = true
142+
trimmedPattern = strings.TrimSuffix(trimmedPattern, "*")
143+
}
144+
145+
if len(trimmedPattern) == 0 {
146+
return "", false, fmt.Errorf("the supplied pattern is too short: %s", pattern)
147+
}
148+
149+
if strings.ContainsRune(trimmedPattern, '*') {
150+
return "", false, fmt.Errorf("not a valid wildcard pattern, only patterns ending with '/*' are allowed: %s", pattern)
151+
}
152+
153+
if isWildcard {
154+
if len(trimmedPattern) == 1 {
155+
return "", false, fmt.Errorf("at least registry hostname is required")
156+
}
157+
} else { // not a wildcard
158+
image, err := dockerref.ParseNormalizedNamed(trimmedPattern)
159+
if err != nil {
160+
return "", false, fmt.Errorf("failed to parse as an image name: %w", err)
161+
}
162+
163+
if trimmedPattern != image.Name() { // image.Name() returns the image name without tag/digest
164+
return "", false, fmt.Errorf("neither tag nor digest is accepted in an image reference: %s", pattern)
165+
}
166+
167+
return trimmedPattern, false, nil
168+
}
169+
170+
return trimmedPattern, true, nil
171+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package images
18+
19+
import (
20+
"reflect"
21+
"testing"
22+
)
23+
24+
func TestNeverVerifyPreloadedPullPolicy(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
imageRecordsExist bool
28+
want bool
29+
}{
30+
{
31+
name: "there are no records about the image being pulled",
32+
imageRecordsExist: false,
33+
want: false,
34+
},
35+
{
36+
name: "there are records about the image being pulled",
37+
imageRecordsExist: true,
38+
want: true,
39+
},
40+
}
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
if got := NeverVerifyPreloadedPullPolicy()("test-image", tt.imageRecordsExist); got != tt.want {
44+
t.Errorf("NeverVerifyPreloadedPullPolicy() = %v, want %v", got, tt.want)
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestNewNeverVerifyAllowListedPullPolicy(t *testing.T) {
51+
tests := []struct {
52+
name string
53+
imageRecordsExist bool
54+
allowlist []string
55+
expectedAbsolutes int
56+
expectedWildcards int
57+
want bool
58+
wantErr bool
59+
}{
60+
{
61+
name: "there are no records about the image being pulled, not in allowlist",
62+
imageRecordsExist: false,
63+
want: true,
64+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/image3"},
65+
expectedAbsolutes: 3,
66+
},
67+
{
68+
name: "there are records about the image being pulled, not in allowlist",
69+
imageRecordsExist: true,
70+
want: true,
71+
allowlist: []string{"test.io/test/image1", "test.io/test/image3", "test.io/test/image2", "test.io/test/image3"},
72+
expectedAbsolutes: 3,
73+
},
74+
{
75+
name: "there are no records about the image being pulled, appears in allowlist",
76+
imageRecordsExist: false,
77+
want: false,
78+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"},
79+
expectedAbsolutes: 4,
80+
},
81+
{
82+
name: "there are records about the image being pulled, appears in allowlist",
83+
imageRecordsExist: true,
84+
want: false,
85+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/test-image", "test.io/test/image3"},
86+
expectedAbsolutes: 4,
87+
},
88+
{
89+
name: "invalid allowlist pattern - wildcard in the middle",
90+
wantErr: true,
91+
allowlist: []string{"image.repo/pokus*/imagename"},
92+
},
93+
{
94+
name: "invalid allowlist pattern - trailing non-segment wildcard middle",
95+
wantErr: true,
96+
allowlist: []string{"image.repo/pokus*"},
97+
},
98+
{
99+
name: "invalid allowlist pattern - wildcard path segment in the middle",
100+
wantErr: true,
101+
allowlist: []string{"image.repo/*/imagename"},
102+
},
103+
{
104+
name: "invalid allowlist pattern - only wildcard segment",
105+
wantErr: true,
106+
allowlist: []string{"/*"},
107+
},
108+
{
109+
name: "invalid allowlist pattern - ends with a '/'",
110+
wantErr: true,
111+
allowlist: []string{"image.repo/"},
112+
},
113+
{
114+
name: "invalid allowlist pattern - empty",
115+
wantErr: true,
116+
allowlist: []string{""},
117+
},
118+
{
119+
name: "invalid allowlist pattern - asterisk",
120+
wantErr: true,
121+
allowlist: []string{"*"},
122+
},
123+
{
124+
name: "invalid allowlist pattern - image with a tag",
125+
wantErr: true,
126+
allowlist: []string{"test.io/test/image1:tagged"},
127+
},
128+
{
129+
name: "invalid allowlist pattern - image with a digest",
130+
wantErr: true,
131+
allowlist: []string{"test.io/test/image1@sha256:38a8906435c4dd5f4258899d46621bfd8eea3ad6ff494ee3c2f17ef0321625bd"},
132+
},
133+
{
134+
name: "invalid allowlist pattern - trailing whitespace",
135+
wantErr: true,
136+
allowlist: []string{"test.io/test/image1 "},
137+
},
138+
{
139+
name: "there are no records about the image being pulled, not in allowlist - different repo wildcard",
140+
imageRecordsExist: false,
141+
want: true,
142+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "different.repo/test/*"},
143+
expectedAbsolutes: 2,
144+
expectedWildcards: 1,
145+
},
146+
{
147+
name: "there are no records about the image being pulled, not in allowlist - matches org wildcard",
148+
imageRecordsExist: false,
149+
want: false,
150+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/test/*"},
151+
expectedAbsolutes: 2,
152+
expectedWildcards: 1,
153+
},
154+
{
155+
name: "there are no records about the image being pulled, not in allowlist - matches repo wildcard",
156+
imageRecordsExist: false,
157+
want: false,
158+
allowlist: []string{"test.io/test/image1", "test.io/test/image2", "test.io/*"},
159+
expectedAbsolutes: 2,
160+
expectedWildcards: 1,
161+
},
162+
}
163+
for _, tt := range tests {
164+
t.Run(tt.name, func(t *testing.T) {
165+
policyEnforcer, err := NewNeverVerifyAllowListedPullPolicy(tt.allowlist)
166+
if tt.wantErr != (err != nil) {
167+
t.Fatalf("wanted error: %t, got: %v", tt.wantErr, err)
168+
}
169+
170+
if err != nil {
171+
return
172+
}
173+
174+
if len(policyEnforcer.absoluteURLs) != tt.expectedAbsolutes {
175+
t.Errorf("expected %d of absolute image URLs in the allowlist policy, got %d: %v", tt.expectedAbsolutes, len(policyEnforcer.absoluteURLs), policyEnforcer.absoluteURLs)
176+
}
177+
178+
if len(policyEnforcer.prefixes) != tt.expectedWildcards {
179+
t.Errorf("expected %d of wildcard image URLs in the allowlist policy, got %d: %v", tt.expectedWildcards, len(policyEnforcer.prefixes), policyEnforcer.prefixes)
180+
}
181+
182+
got := policyEnforcer.RequireCredentialVerificationForImage("test.io/test/test-image", tt.imageRecordsExist)
183+
if !reflect.DeepEqual(got, tt.want) {
184+
t.Errorf("NewNeverVerifyAllowListedPullPolicy() = %v, want %v", got, tt.want)
185+
}
186+
})
187+
}
188+
}

0 commit comments

Comments
 (0)