Skip to content

Commit fc45cff

Browse files
committed
Add imagePullSecrets change
1 parent c21732b commit fc45cff

File tree

11 files changed

+208
-4
lines changed

11 files changed

+208
-4
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.9.0] - 2025-03-20
8+
### Added
9+
- Add imagePullSecrets update when an image is modified
10+
711
## [0.8.1] - 2025-03-17
812
### Fixed
913
- Fixed chart pdb rendering

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ reference which matches at least one match rule and none of the exclusion rules,
4444
by the `replace` contents of the rule. If `checkUpstream` is enabled, the webhook will first fetch the manifest
4545
the rewritten container image reference and verify it exists before rewriting the image.
4646

47+
You can also update the `imagePullSecrets` of a modified pod to have the right docker secret to connect to the modified registry. For that, put `replaceImagePullSecrets` to `true` and be sure that `authSecretName` is set with the Kubernetes secret that you want to add to `imagePullSecrets`. If `imagePullSecrets` already contains a secret, the `authSecretName` will be added to the list anyway.
48+
4749
Example configuration:
4850
```yaml
4951
port: 9443
@@ -67,6 +69,12 @@ rules:
6769
replace: 'harbor.example.com/ubuntu-proxy'
6870
checkUpstream: true # tests if the manifest for the rewritten image exists
6971
authSecretName: harbor-example-image-pull-secret # optional, defaults to "" - secret in the webhook namespace for authenticating to harbor.example.com
72+
- name: 'docker.io rewrite rule with imagePullSecrets update'
73+
matches:
74+
- '^docker.io'
75+
replace: 'harbor.example.com/dockerhub-proxy'
76+
replaceImagePullSecrets: true # enable imagePullSecrets change for modified images
77+
authSecretName: harbor-example-image-pull-secret # secret to add to imagePullSecrets on the modified pod
7078
```
7179
Local Development
7280
===

deploy/charts/harbor-container-webhook/Chart.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: harbor-container-webhook
33
description: Webhook to configure pods with harbor proxy cache projects
44
type: application
55
version: 0.8.1
6-
appVersion: "0.8.0"
6+
appVersion: "0.9.0"
77
kubeVersion: ">= 1.16.0-0"
88
home: https://github.com/IndeedEng/harbor-container-webhook
99
maintainers:

deploy/charts/harbor-container-webhook/values.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ rules: []
113113
# platforms: # defaults to linux/amd64, only used if checkUpstream is set
114114
# - linux/amd64
115115
# - linux/arm64
116+
# - name: 'docker.io rewrite rule with imagePullSecrets update'
117+
# matches:
118+
# - '^docker.io'
119+
# replace: 'harbor.example.com/dockerhub-proxy'
120+
# replaceImagePullSecrets: true # enable imagePullSecrets change for modified images
121+
# authSecretName: harbor-example-image-pull-secret # secret to add to imagePullSecrets on the modified pod
116122

117123
extraRules: []
118124

hack/config.yaml

+8-1
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ rules:
1818
- '^docker.io/(library/)?ubuntu:.*$'
1919
replace: 'harbor-v2.awscmhqa2.k8s.indeed.tech/dockerhub-proxy-auth'
2020
checkUpstream: true # tests if the manifest for the rewritten image exists
21-
authSecretName: "harborv2-qa"
21+
authSecretName: "harborv2-qa"
22+
- name: 'docker.io rewrite rule with imagePullSecret change'
23+
# image refs must match at least one of the rules, and not match any excludes
24+
matches:
25+
- '^docker.io'
26+
replace: 'harbor.example.com/dockerhub-proxy'
27+
authSecretName: "harborv2-qa"
28+
replaceImagePullSecrets: true

hack/test/admission.json

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
"metadata": {
1414
},
1515
"spec": {
16+
"imagePullSecrets": [
17+
{
18+
"name": "foo"
19+
}
20+
],
1621
"containers": [
1722
{
1823
"name": "foo",

hack/test/no-op.json

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
"metadata": {
1414
},
1515
"spec": {
16+
"imagePullSecrets": [
17+
{
18+
"name": "foo"
19+
}
20+
],
1621
"containers": [
1722
{
1823
"name": "foo",

internal/config/config.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,13 @@ type ProxyRule struct {
7979
// If the webhook lacks permissions to fetch the image manifest or the registry is down, the image
8080
// will not be rewritten. Experimental.
8181
CheckUpstream bool `yaml:"checkUpstream"`
82+
// ReplaceImagePullSecrets enables the replacement of the imagePullSecrets of the pod in addition to the image
83+
ReplaceImagePullSecrets bool `yaml:"replaceImagePullSecrets"`
8284
// List of the required platforms to check for if CheckUpstream is set. Defaults to "linux/amd64" if unset.
8385
Platforms []string `yaml:"platforms"`
8486
// AuthSecretName is a reference to an image pull secret (must be .dockerconfigjson type) which
85-
// will be used to authenticate if `checkUpstream` is set. Unused if not specified or `checkUpstream` is false.
87+
// will be used to authenticate if `checkUpstream` is set or to modify the imagePullSecrets if
88+
// `replaceImagePullSecrets` is set.
8689
AuthSecretName string `yaml:"authSecretName"`
8790
// Namespace that the webhook is running in, used for accessing secrets for authenticated proxy rules
8891
Namespace string

internal/webhook/mutate.go

+24-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func (p *PodContainerProxier) Handle(ctx context.Context, req admission.Request)
3838
return admission.Errored(http.StatusBadRequest, err)
3939
}
4040

41+
// container images
4142
initContainers, updatedInit, err := p.updateContainers(ctx, pod.Spec.InitContainers, "init")
4243
if err != nil {
4344
return admission.Errored(http.StatusInternalServerError, err)
@@ -52,6 +53,16 @@ func (p *PodContainerProxier) Handle(ctx context.Context, req admission.Request)
5253
pod.Spec.InitContainers = initContainers
5354
pod.Spec.Containers = containers
5455

56+
// imagePullSecrets
57+
imagePullSecrets, updatedImagePullSecrets, err := p.updateImagePullSecrets(pod.Spec.ImagePullSecrets)
58+
if err != nil {
59+
return admission.Errored(http.StatusInternalServerError, err)
60+
}
61+
if !updatedImagePullSecrets {
62+
return admission.Allowed("no updates")
63+
}
64+
pod.Spec.ImagePullSecrets = imagePullSecrets
65+
5566
marshaledPod, err := json.Marshal(pod)
5667
if err != nil {
5768
return admission.Errored(http.StatusInternalServerError, err)
@@ -68,7 +79,7 @@ func (p *PodContainerProxier) lookupNodeArchAndOS(ctx context.Context, restClien
6879
return node.Status.NodeInfo.Architecture, node.Status.NodeInfo.OperatingSystem, nil
6980
}
7081

71-
func (p *PodContainerProxier) updateContainers(ctx context.Context, containers []corev1.Container, kind string) ([]corev1.Container, bool, error) {
82+
func (p *PodContainerProxier) updateContainers(ctx context.Context, containers []corev1.Container, _ string) ([]corev1.Container, bool, error) {
7283
containersReplacement := make([]corev1.Container, 0, len(containers))
7384
updated := false
7485
for i := range containers {
@@ -118,3 +129,15 @@ func (p *PodContainerProxier) InjectDecoder(d admission.Decoder) error {
118129
p.Decoder = d
119130
return nil
120131
}
132+
133+
func (p *PodContainerProxier) updateImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (newImagePullSecrets []corev1.LocalObjectReference, updated bool, err error) {
134+
pod := &corev1.Pod{}
135+
for _, transformer := range p.Transformers {
136+
updated, newImagePullSecrets, err = transformer.RewriteImagePullSecrets(imagePullSecrets)
137+
if err != nil {
138+
return imagePullSecrets, false, err
139+
}
140+
logger.Info(fmt.Sprintf("rewriting the imagePullSecrets of the pod %q from %q to %q", pod.ObjectMeta.Name, imagePullSecrets, newImagePullSecrets))
141+
}
142+
return newImagePullSecrets, updated, nil
143+
}

internal/webhook/mutate_test.go

+99
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"testing"
66

7+
corev1 "k8s.io/api/core/v1"
8+
79
"github.com/indeedeng-alpha/harbor-container-webhook/internal/config"
810

911
"github.com/stretchr/testify/require"
@@ -27,6 +29,11 @@ func TestPodContainerProxier_rewriteImage(t *testing.T) {
2729
Matches: []string{"^docker.io/(library/)?ubuntu"},
2830
Replace: "harbor.example.com/ubuntu-proxy",
2931
},
32+
{
33+
Name: "docker.io proxy cache with imagePullSecret change",
34+
Matches: []string{"^docker.io/(library/)?ubuntu"},
35+
Replace: "harbor.example.com/ubuntu-proxy",
36+
},
3037
}, nil)
3138
require.NoError(t, err)
3239
proxier := PodContainerProxier{
@@ -99,3 +106,95 @@ func TestPodContainerProxier_rewriteImage(t *testing.T) {
99106
})
100107
}
101108
}
109+
110+
func TestPodContainerProxier_updateImagePullSecretsWithReplaceEnabled(t *testing.T) {
111+
transformers, err := MakeTransformers([]config.ProxyRule{
112+
{
113+
Name: "docker.io proxy cache with imagePullSecrets change",
114+
Matches: []string{"^docker.io"},
115+
Replace: "harbor.example.com/dockerhub-proxy",
116+
ReplaceImagePullSecrets: true,
117+
AuthSecretName: "secret-test",
118+
},
119+
}, nil)
120+
require.NoError(t, err)
121+
proxier := PodContainerProxier{
122+
Transformers: transformers,
123+
}
124+
125+
type testcase struct {
126+
name string
127+
imagePullSecrets []corev1.LocalObjectReference
128+
platform string
129+
os string
130+
expected []corev1.LocalObjectReference
131+
}
132+
tests := []testcase{
133+
{
134+
name: "imagePullSecrets is empty, replacement is expected and secret name should be added",
135+
imagePullSecrets: []corev1.LocalObjectReference{},
136+
os: "linux",
137+
platform: "amd64",
138+
expected: []corev1.LocalObjectReference{{Name: "secret-test"}},
139+
},
140+
{
141+
name: "imagePullSecrets has a secret, replacement is expected and secret name should be added",
142+
imagePullSecrets: []corev1.LocalObjectReference{{Name: "mysecret"}},
143+
os: "linux",
144+
platform: "amd64",
145+
expected: []corev1.LocalObjectReference{{Name: "mysecret"}, {Name: "secret-test"}},
146+
},
147+
}
148+
for _, tc := range tests {
149+
t.Run(tc.name, func(t *testing.T) {
150+
newImagePullSecrets, _, err := proxier.updateImagePullSecrets(tc.imagePullSecrets)
151+
require.NoError(t, err)
152+
require.Equal(t, tc.expected, newImagePullSecrets)
153+
})
154+
}
155+
}
156+
157+
func TestPodContainerProxier_updateImagePullSecretsWithReplaceDinabled(t *testing.T) {
158+
transformers, err := MakeTransformers([]config.ProxyRule{
159+
{
160+
Name: "docker.io proxy cache without imagePullSecrets change",
161+
Matches: []string{"^docker.io"},
162+
Replace: "harbor.example.com/dockerhub-proxy",
163+
},
164+
}, nil)
165+
require.NoError(t, err)
166+
proxier := PodContainerProxier{
167+
Transformers: transformers,
168+
}
169+
170+
type testcase struct {
171+
name string
172+
imagePullSecrets []corev1.LocalObjectReference
173+
platform string
174+
os string
175+
expected []corev1.LocalObjectReference
176+
}
177+
tests := []testcase{
178+
{
179+
name: "imagePullSecrets is empty, replacement is not expected",
180+
imagePullSecrets: []corev1.LocalObjectReference{},
181+
os: "linux",
182+
platform: "amd64",
183+
expected: []corev1.LocalObjectReference{},
184+
},
185+
{
186+
name: "imagePullSecrets has a secret, replacement is not expected",
187+
imagePullSecrets: []corev1.LocalObjectReference{{Name: "mysecret"}},
188+
os: "linux",
189+
platform: "amd64",
190+
expected: []corev1.LocalObjectReference{{Name: "mysecret"}},
191+
},
192+
}
193+
for _, tc := range tests {
194+
t.Run(tc.name, func(t *testing.T) {
195+
newImagePullSecrets, _, err := proxier.updateImagePullSecrets(tc.imagePullSecrets)
196+
require.NoError(t, err)
197+
require.Equal(t, tc.expected, newImagePullSecrets)
198+
})
199+
}
200+
}

internal/webhook/transformer.go

+44
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"regexp"
8+
"slices"
89
"strings"
910
"time"
1011

@@ -76,6 +77,9 @@ type ContainerTransformer interface {
7677
// CheckUpstream ensures that the docker image reference exists in the upstream registry
7778
// and returns if the image exists, or an error if the registry can't be contacted.
7879
CheckUpstream(ctx context.Context, imageRef string) (bool, error)
80+
81+
// RewriteImagePullSecrets takes a list of kubernetes secret name and add the AuthSecretName parameter
82+
RewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (bool, []corev1.LocalObjectReference, error)
7983
}
8084

8185
func MakeTransformers(rules []config.ProxyRule, client client.Client) ([]ContainerTransformer, error) {
@@ -272,3 +276,43 @@ func (t *ruleTransformer) anyExclusion(imageRef string) bool {
272276
}
273277
return false
274278
}
279+
280+
func (t *ruleTransformer) RewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (updated bool, newImagePullSecrets []corev1.LocalObjectReference, err error) {
281+
if t.rule.AuthSecretName == "" && t.rule.ReplaceImagePullSecrets {
282+
return false, imagePullSecrets, fmt.Errorf("replaceImagePullSecrets is enabled but no authSecretName parameter")
283+
}
284+
if !t.rule.ReplaceImagePullSecrets {
285+
return false, imagePullSecrets, nil
286+
}
287+
288+
start := time.Now()
289+
updated, imagePullSecrets = t.doRewriteImagePullSecrets(imagePullSecrets)
290+
duration := time.Since(start)
291+
if updated {
292+
rewrite.WithLabelValues(t.metricName).Inc()
293+
rewriteTime.WithLabelValues(t.metricName).Observe(duration.Seconds())
294+
} else if !updated {
295+
return false, imagePullSecrets, nil
296+
}
297+
return true, imagePullSecrets, nil
298+
}
299+
300+
func (t *ruleTransformer) doRewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (bool, []corev1.LocalObjectReference) {
301+
existingSecrets := t.getExistingSecrets(imagePullSecrets)
302+
303+
if slices.Contains(existingSecrets, t.rule.AuthSecretName) {
304+
return false, imagePullSecrets
305+
}
306+
newImagePullSecret := corev1.LocalObjectReference{
307+
Name: t.rule.AuthSecretName,
308+
}
309+
imagePullSecrets = append(imagePullSecrets, newImagePullSecret)
310+
return true, imagePullSecrets
311+
}
312+
313+
func (t *ruleTransformer) getExistingSecrets(imagePullSecrets []corev1.LocalObjectReference) (existingSecrets []string) {
314+
for _, secret := range imagePullSecrets {
315+
existingSecrets = append(existingSecrets, secret.Name)
316+
}
317+
return existingSecrets
318+
}

0 commit comments

Comments
 (0)