Skip to content

Commit

Permalink
feat: add support for regexp rules
Browse files Browse the repository at this point in the history
In addition to matching and replacing image prefixes, this adds support
for using regexp patterns in exclusion and replacement rules.

This should allow to do any possible image transformation. When using a
regexp replacement rule, the replacement pattern can reference
submatches via `$N` where `N` is a number or `${name}` where `name` is
the name of a named capture group (e.g. `(?P<tag>.+)`).
  • Loading branch information
martinohmann committed Mar 28, 2022
1 parent 9d09c63 commit cbeec63
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 29 deletions.
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

0 comments on commit cbeec63

Please sign in to comment.