mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-25 16:07:24 +00:00
feat: add scheduler enforcement rule (#1971)
* fix(controller): decode old object for delete requests Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * fix: preserve ca-bundles injected from external providers Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: abstract ruling Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api * feat: migrate events api --------- Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// At least one of Exact or Exp must be set.
|
||||
// Both may be set together.
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.exact) || has(self.exp)",message="at least one of exact or exp must be set"
|
||||
type ExpressionMatch struct {
|
||||
ExpressionRegex `json:",inline"`
|
||||
|
||||
// Exact matches one of the provided values exactly.
|
||||
//
|
||||
// +kubebuilder:validation:MinItems=1
|
||||
// +kubebuilder:validation:Items:MinLength=1
|
||||
// +optional
|
||||
Exact []string `json:"exact,omitempty"`
|
||||
}
|
||||
|
||||
type ExpressionRegex struct {
|
||||
// Exp matches regular expression.
|
||||
//
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +optional
|
||||
Expression string `json:"exp,omitempty"`
|
||||
// Negate regular Expression
|
||||
//+kubebuilder:default:=false
|
||||
Negate bool `json:"negate,omitempty"`
|
||||
}
|
||||
|
||||
type ExpressionRegexMatcher interface {
|
||||
MatchRegex(expression ExpressionRegex, value string) (bool, error)
|
||||
}
|
||||
|
||||
func (m ExpressionMatch) Matches(value string) (bool, error) {
|
||||
matched, err := m.matches(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return m.applyNegate(matched), nil
|
||||
}
|
||||
|
||||
func (m ExpressionMatch) MatchesWithExpressionMatcher(
|
||||
matcher ExpressionRegexMatcher,
|
||||
value string,
|
||||
) (bool, error) {
|
||||
if len(m.Exact) == 0 && m.Expression == "" {
|
||||
return false, fmt.Errorf("expression match must define at least one of exact or exp")
|
||||
}
|
||||
|
||||
matched := containsExact(m.Exact, value)
|
||||
if matched {
|
||||
return m.applyNegate(true), nil
|
||||
}
|
||||
|
||||
if m.Expression == "" {
|
||||
return m.applyNegate(false), nil
|
||||
}
|
||||
|
||||
if matcher == nil {
|
||||
return m.Matches(value)
|
||||
}
|
||||
|
||||
matched, err := matcher.MatchRegex(m.ExpressionRegex, value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Important: assume MatchRegex already applies ExpressionRegex.Negate.
|
||||
// If your RegexCache.MatchRegex already handles Negate, return directly.
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (m ExpressionMatch) matches(value string) (bool, error) {
|
||||
if len(m.Exact) == 0 && m.Expression == "" {
|
||||
return false, fmt.Errorf("expression match must define at least one of exact or exp")
|
||||
}
|
||||
|
||||
if containsExact(m.Exact, value) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if m.Expression == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(m.Expression)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("compile regexp %q: %w", m.Expression, err)
|
||||
}
|
||||
|
||||
return re.MatchString(value), nil
|
||||
}
|
||||
|
||||
func containsExact(values []string, value string) bool {
|
||||
return slices.Contains(values, value)
|
||||
}
|
||||
|
||||
func (m ExpressionMatch) applyNegate(matched bool) bool {
|
||||
if m.Negate {
|
||||
return !matched
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeExpressionRegexMatcher struct {
|
||||
t *testing.T
|
||||
|
||||
calls int
|
||||
|
||||
err error
|
||||
|
||||
matches map[string]bool
|
||||
seen []ExpressionRegex
|
||||
}
|
||||
|
||||
func (m *fakeExpressionRegexMatcher) MatchRegex(expr ExpressionRegex, value string) (bool, error) {
|
||||
m.t.Helper()
|
||||
|
||||
m.calls++
|
||||
m.seen = append(m.seen, expr)
|
||||
|
||||
if m.err != nil {
|
||||
return false, m.err
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s|%t|%s", expr.Expression, expr.Negate, value)
|
||||
if matched, ok := m.matches[key]; ok {
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(expr.Expression)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
matched := re.MatchString(value)
|
||||
if expr.Negate {
|
||||
return !matched, nil
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func TestExpressionMatch_Matches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
match ExpressionMatch
|
||||
value string
|
||||
wantMatch bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "exact matches single value",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "exact does not match different value",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
},
|
||||
value: "custom-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "exact matches one of multiple values",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler", "custom-scheduler", "team-scheduler"},
|
||||
},
|
||||
value: "custom-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "exact does not match any of multiple values",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler", "custom-scheduler"},
|
||||
},
|
||||
value: "other-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "exact is case sensitive",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"Default-Scheduler"},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "exact uses literal string not pattern",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"team-.*"},
|
||||
},
|
||||
value: "team-a",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "exact matches literal pattern string",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"team-.*"},
|
||||
},
|
||||
value: "team-.*",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "exact with empty value can match empty string when present",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{""},
|
||||
},
|
||||
value: "",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "regex matches value",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-[a-z0-9-]+$",
|
||||
},
|
||||
},
|
||||
value: "team-alpha-1",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "regex does not match value",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-[a-z0-9-]+$",
|
||||
},
|
||||
},
|
||||
value: "kube-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "regex is not implicitly anchored",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "team",
|
||||
},
|
||||
},
|
||||
value: "my-team-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "invalid regex returns error",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
},
|
||||
value: "team-alpha",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "combined exact and regex matches by exact",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-[a-z0-9-]+$",
|
||||
},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "combined exact and regex matches by regex",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-[a-z0-9-]+$",
|
||||
},
|
||||
},
|
||||
value: "team-alpha",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "combined exact and regex does not match either",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-[a-z0-9-]+$",
|
||||
},
|
||||
},
|
||||
value: "other-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "combined exact match skips invalid regex",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "combined exact miss evaluates invalid regex and returns error",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
},
|
||||
value: "team-alpha",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negated exact matching value returns false",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated exact non matching value returns true",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "custom-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "negated exact with multiple values matching one returns false",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler", "custom-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "custom-scheduler",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated regex matching value returns false",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.*",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "trusted/platform/app:1",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated regex non matching value returns true",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.*",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "docker.io/library/nginx:latest",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "negated combined exact match returns false",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"trusted/platform/app:1"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.+",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "trusted/platform/app:1",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated combined regex match returns false",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"trusted/platform/app:1"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.+",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "trusted/other/app:1",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated combined no match returns true",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"trusted/platform/app:1"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.+",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "harbor/platform/app:1",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "empty matcher returns error",
|
||||
match: ExpressionMatch{},
|
||||
value: "anything",
|
||||
wantErr: true,
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "empty exact slice with empty regex returns error",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{},
|
||||
},
|
||||
value: "anything",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil exact with whitespace regex is treated as regex and does not match",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: " ",
|
||||
},
|
||||
},
|
||||
value: "anything",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate exact values still match",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"a", "a", "b"},
|
||||
},
|
||||
value: "a",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "exact values are not trimmed",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{" value "},
|
||||
},
|
||||
value: "value",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "exact values match with spaces when value has spaces",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{" value "},
|
||||
},
|
||||
value: " value ",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "regex can match empty value",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^$",
|
||||
},
|
||||
},
|
||||
value: "",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "negated regex can reject empty value",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^$",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "",
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "negated regex can match non empty value against empty regex",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^$",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "non-empty",
|
||||
wantMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := tt.match.Matches(tt.value)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("Matches() expected error, got nil")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Matches() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.wantMatch {
|
||||
t.Fatalf("Matches() = %t, want %t", got, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_NilMatcherFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
match ExpressionMatch
|
||||
value string
|
||||
wantMatch bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nil matcher exact match",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
},
|
||||
value: "default-scheduler",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "nil matcher regex match",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-.*",
|
||||
},
|
||||
},
|
||||
value: "team-a",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "nil matcher negated regex non match returns true",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.*",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "docker.io/library/nginx:latest",
|
||||
wantMatch: true,
|
||||
},
|
||||
{
|
||||
name: "nil matcher invalid regex returns error",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
},
|
||||
value: "team-a",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil matcher empty expression match returns error",
|
||||
match: ExpressionMatch{},
|
||||
value: "team-a",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := tt.match.MatchesWithExpressionMatcher(nil, tt.value)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher(nil) expected error, got nil")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher(nil) unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != tt.wantMatch {
|
||||
t.Fatalf("MatchesWithExpressionMatcher(nil) = %t, want %t", got, tt.wantMatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_UsesMatcherForRegex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcher := &fakeExpressionRegexMatcher{
|
||||
t: t,
|
||||
matches: map[string]bool{
|
||||
"^team-.*|false|team-a": true,
|
||||
},
|
||||
}
|
||||
|
||||
match := ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-.*",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := match.MatchesWithExpressionMatcher(matcher, "team-a")
|
||||
if err != nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !got {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() = false, want true")
|
||||
}
|
||||
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls)
|
||||
}
|
||||
|
||||
if len(matcher.seen) != 1 {
|
||||
t.Fatalf("seen expressions = %d, want 1", len(matcher.seen))
|
||||
}
|
||||
|
||||
if matcher.seen[0].Expression != "^team-.*" {
|
||||
t.Fatalf("seen expression = %q, want %q", matcher.seen[0].Expression, "^team-.*")
|
||||
}
|
||||
|
||||
if matcher.seen[0].Negate {
|
||||
t.Fatalf("seen negate = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_PassesNegateToMatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcher := &fakeExpressionRegexMatcher{
|
||||
t: t,
|
||||
matches: map[string]bool{
|
||||
"^trusted/.*|true|docker.io/library/nginx:latest": true,
|
||||
},
|
||||
}
|
||||
|
||||
match := ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^trusted/.*",
|
||||
Negate: true,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := match.MatchesWithExpressionMatcher(matcher, "docker.io/library/nginx:latest")
|
||||
if err != nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !got {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() = false, want true")
|
||||
}
|
||||
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls)
|
||||
}
|
||||
|
||||
if len(matcher.seen) != 1 {
|
||||
t.Fatalf("seen expressions = %d, want 1", len(matcher.seen))
|
||||
}
|
||||
|
||||
if !matcher.seen[0].Negate {
|
||||
t.Fatalf("seen negate = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_DoesNotUseMatcherWhenExactMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcher := &fakeExpressionRegexMatcher{
|
||||
t: t,
|
||||
err: errors.New("matcher should not be called"),
|
||||
}
|
||||
|
||||
match := ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := match.MatchesWithExpressionMatcher(matcher, "default-scheduler")
|
||||
if err != nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !got {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() = false, want true")
|
||||
}
|
||||
|
||||
if matcher.calls != 0 {
|
||||
t.Fatalf("MatchRegex() calls = %d, want 0", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_UsesMatcherWhenExactDoesNotMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcher := &fakeExpressionRegexMatcher{
|
||||
t: t,
|
||||
matches: map[string]bool{
|
||||
"^team-.*|false|team-a": true,
|
||||
},
|
||||
}
|
||||
|
||||
match := ExpressionMatch{
|
||||
Exact: []string{"default-scheduler"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-.*",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := match.MatchesWithExpressionMatcher(matcher, "team-a")
|
||||
if err != nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !got {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() = false, want true")
|
||||
}
|
||||
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesWithExpressionMatcher_ReturnsMatcherError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantErr := errors.New("compile failed")
|
||||
|
||||
matcher := &fakeExpressionRegexMatcher{
|
||||
t: t,
|
||||
err: wantErr,
|
||||
}
|
||||
|
||||
match := ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^team-.*",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := match.MatchesWithExpressionMatcher(matcher, "team-a")
|
||||
if err == nil {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() expected error, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
|
||||
if got {
|
||||
t.Fatalf("MatchesWithExpressionMatcher() = true, want false on error")
|
||||
}
|
||||
|
||||
if matcher.calls != 1 {
|
||||
t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_MatchesAndMatchesWithExpressionMatcher_AgreeForNilMatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
match ExpressionMatch
|
||||
value string
|
||||
}{
|
||||
{
|
||||
name: "exact only",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"a", "b"},
|
||||
},
|
||||
value: "a",
|
||||
},
|
||||
{
|
||||
name: "regex only",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^a+$",
|
||||
},
|
||||
},
|
||||
value: "aaa",
|
||||
},
|
||||
{
|
||||
name: "combined exact and regex exact wins",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"a"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^b+$",
|
||||
},
|
||||
},
|
||||
value: "a",
|
||||
},
|
||||
{
|
||||
name: "combined exact and regex regex wins",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"a"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^b+$",
|
||||
},
|
||||
},
|
||||
value: "bbb",
|
||||
},
|
||||
{
|
||||
name: "negated exact",
|
||||
match: ExpressionMatch{
|
||||
Exact: []string{"a"},
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "b",
|
||||
},
|
||||
{
|
||||
name: "negated regex",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "^a+$",
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
value: "bbb",
|
||||
},
|
||||
{
|
||||
name: "invalid regex",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Expression: "[",
|
||||
},
|
||||
},
|
||||
value: "a",
|
||||
},
|
||||
{
|
||||
name: "empty matcher",
|
||||
match: ExpressionMatch{},
|
||||
value: "a",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotMatches, errMatches := tt.match.Matches(tt.value)
|
||||
gotWithMatcher, errWithMatcher := tt.match.MatchesWithExpressionMatcher(nil, tt.value)
|
||||
|
||||
if (errMatches != nil) != (errWithMatcher != nil) {
|
||||
t.Fatalf(
|
||||
"error mismatch: Matches() err=%v, MatchesWithExpressionMatcher(nil) err=%v",
|
||||
errMatches,
|
||||
errWithMatcher,
|
||||
)
|
||||
}
|
||||
|
||||
if gotMatches != gotWithMatcher {
|
||||
t.Fatalf(
|
||||
"result mismatch: Matches()=%t, MatchesWithExpressionMatcher(nil)=%t",
|
||||
gotMatches,
|
||||
gotWithMatcher,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsExact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil values",
|
||||
values: nil,
|
||||
value: "a",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty values",
|
||||
values: []string{},
|
||||
value: "a",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "contains value",
|
||||
values: []string{"a", "b", "c"},
|
||||
value: "b",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "does not contain value",
|
||||
values: []string{"a", "b", "c"},
|
||||
value: "d",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case sensitive",
|
||||
values: []string{"A"},
|
||||
value: "a",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
values: []string{""},
|
||||
value: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace is significant",
|
||||
values: []string{" a "},
|
||||
value: "a",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "whitespace matches exactly",
|
||||
values: []string{" a "},
|
||||
value: " a ",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := containsExact(tt.values, tt.value)
|
||||
if got != tt.want {
|
||||
t.Fatalf("containsExact(%v, %q) = %t, want %t", tt.values, tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpressionMatch_applyNegate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
match ExpressionMatch
|
||||
matched bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "non negated true",
|
||||
match: ExpressionMatch{},
|
||||
matched: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non negated false",
|
||||
match: ExpressionMatch{},
|
||||
matched: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negated true",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
matched: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negated false",
|
||||
match: ExpressionMatch{
|
||||
ExpressionRegex: ExpressionRegex{
|
||||
Negate: true,
|
||||
},
|
||||
},
|
||||
matched: false,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := tt.match.applyNegate(tt.matched)
|
||||
if got != tt.want {
|
||||
t.Fatalf("applyNegate(%t) = %t, want %t", tt.matched, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ const (
|
||||
|
||||
ResourceQuotaAnnotationPrefix = "quota.resources.capsule.clastix.io"
|
||||
ResourceUsedAnnotationPrefix = "used.resources.capsule.clastix.io"
|
||||
|
||||
// Audit Annotations.
|
||||
AuditRequestUID = "audit.projectcapsule.dev/request-uid"
|
||||
AuditUsername = "audit.projectcapsule.dev/username"
|
||||
AuditRuleSetName = "audit.projectcapsule.dev/rule-set"
|
||||
AuditRuleSetAction = "audit.projectcapsule.dev/rule-action"
|
||||
)
|
||||
|
||||
func ReleaseAnnotationTriggers(obj client.Object) bool {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type RegExpression struct {
|
||||
// Expression used to evaluate regex
|
||||
Expression string `json:"exp,omitempty"`
|
||||
// Negate regular Expression
|
||||
//+kubebuilder:default:=false
|
||||
Negate bool `json:"negate,omitempty"`
|
||||
}
|
||||
@@ -18,14 +18,10 @@ func (i ImagePullPolicySpec) String() string {
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type OCIRegistry struct {
|
||||
api.RegExpression `json:",inline"`
|
||||
api.ExpressionMatch `json:",inline"`
|
||||
|
||||
// Allowed PullPolicy for the given registry. Supplying no value allows all policies.
|
||||
// +optional
|
||||
// +kubebuilder:validation:Items:Enum=Always;Never;IfNotPresent
|
||||
Policy []corev1.PullPolicy `json:"policy,omitempty"`
|
||||
}
|
||||
|
||||
func (r OCIRegistry) Expression() api.RegExpression {
|
||||
return r.RegExpression
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package rules
|
||||
|
||||
import corev1 "k8s.io/api/core/v1"
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
)
|
||||
|
||||
// +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes
|
||||
type WorkloadValidationTarget string
|
||||
@@ -31,5 +35,14 @@ type NamespaceRuleEnforceWorkloadsBody struct {
|
||||
|
||||
// Define registries which are allowed to be used within this tenant
|
||||
// The rules are aggregated, since you can use Regular Expressions the match registry endpoints
|
||||
// +optional
|
||||
Registries []OCIRegistry `json:"registries,omitempty"`
|
||||
|
||||
// Schedulers defines schedulerName matchers for Pod admission.
|
||||
//
|
||||
// The rule is evaluated against pod.spec.schedulerName.
|
||||
// Empty schedulerName is ignored and is not normalized to default-scheduler.
|
||||
//
|
||||
// +optional
|
||||
Schedulers []api.ExpressionMatch `json:"schedulers,omitempty"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
@@ -94,6 +95,13 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.Schedulers != nil {
|
||||
in, out := &in.Schedulers, &out.Schedulers
|
||||
*out = make([]api.ExpressionMatch, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceWorkloadsBody.
|
||||
@@ -160,7 +168,7 @@ func (in *NamespaceRulePromotionRule) DeepCopy() *NamespaceRulePromotionRule {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *OCIRegistry) DeepCopyInto(out *OCIRegistry) {
|
||||
*out = *in
|
||||
out.RegExpression = in.RegExpression
|
||||
in.ExpressionMatch.DeepCopyInto(&out.ExpressionMatch)
|
||||
if in.Policy != nil {
|
||||
in, out := &in.Policy, &out.Policy
|
||||
*out = make([]v1.PullPolicy, len(*in))
|
||||
|
||||
@@ -142,6 +142,27 @@ func (in *DefaultAllowedListSpec) DeepCopy() *DefaultAllowedListSpec {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) {
|
||||
*out = *in
|
||||
out.ExpressionRegex = in.ExpressionRegex
|
||||
if in.Exact != nil {
|
||||
in, out := &in.Exact, &out.Exact
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch.
|
||||
func (in *ExpressionMatch) DeepCopy() *ExpressionMatch {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(ExpressionMatch)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ExternalServiceIPsSpec) DeepCopyInto(out *ExternalServiceIPsSpec) {
|
||||
*out = *in
|
||||
@@ -288,21 +309,6 @@ func (in *PoolExhaustionResource) DeepCopy() *PoolExhaustionResource {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *RegExpression) DeepCopyInto(out *RegExpression) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegExpression.
|
||||
func (in *RegExpression) DeepCopy() *RegExpression {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(RegExpression)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ResourceQuotaSpec) DeepCopyInto(out *ResourceQuotaSpec) {
|
||||
*out = *in
|
||||
|
||||
Reference in New Issue
Block a user