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

feat: add support for regexp rules #14

Open
wants to merge 1 commit 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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ replacement configuration, for example:
webhookConfig:
exclude:
- prefix: k8s.gcr.io/ingress-nginx/controller
- regexp: ^quay\.io/.*prometheus.*$
replace:
- prefix: quay.io
replacement: registry.example.org/quay.io
- prefix: k8s.gcr.io
replacement: registry.example.org/k8s.gcr.io
- prefix: docker.io
replacement: registry.example.org/docker.io
- regexp: ^.*busybox(:(?P<tag>.+))?$
replacement: registry.example.org/library/busybox:1.35
```

You can find documentation for all available `webhookConfig` fields in
Expand Down
3 changes: 3 additions & 0 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# prefixes within this registry.
exclude:
- prefix: k8s.gcr.io/ingress-nginx/controller
- regexp: ^quay\.io/.*prometheus.*$
# Replacement rules: these define an image prefix and a replacement for it.
# Images from dockerhub are expanded to their fully qualified image name before
# the rules are applied.
Expand All @@ -21,3 +22,5 @@ replace:
replacement: registry.example.org/k8s.gcr.io
- prefix: docker.io
replacement: registry.example.org/docker.io
- regexp: ^.*busybox(:(?P<tag>.+))?$
replacement: registry.example.org/library/busybox:1.35
19 changes: 11 additions & 8 deletions pkg/admission/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,23 @@ func (h *PodImageHandler) patchContainers(containers []corev1.Container) []corev
func (h *PodImageHandler) patchContainer(container corev1.Container) corev1.Container {
image := normalizeImage(container.Image)

for _, rule := range h.config.Exclude {
if strings.HasPrefix(image, rule.Prefix) {
logger.Info("image excluded from replacement via config, not patching", "image", image)
for i, rule := range h.config.Exclude {
if rule.MatchImage(image) {
logger.Info("image excluded from replacement via config, not patching",
"image", container.Image, "rule_id", i)

return container
}
}

for _, rule := range h.config.Replace {
if strings.HasPrefix(image, rule.Prefix) {
replacedImage := strings.Replace(image, rule.Prefix, rule.Replacement, 1)
container.Image = replacedImage
for i, rule := range h.config.Replace {
replacement := rule.ReplaceImage(image, rule.Replacement)

logger.Info("patching container image", "from", image, "to", replacedImage)
if replacement != image {
logger.Info("patching container image",
"original", container.Image, "replacement", replacement, "rule_id", i)

container.Image = replacement

return container
}
Expand Down
38 changes: 35 additions & 3 deletions pkg/admission/testcases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ var testCases = []testCase{
config: &config.Config{
Replace: []config.ReplacementRule{
{
Prefix: "docker.io",
Pattern: config.Pattern{Prefix: "docker.io"},
Replacement: "registry.example.com/docker.io",
},
},
Expand Down Expand Up @@ -73,12 +73,44 @@ var testCases = []testCase{
config: &config.Config{
Exclude: []config.ExclusionRule{
{
Prefix: "someregistry.org/excluded-namespace",
Pattern: config.Pattern{Prefix: "someregistry.org/excluded-namespace"},
},
},
Replace: []config.ReplacementRule{
{
Prefix: "someregistry.org",
Pattern: config.Pattern{Prefix: "someregistry.org"},
Replacement: "registry.example.com/someregistry.org",
},
},
},
pod: &corev1.Pod{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Image: "someregistry.org/excluded-namespace/some-other-image:v1.0.0"},
{Image: "someregistry.org/some-namespace/some-image:latest"},
},
},
},
expectedCode: http.StatusOK,
expectedPatches: []jsonpatch.JsonPatchOperation{
{
Operation: "replace",
Path: "/spec/containers/1/image",
Value: "registry.example.com/someregistry.org/some-namespace/some-image:latest",
},
},
},
{
name: "does not replace images matching exclude rules, regexp",
config: &config.Config{
Exclude: []config.ExclusionRule{
{
Pattern: config.Pattern{Regexp: config.MustCompileRegexp(".*excluded-namespace.*")},
},
},
Replace: []config.ReplacementRule{
{
Pattern: config.Pattern{Prefix: "someregistry.org"},
Replacement: "registry.example.com/someregistry.org",
},
},
Expand Down
63 changes: 51 additions & 12 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"strings"

"gopkg.in/yaml.v2"
"sigs.k8s.io/controller-runtime/pkg/log"
Expand All @@ -21,16 +22,23 @@ type Config struct {
// ExclusionRule represents a rule for an image prefix that should explicitly
// be excluded from any replacements.
type ExclusionRule struct {
Prefix string `json:"prefix"`
Pattern `yaml:",inline" json:",inline"`
}

// ReplacementRule represents a rule that matches an image prefix and replaces
// it with the provided replacement.
type ReplacementRule struct {
Prefix string `json:"prefix"`
Pattern `yaml:",inline" json:",inline"`
Replacement string `json:"replacement"`
}

// Pattern holds the different possible match pattern for exclusion and
// replacement rules.
type Pattern struct {
Prefix string `json:"prefix"`
Regexp *Regexp `json:"regexp"`
}

// Load loads the configuration for the webhook from the given path.
func Load(path string) (*Config, error) {
logger.V(1).Info("loading configuration", "path", path)
Expand Down Expand Up @@ -70,24 +78,55 @@ func (c *Config) Validate() error {
return nil
}

// Validate validates the exclusion rule.
func (r *ExclusionRule) Validate() error {
if r.Prefix == "" {
return errors.New("prefix must not be empty")
// Validate validates the replacement rule.
func (r *ReplacementRule) Validate() error {
if err := r.Pattern.Validate(); err != nil {
return err
}

if r.Replacement == "" {
return errors.New("replacement must not be empty")
}

return nil
}

// Validate validates the replacement rule.
func (r *ReplacementRule) Validate() error {
if r.Prefix == "" {
return errors.New("prefix must not be empty")
// Validate validates the match pattern.
func (p *Pattern) Validate() error {
if p.Prefix == "" && p.Regexp.IsEmpty() {
return errors.New("one of `prefix` and `regexp` must be non-empty")
}

if r.Replacement == "" {
return errors.New("replacement must not be empty")
if p.Prefix != "" && !p.Regexp.IsEmpty() {
return errors.New("only one of `prefix` and `regexp` must be set")
}

return nil
}

// ReplaceImage replaces the provides image based on the kind of pattern
// used. Returns the image unchanged if no replacement occurred.
func (p *Pattern) ReplaceImage(image string, replacement string) string {
if p.matchPrefix(image) {
return strings.Replace(image, p.Prefix, replacement, 1)
}

if p.matchRegexp(image) {
return p.Regexp.ReplaceAllString(image, replacement)
}

return image
}

// MatchImage returns true if the pattern matches on the provided image.
func (p *Pattern) MatchImage(image string) bool {
return p.matchPrefix(image) || p.matchRegexp(image)
}

func (p *Pattern) matchPrefix(image string) bool {
return p.Prefix != "" && strings.HasPrefix(image, p.Prefix)
}

func (p *Pattern) matchRegexp(image string) bool {
return !p.Regexp.IsEmpty() && p.Regexp.MatchString(image)
}
129 changes: 123 additions & 6 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
package config

import (
"fmt"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfig_Load(t *testing.T) {
t.Run("sample config is always valid", func(t *testing.T) {
cfg, err := Load("../../config.sample.yaml")
assert.NoError(t, err)
require.NoError(t, err)
assert.Greater(t, len(cfg.Exclude), 0)
assert.Greater(t, len(cfg.Replace), 0)
})

t.Run("invalid regexp causes error", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := "exclude:\n- regexp: '(]invalid'"
require.NoError(t, os.WriteFile(path, []byte(content), 0664))

_, err := Load(path)
require.Error(t, err)
require.Contains(t, err.Error(), "error parsing regexp")
})
}

func TestConfig_Validate(t *testing.T) {
Expand All @@ -24,11 +38,13 @@ func TestConfig_Validate(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
cfg := &Config{
Exclude: []ExclusionRule{
{Prefix: "someregistry.org/some-namespace"},
{
Pattern: Pattern{Prefix: "someregistry.org/some-namespace"},
},
},
Replace: []ReplacementRule{
{
Prefix: "someregistry.org",
Pattern: Pattern{Prefix: "someregistry.org"},
Replacement: "otherregistry.org/someregistry.org",
},
},
Expand All @@ -39,7 +55,9 @@ func TestConfig_Validate(t *testing.T) {
t.Run("exclusion rule prefix must not be empty", func(t *testing.T) {
cfg := &Config{
Exclude: []ExclusionRule{
{Prefix: ""},
{
Pattern: Pattern{Prefix: ""},
},
},
}
assert.Error(t, cfg.Validate())
Expand All @@ -48,7 +66,10 @@ func TestConfig_Validate(t *testing.T) {
t.Run("replacement rule prefix must not be empty", func(t *testing.T) {
cfg := &Config{
Replace: []ReplacementRule{
{Prefix: "", Replacement: "otherregistry.org/someregistry.org"},
{
Pattern: Pattern{Prefix: ""},
Replacement: "otherregistry.org/someregistry.org",
},
},
}
assert.Error(t, cfg.Validate())
Expand All @@ -57,9 +78,105 @@ func TestConfig_Validate(t *testing.T) {
t.Run("replacement rule replacement must not be empty", func(t *testing.T) {
cfg := &Config{
Replace: []ReplacementRule{
{Prefix: "someregistry.org", Replacement: ""},
{
Pattern: Pattern{Prefix: "someregistry.org"},
Replacement: "",
},
},
}
assert.Error(t, cfg.Validate())
})

t.Run("only one of prefix and regexp must be set at the same time", func(t *testing.T) {
cfg := &Config{
Replace: []ReplacementRule{
{
Pattern: Pattern{
Prefix: "someregistry.org",
Regexp: MustCompileRegexp("^someregistry.org"),
},
Replacement: "otherregistry.org",
},
},
}
assert.Error(t, cfg.Validate())
})
}

var patternTestCases = []struct {
pattern Pattern
image string
match bool
replacement string
expected string
}{
{
pattern: Pattern{Prefix: "busybox"},
image: "busybox",
replacement: "lazybox",
expected: "lazybox",
match: true,
},
{
pattern: Pattern{Prefix: "busybox"},
image: "busybox:latest",
replacement: "lazybox",
expected: "lazybox:latest",
match: true,
},
{
pattern: Pattern{Prefix: "busybox"},
image: "someregistry.org/library/busybox:latest",
replacement: "lazybox",
expected: "someregistry.org/library/busybox:latest",
match: false,
},
{
pattern: Pattern{Regexp: MustCompileRegexp("^busybox$")},
image: "busybox",
replacement: "foo",
expected: "foo",
match: true,
},
{
pattern: Pattern{Regexp: MustCompileRegexp("^busybox$")},
image: "busybox:latest",
replacement: "foo",
expected: "busybox:latest",
match: false,
},
{
pattern: Pattern{Regexp: MustCompileRegexp("^busybox$")},
image: "someregistry.org/library/busybox:latest",
replacement: "foo",
expected: "someregistry.org/library/busybox:latest",
match: false,
},
{
pattern: Pattern{Regexp: MustCompileRegexp("^busy")},
image: "busybox",
replacement: "lazy",
expected: "lazybox",
match: true,
},
{
pattern: Pattern{Regexp: MustCompileRegexp("^(?P<registry>[^/]+)/(?P<image>[^:]+):(?P<tag>.+)$")},
image: "docker.io/library/nginx:latest",
replacement: "myregistry.org/${image}:1.0.0",
expected: "myregistry.org/library/nginx:1.0.0",
match: true,
},
}

func TestPattern(t *testing.T) {
t.Parallel()

for i, tc := range patternTestCases {
tc := tc

t.Run(fmt.Sprintf("case #%d", i), func(t *testing.T) {
assert.Equal(t, tc.match, tc.pattern.MatchImage(tc.image))
assert.Equal(t, tc.expected, tc.pattern.ReplaceImage(tc.image, tc.replacement))
})
}
}
Loading