Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add imagePullSecrets change #53

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.0] - 2025-03-20
### Added
- Add imagePullSecrets update when an image is modified

## [0.8.1] - 2025-03-17
### Fixed
- Fixed chart pdb rendering
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ reference which matches at least one match rule and none of the exclusion rules,
by the `replace` contents of the rule. If `checkUpstream` is enabled, the webhook will first fetch the manifest
the rewritten container image reference and verify it exists before rewriting the image.

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.

Example configuration:
```yaml
port: 9443
Expand All @@ -67,6 +69,12 @@ rules:
replace: 'harbor.example.com/ubuntu-proxy'
checkUpstream: true # tests if the manifest for the rewritten image exists
authSecretName: harbor-example-image-pull-secret # optional, defaults to "" - secret in the webhook namespace for authenticating to harbor.example.com
- name: 'docker.io rewrite rule with imagePullSecrets update'
matches:
- '^docker.io'
replace: 'harbor.example.com/dockerhub-proxy'
replaceImagePullSecrets: true # enable imagePullSecrets change for modified images
authSecretName: harbor-example-image-pull-secret # secret to add to imagePullSecrets on the modified pod
```
Local Development
===
Expand Down
2 changes: 1 addition & 1 deletion deploy/charts/harbor-container-webhook/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: harbor-container-webhook
description: Webhook to configure pods with harbor proxy cache projects
type: application
version: 0.8.1
appVersion: "0.8.0"
appVersion: "0.9.0"
kubeVersion: ">= 1.16.0-0"
home: https://github.com/IndeedEng/harbor-container-webhook
maintainers:
Expand Down
6 changes: 6 additions & 0 deletions deploy/charts/harbor-container-webhook/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ rules: []
# platforms: # defaults to linux/amd64, only used if checkUpstream is set
# - linux/amd64
# - linux/arm64
# - name: 'docker.io rewrite rule with imagePullSecrets update'
# matches:
# - '^docker.io'
# replace: 'harbor.example.com/dockerhub-proxy'
# replaceImagePullSecrets: true # enable imagePullSecrets change for modified images
# authSecretName: harbor-example-image-pull-secret # secret to add to imagePullSecrets on the modified pod

extraRules: []

Expand Down
9 changes: 8 additions & 1 deletion hack/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ rules:
- '^docker.io/(library/)?ubuntu:.*$'
replace: 'harbor-v2.awscmhqa2.k8s.indeed.tech/dockerhub-proxy-auth'
checkUpstream: true # tests if the manifest for the rewritten image exists
authSecretName: "harborv2-qa"
authSecretName: "harborv2-qa"
- name: 'docker.io rewrite rule with imagePullSecret change'
# image refs must match at least one of the rules, and not match any excludes
matches:
- '^docker.io'
replace: 'harbor.example.com/dockerhub-proxy'
authSecretName: "harborv2-qa"
replaceImagePullSecrets: true
5 changes: 5 additions & 0 deletions hack/test/admission.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
"metadata": {
},
"spec": {
"imagePullSecrets": [
{
"name": "foo"
}
],
"containers": [
{
"name": "foo",
Expand Down
5 changes: 5 additions & 0 deletions hack/test/no-op.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
"metadata": {
},
"spec": {
"imagePullSecrets": [
{
"name": "foo"
}
],
"containers": [
{
"name": "foo",
Expand Down
5 changes: 4 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,13 @@ type ProxyRule struct {
// If the webhook lacks permissions to fetch the image manifest or the registry is down, the image
// will not be rewritten. Experimental.
CheckUpstream bool `yaml:"checkUpstream"`
// ReplaceImagePullSecrets enables the replacement of the imagePullSecrets of the pod in addition to the image
ReplaceImagePullSecrets bool `yaml:"replaceImagePullSecrets"`
// List of the required platforms to check for if CheckUpstream is set. Defaults to "linux/amd64" if unset.
Platforms []string `yaml:"platforms"`
// AuthSecretName is a reference to an image pull secret (must be .dockerconfigjson type) which
// will be used to authenticate if `checkUpstream` is set. Unused if not specified or `checkUpstream` is false.
// will be used to authenticate if `checkUpstream` is set or to modify the imagePullSecrets if
// `replaceImagePullSecrets` is set.
AuthSecretName string `yaml:"authSecretName"`
// Namespace that the webhook is running in, used for accessing secrets for authenticated proxy rules
Namespace string
Expand Down
43 changes: 40 additions & 3 deletions internal/webhook/mutate.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func (p *PodContainerProxier) Handle(ctx context.Context, req admission.Request)
return admission.Errored(http.StatusBadRequest, err)
}

// container images
initContainers, updatedInit, err := p.updateContainers(ctx, pod.Spec.InitContainers, "init")
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
Expand All @@ -46,11 +47,19 @@ func (p *PodContainerProxier) Handle(ctx context.Context, req admission.Request)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
pod.Spec.InitContainers = initContainers
pod.Spec.Containers = containers

if !updated && !updatedInit {
return admission.Allowed("no updates")
}
pod.Spec.InitContainers = initContainers
pod.Spec.Containers = containers

// imagePullSecrets
imagePullSecrets, err := p.updateImagePullSecrets(p.getPodName(pod), pod.Spec.ImagePullSecrets)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
pod.Spec.ImagePullSecrets = imagePullSecrets

marshaledPod, err := json.Marshal(pod)
if err != nil {
Expand All @@ -68,7 +77,7 @@ func (p *PodContainerProxier) lookupNodeArchAndOS(ctx context.Context, restClien
return node.Status.NodeInfo.Architecture, node.Status.NodeInfo.OperatingSystem, nil
}

func (p *PodContainerProxier) updateContainers(ctx context.Context, containers []corev1.Container, kind string) ([]corev1.Container, bool, error) {
func (p *PodContainerProxier) updateContainers(ctx context.Context, containers []corev1.Container, _ string) ([]corev1.Container, bool, error) {
containersReplacement := make([]corev1.Container, 0, len(containers))
updated := false
for i := range containers {
Expand Down Expand Up @@ -118,3 +127,31 @@ func (p *PodContainerProxier) InjectDecoder(d admission.Decoder) error {
p.Decoder = d
return nil
}

func (p *PodContainerProxier) updateImagePullSecrets(podName string, imagePullSecrets []corev1.LocalObjectReference) (newImagePullSecrets []corev1.LocalObjectReference, err error) {
updated := false
for _, transformer := range p.Transformers {
updated, newImagePullSecrets, err = transformer.RewriteImagePullSecrets(imagePullSecrets)
if err != nil {
return imagePullSecrets, err
}
if !updated {
return imagePullSecrets, nil
}
logger.Info(fmt.Sprintf("rewriting the imagePullSecrets of the pod %s from %q to %q", podName, imagePullSecrets, newImagePullSecrets))
}
return newImagePullSecrets, nil
}

func (p *PodContainerProxier) getPodName(pod *corev1.Pod) (podName string) {
if pod.Name != "" {
return pod.Name
}
if pod.ObjectMeta.Labels["app.kubernetes.io/name"] != "" {
return pod.ObjectMeta.Labels["app.kubernetes.io/name"]
}
if pod.ObjectMeta.Labels["app"] != "" {
return pod.ObjectMeta.Labels["app"]
}
return pod.GenerateName
}
87 changes: 87 additions & 0 deletions internal/webhook/mutate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"testing"

corev1 "k8s.io/api/core/v1"

"github.com/indeedeng-alpha/harbor-container-webhook/internal/config"

"github.com/stretchr/testify/require"
Expand All @@ -27,6 +29,11 @@ func TestPodContainerProxier_rewriteImage(t *testing.T) {
Matches: []string{"^docker.io/(library/)?ubuntu"},
Replace: "harbor.example.com/ubuntu-proxy",
},
{
Name: "docker.io proxy cache with imagePullSecret change",
Matches: []string{"^docker.io/(library/)?ubuntu"},
Replace: "harbor.example.com/ubuntu-proxy",
},
}, nil)
require.NoError(t, err)
proxier := PodContainerProxier{
Expand Down Expand Up @@ -99,3 +106,83 @@ func TestPodContainerProxier_rewriteImage(t *testing.T) {
})
}
}

func TestPodContainerProxier_updateImagePullSecretsWithReplaceEnabled(t *testing.T) {
transformers, err := MakeTransformers([]config.ProxyRule{
{
Name: "docker.io proxy cache with imagePullSecrets change",
Matches: []string{"^docker.io"},
Replace: "harbor.example.com/dockerhub-proxy",
ReplaceImagePullSecrets: true,
AuthSecretName: "secret-test",
},
}, nil)
require.NoError(t, err)
proxier := PodContainerProxier{
Transformers: transformers,
}

type testcase struct {
name string
imagePullSecrets []corev1.LocalObjectReference
expected []corev1.LocalObjectReference
}
tests := []testcase{
{
name: "imagePullSecrets is empty, replacement is expected and secret name should be added",
imagePullSecrets: []corev1.LocalObjectReference{},
expected: []corev1.LocalObjectReference{{Name: "secret-test"}},
},
{
name: "imagePullSecrets has a secret, replacement is expected and secret name should be added",
imagePullSecrets: []corev1.LocalObjectReference{{Name: "mysecret"}},
expected: []corev1.LocalObjectReference{{Name: "mysecret"}, {Name: "secret-test"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
newImagePullSecrets, err := proxier.updateImagePullSecrets("pod-test", tc.imagePullSecrets)
require.NoError(t, err)
require.Equal(t, tc.expected, newImagePullSecrets)
})
}
}

func TestPodContainerProxier_updateImagePullSecretsWithReplaceDinabled(t *testing.T) {
transformers, err := MakeTransformers([]config.ProxyRule{
{
Name: "docker.io proxy cache without imagePullSecrets change",
Matches: []string{"^docker.io"},
Replace: "harbor.example.com/dockerhub-proxy",
},
}, nil)
require.NoError(t, err)
proxier := PodContainerProxier{
Transformers: transformers,
}

type testcase struct {
name string
imagePullSecrets []corev1.LocalObjectReference
expected []corev1.LocalObjectReference
}
tests := []testcase{
{
name: "imagePullSecrets is empty, replacement is not expected",
imagePullSecrets: []corev1.LocalObjectReference{},
expected: []corev1.LocalObjectReference{},
},
{
name: "imagePullSecrets has a secret, replacement is not expected",
imagePullSecrets: []corev1.LocalObjectReference{{Name: "mysecret"}},
expected: []corev1.LocalObjectReference{{Name: "mysecret"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
newImagePullSecrets, err := proxier.updateImagePullSecrets("pod-test", tc.imagePullSecrets)
require.NoError(t, err)
require.Equal(t, tc.expected, newImagePullSecrets)
})
}
}
44 changes: 44 additions & 0 deletions internal/webhook/transformer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -76,6 +77,9 @@ type ContainerTransformer interface {
// CheckUpstream ensures that the docker image reference exists in the upstream registry
// and returns if the image exists, or an error if the registry can't be contacted.
CheckUpstream(ctx context.Context, imageRef string) (bool, error)

// RewriteImagePullSecrets takes a list of kubernetes secret name and add the AuthSecretName parameter
RewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (bool, []corev1.LocalObjectReference, error)
}

func MakeTransformers(rules []config.ProxyRule, client client.Client) ([]ContainerTransformer, error) {
Expand Down Expand Up @@ -272,3 +276,43 @@ func (t *ruleTransformer) anyExclusion(imageRef string) bool {
}
return false
}

func (t *ruleTransformer) RewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (updated bool, newImagePullSecrets []corev1.LocalObjectReference, err error) {
if t.rule.AuthSecretName == "" && t.rule.ReplaceImagePullSecrets {
return false, imagePullSecrets, fmt.Errorf("replaceImagePullSecrets is enabled but no authSecretName parameter")
}
if !t.rule.ReplaceImagePullSecrets {
return false, imagePullSecrets, nil
}

start := time.Now()
updated, imagePullSecrets = t.doRewriteImagePullSecrets(imagePullSecrets)
duration := time.Since(start)
if updated {
rewrite.WithLabelValues(t.metricName).Inc()
rewriteTime.WithLabelValues(t.metricName).Observe(duration.Seconds())
} else if !updated {
return false, imagePullSecrets, nil
}
return true, imagePullSecrets, nil
}

func (t *ruleTransformer) doRewriteImagePullSecrets(imagePullSecrets []corev1.LocalObjectReference) (bool, []corev1.LocalObjectReference) {
existingSecrets := t.getExistingSecrets(imagePullSecrets)

if slices.Contains(existingSecrets, t.rule.AuthSecretName) {
return false, imagePullSecrets
}
newImagePullSecret := corev1.LocalObjectReference{
Name: t.rule.AuthSecretName,
}
imagePullSecrets = append(imagePullSecrets, newImagePullSecret)
return true, imagePullSecrets
}

func (t *ruleTransformer) getExistingSecrets(imagePullSecrets []corev1.LocalObjectReference) (existingSecrets []string) {
for _, secret := range imagePullSecrets {
existingSecrets = append(existingSecrets, secret.Name)
}
return existingSecrets
}