mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-19 04:26:45 +00:00
feat: add metadata enforcement (#1990)
* 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: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> --------- Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> Signed-off-by: Oliver Baehler <oliver@sudo-i.net> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
6fbd472f27
commit
77d1810bb9
@@ -33,6 +33,7 @@ const (
|
||||
|
||||
CreatedByCapsuleLabel = "projectcapsule.dev/created-by"
|
||||
CustomResourcesLabel = "projectcapsule.dev/custom-resources"
|
||||
ResourceOriginLabel = "projectcapsule.dev/resource-origin"
|
||||
|
||||
NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by"
|
||||
ManagedByCapsuleLabel = "capsule.clastix.io/managed-by"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type ManagedMetadata struct {
|
||||
labels map[string]struct{}
|
||||
annotations map[string]struct{}
|
||||
annotationPrefixes []string
|
||||
}
|
||||
|
||||
func NewManagedMetadata(
|
||||
labels []string,
|
||||
annotations []string,
|
||||
) ManagedMetadata {
|
||||
m := ManagedMetadata{
|
||||
labels: stringSet(
|
||||
ResourcesLabel,
|
||||
TenantNameLabel,
|
||||
TenantLabel,
|
||||
NewTenantLabel,
|
||||
ResourcePoolLabel,
|
||||
FreezeLabel,
|
||||
OwnerPromotionLabel,
|
||||
ServiceAccountPromotionLabel,
|
||||
CordonedLabel,
|
||||
CapsuleNameLabel,
|
||||
CreatedByCapsuleLabel,
|
||||
CustomResourcesLabel,
|
||||
NewManagedByCapsuleLabel,
|
||||
ManagedByCapsuleLabel,
|
||||
LimitRangeLabel,
|
||||
NetworkPolicyLabel,
|
||||
ResourceQuotaLabel,
|
||||
RolebindingLabel,
|
||||
),
|
||||
annotations: stringSet(
|
||||
ReleaseAnnotation,
|
||||
ReconcileAnnotation,
|
||||
AvailableIngressClassesAnnotation,
|
||||
AvailableIngressClassesRegexpAnnotation,
|
||||
AvailableStorageClassesAnnotation,
|
||||
AvailableStorageClassesRegexpAnnotation,
|
||||
AllowedRegistriesAnnotation,
|
||||
AllowedRegistriesRegexpAnnotation,
|
||||
ForbiddenNamespaceLabelsAnnotation,
|
||||
ForbiddenNamespaceLabelsRegexpAnnotation,
|
||||
ForbiddenNamespaceAnnotationsAnnotation,
|
||||
ForbiddenNamespaceAnnotationsRegexpAnnotation,
|
||||
ProtectedTenantAnnotation,
|
||||
),
|
||||
annotationPrefixes: compactStrings(
|
||||
ResourceQuotaAnnotationPrefix,
|
||||
ResourceUsedAnnotationPrefix,
|
||||
),
|
||||
}
|
||||
|
||||
m.addLabels(labels...)
|
||||
m.addAnnotations(annotations...)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m ManagedMetadata) HasLabel(key string) bool {
|
||||
_, ok := m.labels[key]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m ManagedMetadata) HasAnnotation(key string) bool {
|
||||
if _, ok := m.annotations[key]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prefix := range m.annotationPrefixes {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m ManagedMetadata) addLabels(values ...string) {
|
||||
addStrings(m.labels, values...)
|
||||
}
|
||||
|
||||
func (m ManagedMetadata) addAnnotations(values ...string) {
|
||||
addStrings(m.annotations, values...)
|
||||
}
|
||||
|
||||
func addStrings(set map[string]struct{}, values ...string) {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func stringSet(values ...string) map[string]struct{} {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[string]struct{}, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out[value] = struct{}{}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func compactStrings(values ...string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, value)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type ObjectSkipRule struct {
|
||||
// Labels with values which indicate a skip condition.
|
||||
Labels map[string]string
|
||||
|
||||
// Annotations with values which indicate a skip condition.
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
func (s *ObjectSkipRule) ShouldSkip(
|
||||
labels map[string]string,
|
||||
annotations map[string]string,
|
||||
) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, expected := range s.Labels {
|
||||
value, ok := labels[key]
|
||||
if !ok || value != expected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for key, expected := range s.Annotations {
|
||||
value, ok := annotations[key]
|
||||
if !ok || value != expected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return len(s.Labels) > 0 || len(s.Annotations) > 0
|
||||
}
|
||||
|
||||
func DefaultObjectSkipRules() []ObjectSkipRule {
|
||||
return []ObjectSkipRule{
|
||||
{
|
||||
Labels: map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueController,
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueControllerResources,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldSkipObjectByRules(
|
||||
obj *metav1.PartialObjectMetadata,
|
||||
rules []ObjectSkipRule,
|
||||
) bool {
|
||||
if obj == nil || len(rules) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
labels := obj.GetLabels()
|
||||
annotations := obj.GetAnnotations()
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.ShouldSkip(labels, annotations) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestNewManagedMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("contains default managed labels", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(nil, nil)
|
||||
|
||||
for _, key := range []string{
|
||||
ResourcesLabel,
|
||||
TenantNameLabel,
|
||||
TenantLabel,
|
||||
NewTenantLabel,
|
||||
ResourcePoolLabel,
|
||||
FreezeLabel,
|
||||
OwnerPromotionLabel,
|
||||
ServiceAccountPromotionLabel,
|
||||
CordonedLabel,
|
||||
CapsuleNameLabel,
|
||||
CreatedByCapsuleLabel,
|
||||
CustomResourcesLabel,
|
||||
NewManagedByCapsuleLabel,
|
||||
ManagedByCapsuleLabel,
|
||||
LimitRangeLabel,
|
||||
NetworkPolicyLabel,
|
||||
ResourceQuotaLabel,
|
||||
RolebindingLabel,
|
||||
} {
|
||||
if !m.HasLabel(key) {
|
||||
t.Fatalf("expected managed label %q", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains default managed annotations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(nil, nil)
|
||||
|
||||
for _, key := range []string{
|
||||
ReleaseAnnotation,
|
||||
ReconcileAnnotation,
|
||||
AvailableIngressClassesAnnotation,
|
||||
AvailableIngressClassesRegexpAnnotation,
|
||||
AvailableStorageClassesAnnotation,
|
||||
AvailableStorageClassesRegexpAnnotation,
|
||||
AllowedRegistriesAnnotation,
|
||||
AllowedRegistriesRegexpAnnotation,
|
||||
ForbiddenNamespaceLabelsAnnotation,
|
||||
ForbiddenNamespaceLabelsRegexpAnnotation,
|
||||
ForbiddenNamespaceAnnotationsAnnotation,
|
||||
ForbiddenNamespaceAnnotationsRegexpAnnotation,
|
||||
ProtectedTenantAnnotation,
|
||||
} {
|
||||
if !m.HasAnnotation(key) {
|
||||
t.Fatalf("expected managed annotation %q", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains default managed annotation prefixes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(nil, nil)
|
||||
|
||||
for _, key := range []string{
|
||||
ResourceQuotaAnnotationPrefix + "cpu",
|
||||
ResourceQuotaAnnotationPrefix + "memory",
|
||||
ResourceUsedAnnotationPrefix + "cpu",
|
||||
ResourceUsedAnnotationPrefix + "memory",
|
||||
} {
|
||||
if !m.HasAnnotation(key) {
|
||||
t.Fatalf("expected managed annotation prefix match for %q", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adds custom managed labels and annotations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(
|
||||
[]string{
|
||||
"example.corp/label",
|
||||
" example.corp/trimmed-label ",
|
||||
"",
|
||||
" ",
|
||||
},
|
||||
[]string{
|
||||
"example.corp/annotation",
|
||||
" example.corp/trimmed-annotation ",
|
||||
"",
|
||||
" ",
|
||||
},
|
||||
)
|
||||
|
||||
for _, key := range []string{
|
||||
"example.corp/label",
|
||||
"example.corp/trimmed-label",
|
||||
} {
|
||||
if !m.HasLabel(key) {
|
||||
t.Fatalf("expected custom managed label %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"example.corp/annotation",
|
||||
"example.corp/trimmed-annotation",
|
||||
} {
|
||||
if !m.HasAnnotation(key) {
|
||||
t.Fatalf("expected custom managed annotation %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
if m.HasLabel("") {
|
||||
t.Fatalf("empty label must not be managed")
|
||||
}
|
||||
|
||||
if m.HasAnnotation("") {
|
||||
t.Fatalf("empty annotation must not be managed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching is exact and case-sensitive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(
|
||||
[]string{
|
||||
"example.corp/managed",
|
||||
},
|
||||
[]string{
|
||||
"example.corp/managed",
|
||||
},
|
||||
)
|
||||
|
||||
if !m.HasLabel("example.corp/managed") {
|
||||
t.Fatalf("expected exact label match")
|
||||
}
|
||||
|
||||
if m.HasLabel("Example.Corp/managed") {
|
||||
t.Fatalf("expected label lookup to be case-sensitive")
|
||||
}
|
||||
|
||||
if m.HasLabel(" example.corp/managed ") {
|
||||
t.Fatalf("expected label lookup not to trim lookup key")
|
||||
}
|
||||
|
||||
if !m.HasAnnotation("example.corp/managed") {
|
||||
t.Fatalf("expected exact annotation match")
|
||||
}
|
||||
|
||||
if m.HasAnnotation("Example.Corp/managed") {
|
||||
t.Fatalf("expected annotation lookup to be case-sensitive")
|
||||
}
|
||||
|
||||
if m.HasAnnotation(" example.corp/managed ") {
|
||||
t.Fatalf("expected annotation lookup not to trim lookup key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown metadata is not managed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := NewManagedMetadata(nil, nil)
|
||||
|
||||
if m.HasLabel("example.corp/not-managed") {
|
||||
t.Fatalf("unexpected managed label")
|
||||
}
|
||||
|
||||
if m.HasAnnotation("example.corp/not-managed") {
|
||||
t.Fatalf("unexpected managed annotation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStringSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want map[string]struct{}
|
||||
}{
|
||||
{
|
||||
name: "empty input returns nil",
|
||||
in: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "trims values skips blanks and deduplicates",
|
||||
in: []string{
|
||||
"alpha",
|
||||
" alpha ",
|
||||
"",
|
||||
" ",
|
||||
"beta",
|
||||
},
|
||||
want: map[string]struct{}{
|
||||
"alpha": {},
|
||||
"beta": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case sensitive",
|
||||
in: []string{
|
||||
"alpha",
|
||||
"Alpha",
|
||||
},
|
||||
want: map[string]struct{}{
|
||||
"alpha": {},
|
||||
"Alpha": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := stringSet(tt.in...)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("expected %#v, got %#v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty input returns nil",
|
||||
in: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "trims values and skips blanks",
|
||||
in: []string{
|
||||
"alpha",
|
||||
" alpha ",
|
||||
"",
|
||||
" ",
|
||||
"beta",
|
||||
},
|
||||
want: []string{
|
||||
"alpha",
|
||||
"alpha",
|
||||
"beta",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not deduplicate prefixes",
|
||||
in: []string{
|
||||
"alpha/",
|
||||
"alpha/",
|
||||
},
|
||||
want: []string{
|
||||
"alpha/",
|
||||
"alpha/",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := compactStrings(tt.in...)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("expected %#v, got %#v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := map[string]struct{}{
|
||||
"existing": {},
|
||||
}
|
||||
|
||||
addStrings(
|
||||
set,
|
||||
"alpha",
|
||||
" alpha ",
|
||||
"",
|
||||
" ",
|
||||
"beta",
|
||||
)
|
||||
|
||||
want := map[string]struct{}{
|
||||
"existing": {},
|
||||
"alpha": {},
|
||||
"beta": {},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(set, want) {
|
||||
t.Fatalf("expected %#v, got %#v", want, set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedMetadataAddMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := ManagedMetadata{
|
||||
labels: map[string]struct{}{},
|
||||
annotations: map[string]struct{}{},
|
||||
}
|
||||
|
||||
m.addLabels("example.corp/label", " example.corp/trimmed-label ", "")
|
||||
m.addAnnotations("example.corp/annotation", " example.corp/trimmed-annotation ", "")
|
||||
|
||||
if !m.HasLabel("example.corp/label") {
|
||||
t.Fatalf("expected added label")
|
||||
}
|
||||
|
||||
if !m.HasLabel("example.corp/trimmed-label") {
|
||||
t.Fatalf("expected trimmed added label")
|
||||
}
|
||||
|
||||
if !m.HasAnnotation("example.corp/annotation") {
|
||||
t.Fatalf("expected added annotation")
|
||||
}
|
||||
|
||||
if !m.HasAnnotation("example.corp/trimmed-annotation") {
|
||||
t.Fatalf("expected trimmed added annotation")
|
||||
}
|
||||
|
||||
if m.HasLabel("") {
|
||||
t.Fatalf("empty label must not be added")
|
||||
}
|
||||
|
||||
if m.HasAnnotation("") {
|
||||
t.Fatalf("empty annotation must not be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedMetadataHasAnnotationPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := ManagedMetadata{
|
||||
annotations: map[string]struct{}{
|
||||
"example.corp/exact": {},
|
||||
},
|
||||
annotationPrefixes: []string{
|
||||
"example.corp/prefix/",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact annotation",
|
||||
key: "example.corp/exact",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "prefix annotation",
|
||||
key: "example.corp/prefix/value",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "prefix itself also matches",
|
||||
key: "example.corp/prefix/",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "similar prefix does not match",
|
||||
key: "example.corp/prefix-other/value",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown annotation",
|
||||
key: "example.corp/unknown",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case sensitive prefix",
|
||||
key: "Example.Corp/prefix/value",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
key: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := m.HasAnnotation(tt.key)
|
||||
if got != tt.want {
|
||||
t.Fatalf("expected %t, got %t", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectSkipRuleShouldSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rule ObjectSkipRule
|
||||
labels map[string]string
|
||||
annotations map[string]string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty rule does not skip",
|
||||
rule: ObjectSkipRule{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matching label skips",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing label does not skip",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
labels: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non matching label value does not skip",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "human",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "label match is case-sensitive",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "Controller",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "matching annotation skips",
|
||||
rule: ObjectSkipRule{
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing annotation does not skip",
|
||||
rule: ObjectSkipRule{
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
annotations: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non matching annotation value does not skip",
|
||||
rule: ObjectSkipRule{
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
annotations: map[string]string{
|
||||
"example.corp/skip": "false",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "all labels must match",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
"owner": "capsule",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
"owner": "other",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "all annotations must match",
|
||||
rule: ObjectSkipRule{
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
"example.corp/owner": "capsule",
|
||||
},
|
||||
},
|
||||
annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
"example.corp/owner": "other",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "labels and annotations must both match",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matching label but missing required annotation does not skip",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
annotations: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "missing key must not match empty expected value",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"empty": "",
|
||||
},
|
||||
},
|
||||
labels: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "present empty value matches empty expected value",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"empty": "",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"empty": "",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "extra labels and annotations do not prevent match",
|
||||
rule: ObjectSkipRule{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
"extra": "value",
|
||||
},
|
||||
annotations: map[string]string{
|
||||
"extra": "value",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := tt.rule.ShouldSkip(tt.labels, tt.annotations)
|
||||
if got != tt.want {
|
||||
t.Fatalf("expected %t, got %t", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectSkipRuleShouldSkipNilReceiver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var rule *ObjectSkipRule
|
||||
|
||||
if rule.ShouldSkip(
|
||||
map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
nil,
|
||||
) {
|
||||
t.Fatalf("nil rule must not skip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultObjectSkipRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rules := DefaultObjectSkipRules()
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("expected three default skip rules, got %d", len(rules))
|
||||
}
|
||||
|
||||
if got := rules[0].Labels[NewManagedByCapsuleLabel]; got != ValueController {
|
||||
t.Fatalf("expected default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueController, got)
|
||||
}
|
||||
|
||||
if got := rules[1].Labels[NewManagedByCapsuleLabel]; got != ValueControllerResources {
|
||||
t.Fatalf("expected legacy default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueControllerResources, got)
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.Annotations) != 0 {
|
||||
t.Fatalf("expected no default skip annotations, got %#v", rule.Annotations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipObjectByRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
obj *metav1.PartialObjectMetadata
|
||||
rules []ObjectSkipRule
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil object does not skip",
|
||||
obj: nil,
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil rules do not skip",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueController,
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty rules do not skip",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueController,
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: []ObjectSkipRule{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "default Capsule controller-managed object skips",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueController,
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "legacy Capsule resources object skips",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: ValueControllerResources,
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "default skip is case-sensitive",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: "Controller",
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain managed-by controller is not skipped by default",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non matching object does not skip",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
NewManagedByCapsuleLabel: "human",
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: DefaultObjectSkipRules(),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "any matching rule skips",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
"app": "demo",
|
||||
},
|
||||
map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
),
|
||||
rules: []ObjectSkipRule{
|
||||
{
|
||||
Labels: map[string]string{
|
||||
"app": "other",
|
||||
},
|
||||
},
|
||||
{
|
||||
Annotations: map[string]string{
|
||||
"example.corp/skip": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "object without labels and annotations does not skip",
|
||||
obj: objectWithMetadata(nil, nil),
|
||||
rules: []ObjectSkipRule{
|
||||
{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty skip rule does not skip object",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
"app": "demo",
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: []ObjectSkipRule{
|
||||
{},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "custom plain managed-by rule skips",
|
||||
obj: objectWithMetadata(
|
||||
map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
nil,
|
||||
),
|
||||
rules: []ObjectSkipRule{
|
||||
{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "controller",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := ShouldSkipObjectByRules(tt.obj, tt.rules)
|
||||
if got != tt.want {
|
||||
t.Fatalf("expected %t, got %t", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func objectWithMetadata(
|
||||
labels map[string]string,
|
||||
annotations map[string]string,
|
||||
) *metav1.PartialObjectMetadata {
|
||||
return &metav1.PartialObjectMetadata{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ var WithoutCapsuleManagedResourcesLabelSelector = func() string {
|
||||
selection.NotIn,
|
||||
[]string{
|
||||
ValueController,
|
||||
ValueControllerResources,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -27,7 +26,6 @@ var WithCapsuleManagedResourcesLabelSelector = func() string {
|
||||
selection.In,
|
||||
[]string{
|
||||
ValueController,
|
||||
ValueControllerResources,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
|
||||
import corev1 "k8s.io/api/core/v1"
|
||||
|
||||
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
|
||||
type ImagePullPolicySpec string
|
||||
|
||||
func (i ImagePullPolicySpec) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
// +kubebuilder:validation:Enum=pod/images;pod/volumes
|
||||
type RegistryValidationTarget string
|
||||
|
||||
const (
|
||||
ValidateImages RegistryValidationTarget = "pod/images"
|
||||
ValidateVolumes RegistryValidationTarget = "pod/volumes"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type OCIRegistry struct {
|
||||
// OCI Registry endpoint, is treated as regular expression.
|
||||
Registry string `json:"url,omitzero"`
|
||||
|
||||
// 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"`
|
||||
|
||||
// Requesting Resources
|
||||
//+kubebuilder:default:={pod/images,pod/volumes}
|
||||
Validation []RegistryValidationTarget `json:"validation,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package rules
|
||||
|
||||
import (
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
// MetadataRule defines metadata constraints for namespaced resources.
|
||||
//
|
||||
// +kubebuilder:object:generate=true
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.labels) || has(self.annotations)",message="at least one of labels or annotations must be set"
|
||||
type MetadataRule struct {
|
||||
runtime.VersionKinds `json:",inline"`
|
||||
|
||||
// Labels defines metadata policies by label key.
|
||||
//
|
||||
// +optional
|
||||
Labels map[string]MetadataValueRule `json:"labels,omitempty"`
|
||||
|
||||
// Annotations defines metadata policies by annotation key.
|
||||
//
|
||||
// +optional
|
||||
Annotations map[string]MetadataValueRule `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type MetadataValueRule struct {
|
||||
// Required enforces that the metadata key must be present.
|
||||
//
|
||||
// This is mainly meaningful with action=allow. Deny and audit rules remain
|
||||
// value matchers and do not require missing metadata to exist.
|
||||
//
|
||||
// +optional
|
||||
// +kubebuilder:default:=false
|
||||
Required bool `json:"required,omitempty"`
|
||||
|
||||
// Values defines allowed, denied, or audited values for the metadata key.
|
||||
//
|
||||
// If Required=true and Values is empty, only presence is enforced.
|
||||
//
|
||||
// +optional
|
||||
Values []runtime.ExpressionMatch `json:"values,omitempty"`
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package rules
|
||||
|
||||
import "github.com/projectcapsule/capsule/pkg/api"
|
||||
import "github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type NamespaceRuleEnforceServicesBody struct {
|
||||
@@ -55,7 +55,7 @@ type ServiceExternalNameRule struct {
|
||||
// Hostnames restricts spec.externalName.
|
||||
// Empty means no additional hostname restriction once ExternalName is allowed by types.
|
||||
// +optional
|
||||
Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"`
|
||||
Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
|
||||
@@ -18,4 +18,9 @@ type NamespaceRuleEnforceBody struct {
|
||||
// Enforcement for Services.
|
||||
// +optional
|
||||
Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"`
|
||||
|
||||
// Enforcement for object metadata on namespaced resources.
|
||||
//
|
||||
// +optional
|
||||
Metadata []MetadataRule `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package rules
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
|
||||
@@ -18,7 +18,7 @@ func (i ImagePullPolicySpec) String() string {
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type OCIRegistry struct {
|
||||
api.ExpressionMatch `json:",inline"`
|
||||
runtime.ExpressionMatch `json:",inline"`
|
||||
|
||||
// Allowed PullPolicy for the given registry. Supplying no value allows all policies.
|
||||
// +optional
|
||||
|
||||
@@ -6,7 +6,7 @@ package rules
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
// +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes
|
||||
@@ -44,5 +44,5 @@ type NamespaceRuleEnforceWorkloadsBody struct {
|
||||
// Empty schedulerName is ignored and is not normalized to default-scheduler.
|
||||
//
|
||||
// +optional
|
||||
Schedulers []api.ExpressionMatch `json:"schedulers,omitempty"`
|
||||
Schedulers []runtime.ExpressionMatch `json:"schedulers,omitempty"`
|
||||
}
|
||||
|
||||
@@ -8,11 +8,63 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
"k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *MetadataRule) DeepCopyInto(out *MetadataRule) {
|
||||
*out = *in
|
||||
in.VersionKinds.DeepCopyInto(&out.VersionKinds)
|
||||
if in.Labels != nil {
|
||||
in, out := &in.Labels, &out.Labels
|
||||
*out = make(map[string]MetadataValueRule, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = *val.DeepCopy()
|
||||
}
|
||||
}
|
||||
if in.Annotations != nil {
|
||||
in, out := &in.Annotations, &out.Annotations
|
||||
*out = make(map[string]MetadataValueRule, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = *val.DeepCopy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataRule.
|
||||
func (in *MetadataRule) DeepCopy() *MetadataRule {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(MetadataRule)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) {
|
||||
*out = *in
|
||||
if in.Values != nil {
|
||||
in, out := &in.Values, &out.Values
|
||||
*out = make([]runtime.ExpressionMatch, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataValueRule.
|
||||
func (in *MetadataValueRule) DeepCopy() *MetadataValueRule {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(MetadataValueRule)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) {
|
||||
*out = *in
|
||||
@@ -64,6 +116,13 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody)
|
||||
*out = *in
|
||||
in.Workloads.DeepCopyInto(&out.Workloads)
|
||||
in.Services.DeepCopyInto(&out.Services)
|
||||
if in.Metadata != nil {
|
||||
in, out := &in.Metadata, &out.Metadata
|
||||
*out = make([]MetadataRule, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
|
||||
@@ -133,7 +192,7 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo
|
||||
}
|
||||
if in.Schedulers != nil {
|
||||
in, out := &in.Schedulers, &out.Schedulers
|
||||
*out = make([]api.ExpressionMatch, len(*in))
|
||||
*out = make([]runtime.ExpressionMatch, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
@@ -227,7 +286,7 @@ func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) {
|
||||
*out = *in
|
||||
if in.Hostnames != nil {
|
||||
in, out := &in.Hostnames, &out.Hostnames
|
||||
*out = make([]api.ExpressionMatch, len(*in))
|
||||
*out = make([]runtime.ExpressionMatch, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// At least one of Exact or Exp must be set.
|
||||
@@ -79,6 +80,10 @@ func (m ExpressionMatch) MatchesWithExpressionMatcher(
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (m ExpressionMatch) Describe() string {
|
||||
return DescribeExpressionMatch(m)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -111,3 +116,26 @@ func (m ExpressionMatch) applyNegate(matched bool) bool {
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
func DescribeExpressionMatch(match ExpressionMatch) string {
|
||||
parts := make([]string, 0, 3)
|
||||
|
||||
prefix := ""
|
||||
if match.Negate {
|
||||
prefix = "not "
|
||||
}
|
||||
|
||||
if len(match.Exact) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", ")))
|
||||
}
|
||||
|
||||
if match.Expression != "" {
|
||||
parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression))
|
||||
}
|
||||
|
||||
if len(parts) == 0 && match.Negate {
|
||||
return "not <empty>"
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package api
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -0,0 +1,440 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
WildcardVersionKindMatcher = "*"
|
||||
CoreAPIVersion = "v1"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type VersionKind struct {
|
||||
// Kind of the referent.
|
||||
//
|
||||
// Use "*" to match all kinds.
|
||||
//
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"`
|
||||
|
||||
// API version, API group, or API group/version selector of the referent.
|
||||
//
|
||||
// Empty APIVersion means the core Kubernetes API version "v1".
|
||||
// Use "*" to explicitly match all API groups and versions.
|
||||
//
|
||||
// Examples:
|
||||
// - "" means core "v1".
|
||||
// - "v1" means core "v1".
|
||||
// - "apps" means any version in the "apps" API group.
|
||||
// - "apps/v1" means the "apps/v1" API group/version.
|
||||
// - "apps/*" means any version in the "apps" API group.
|
||||
//
|
||||
// +optional
|
||||
APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,5,opt,name=apiVersion"`
|
||||
}
|
||||
|
||||
func (s VersionKind) GroupVersionKind() schema.GroupVersionKind {
|
||||
apiVersion := normalizeAPIVersion(s.APIVersion)
|
||||
|
||||
if apiVersion == CoreAPIVersion {
|
||||
return schema.GroupVersionKind{
|
||||
Group: "",
|
||||
Version: CoreAPIVersion,
|
||||
Kind: s.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
if apiVersion == WildcardVersionKindMatcher {
|
||||
return schema.GroupVersionKind{
|
||||
Group: "",
|
||||
Version: WildcardVersionKindMatcher,
|
||||
Kind: s.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(apiVersion, "/") {
|
||||
gv, err := schema.ParseGroupVersion(apiVersion)
|
||||
if err != nil {
|
||||
return schema.GroupVersionKind{
|
||||
Kind: s.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
return gv.WithKind(s.Kind)
|
||||
}
|
||||
|
||||
return schema.GroupVersionKind{
|
||||
Group: apiVersion,
|
||||
Kind: s.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
// MatchesGroupVersionKind returns true when the receiver matches the provided GVK.
|
||||
//
|
||||
// Matching is exact unless the receiver contains '*'.
|
||||
// Empty APIVersion is treated as "v1".
|
||||
// Kind must be set. Use "*" to explicitly match all kinds.
|
||||
func (s VersionKind) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
|
||||
return matchAPIGroupPattern(normalizeAPIVersion(s.APIVersion), gvk) &&
|
||||
matchPattern(s.Kind, gvk.Kind)
|
||||
}
|
||||
|
||||
// MatchesVersionKind returns true when the receiver matches another VersionKind.
|
||||
//
|
||||
// The receiver is interpreted as the pattern.
|
||||
// The provided VersionKind is interpreted as the concrete value.
|
||||
func (s VersionKind) MatchesVersionKind(value VersionKind) bool {
|
||||
return s.MatchesGroupVersionKind(value.GroupVersionKind())
|
||||
}
|
||||
|
||||
// HasWildcard returns true when APIVersion or Kind contains a wildcard matcher.
|
||||
func (s VersionKind) HasWildcard() bool {
|
||||
return strings.Contains(s.APIVersion, WildcardVersionKindMatcher) ||
|
||||
strings.Contains(s.Kind, WildcardVersionKindMatcher)
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type VersionKinds struct {
|
||||
// API groups or API group/version selectors of the referents.
|
||||
//
|
||||
// Empty or omitted APIGroups means the core Kubernetes API version "v1".
|
||||
// Use "*" to match all API groups and versions.
|
||||
//
|
||||
// Examples:
|
||||
// - [] or [""] means core "v1".
|
||||
// - ["v1"] means core "v1".
|
||||
// - ["apps"] means any version in the "apps" API group.
|
||||
// - ["apps/v1"] means only "apps/v1".
|
||||
// - ["apps", "batch/v1"] means any "apps" version and "batch/v1".
|
||||
// - ["*"] means all API groups and versions.
|
||||
//
|
||||
// +optional
|
||||
APIGroups []string `json:"apiGroups,omitempty"`
|
||||
|
||||
// Kinds of the referents.
|
||||
//
|
||||
// Use "*" to match all kinds.
|
||||
//
|
||||
// +kubebuilder:validation:MinItems=1
|
||||
// +kubebuilder:validation:items:MinLength=1
|
||||
Kinds []string `json:"kinds"`
|
||||
}
|
||||
|
||||
func (s VersionKinds) VersionKinds() []VersionKind {
|
||||
apiGroups := s.NormalizedAPIGroups()
|
||||
kinds := s.normalizedKinds()
|
||||
|
||||
out := make([]VersionKind, 0, len(apiGroups)*len(kinds))
|
||||
|
||||
for _, apiGroup := range apiGroups {
|
||||
for _, kind := range kinds {
|
||||
out = append(out, VersionKind{
|
||||
APIVersion: apiGroupPatternToAPIVersionPattern(apiGroup),
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (s VersionKinds) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
|
||||
for _, kind := range s.normalizedKinds() {
|
||||
if !matchPattern(kind, gvk.Kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, apiGroup := range s.NormalizedAPIGroups() {
|
||||
if matchAPIGroupPattern(apiGroup, gvk) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s VersionKinds) HasWildcard() bool {
|
||||
for _, apiGroup := range s.APIGroups {
|
||||
if strings.Contains(apiGroup, WildcardVersionKindMatcher) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, kind := range s.Kinds {
|
||||
if strings.Contains(kind, WildcardVersionKindMatcher) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateKnownKinds validates concrete apiGroup/kind or apiGroupVersion/kind combinations against the RESTMapper.
|
||||
// Wildcard API groups or wildcard kinds are intentionally skipped because they are selectors,
|
||||
// not concrete Kubernetes resources.
|
||||
func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath string) error {
|
||||
if mapper == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
kinds := s.normalizedKinds()
|
||||
apiGroups := s.NormalizedAPIGroups()
|
||||
|
||||
for kindIndex, kind := range kinds {
|
||||
if strings.Contains(kind, WildcardVersionKindMatcher) {
|
||||
continue
|
||||
}
|
||||
|
||||
for apiGroupIndex, apiGroup := range apiGroups {
|
||||
if strings.Contains(apiGroup, WildcardVersionKindMatcher) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil {
|
||||
return fmt.Errorf(
|
||||
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w",
|
||||
fieldPath,
|
||||
kindIndex,
|
||||
kind,
|
||||
apiGroupIndex,
|
||||
apiGroup,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s VersionKinds) StatusAPIGroups() []string {
|
||||
apiGroups := s.NormalizedAPIGroups()
|
||||
if len(apiGroups) == 0 {
|
||||
return []string{CoreAPIVersion}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(apiGroups))
|
||||
seen := make(map[string]struct{}, len(apiGroups))
|
||||
|
||||
for _, apiGroup := range apiGroups {
|
||||
apiGroup = strings.TrimSpace(apiGroup)
|
||||
if apiGroup == "" {
|
||||
apiGroup = CoreAPIVersion
|
||||
}
|
||||
|
||||
if _, ok := seen[apiGroup]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[apiGroup] = struct{}{}
|
||||
|
||||
out = append(out, apiGroup)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []string{CoreAPIVersion}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func validateKnownKindForAPIGroup(
|
||||
mapper apimeta.RESTMapper,
|
||||
apiGroup string,
|
||||
kind string,
|
||||
) error {
|
||||
apiGroup = normalizeAPIVersion(apiGroup)
|
||||
|
||||
if apiGroup == CoreAPIVersion {
|
||||
_, err := mapper.RESTMapping(
|
||||
schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: kind,
|
||||
},
|
||||
CoreAPIVersion,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.Contains(apiGroup, "/") {
|
||||
gv, err := schema.ParseGroupVersion(apiGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = mapper.RESTMapping(
|
||||
schema.GroupKind{
|
||||
Group: gv.Group,
|
||||
Kind: kind,
|
||||
},
|
||||
gv.Version,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := mapper.RESTMapping(
|
||||
schema.GroupKind{
|
||||
Group: apiGroup,
|
||||
Kind: kind,
|
||||
},
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s VersionKinds) NormalizedAPIGroups() []string {
|
||||
if len(s.APIGroups) == 0 {
|
||||
return []string{CoreAPIVersion}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(s.APIGroups))
|
||||
|
||||
for _, apiGroup := range s.APIGroups {
|
||||
apiGroup = strings.TrimSpace(apiGroup)
|
||||
if apiGroup == "" {
|
||||
apiGroup = CoreAPIVersion
|
||||
}
|
||||
|
||||
out = append(out, apiGroup)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []string{CoreAPIVersion}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (s VersionKinds) normalizedKinds() []string {
|
||||
if len(s.Kinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(s.Kinds))
|
||||
|
||||
for _, kind := range s.Kinds {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, kind)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func apiGroupPatternToAPIVersionPattern(apiGroup string) string {
|
||||
apiGroup = normalizeAPIVersion(apiGroup)
|
||||
|
||||
if apiGroup == CoreAPIVersion {
|
||||
return ""
|
||||
}
|
||||
|
||||
if apiGroup == WildcardVersionKindMatcher {
|
||||
return WildcardVersionKindMatcher
|
||||
}
|
||||
|
||||
if strings.Contains(apiGroup, "/") {
|
||||
return apiGroup
|
||||
}
|
||||
|
||||
return apiGroup + "/" + WildcardVersionKindMatcher
|
||||
}
|
||||
|
||||
func normalizeAPIVersion(apiVersion string) string {
|
||||
if apiVersion == "" {
|
||||
return CoreAPIVersion
|
||||
}
|
||||
|
||||
return apiVersion
|
||||
}
|
||||
|
||||
func matchAPIGroupPattern(pattern string, gvk schema.GroupVersionKind) bool {
|
||||
pattern = normalizeAPIVersion(strings.TrimSpace(pattern))
|
||||
|
||||
if pattern == WildcardVersionKindMatcher {
|
||||
return true
|
||||
}
|
||||
|
||||
target := gvk.Group
|
||||
if pattern == CoreAPIVersion || strings.Contains(pattern, "/") {
|
||||
target = gvk.GroupVersion().String()
|
||||
}
|
||||
|
||||
return matchPattern(pattern, target)
|
||||
}
|
||||
|
||||
func matchPattern(pattern, value string) bool {
|
||||
if pattern == WildcardVersionKindMatcher {
|
||||
return true
|
||||
}
|
||||
|
||||
if !strings.Contains(pattern, WildcardVersionKindMatcher) {
|
||||
return pattern == value
|
||||
}
|
||||
|
||||
parts := strings.Split(pattern, WildcardVersionKindMatcher)
|
||||
|
||||
if len(parts) == 2 {
|
||||
if parts[0] == "" {
|
||||
return strings.HasSuffix(value, parts[1])
|
||||
}
|
||||
|
||||
if parts[1] == "" {
|
||||
return strings.HasPrefix(value, parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
idx := 0
|
||||
|
||||
if parts[0] != "" {
|
||||
if !strings.HasPrefix(value, parts[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
idx = len(parts[0])
|
||||
}
|
||||
|
||||
lastPartIndex := len(parts) - 1
|
||||
suffix := parts[lastPartIndex]
|
||||
limit := len(value)
|
||||
|
||||
if suffix != "" {
|
||||
if !strings.HasSuffix(value, suffix) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit -= len(suffix)
|
||||
}
|
||||
|
||||
for _, part := range parts[1:lastPartIndex] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if idx > limit {
|
||||
return false
|
||||
}
|
||||
|
||||
found := strings.Index(value[idx:limit], part)
|
||||
if found < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
idx += found + len(part)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
// Copyright 2020-2023 Project Capsule Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package runtime
|
||||
|
||||
import ()
|
||||
|
||||
// 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 *VersionKind) DeepCopyInto(out *VersionKind) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKind.
|
||||
func (in *VersionKind) DeepCopy() *VersionKind {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VersionKind)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *VersionKinds) DeepCopyInto(out *VersionKinds) {
|
||||
*out = *in
|
||||
if in.APIGroups != nil {
|
||||
in, out := &in.APIGroups, &out.APIGroups
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Kinds != nil {
|
||||
in, out := &in.Kinds, &out.Kinds
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKinds.
|
||||
func (in *VersionKinds) DeepCopy() *VersionKinds {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VersionKinds)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
@@ -142,27 +142,6 @@ 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
|
||||
@@ -247,31 +226,6 @@ func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
if in.Policy != nil {
|
||||
in, out := &in.Policy, &out.Policy
|
||||
*out = make([]corev1.PullPolicy, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Validation != nil {
|
||||
in, out := &in.Validation, &out.Validation
|
||||
*out = make([]RegistryValidationTarget, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRegistry.
|
||||
func (in *OCIRegistry) DeepCopy() *OCIRegistry {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(OCIRegistry)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PodOptions) DeepCopyInto(out *PodOptions) {
|
||||
*out = *in
|
||||
|
||||
@@ -903,6 +903,234 @@ func TestEvaluation_Append(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateEnforce_SkipsEmptyExtractedValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
action api.ActionType
|
||||
values []Value
|
||||
rules []string
|
||||
wantMatcherCalls int
|
||||
wantBlocking bool
|
||||
wantFinal bool
|
||||
wantAudits int
|
||||
wantBlockingPath string
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "empty value is skipped before deny evaluation",
|
||||
action: api.ActionTypeDeny,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.value",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"",
|
||||
},
|
||||
wantMatcherCalls: 0,
|
||||
wantBlocking: false,
|
||||
wantFinal: false,
|
||||
wantAudits: 0,
|
||||
},
|
||||
{
|
||||
name: "empty value is skipped before allow miss",
|
||||
action: api.ActionTypeAllow,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.value",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"allowed",
|
||||
},
|
||||
wantMatcherCalls: 0,
|
||||
wantBlocking: false,
|
||||
wantFinal: false,
|
||||
wantAudits: 0,
|
||||
},
|
||||
{
|
||||
name: "empty value is skipped before audit evaluation",
|
||||
action: api.ActionTypeAudit,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.value",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"",
|
||||
},
|
||||
wantMatcherCalls: 0,
|
||||
wantBlocking: false,
|
||||
wantFinal: false,
|
||||
wantAudits: 0,
|
||||
},
|
||||
{
|
||||
name: "empty value is skipped but later non empty value is evaluated",
|
||||
action: api.ActionTypeDeny,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.empty",
|
||||
},
|
||||
{
|
||||
Value: "deny",
|
||||
Path: "spec.nonEmpty",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"deny",
|
||||
},
|
||||
wantMatcherCalls: 1,
|
||||
wantBlocking: true,
|
||||
wantFinal: true,
|
||||
wantAudits: 0,
|
||||
wantBlockingPath: "spec.nonEmpty",
|
||||
},
|
||||
{
|
||||
name: "whitespace value is not skipped",
|
||||
action: api.ActionTypeDeny,
|
||||
values: []Value{
|
||||
{
|
||||
Value: " ",
|
||||
Path: "spec.value",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
" ",
|
||||
},
|
||||
wantMatcherCalls: 1,
|
||||
wantBlocking: true,
|
||||
wantFinal: true,
|
||||
wantAudits: 0,
|
||||
wantBlockingPath: "spec.value",
|
||||
},
|
||||
{
|
||||
name: "empty values are skipped before non matching allow value triggers allow miss",
|
||||
action: api.ActionTypeAllow,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.empty",
|
||||
},
|
||||
{
|
||||
Value: "actual",
|
||||
Path: "spec.actual",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"allowed",
|
||||
},
|
||||
wantMatcherCalls: 1,
|
||||
wantBlocking: true,
|
||||
wantFinal: false,
|
||||
wantAudits: 0,
|
||||
wantBlockingPath: "spec.actual",
|
||||
},
|
||||
{
|
||||
name: "all empty values are skipped",
|
||||
action: api.ActionTypeDeny,
|
||||
values: []Value{
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.first",
|
||||
},
|
||||
{
|
||||
Value: "",
|
||||
Path: "spec.second",
|
||||
},
|
||||
},
|
||||
rules: []string{
|
||||
"",
|
||||
},
|
||||
wantMatcherCalls: 0,
|
||||
wantBlocking: false,
|
||||
wantFinal: false,
|
||||
wantAudits: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcherCalls := 0
|
||||
|
||||
evaluation, err := EvaluateEnforce(
|
||||
struct{}{},
|
||||
[]*api.NamespaceRuleEnforceBody{
|
||||
{
|
||||
Action: tt.action,
|
||||
},
|
||||
},
|
||||
Set[string, struct{}]{
|
||||
Name: "registry",
|
||||
EventReason: "NamespaceRuleViolation",
|
||||
|
||||
Values: func(struct{}) []Value {
|
||||
return tt.values
|
||||
},
|
||||
|
||||
Rules: func(*api.NamespaceRuleEnforceBody) []string {
|
||||
return tt.rules
|
||||
},
|
||||
|
||||
Matches: func(rule string, value Value) (Match, error) {
|
||||
matcherCalls++
|
||||
|
||||
return Match{
|
||||
Matched: value.Value == rule,
|
||||
MatchedValue: rule,
|
||||
}, nil
|
||||
},
|
||||
|
||||
RuleDescription: func(rule string) string {
|
||||
return rule
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if evaluation == nil {
|
||||
t.Fatalf("expected evaluation")
|
||||
}
|
||||
|
||||
if matcherCalls != tt.wantMatcherCalls {
|
||||
t.Fatalf("expected %d matcher calls, got %d", tt.wantMatcherCalls, matcherCalls)
|
||||
}
|
||||
|
||||
if got := evaluation.Blocking != nil; got != tt.wantBlocking {
|
||||
t.Fatalf("expected blocking=%t, got %t: %#v", tt.wantBlocking, got, evaluation.Blocking)
|
||||
}
|
||||
|
||||
if got := evaluation.Final != nil; got != tt.wantFinal {
|
||||
t.Fatalf("expected final=%t, got %t: %#v", tt.wantFinal, got, evaluation.Final)
|
||||
}
|
||||
|
||||
if len(evaluation.Audits) != tt.wantAudits {
|
||||
t.Fatalf("expected %d audits, got %d", tt.wantAudits, len(evaluation.Audits))
|
||||
}
|
||||
|
||||
if tt.wantBlockingPath != "" {
|
||||
if evaluation.Blocking == nil {
|
||||
t.Fatalf("expected blocking decision")
|
||||
}
|
||||
|
||||
if evaluation.Blocking.Value.Path != tt.wantBlockingPath {
|
||||
t.Fatalf("expected blocking path %q, got %q", tt.wantBlockingPath, evaluation.Blocking.Value.Path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+110
-3
@@ -3,16 +3,23 @@
|
||||
package ruleengine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
k8smeta "k8s.io/apimachinery/pkg/api/meta"
|
||||
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error {
|
||||
func ValidateRuleStatusBody(
|
||||
mapper k8smeta.RESTMapper,
|
||||
bodies []*rules.NamespaceRuleBodyNamespace,
|
||||
) error {
|
||||
for i, rule := range bodies {
|
||||
if rule == nil || rule.Enforce == nil {
|
||||
continue
|
||||
@@ -25,6 +32,10 @@ func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error {
|
||||
if err := validateServiceRules(i, rule.Enforce.Services); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -112,7 +123,76 @@ func validateServiceRules(
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error {
|
||||
func validateMetadataRules(
|
||||
ruleIndex int,
|
||||
metadata []rules.MetadataRule,
|
||||
mapper k8smeta.RESTMapper,
|
||||
) error {
|
||||
for j, rule := range metadata {
|
||||
fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j)
|
||||
|
||||
if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, policy := range rule.Labels {
|
||||
if err := validateMetadataKey(key); err != nil {
|
||||
return fmt.Errorf(
|
||||
"%s.labels[%q] is invalid: %w",
|
||||
fieldPath,
|
||||
key,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
for k, matcher := range policy.Values {
|
||||
if err := validateExpressionMatch(
|
||||
matcher,
|
||||
fmt.Sprintf("%s.labels[%q].values[%d]", fieldPath, key, k),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, policy := range rule.Annotations {
|
||||
if err := validateMetadataKey(key); err != nil {
|
||||
return fmt.Errorf(
|
||||
"%s.annotations[%q] is invalid: %w",
|
||||
fieldPath,
|
||||
key,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
for k, matcher := range policy.Values {
|
||||
if err := validateExpressionMatch(
|
||||
matcher,
|
||||
fmt.Sprintf("%s.annotations[%q].values[%d]", fieldPath, key, k),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMetadataKey(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return errors.New("key is empty")
|
||||
}
|
||||
|
||||
if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 {
|
||||
return errors.New(strings.Join(errs, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExpressionMatch(match runtime.ExpressionMatch, fieldPath string) error {
|
||||
if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -181,3 +261,30 @@ func validateNodePortRange(portRange rules.ServiceNodePortRange) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMetadataTargets(
|
||||
fieldPath string,
|
||||
rule rules.MetadataRule,
|
||||
mapper k8smeta.RESTMapper,
|
||||
) error {
|
||||
if len(rule.Kinds) == 0 {
|
||||
return fmt.Errorf("%s.kinds is invalid: at least one kind must be configured", fieldPath)
|
||||
}
|
||||
|
||||
for i, kind := range rule.Kinds {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if kind == "" {
|
||||
return fmt.Errorf("%s.kinds[%d] is invalid: kind is empty", fieldPath, i)
|
||||
}
|
||||
}
|
||||
|
||||
if mapper == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := rule.ValidateKnownKinds(mapper, fieldPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+867
-32
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,14 @@ const (
|
||||
ReasonTenantResourceWriteOp string = "TenantResourceWriteOp"
|
||||
ReasonOverprovision string = "Overprovisioned"
|
||||
ReasonCordoning string = "Cordoned"
|
||||
|
||||
// ForbiddenLabelReason used as reason string to deny forbidden labels.
|
||||
ReasonForbiddenLabel string = "ForbiddenLabel"
|
||||
// ForbiddenAnnotationReason used as reason string to deny forbidden annotations.
|
||||
ReasonForbiddenAnnotation string = "ForbiddenAnnotation"
|
||||
ReasonAdmissionFailure string = "AdmissionFailed"
|
||||
ReasonForbiddenMetadata string = "ForbiddenMetadata"
|
||||
|
||||
ReasonAdmissionFailure string = "AdmissionFailed"
|
||||
|
||||
// RuleStatus.
|
||||
ReasonNamespaceRuleAudit string = "NamespaceRuleAudit"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package gvk
|
||||
|
||||
import "k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
type VersionKind struct {
|
||||
// Kind of the referent.
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"`
|
||||
// API version of the referent.
|
||||
APIVersion string `json:"apiVersion" protobuf:"bytes,5,opt,name=apiVersion"`
|
||||
}
|
||||
|
||||
func (s VersionKind) GroupVersionKind() schema.GroupVersionKind {
|
||||
gv, err := schema.ParseGroupVersion(s.APIVersion)
|
||||
if err != nil {
|
||||
return schema.GroupVersionKind{
|
||||
Kind: s.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
return gv.WithKind(s.Kind)
|
||||
}
|
||||
@@ -16,14 +16,14 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/runtime/gvk"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
|
||||
)
|
||||
|
||||
// Reference
|
||||
// +kubebuilder:object:generate=true
|
||||
type ResourceReference struct {
|
||||
gvk.VersionKind `json:",inline"`
|
||||
runtime.VersionKind `json:",inline"`
|
||||
|
||||
// Name of the values referent. This is useful
|
||||
// when you traying to get a specific resource
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
"github.com/projectcapsule/capsule/pkg/api/runtime"
|
||||
)
|
||||
|
||||
func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
@@ -57,7 +57,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
Exact: []string{
|
||||
"{{ .tenant.metadata.name }}/{{ .namespace.metadata.name }}/app:1",
|
||||
},
|
||||
@@ -114,7 +114,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
Exact: []string{
|
||||
`{{ index .namespace.metadata.labels "registry-prefix" }}/app:1`,
|
||||
},
|
||||
@@ -151,8 +151,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionRegex: api.ExpressionRegex{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
ExpressionRegex: runtime.ExpressionRegex{
|
||||
Expression: "{{ .tenant.metadata.name }}/allow/.*",
|
||||
},
|
||||
},
|
||||
@@ -170,8 +170,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
},
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionRegex: api.ExpressionRegex{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
ExpressionRegex: runtime.ExpressionRegex{
|
||||
Expression: "{{ .tenant.metadata.name }}/deny/.*",
|
||||
},
|
||||
},
|
||||
@@ -229,7 +229,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
Exact: []string{
|
||||
"{{ .namespace.metadata.labels.registry }}/app:1",
|
||||
},
|
||||
@@ -284,7 +284,7 @@ func TestRenderNamespaceRuleBodies_DoesNotMutateInput(t *testing.T) {
|
||||
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
|
||||
Registries: []rules.OCIRegistry{
|
||||
{
|
||||
ExpressionMatch: api.ExpressionMatch{
|
||||
ExpressionMatch: runtime.ExpressionMatch{
|
||||
Exact: []string{
|
||||
"{{ .tenant.metadata.name }}/app:1",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user