feat: add ruleset api(#1844)

* 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(config): remove usergroups default

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix(config): remove usergroups default

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* sec(ghsa-2ww6-hf35-mfjm): intercept namespace subresource

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: conflicts

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(api): add rulestatus api

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2026-01-27 14:28:48 +01:00
committed by GitHub
parent b9a14a954d
commit a6b830b1af
284 changed files with 12699 additions and 2162 deletions

201
pkg/runtime/cert/ca.go Normal file
View File

@@ -0,0 +1,201 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package cert
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"
"github.com/pkg/errors"
)
type CA interface {
GenerateCertificate(opts CertificateOptions) (certificatePem *bytes.Buffer, certificateKey *bytes.Buffer, err error)
CACertificatePem() (b *bytes.Buffer, err error)
CAPrivateKeyPem() (b *bytes.Buffer, err error)
ExpiresIn(now time.Time) (time.Duration, error)
ValidateCert(certificate *x509.Certificate) error
}
type CapsuleCA struct {
certificate *x509.Certificate
key *rsa.PrivateKey
}
func NewCertificateAuthorityFromBytes(certBytes, keyBytes []byte) (*CapsuleCA, error) {
cert, key, err := GetCertificateWithPrivateKeyFromBytes(certBytes, keyBytes)
if err != nil {
return nil, err
}
return &CapsuleCA{
certificate: cert,
key: key,
}, nil
}
func (c CapsuleCA) CACertificatePem() (b *bytes.Buffer, err error) {
var crtBytes []byte
crtBytes, err = x509.CreateCertificate(rand.Reader, c.certificate, c.certificate, &c.key.PublicKey, c.key)
if err != nil {
return b, err
}
b = new(bytes.Buffer)
err = pem.Encode(b, &pem.Block{
Type: "CERTIFICATE",
Bytes: crtBytes,
})
return b, err
}
func (c CapsuleCA) CAPrivateKeyPem() (b *bytes.Buffer, err error) {
b = new(bytes.Buffer)
return b, pem.Encode(b, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(c.key),
})
}
func ValidateCertificate(cert *x509.Certificate, key *rsa.PrivateKey, expirationThreshold time.Duration) error {
if !key.PublicKey.Equal(cert.PublicKey) {
return errors.New("certificate signed by wrong public key")
}
now := time.Now()
if now.Before(cert.NotBefore) {
return errors.New("certificate is not valid yet")
}
if now.After(cert.NotAfter.Add(-expirationThreshold)) {
return errors.New("certificate expired or going to expire soon")
}
return nil
}
func GenerateCertificateAuthority() (s *CapsuleCA, err error) {
s = &CapsuleCA{
certificate: &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Clastix"},
Country: []string{"UK"},
Province: []string{""},
Locality: []string{"London"},
StreetAddress: []string{"27, Old Gloucester Street"},
PostalCode: []string{"WC1N 3AX"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
},
}
s.key, err = rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, err
}
return s, err
}
func GetCertificateFromBytes(certBytes []byte) (*x509.Certificate, error) {
var b *pem.Block
b, _ = pem.Decode(certBytes)
return x509.ParseCertificate(b.Bytes)
}
func GetPrivateKeyFromBytes(keyBytes []byte) (*rsa.PrivateKey, error) {
var b *pem.Block
b, _ = pem.Decode(keyBytes)
return x509.ParsePKCS1PrivateKey(b.Bytes)
}
func GetCertificateWithPrivateKeyFromBytes(certBytes, keyBytes []byte) (*x509.Certificate, *rsa.PrivateKey, error) {
cert, err := GetCertificateFromBytes(certBytes)
if err != nil {
return nil, nil, err
}
key, err := GetPrivateKeyFromBytes(keyBytes)
if err != nil {
return nil, nil, err
}
return cert, key, nil
}
func (c *CapsuleCA) GenerateCertificate(opts CertificateOptions) (certificatePem *bytes.Buffer, certificateKey *bytes.Buffer, err error) {
var certPrivKey *rsa.PrivateKey
certPrivKey, err = rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
cert := &x509.Certificate{
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
Organization: []string{"Clastix"},
Country: []string{"UK"},
Province: []string{""},
Locality: []string{"London"},
StreetAddress: []string{"27, Old Gloucester Street"},
PostalCode: []string{"WC1N 3AX"},
},
DNSNames: opts.DNSNames(),
NotBefore: time.Now().AddDate(0, 0, -1),
NotAfter: opts.ExpirationDate(),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
var certBytes []byte
certBytes, err = x509.CreateCertificate(rand.Reader, cert, c.certificate, &certPrivKey.PublicKey, c.key)
if err != nil {
return nil, nil, err
}
certificatePem = new(bytes.Buffer)
err = pem.Encode(certificatePem, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
if err != nil {
return certificatePem, certificateKey, err
}
certificateKey = new(bytes.Buffer)
err = pem.Encode(certificateKey, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
})
if err != nil {
return certificatePem, certificateKey, err
}
return certificatePem, certificateKey, err
}

View File

@@ -0,0 +1,76 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package cert
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNewCertificateAuthorityFromBytes(t *testing.T) {
var ca *CapsuleCA
var err error
ca, err = GenerateCertificateAuthority()
assert.Nil(t, err)
var crt *bytes.Buffer
crt, err = ca.CACertificatePem()
assert.Nil(t, err)
var key *bytes.Buffer
key, err = ca.CAPrivateKeyPem()
assert.Nil(t, err)
_, err = NewCertificateAuthorityFromBytes(crt.Bytes(), key.Bytes())
assert.Nil(t, err)
}
func TestCapsuleCa_GenerateCertificate(t *testing.T) {
type testCase struct {
dnsNames []string
}
for name, c := range map[string]testCase{
"foo.tld": {[]string{"foo.tld"}},
"SAN": {[]string{"capsule-webhook-service.capsule-system.svc", "capsule-webhook-service.capsule-system.default.cluster"}},
} {
t.Run(name, func(t *testing.T) {
var ca *CapsuleCA
var err error
e := time.Now().AddDate(1, 0, 0)
ca, err = GenerateCertificateAuthority()
assert.Nil(t, err)
var crt *bytes.Buffer
var key *bytes.Buffer
crt, key, err = ca.GenerateCertificate(NewCertOpts(e, c.dnsNames...))
assert.Nil(t, err)
var b *pem.Block
var c *x509.Certificate
b, _ = pem.Decode(crt.Bytes())
c, err = x509.ParseCertificate(b.Bytes)
assert.Nil(t, err)
assert.Equal(t, e.Unix(), c.NotAfter.Unix())
for _, i := range c.DNSNames {
assert.Contains(t, c.DNSNames, i)
}
_, err = tls.X509KeyPair(crt.Bytes(), key.Bytes())
assert.Nil(t, err)
})
}
}

View File

@@ -0,0 +1,16 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package cert
type CaNotYetValidError struct{}
func (CaNotYetValidError) Error() string {
return "The current CA is not yet valid"
}
type CaExpiredError struct{}
func (CaExpiredError) Error() string {
return "The current CA is expired"
}

View File

@@ -0,0 +1,28 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package cert
import "time"
type CertificateOptions interface {
DNSNames() []string
ExpirationDate() time.Time
}
type certOpts struct {
dnsNames []string
expirationDate time.Time
}
func (c certOpts) DNSNames() []string {
return c.dnsNames
}
func (c certOpts) ExpirationDate() time.Time {
return c.expirationDate
}
func NewCertOpts(expirationDate time.Time, dnsNames ...string) CertificateOptions {
return &certOpts{dnsNames: dnsNames, expirationDate: expirationDate}
}

View File

@@ -0,0 +1,90 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"fmt"
apierr "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
)
func CreateOrPatch(
ctx context.Context,
c client.Client,
obj client.Object,
fieldOwner string,
overwrite bool,
) error {
gvks, _, err := c.Scheme().ObjectKinds(obj)
if err != nil {
return err
}
if len(gvks) == 0 {
return fmt.Errorf("no GVK found for object %T", obj)
}
obj.GetObjectKind().SetGroupVersionKind(gvks[0])
//nolint:forcetypeassert
actual := obj.DeepCopyObject().(client.Object)
key := client.ObjectKeyFromObject(obj)
err = c.Get(ctx, key, actual)
notFound := apierr.IsNotFound(err)
if err != nil && !notFound {
return err
}
if !notFound {
obj.SetResourceVersion(actual.GetResourceVersion())
} else {
obj.SetResourceVersion("")
}
patchOpts := []client.PatchOption{
client.FieldOwner(fieldOwner),
}
if overwrite {
patchOpts = append(patchOpts, client.ForceOwnership)
}
//nolint:staticcheck
return c.Patch(ctx, obj, client.Apply, patchOpts...)
}
// Returns timestamp of last apply for a manager.
func LastApplyTimeForManager(obj *unstructured.Unstructured, manager string) *metav1.Time {
var latest *metav1.Time
for i := range obj.GetManagedFields() {
mf := obj.GetManagedFields()[i]
if mf.Manager != manager {
continue
}
if mf.Operation != metav1.ManagedFieldsOperationApply {
continue
}
if mf.Time == nil {
continue
}
if latest == nil || mf.Time.After(latest.Time) {
t := *mf.Time
latest = &t
}
}
return latest
}

View File

@@ -0,0 +1,185 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package client
import (
"fmt"
"strconv"
"strings"
"github.com/fluxcd/pkg/apis/kustomize"
"github.com/fluxcd/pkg/ssa/jsondiff"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// +kubebuilder:object:generate=true
type IgnoreRule struct {
// Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from
// consideration in a Kubernetes object.
// +required
Paths []string `json:"paths"`
// Target is a selector for specifying Kubernetes objects to which this
// rule applies.
// If Target is not set, the Paths will be ignored for all Kubernetes
// objects within the manifest of the Helm release.
// +optional
Target *kustomize.Selector `json:"target,omitempty"`
}
func (i *IgnoreRule) Matches(obj *unstructured.Unstructured) bool {
if i == nil || i.Target == nil {
return true
}
sr, err := jsondiff.NewSelectorRegex(&jsondiff.Selector{
Group: i.Target.Group,
Version: i.Target.Version,
Kind: i.Target.Kind,
Namespace: i.Target.Namespace,
Name: i.Target.Name,
LabelSelector: i.Target.LabelSelector,
AnnotationSelector: i.Target.AnnotationSelector,
})
if err != nil {
return false
}
return sr.MatchUnstructured(obj)
}
// jsonPointerGet returns (value, true) if JSON pointer p exists.
func JsonPointerGet(obj map[string]any, p string) (any, bool) {
if p == "" || p == "/" {
return obj, true
}
parts := strings.Split(p, "/")[1:]
cur := any(obj)
for _, raw := range parts {
key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
switch node := cur.(type) {
case map[string]any:
next, ok := node[key]
if !ok {
return nil, false
}
cur = next
case []any:
idx, err := strconv.Atoi(key)
if err != nil || idx < 0 || idx >= len(node) {
return nil, false
}
cur = node[idx]
default:
return nil, false
}
}
return cur, true
}
func JsonPointerSet(obj map[string]any, p string, val any) error {
if p == "" || p == "/" {
return fmt.Errorf("cannot set root with pointer")
}
parts := strings.Split(p, "/")[1:]
cur := obj
for i, raw := range parts {
key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
last := i == len(parts)-1
if last {
cur[key] = val
return nil
}
nxt, ok := cur[key]
if !ok {
n := map[string]any{}
cur[key] = n
cur = n
continue
}
switch m := nxt.(type) {
case map[string]any:
cur = m
default:
n := map[string]any{}
cur[key] = n
cur = n
}
}
return nil
}
func JsonPointerDelete(obj map[string]any, p string) error {
if p == "" || p == "/" {
return fmt.Errorf("cannot delete root with pointer")
}
parts := strings.Split(p, "/")[1:]
cur := obj
for i, raw := range parts {
key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~")
last := i == len(parts)-1
if last {
delete(cur, key)
return nil
}
nxt, ok := cur[key]
if !ok {
return nil
}
m, ok := nxt.(map[string]any)
if !ok {
return nil
}
cur = m
}
return nil
}
func PreserveIgnoredPaths(desired, live map[string]any, ptrs []string) {
for _, p := range ptrs {
if v, ok := JsonPointerGet(live, p); ok {
_ = JsonPointerSet(desired, p, v)
} else {
_ = JsonPointerDelete(desired, p)
}
}
}
func MatchIgnorePaths(rules []IgnoreRule, obj *unstructured.Unstructured) []string {
var out []string
for _, r := range rules {
if !r.Matches(obj) {
continue
}
out = append(out, r.Paths...)
}
return out
}

View File

@@ -0,0 +1,424 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package client_test
import (
"reflect"
"testing"
"github.com/fluxcd/pkg/apis/kustomize"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/projectcapsule/capsule/pkg/runtime/client"
)
func TestIgnoreRule_Matches(t *testing.T) {
obj := &unstructured.Unstructured{}
obj.SetAPIVersion("apps/v1")
obj.SetKind("Deployment")
obj.SetNamespace("ns1")
obj.SetName("my-deploy")
obj.SetLabels(map[string]string{"app": "demo"})
obj.SetAnnotations(map[string]string{"a": "b"})
t.Run("nil receiver matches all", func(t *testing.T) {
var r *client.IgnoreRule
if !r.Matches(obj) {
t.Fatalf("expected true")
}
})
t.Run("nil target matches all", func(t *testing.T) {
r := &client.IgnoreRule{Paths: []string{"/x"}, Target: nil}
if !r.Matches(obj) {
t.Fatalf("expected true")
}
})
t.Run("matches by kind/name/namespace", func(t *testing.T) {
r := &client.IgnoreRule{
Paths: []string{"/x"},
Target: &kustomize.Selector{
Group: "apps",
Version: "v1",
Kind: "Deployment",
Namespace: "ns1",
Name: "my-deploy",
},
}
if !r.Matches(obj) {
t.Fatalf("expected true")
}
})
t.Run("does not match when kind differs", func(t *testing.T) {
r := &client.IgnoreRule{
Paths: []string{"/x"},
Target: &kustomize.Selector{
Group: "apps",
Version: "v1",
Kind: "StatefulSet",
Namespace: "ns1",
Name: "my-deploy",
},
}
if r.Matches(obj) {
t.Fatalf("expected false")
}
})
t.Run("matches by label selector", func(t *testing.T) {
r := &client.IgnoreRule{
Paths: []string{"/x"},
Target: &kustomize.Selector{
Group: "apps",
Version: "v1",
Kind: "Deployment",
LabelSelector: "app=demo",
},
}
if !r.Matches(obj) {
t.Fatalf("expected true")
}
})
t.Run("matches by annotation selector", func(t *testing.T) {
r := &client.IgnoreRule{
Paths: []string{"/x"},
Target: &kustomize.Selector{
Group: "apps",
Version: "v1",
Kind: "Deployment",
AnnotationSelector: "a=b",
},
}
if !r.Matches(obj) {
t.Fatalf("expected true")
}
})
t.Run("invalid regex in selector returns false", func(t *testing.T) {
// jsondiff.NewSelectorRegex treats certain fields as regex; a broken one should error.
r := &client.IgnoreRule{
Paths: []string{"/x"},
Target: &kustomize.Selector{
Kind: "Deployment",
Name: "[", // invalid regex
},
}
if r.Matches(obj) {
t.Fatalf("expected false")
}
})
}
func Test_jsonPointerGet(t *testing.T) {
obj := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{
"app": "demo",
"a/b": "v",
"t~k": "v2",
},
},
"spec": map[string]any{
"list": []any{
"zero",
map[string]any{"x": "y"},
},
},
}
t.Run("root empty pointer", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "")
if !ok {
t.Fatalf("expected ok")
}
if v == nil {
t.Fatalf("expected value")
}
})
t.Run("root slash pointer", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/")
if !ok {
t.Fatalf("expected ok")
}
_, isMap := v.(map[string]any)
if !isMap {
t.Fatalf("expected map root")
}
})
t.Run("simple path", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/metadata/labels/app")
if !ok || v != "demo" {
t.Fatalf("expected demo, got ok=%v v=%v", ok, v)
}
})
t.Run("escaped slash key ~1", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/metadata/labels/a~1b")
if !ok || v != "v" {
t.Fatalf("expected v, got ok=%v v=%v", ok, v)
}
})
t.Run("escaped tilde key ~0", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/metadata/labels/t~0k")
if !ok || v != "v2" {
t.Fatalf("expected v2, got ok=%v v=%v", ok, v)
}
})
t.Run("array index", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/spec/list/0")
if !ok || v != "zero" {
t.Fatalf("expected zero, got ok=%v v=%v", ok, v)
}
})
t.Run("array index into object", func(t *testing.T) {
v, ok := client.JsonPointerGet(obj, "/spec/list/1/x")
if !ok || v != "y" {
t.Fatalf("expected y, got ok=%v v=%v", ok, v)
}
})
t.Run("missing path", func(t *testing.T) {
_, ok := client.JsonPointerGet(obj, "/metadata/labels/nope")
if ok {
t.Fatalf("expected not ok")
}
})
t.Run("bad array index", func(t *testing.T) {
_, ok := client.JsonPointerGet(obj, "/spec/list/nope")
if ok {
t.Fatalf("expected not ok")
}
})
t.Run("out of bounds array index", func(t *testing.T) {
_, ok := client.JsonPointerGet(obj, "/spec/list/99")
if ok {
t.Fatalf("expected not ok")
}
})
t.Run("type mismatch", func(t *testing.T) {
_, ok := client.JsonPointerGet(obj, "/metadata/labels/app/x")
if ok {
t.Fatalf("expected not ok")
}
})
}
func Test_jsonPointerSet(t *testing.T) {
t.Run("set root fails", func(t *testing.T) {
obj := map[string]any{"a": "b"}
if err := client.JsonPointerSet(obj, "", "x"); err == nil {
t.Fatalf("expected error")
}
if err := client.JsonPointerSet(obj, "/", "x"); err == nil {
t.Fatalf("expected error")
}
})
t.Run("set creates intermediate maps", func(t *testing.T) {
obj := map[string]any{}
if err := client.JsonPointerSet(obj, "/spec/template/metadata/labels/app", "demo"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
v, ok := client.JsonPointerGet(obj, "/spec/template/metadata/labels/app")
if !ok || v != "demo" {
t.Fatalf("expected demo, got ok=%v v=%v", ok, v)
}
})
t.Run("set overwrites non-map intermediate with map", func(t *testing.T) {
obj := map[string]any{
"spec": "not-a-map",
}
if err := client.JsonPointerSet(obj, "/spec/x", "y"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
v, ok := client.JsonPointerGet(obj, "/spec/x")
if !ok || v != "y" {
t.Fatalf("expected y, got ok=%v v=%v", ok, v)
}
})
t.Run("set supports escaped keys", func(t *testing.T) {
obj := map[string]any{}
if err := client.JsonPointerSet(obj, "/metadata/labels/a~1b", "v"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
v, ok := client.JsonPointerGet(obj, "/metadata/labels/a~1b")
if !ok || v != "v" {
t.Fatalf("expected v, got ok=%v v=%v", ok, v)
}
})
}
func Test_jsonPointerDelete(t *testing.T) {
t.Run("delete root fails", func(t *testing.T) {
obj := map[string]any{"a": "b"}
if err := client.JsonPointerDelete(obj, ""); err == nil {
t.Fatalf("expected error")
}
if err := client.JsonPointerDelete(obj, "/"); err == nil {
t.Fatalf("expected error")
}
})
t.Run("delete existing leaf", func(t *testing.T) {
obj := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{
"app": "demo",
},
},
}
if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
_, ok := client.JsonPointerGet(obj, "/metadata/labels/app")
if ok {
t.Fatalf("expected deleted")
}
})
t.Run("delete missing path is no-op", func(t *testing.T) {
obj := map[string]any{
"metadata": map[string]any{},
}
if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
})
t.Run("delete stops on non-map intermediate", func(t *testing.T) {
obj := map[string]any{
"metadata": "not-a-map",
}
if err := client.JsonPointerDelete(obj, "/metadata/labels/app"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
// still unchanged
if obj["metadata"] != "not-a-map" {
t.Fatalf("expected unchanged")
}
})
}
func Test_preserveIgnoredPaths(t *testing.T) {
t.Run("copies live value into desired when present", func(t *testing.T) {
desired := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{
"keep": "x",
},
},
}
live := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{
"keep": "x",
"other": "y",
},
},
}
client.PreserveIgnoredPaths(desired, live, []string{"/metadata/labels/other"})
v, ok := client.JsonPointerGet(desired, "/metadata/labels/other")
if !ok || v != "y" {
t.Fatalf("expected preserved value y, got ok=%v v=%v", ok, v)
}
})
t.Run("deletes desired value when missing from live", func(t *testing.T) {
desired := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{
"toDelete": "x",
},
},
}
live := map[string]any{
"metadata": map[string]any{
"labels": map[string]any{},
},
}
client.PreserveIgnoredPaths(desired, live, []string{"/metadata/labels/toDelete"})
_, ok := client.JsonPointerGet(desired, "/metadata/labels/toDelete")
if ok {
t.Fatalf("expected key to be deleted in desired")
}
})
t.Run("handles nested missing parents by creating them on set", func(t *testing.T) {
desired := map[string]any{}
live := map[string]any{
"spec": map[string]any{
"template": map[string]any{
"metadata": map[string]any{
"annotations": map[string]any{
"a": "b",
},
},
},
},
}
client.PreserveIgnoredPaths(desired, live, []string{"/spec/template/metadata/annotations/a"})
v, ok := client.JsonPointerGet(desired, "/spec/template/metadata/annotations/a")
if !ok || v != "b" {
t.Fatalf("expected b, got ok=%v v=%v", ok, v)
}
})
}
func Test_matchIgnorePaths(t *testing.T) {
obj := &unstructured.Unstructured{}
obj.SetAPIVersion("apps/v1")
obj.SetKind("Deployment")
obj.SetNamespace("ns1")
obj.SetName("my-deploy")
obj.SetLabels(map[string]string{"app": "demo"})
rules := []client.IgnoreRule{
{
Paths: []string{"/a"},
// nil target => matches all
},
{
Paths: []string{"/b", "/c"},
Target: &kustomize.Selector{
Group: "apps",
Version: "v1",
Kind: "Deployment",
Namespace: "ns1",
Name: "my-deploy",
},
},
{
Paths: []string{"/nope"},
Target: &kustomize.Selector{
Kind: "StatefulSet",
},
},
}
out := client.MatchIgnorePaths(rules, obj)
want := []string{"/a", "/b", "/c"}
if !reflect.DeepEqual(out, want) {
t.Fatalf("unexpected paths:\nwant=%v\ngot =%v", want, out)
}
}

285
pkg/runtime/client/patch.go Normal file
View File

@@ -0,0 +1,285 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package client
import (
"context"
"encoding/json"
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
type JSONPatch struct {
Operation JSONPatchOperation `json:"op"`
Path string `json:"path"`
Value any `json:"value,omitempty"`
}
type JSONPatchOperation string
const (
JSONPatchAdd JSONPatchOperation = "add"
JSONPatchReplace JSONPatchOperation = "replace"
JSONPatchRemove JSONPatchOperation = "remove"
)
func (j JSONPatchOperation) String() string {
return string(j)
}
func JSONPatchesToRawPatch(patches []JSONPatch) (patch []byte, err error) {
return json.Marshal(patches)
}
func ApplyPatches(
ctx context.Context,
c client.Client,
obj client.Object,
patches []JSONPatch,
manager string,
) (err error) {
if len(patches) == 0 {
return nil
}
rawPatch, err := JSONPatchesToRawPatch(patches)
if err != nil {
return err
}
return c.Patch(
ctx,
obj,
client.RawPatch(types.JSONPatchType, rawPatch),
client.FieldOwner(manager),
)
}
func AddLabelsPatch(labels map[string]string, keys map[string]string) []JSONPatch {
if len(keys) == 0 {
return nil
}
patches := make([]JSONPatch, 0, len(keys)+1)
// If labels is nil, /metadata/labels likely doesn't exist.
// JSONPatch add/replace to /metadata/labels/<k> requires /metadata/labels to exist.
if labels == nil {
patches = append(patches, JSONPatch{
Operation: JSONPatchAdd,
Path: "/metadata/labels",
Value: map[string]string{},
})
labels = map[string]string{} // local view for replace/add decision
}
for key, val := range keys {
op := JSONPatchAdd
if existing, ok := labels[key]; ok {
if existing == val {
continue
}
op = JSONPatchReplace
}
patches = append(patches, JSONPatch{
Operation: op,
Path: fmt.Sprintf("/metadata/labels/%s", strings.ReplaceAll(key, "/", "~1")),
Value: val,
})
}
return patches
}
func AddAnnotationsPatch(annotations map[string]string, keys map[string]string) []JSONPatch {
if len(keys) == 0 {
return nil
}
patches := make([]JSONPatch, 0, len(keys)+1)
// If annotations is nil, /metadata/annotations likely doesn't exist.
// JSONPatch add/replace to /metadata/annotations/<k> requires /metadata/annotations to exist.
if annotations == nil {
patches = append(patches, JSONPatch{
Operation: JSONPatchAdd,
Path: "/metadata/annotations",
Value: map[string]string{},
})
annotations = map[string]string{}
}
for key, val := range keys {
op := JSONPatchAdd
if existing, ok := annotations[key]; ok {
if existing == val {
continue
}
op = JSONPatchReplace
}
patches = append(patches, JSONPatch{
Operation: op,
Path: fmt.Sprintf("/metadata/annotations/%s", strings.ReplaceAll(key, "/", "~1")),
Value: val,
})
}
return patches
}
// PatchRemoveLabels returns a JSONPatch array for removing labels with matching keys.
func PatchRemoveLabels(labels map[string]string, keys []string) []JSONPatch {
var patches []JSONPatch
if labels == nil {
return patches
}
for _, key := range keys {
if _, ok := labels[key]; ok {
path := fmt.Sprintf("/metadata/labels/%s", strings.ReplaceAll(key, "/", "~1"))
patches = append(patches, JSONPatch{
Operation: JSONPatchRemove,
Path: path,
})
}
}
return patches
}
// PatchRemoveAnnotations returns a JSONPatch array for removing annotations with matching keys.
func PatchRemoveAnnotations(annotations map[string]string, keys []string) []JSONPatch {
var patches []JSONPatch
if annotations == nil {
return patches
}
for _, key := range keys {
if _, ok := annotations[key]; ok {
path := fmt.Sprintf("/metadata/annotations/%s", strings.ReplaceAll(key, "/", "~1"))
patches = append(patches, JSONPatch{
Operation: JSONPatchRemove,
Path: path,
})
}
}
return patches
}
func AddOwnerReferencePatch(
ownerrefs []metav1.OwnerReference,
ownerreference *metav1.OwnerReference,
) []JSONPatch {
if ownerreference == nil {
return nil
}
patches := make([]JSONPatch, 0, 2)
// Ensure parent exists if missing (nil slice usually means field absent)
if ownerrefs == nil {
patches = append(patches, JSONPatch{
Operation: JSONPatchAdd,
Path: "/metadata/ownerReferences",
Value: []metav1.OwnerReference{},
})
patches = append(patches, JSONPatch{
Operation: JSONPatchAdd,
Path: "/metadata/ownerReferences/-",
Value: ownerreference,
})
return patches
}
for i := range ownerrefs {
if ownerrefs[i].UID != ownerreference.UID {
continue
}
existing := ownerrefs[i]
if meta.LooseOwnerReferenceEqual(existing, *ownerreference) {
return nil
}
patches = append(patches, JSONPatch{
Operation: JSONPatchReplace,
Path: fmt.Sprintf("/metadata/ownerReferences/%d", i),
Value: ownerreference,
})
return patches
}
// Otherwise append
patches = append(patches, JSONPatch{
Operation: JSONPatchAdd,
Path: "/metadata/ownerReferences/-",
Value: ownerreference,
})
return patches
}
func RemoveOwnerReferencePatch(
ownerRefs []metav1.OwnerReference,
toRemove *metav1.OwnerReference,
) []JSONPatch {
if toRemove == nil {
return nil
}
if len(ownerRefs) == 0 {
return nil
}
idx := -1
for i := range ownerRefs {
if meta.LooseOwnerReferenceEqual(ownerRefs[i], *toRemove) {
idx = i
break
}
}
if idx == -1 {
return nil
}
patches := []JSONPatch{
{
Operation: JSONPatchRemove,
Path: fmt.Sprintf("/metadata/ownerReferences/%d", idx),
},
}
if len(ownerRefs) == 1 {
patches = append(patches, JSONPatch{
Operation: JSONPatchRemove,
Path: "/metadata/ownerReferences",
})
}
return patches
}

View File

@@ -0,0 +1,424 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package client_test
import (
"fmt"
"reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"github.com/projectcapsule/capsule/pkg/runtime/client"
)
func TestAddLabelsPatch_MapInput(t *testing.T) {
t.Run("nil labels => add op", func(t *testing.T) {
var labels map[string]string // nil
patches := client.AddLabelsPatch(labels, map[string]string{
"a": "1",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/labels", Value: map[string]string{}},
{Operation: "add", Path: "/metadata/labels/a", Value: "1"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("existing key same value => no patch", func(t *testing.T) {
labels := map[string]string{"a": "1"}
patches := client.AddLabelsPatch(labels, map[string]string{
"a": "1",
})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("existing key different value => replace op", func(t *testing.T) {
labels := map[string]string{"a": "1"}
patches := client.AddLabelsPatch(labels, map[string]string{
"a": "2",
})
want := []client.JSONPatch{
{Operation: "replace", Path: "/metadata/labels/a", Value: "2"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("missing key => add op", func(t *testing.T) {
labels := map[string]string{"a": "1"}
patches := client.AddLabelsPatch(labels, map[string]string{
"b": "2",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/labels/b", Value: "2"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
labels := map[string]string{}
patches := client.AddLabelsPatch(labels, map[string]string{
"projectcapsule.dev/tenant": "wind",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/labels/projectcapsule.dev~1tenant", Value: "wind"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
}
func TestAddAnnotationsPatch_MapInput(t *testing.T) {
t.Run("nil annotations => add op", func(t *testing.T) {
var annotations map[string]string // nil
patches := client.AddAnnotationsPatch(annotations, map[string]string{
"a": "1",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/annotations", Value: map[string]string{}},
{Operation: "add", Path: "/metadata/annotations/a", Value: "1"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("existing key same value => no patch", func(t *testing.T) {
annotations := map[string]string{"a": "1"}
patches := client.AddAnnotationsPatch(annotations, map[string]string{
"a": "1",
})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("existing key different value => replace op", func(t *testing.T) {
annotations := map[string]string{"a": "1"}
patches := client.AddAnnotationsPatch(annotations, map[string]string{
"a": "2",
})
want := []client.JSONPatch{
{Operation: "replace", Path: "/metadata/annotations/a", Value: "2"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("missing key => add op", func(t *testing.T) {
annotations := map[string]string{"a": "1"}
patches := client.AddAnnotationsPatch(annotations, map[string]string{
"b": "2",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/annotations/b", Value: "2"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
annotations := map[string]string{}
patches := client.AddAnnotationsPatch(annotations, map[string]string{
"example.com/foo": "bar",
})
want := []client.JSONPatch{
{Operation: "add", Path: "/metadata/annotations/example.com~1foo", Value: "bar"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
}
func TestPatchRemoveLabels_MapInput(t *testing.T) {
t.Run("nil labels => no patch", func(t *testing.T) {
var labels map[string]string // nil
patches := client.PatchRemoveLabels(labels, []string{"a"})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("existing key => remove patch", func(t *testing.T) {
labels := map[string]string{"a": "1"}
patches := client.PatchRemoveLabels(labels, []string{"a"})
want := []client.JSONPatch{
{Operation: "remove", Path: "/metadata/labels/a"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("missing key => no patch", func(t *testing.T) {
labels := map[string]string{"a": "1"}
patches := client.PatchRemoveLabels(labels, []string{"nope"})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
labels := map[string]string{"projectcapsule.dev/tenant": "wind"}
patches := client.PatchRemoveLabels(labels, []string{"projectcapsule.dev/tenant"})
want := []client.JSONPatch{
{Operation: "remove", Path: "/metadata/labels/projectcapsule.dev~1tenant"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
}
func TestPatchRemoveAnnotations_MapInput(t *testing.T) {
t.Run("nil annotations => no patch", func(t *testing.T) {
var annotations map[string]string // nil
patches := client.PatchRemoveAnnotations(annotations, []string{"a"})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("existing key => remove patch", func(t *testing.T) {
annotations := map[string]string{"a": "1"}
patches := client.PatchRemoveAnnotations(annotations, []string{"a"})
want := []client.JSONPatch{
{Operation: "remove", Path: "/metadata/annotations/a"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
t.Run("missing key => no patch", func(t *testing.T) {
annotations := map[string]string{"a": "1"}
patches := client.PatchRemoveAnnotations(annotations, []string{"nope"})
if len(patches) != 0 {
t.Fatalf("expected no patches, got %v", patches)
}
})
t.Run("key contains slash => path escaped with ~1", func(t *testing.T) {
annotations := map[string]string{"example.com/foo": "bar"}
patches := client.PatchRemoveAnnotations(annotations, []string{"example.com/foo"})
want := []client.JSONPatch{
{Operation: "remove", Path: "/metadata/annotations/example.com~1foo"},
}
if !reflect.DeepEqual(patches, want) {
t.Fatalf("unexpected patches\nwant=%v\ngot =%v", want, patches)
}
})
}
func TestRemoveOwnerReferencePatch(t *testing.T) {
t.Parallel()
mkRef := func(name, uid string, controller, block bool) metav1.OwnerReference {
c := controller
b := block
return metav1.OwnerReference{
APIVersion: "v1",
Kind: "ConfigMap",
Name: name,
UID: types.UID(uid),
Controller: &c,
BlockOwnerDeletion: &b,
}
}
t.Run("nil toRemove returns nil", func(t *testing.T) {
t.Parallel()
refs := []metav1.OwnerReference{mkRef("a", "uid-a", true, true)}
got := client.RemoveOwnerReferencePatch(refs, nil)
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
})
t.Run("empty ownerRefs returns nil", func(t *testing.T) {
t.Parallel()
toRemove := mkRef("a", "uid-a", true, true)
got := client.RemoveOwnerReferencePatch(nil, &toRemove)
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
got = client.RemoveOwnerReferencePatch([]metav1.OwnerReference{}, &toRemove)
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
})
t.Run("no matching ownerReference returns nil", func(t *testing.T) {
t.Parallel()
refs := []metav1.OwnerReference{
mkRef("a", "uid-a", true, true),
mkRef("b", "uid-b", false, false),
}
// Different UID and name/kind => should not match
toRemove := mkRef("c", "uid-c", true, true)
got := client.RemoveOwnerReferencePatch(refs, &toRemove)
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
})
t.Run("match in middle returns single remove patch with correct index", func(t *testing.T) {
t.Parallel()
refs := []metav1.OwnerReference{
mkRef("a", "uid-a", true, true),
mkRef("b", "uid-b", false, false),
mkRef("c", "uid-c", true, false),
}
// Make toRemove identical to refs[1] so LooseOwnerReferenceEqual is true.
toRemove := refs[1]
got := client.RemoveOwnerReferencePatch(refs, &toRemove)
if got == nil {
t.Fatalf("expected patches, got nil")
}
if len(got) != 1 {
t.Fatalf("expected 1 patch, got %d: %#v", len(got), got)
}
if got[0].Operation != "remove" {
t.Fatalf("expected op=remove, got %q", got[0].Operation)
}
wantPath := "/metadata/ownerReferences/1"
if got[0].Path != wantPath {
t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
}
})
t.Run("match first occurrence only", func(t *testing.T) {
t.Parallel()
// Duplicate entries (shouldn't happen, but function breaks on first match).
ref := mkRef("dup", "uid-dup", true, true)
refs := []metav1.OwnerReference{ref, ref}
toRemove := ref
got := client.RemoveOwnerReferencePatch(refs, &toRemove)
if got == nil || len(got) != 1 {
t.Fatalf("expected 1 patch, got %#v", got)
}
wantPath := "/metadata/ownerReferences/0"
if got[0].Path != wantPath {
t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
}
})
t.Run("single ownerRef match returns remove element patch AND remove field patch", func(t *testing.T) {
t.Parallel()
only := mkRef("only", "uid-only", true, true)
refs := []metav1.OwnerReference{only}
toRemove := only
got := client.RemoveOwnerReferencePatch(refs, &toRemove)
if got == nil {
t.Fatalf("expected patches, got nil")
}
if len(got) != 2 {
t.Fatalf("expected 2 patches, got %d: %#v", len(got), got)
}
if got[0].Operation != "remove" || got[0].Path != "/metadata/ownerReferences/0" {
t.Fatalf("unexpected first patch: %#v", got[0])
}
if got[1].Operation != "remove" || got[1].Path != "/metadata/ownerReferences" {
t.Fatalf("unexpected second patch: %#v", got[1])
}
})
t.Run("index in path is correct for each position", func(t *testing.T) {
t.Parallel()
refs := []metav1.OwnerReference{
mkRef("a", "uid-a", true, true),
mkRef("b", "uid-b", true, true),
mkRef("c", "uid-c", true, true),
}
for i := range refs {
i := i
t.Run(fmt.Sprintf("match index %d", i), func(t *testing.T) {
t.Parallel()
toRemove := refs[i]
got := client.RemoveOwnerReferencePatch(refs, &toRemove)
if got == nil || len(got) != 1 {
t.Fatalf("expected 1 patch, got %#v", got)
}
wantPath := fmt.Sprintf("/metadata/ownerReferences/%d", i)
if got[0].Path != wantPath {
t.Fatalf("expected path=%q, got %q", wantPath, got[0].Path)
}
})
}
})
}

View File

@@ -0,0 +1,53 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package client
import (
"context"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// CreateOrUpdate Implementation with optional IgnoreRules.
func CreateOrUpdate(
ctx context.Context,
c client.Client,
obj *unstructured.Unstructured,
labels, annotations map[string]string,
ignore []IgnoreRule,
) error {
actual := &unstructured.Unstructured{}
actual.SetGroupVersionKind(obj.GroupVersionKind())
actual.SetNamespace(obj.GetNamespace())
actual.SetName(obj.GetName())
_ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) // ignore notfound here
igPaths := MatchIgnorePaths(ignore, obj)
for _, p := range igPaths {
_ = JsonPointerDelete(obj.Object, p)
}
_, err := controllerutil.CreateOrPatch(ctx, c, actual, func() error {
live := actual.DeepCopy()
desired := obj.DeepCopy()
if len(igPaths) > 0 {
PreserveIgnoredPaths(desired.Object, live.Object, igPaths)
}
uid := actual.GetUID()
rv := actual.GetResourceVersion()
actual.Object = desired.Object
actual.SetUID(uid)
actual.SetResourceVersion(rv)
return nil
})
return err
}

View File

@@ -0,0 +1,37 @@
//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 client
import (
"github.com/fluxcd/pkg/apis/kustomize"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IgnoreRule) DeepCopyInto(out *IgnoreRule) {
*out = *in
if in.Paths != nil {
in, out := &in.Paths, &out.Paths
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Target != nil {
in, out := &in.Target, &out.Target
*out = new(kustomize.Selector)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IgnoreRule.
func (in *IgnoreRule) DeepCopy() *IgnoreRule {
if in == nil {
return nil
}
out := new(IgnoreRule)
in.DeepCopyInto(out)
return out
}

View File

@@ -0,0 +1,183 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package configuration
import (
"context"
"regexp"
"github.com/pkg/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
)
// capsuleConfiguration is the Capsule Configuration retrieval mode
// using a closure that provides the desired configuration.
type capsuleConfiguration struct {
retrievalFn func() *capsulev1beta2.CapsuleConfiguration
}
func DefaultCapsuleConfiguration() capsulev1beta2.CapsuleConfigurationSpec {
return capsulev1beta2.CapsuleConfigurationSpec{
Users: []capsuleapi.UserSpec{
{
Name: "projectcapsule.dev",
Kind: capsuleapi.GroupOwner,
},
},
ForceTenantPrefix: false,
ProtectedNamespaceRegexpString: "",
}
}
func NewCapsuleConfiguration(ctx context.Context, c client.Client, name string) Configuration {
return &capsuleConfiguration{retrievalFn: func() *capsulev1beta2.CapsuleConfiguration {
cfg := &capsulev1beta2.CapsuleConfiguration{}
key := types.NamespacedName{Name: name}
if err := c.Get(ctx, key, cfg); err == nil {
return cfg
} else if !apierrors.IsNotFound(err) {
panic(errors.Wrap(err, "cannot retrieve Capsule configuration with name "+name))
}
cfg = &capsulev1beta2.CapsuleConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Spec: DefaultCapsuleConfiguration(),
}
if err := c.Create(ctx, cfg); err != nil {
if apierrors.IsAlreadyExists(err) {
if err := c.Get(ctx, key, cfg); err != nil {
panic(errors.Wrap(err, "configuration created concurrently but cannot be retrieved"))
}
return cfg
}
panic(errors.Wrap(err, "cannot create Capsule configuration with name "+name))
}
return cfg
}}
}
func (c *capsuleConfiguration) ProtectedNamespaceRegexp() (*regexp.Regexp, error) {
expr := c.retrievalFn().Spec.ProtectedNamespaceRegexpString
if len(expr) == 0 {
return nil, nil //nolint:nilnil
}
r, err := regexp.Compile(expr)
if err != nil {
return nil, errors.Wrap(err, "Cannot compile the protected namespace regexp")
}
return r, nil
}
func (c *capsuleConfiguration) ForceTenantPrefix() bool {
return c.retrievalFn().Spec.ForceTenantPrefix
}
func (c *capsuleConfiguration) TLSSecretName() (name string) {
return c.retrievalFn().Spec.CapsuleResources.TLSSecretName
}
func (c *capsuleConfiguration) EnableTLSConfiguration() bool {
return c.retrievalFn().Spec.EnableTLSReconciler
}
func (c *capsuleConfiguration) AllowServiceAccountPromotion() bool {
return c.retrievalFn().Spec.AllowServiceAccountPromotion
}
func (c *capsuleConfiguration) MutatingWebhookConfigurationName() (name string) {
return c.retrievalFn().Spec.CapsuleResources.MutatingWebhookConfigurationName
}
func (c *capsuleConfiguration) TenantCRDName() string {
return TenantCRDName
}
func (c *capsuleConfiguration) ValidatingWebhookConfigurationName() (name string) {
return c.retrievalFn().Spec.CapsuleResources.ValidatingWebhookConfigurationName
}
//nolint:staticcheck
func (c *capsuleConfiguration) UserGroups() []string {
return append(c.retrievalFn().Spec.UserGroups, c.retrievalFn().Spec.Users.GetByKinds([]capsuleapi.OwnerKind{capsuleapi.GroupOwner})...)
}
//nolint:staticcheck
func (c *capsuleConfiguration) UserNames() []string {
return append(c.retrievalFn().Spec.UserNames, c.retrievalFn().Spec.Users.GetByKinds([]capsuleapi.OwnerKind{capsuleapi.UserOwner, capsuleapi.ServiceAccountOwner})...)
}
func (c *capsuleConfiguration) Users() capsuleapi.UserListSpec {
out := capsuleapi.UserListSpec{}
for _, user := range c.UserNames() {
out.Upsert(capsuleapi.UserSpec{
Kind: capsuleapi.UserOwner,
Name: user,
})
}
for _, group := range c.UserGroups() {
out.Upsert(capsuleapi.UserSpec{
Kind: capsuleapi.GroupOwner,
Name: group,
})
}
return out
}
func (c *capsuleConfiguration) GetUsersByStatus() capsuleapi.UserListSpec {
return c.retrievalFn().Status.Users
}
func (c *capsuleConfiguration) IgnoreUserWithGroups() []string {
return c.retrievalFn().Spec.IgnoreUserWithGroups
}
func (c *capsuleConfiguration) ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec {
if c.retrievalFn().Spec.NodeMetadata == nil {
return nil
}
return &c.retrievalFn().Spec.NodeMetadata.ForbiddenLabels
}
func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec {
if c.retrievalFn().Spec.NodeMetadata == nil {
return nil
}
return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations
}
func (c *capsuleConfiguration) Administrators() capsuleapi.UserListSpec {
return c.retrievalFn().Spec.Administrators
}
func (c *capsuleConfiguration) Admission() capsulev1beta2.DynamicAdmission {
return c.retrievalFn().Spec.Admission
}
func (c *capsuleConfiguration) RBAC() *capsulev1beta2.RBACConfiguration {
return c.retrievalFn().Spec.RBAC
}
func (c *capsuleConfiguration) CacheInvalidation() metav1.Duration {
return c.retrievalFn().Spec.CacheInvalidation
}

View File

@@ -0,0 +1,41 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package configuration
import (
"regexp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
)
const (
TenantCRDName = "tenants.capsule.clastix.io"
)
type Configuration interface {
ProtectedNamespaceRegexp() (*regexp.Regexp, error)
ForceTenantPrefix() bool
// EnableTLSConfiguration enabled the TLS reconciler, responsible for creating CA and TLS certificate required
// for the CRD conversion and webhooks.
EnableTLSConfiguration() bool
AllowServiceAccountPromotion() bool
TLSSecretName() string
MutatingWebhookConfigurationName() string
ValidatingWebhookConfigurationName() string
TenantCRDName() string
UserNames() []string
UserGroups() []string
Users() capsuleapi.UserListSpec
GetUsersByStatus() capsuleapi.UserListSpec
IgnoreUserWithGroups() []string
ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec
Administrators() capsuleapi.UserListSpec
Admission() capsulev1beta2.DynamicAdmission
RBAC() *capsulev1beta2.RBACConfiguration
CacheInvalidation() metav1.Duration
}

View File

@@ -0,0 +1,14 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package events
const (
ActionCordoned string = "Cordoned"
ActionUncordoned string = "UnCordoned"
ActionReconciled string = "Reconciled"
ActionDisassociating string = "Disassociating"
ActionMutated string = "Mutated"
ActionValidationDenied string = "ValidationDenied"
)

View File

@@ -0,0 +1,59 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package events
const (
// Generic.
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"
// Namespace.
ReasonNamespaceHijack string = "ReasonNamespacePatch"
// Tenant.
ReasonTenantDefaulted string = "TenantDefaulted"
ReasonTenantAssigned string = "TenantAssigned"
ReasonInvalidTenantPrefix string = "InvalidTenantPrefix"
ReasonPromotionDenied string = "ReasonPromotionDenied"
// Classes.
ReasonMissingStorageClass string = "MissingStorageClass"
ReasonForbiddenStorageClass string = "ForbiddenStorageClass"
ReasonForbiddenPriorityClass string = "ForbiddenPriorityClass"
ReasonForbiddenRuntimeClass string = "ForbiddenRuntimeClass"
ReasonForbiddenIngressClass string = "ForbiddenIngressClass"
ReasonMissingIngressClass string = "MissingIngressClass"
ReasonForbiddenGatewayClass string = "ForbiddenGatewayClass"
ReasonMissingGatewayClass string = "MissingGatewayClass"
ReasonMissingDeviceClass string = "MissingDeviceClass"
ReasonForbiddenDeviceClass string = "ForbiddenDeviceClass"
// Pods.
ReasonMissingFQCI string = "MissingFQCI"
ReasonForbiddenContainerRegistry string = "ForbiddenContainerRegistry"
ReasonForbiddenPullPolicy string = "ForbiddenPullPolicy"
// Ingress.
ReasonWildcardDenied string = "WildcardDenied"
ReasonIngressHostnameNotValid string = "IngressHostnameNotValid"
ReasonIngressHostnameEmpty string = "IngressHostnameEmpty"
ReasonIngressHostnameCollision string = "IngressHostnameCollision"
// Services.
ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP"
ReasonForbiddenLoadBalancer string = "ForbiddenLoadBalancer"
ReasonForbiddenExternalName string = "ForbiddenExternalName"
ReasonForbiddenNodePort string = "ForbiddenNodePort"
// Storage.
ReasonCrossTenantReference string = "CrossTenantReference"
// ResourcePools.
ReasonDisassociated string = "Disassociated"
)

View File

@@ -0,0 +1,25 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gvk
import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
ctrl "sigs.k8s.io/controller-runtime"
)
func HasGVK(mapper meta.RESTMapper, gvk schema.GroupVersionKind) bool {
_, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
if err != nil {
if meta.IsNoMatchError(err) {
return false
}
ctrl.Log.WithName("gvk-check").Error(err, "failed to check RESTMapping", "gvk", gvk.String())
return false
}
return true
}

View File

@@ -0,0 +1,133 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gvk_test
import (
"errors"
"testing"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/pkg/runtime/gvk"
)
// stubRESTMapper implements meta.RESTMapper (a "fat" interface), but we only
// care about RESTMapping() for these tests.
type stubRESTMapper struct {
mapping *meta.RESTMapping
err error
lastGK schema.GroupKind
lastVersion string
calls int
}
func (s *stubRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
return schema.GroupVersionKind{}, errors.New("not implemented")
}
func (s *stubRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
return nil, errors.New("not implemented")
}
func (s *stubRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
return schema.GroupVersionResource{}, errors.New("not implemented")
}
func (s *stubRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
return nil, errors.New("not implemented")
}
func (s *stubRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
s.calls++
s.lastGK = gk
if len(versions) > 0 {
s.lastVersion = versions[0]
}
if s.err != nil {
return nil, s.err
}
return s.mapping, nil
}
func (s *stubRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
return nil, errors.New("not implemented")
}
func (s *stubRESTMapper) ResourceSingularizer(resource string) (string, error) {
return "", errors.New("not implemented")
}
func TestHasGVK(t *testing.T) {
t.Parallel()
gvkT := schema.GroupVersionKind{
Group: "capsule.clastix.io",
Version: "v1beta2",
Kind: "RuleStatus",
}
t.Run("returns true when RESTMapping succeeds", func(t *testing.T) {
t.Parallel()
m := &stubRESTMapper{
mapping: &meta.RESTMapping{
Resource: schema.GroupVersionResource{
Group: gvkT.Group,
Version: gvkT.Version,
Resource: "rulestatuses",
},
GroupVersionKind: gvkT,
},
}
got := gvk.HasGVK(m, gvkT)
if got != true {
t.Fatalf("expected true, got %v", got)
}
if m.calls != 1 {
t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
}
if m.lastGK != gvkT.GroupKind() {
t.Fatalf("expected GroupKind=%v, got %v", gvkT.GroupKind(), m.lastGK)
}
if m.lastVersion != gvkT.Version {
t.Fatalf("expected version=%q, got %q", gvkT.Version, m.lastVersion)
}
})
t.Run("returns false on NoMatchError", func(t *testing.T) {
t.Parallel()
noMatch := &meta.NoKindMatchError{
GroupKind: gvkT.GroupKind(),
SearchedVersions: []string{gvkT.Version},
}
m := &stubRESTMapper{err: noMatch}
got := gvk.HasGVK(m, gvkT)
if got != false {
t.Fatalf("expected false, got %v", got)
}
if m.calls != 1 {
t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
}
})
t.Run("returns false on generic error (and does not panic)", func(t *testing.T) {
t.Parallel()
m := &stubRESTMapper{err: errors.New("boom")}
got := gvk.HasGVK(m, gvkT)
if got != false {
t.Fatalf("expected false, got %v", got)
}
if m.calls != 1 {
t.Fatalf("expected RESTMapping to be called once, calls=%d", m.calls)
}
})
}

View File

@@ -0,0 +1,16 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package handlers
import (
"net/http"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
func ErroredResponse(err error) *admission.Response {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}

View File

@@ -0,0 +1,34 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package handlers
import (
"context"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type Func func(ctx context.Context, req admission.Request) *admission.Response
type Handler interface {
OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
OnDelete(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func
}
type HanderWithTenant interface {
OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
}
type TypedHandler[T client.Object] interface {
OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder) Func
OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder) Func
OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder) Func
}

View File

@@ -0,0 +1,79 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package handlers
import (
"context"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/users"
)
func InCapsuleGroups(configuration configuration.Configuration, handlers ...Handler) Handler {
return &handler{
configuration: configuration,
handlers: handlers,
}
}
type handler struct {
configuration configuration.Configuration
handlers []Handler
}
//nolint:dupl
func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
return nil
}
for _, hndl := range h.handlers {
if response := hndl.OnCreate(client, decoder, recorder)(ctx, req); response != nil {
return response
}
}
return nil
}
}
//nolint:dupl
func (h *handler) OnDelete(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
return nil
}
for _, hndl := range h.handlers {
if response := hndl.OnDelete(client, decoder, recorder)(ctx, req); response != nil {
return response
}
}
return nil
}
}
//nolint:dupl
func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if !users.IsCapsuleUser(ctx, client, h.configuration, req.UserInfo.Username, req.UserInfo.Groups) {
return nil
}
for _, hndl := range h.handlers {
if response := hndl.OnUpdate(client, decoder, recorder)(ctx, req); response != nil {
return response
}
}
return nil
}
}

View File

@@ -0,0 +1,118 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package handlers
import (
"context"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type TypedHandlerWithTenant[T client.Object] interface {
OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant) Func
}
type TypedTenantHandler[T client.Object] struct {
Factory NewObjectFunc[T]
Handlers []TypedHandlerWithTenant[T]
}
func (h *TypedTenantHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnCreate(c, obj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
newObj := h.Factory()
if err := decoder.Decode(req, newObj); err != nil {
return ErroredResponse(err)
}
oldObj := h.Factory()
if err := decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnUpdate(c, oldObj, newObj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnDelete(c, obj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) resolveTenant(ctx context.Context, c client.Client, req admission.Request) (*capsulev1beta2.Tenant, error) {
if req.Namespace == "" {
return nil, nil
}
return tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
}

View File

@@ -0,0 +1,168 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package handlers
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type TypedHandlerWithTenantWithRuleset[T client.Object] interface {
OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *capsulev1beta2.NamespaceRuleBody) Func
}
type TypedTenantWithRulesetHandler[T client.Object] struct {
Factory NewObjectFunc[T]
Handlers []TypedHandlerWithTenantWithRuleset[T]
}
func (h *TypedTenantWithRulesetHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnCreate(c, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
newObj := h.Factory()
if err := decoder.Decode(req, newObj); err != nil {
return ErroredResponse(err)
}
oldObj := h.Factory()
if err := decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
return ErroredResponse(err)
}
rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnUpdate(c, oldObj, newObj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantWithRulesetHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder events.EventRecorder) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnDelete(c, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(ctx context.Context, c client.Client, req admission.Request) (*capsulev1beta2.Tenant, error) {
if req.Namespace == "" {
return nil, nil
}
return tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
}
// Resolve the corresponding managed ruleset for this namespace
// If not yet present try to calculate it.
func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
ctx context.Context,
c client.Client,
req admission.Request,
namespace string,
tnt *capsulev1beta2.Tenant,
) (*capsulev1beta2.NamespaceRuleBody, error) {
rs := &capsulev1beta2.RuleStatus{}
key := types.NamespacedName{
Namespace: namespace,
Name: meta.NameForManagedRuleStatus(),
}
if err := c.Get(ctx, key, rs); err == nil {
rule := rs.Status.Rule
return &rule, nil
} else if !apierrors.IsNotFound(err) {
return nil, err
}
ns := &corev1.Namespace{}
if err := c.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return nil, err
}
return tenant.BuildNamespaceRuleBodyForNamespace(ns, tnt)
}

View File

@@ -0,0 +1,8 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package handlers
import "sigs.k8s.io/controller-runtime/pkg/client"
type NewObjectFunc[T client.Object] func() T

View File

@@ -0,0 +1,9 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package handlers
type Webhook interface {
GetPath() string
GetHandlers() []Handler
}

View File

@@ -0,0 +1,59 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package indexers
import (
"context"
"fmt"
"github.com/go-logr/logr"
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/manager"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/runtime/indexers/ingress"
"github.com/projectcapsule/capsule/pkg/runtime/indexers/namespace"
"github.com/projectcapsule/capsule/pkg/runtime/indexers/resourcepool"
"github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant"
"github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource"
"github.com/projectcapsule/capsule/pkg/utils"
)
type CustomIndexer interface {
Object() client.Object
Field() string
Func() client.IndexerFunc
}
func AddToManager(ctx context.Context, log logr.Logger, mgr manager.Manager) error {
indexers := []CustomIndexer{
tenant.NamespacesReference{Obj: &capsulev1beta2.Tenant{}},
resourcepool.NamespacesReference{Obj: &capsulev1beta2.ResourcePool{}},
resourcepool.PoolUIDReference{Obj: &capsulev1beta2.ResourcePoolClaim{}},
tenant.OwnerReference{},
namespace.OwnerReference{},
ingress.HostnamePath{Obj: &extensionsv1beta1.Ingress{}},
ingress.HostnamePath{Obj: &networkingv1beta1.Ingress{}},
ingress.HostnamePath{Obj: &networkingv1.Ingress{}},
tenantresource.GlobalProcessedItems{},
tenantresource.LocalProcessedItems{},
}
for _, f := range indexers {
if err := mgr.GetFieldIndexer().IndexField(ctx, f.Object(), f.Field(), f.Func()); err != nil {
if utils.IsUnsupportedAPI(err) {
log.Info(fmt.Sprintf("skipping setup of Indexer %T for object %T", f, f.Object()), "error", err.Error())
continue
}
return err
}
}
return nil
}

View File

@@ -0,0 +1,55 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"fmt"
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
HostPathPair = "hostnamePathPair"
)
type HostnamePath struct {
Obj metav1.Object
}
//nolint:forcetypeassert
func (s HostnamePath) Object() client.Object {
return s.Obj.(client.Object)
}
func (s HostnamePath) Field() string {
return HostPathPair
}
func (s HostnamePath) Func() client.IndexerFunc {
return func(object client.Object) (entries []string) {
hostPathMap := make(map[string]sets.Set[string])
switch ing := object.(type) {
case *networkingv1.Ingress:
hostPathMap = hostPathMapForNetworkingV1(ing)
case *networkingv1beta1.Ingress:
hostPathMap = hostPathMapForNetworkingV1Beta1(ing)
case *extensionsv1beta1.Ingress:
hostPathMap = hostPathMapForExtensionsV1Beta1(ing)
}
for host, paths := range hostPathMap {
for path := range paths {
entries = append(entries, fmt.Sprintf("%s;%s", host, path))
}
}
return entries
}
}

View File

@@ -0,0 +1,71 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
"k8s.io/apimachinery/pkg/util/sets"
)
func hostPathMapForExtensionsV1Beta1(ing *extensionsv1beta1.Ingress) map[string]sets.Set[string] {
hostPathMap := make(map[string]sets.Set[string])
for _, r := range ing.Spec.Rules {
if r.HTTP == nil {
continue
}
if _, ok := hostPathMap[r.Host]; !ok {
hostPathMap[r.Host] = sets.New[string]()
}
for _, path := range r.HTTP.Paths {
hostPathMap[r.Host].Insert(path.Path)
}
}
return hostPathMap
}
func hostPathMapForNetworkingV1Beta1(ing *networkingv1beta1.Ingress) map[string]sets.Set[string] {
hostPathMap := make(map[string]sets.Set[string])
for _, r := range ing.Spec.Rules {
if r.HTTP == nil {
continue
}
if _, ok := hostPathMap[r.Host]; !ok {
hostPathMap[r.Host] = sets.New[string]()
}
for _, path := range r.HTTP.Paths {
hostPathMap[r.Host].Insert(path.Path)
}
}
return hostPathMap
}
func hostPathMapForNetworkingV1(ing *networkingv1.Ingress) map[string]sets.Set[string] {
hostPathMap := make(map[string]sets.Set[string])
for _, r := range ing.Spec.Rules {
if r.HTTP == nil {
continue
}
if _, ok := hostPathMap[r.Host]; !ok {
hostPathMap[r.Host] = sets.New[string]()
}
for _, path := range r.HTTP.Paths {
hostPathMap[r.Host].Insert(path.Path)
}
}
return hostPathMap
}

View File

@@ -0,0 +1,42 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package namespace
import (
"fmt"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type OwnerReference struct{}
func (o OwnerReference) Object() client.Object {
return &corev1.Namespace{}
}
func (o OwnerReference) Field() string {
return ".metadata.ownerReferences[*].capsule"
}
func (o OwnerReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
res := []string{}
ns, ok := object.(*corev1.Namespace)
if !ok {
panic(fmt.Errorf("expected *corev1.Namespace, got %T", ns))
}
for _, or := range ns.OwnerReferences {
if tenant.IsTenantOwnerReference(or) {
res = append(res, or.Name)
}
}
return res
}
}

View File

@@ -0,0 +1,33 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type PoolUIDReference struct {
Obj client.Object
}
func (o PoolUIDReference) Object() client.Object {
return o.Obj
}
func (o PoolUIDReference) Field() string {
return ".status.pool.uid"
}
func (o PoolUIDReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
grq, ok := object.(*capsulev1beta2.ResourcePoolClaim)
if !ok {
return nil
}
return []string{string(grq.Status.Pool.UID)}
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
// NamespacesReference defines the indexer logic for GlobalResourceQuota namespaces.
type NamespacesReference struct {
Obj client.Object
}
func (o NamespacesReference) Object() client.Object {
return o.Obj
}
func (o NamespacesReference) Field() string {
return ".status.namespaces"
}
func (o NamespacesReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
rp, ok := object.(*capsulev1beta2.ResourcePool)
if !ok {
return nil
}
return rp.Status.Namespaces
}
}

View File

@@ -0,0 +1,29 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/api"
)
type NamespacesReference struct {
Obj client.Object
}
func (o NamespacesReference) Object() client.Object {
return o.Obj
}
func (o NamespacesReference) Field() string {
return ".status.namespaces"
}
//nolint:forcetypeassert
func (o NamespacesReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
return object.(api.Tenant).GetNamespaces()
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"fmt"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/tenant"
)
type OwnerReference struct{}
func (o OwnerReference) Object() client.Object {
return &capsulev1beta2.Tenant{}
}
func (o OwnerReference) Field() string {
return ".spec.owner.ownerkind"
}
func (o OwnerReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
tnt, ok := object.(*capsulev1beta2.Tenant)
if !ok {
panic(fmt.Errorf("expected type *capsulev1beta2.Tenant, got %T", tnt))
}
return tenant.GetOwnersWithKinds(tnt)
}
}

View File

@@ -0,0 +1,8 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenantresource
const (
IndexerFieldName = "status.processedItems"
)

View File

@@ -0,0 +1,34 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenantresource
import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type GlobalProcessedItems struct{}
func (g GlobalProcessedItems) Object() client.Object {
return &capsulev1beta2.GlobalTenantResource{}
}
func (g GlobalProcessedItems) Field() string {
return IndexerFieldName
}
func (g GlobalProcessedItems) Func() client.IndexerFunc {
return func(object client.Object) []string {
tgr := object.(*capsulev1beta2.GlobalTenantResource) //nolint:forcetypeassert
out := make([]string, 0, len(tgr.Status.ProcessedItems))
for _, pi := range tgr.Status.ProcessedItems {
out = append(out, pi.String())
}
return out
}
}

View File

@@ -0,0 +1,34 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenantresource
import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type LocalProcessedItems struct{}
func (g LocalProcessedItems) Object() client.Object {
return &capsulev1beta2.TenantResource{}
}
func (g LocalProcessedItems) Field() string {
return IndexerFieldName
}
func (g LocalProcessedItems) Func() client.IndexerFunc {
return func(object client.Object) []string {
tgr := object.(*capsulev1beta2.TenantResource) //nolint:forcetypeassert
out := make([]string, 0, len(tgr.Status.ProcessedItems))
for _, pi := range tgr.Status.ProcessedItems {
out = append(out, pi.String())
}
return out
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import (
"sigs.k8s.io/controller-runtime/pkg/event"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type CapsuleConfigSpecChangedPredicate struct{}
func (CapsuleConfigSpecChangedPredicate) Create(event.CreateEvent) bool { return false }
func (CapsuleConfigSpecChangedPredicate) Delete(event.DeleteEvent) bool { return false }
func (CapsuleConfigSpecChangedPredicate) Generic(event.GenericEvent) bool { return false }
func (CapsuleConfigSpecChangedPredicate) Update(e event.UpdateEvent) bool {
oldObj, ok1 := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration)
newObj, ok2 := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration)
if !ok1 || !ok2 {
return false
}
return len(oldObj.Spec.Administrators) != len(newObj.Spec.Administrators)
}

View File

@@ -0,0 +1,99 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"sigs.k8s.io/controller-runtime/pkg/event"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestCapsuleConfigSpecChangedPredicate_StaticFuncs(t *testing.T) {
t.Parallel()
p := predicates.CapsuleConfigSpecChangedPredicate{}
if got := p.Create(event.CreateEvent{}); got {
t.Fatalf("Create() = %v, want false", got)
}
if got := p.Delete(event.DeleteEvent{}); got {
t.Fatalf("Delete() = %v, want false", got)
}
if got := p.Generic(event.GenericEvent{}); got {
t.Fatalf("Generic() = %v, want false", got)
}
}
func TestCapsuleConfigSpecChangedPredicate_Update(t *testing.T) {
t.Parallel()
p := predicates.CapsuleConfigSpecChangedPredicate{}
t.Run("returns false when types are not CapsuleConfiguration", func(t *testing.T) {
t.Parallel()
ev := event.UpdateEvent{
ObjectOld: &capsulev1beta2.GlobalTenantResource{},
ObjectNew: &capsulev1beta2.GlobalTenantResource{},
}
if got := p.Update(ev); got {
t.Fatalf("Update() = %v, want false", got)
}
})
t.Run("returns false when administrators length unchanged", func(t *testing.T) {
t.Parallel()
oldObj := &capsulev1beta2.CapsuleConfiguration{}
newObj := &capsulev1beta2.CapsuleConfiguration{}
// same length (0)
ev := event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
if got := p.Update(ev); got {
t.Fatalf("Update() = %v, want false", got)
}
// same length (2)
oldObj.Spec.Administrators = []api.UserSpec{
{Name: "a"},
{Name: "b"},
}
newObj.Spec.Administrators = []api.UserSpec{
{Name: "x"},
{Name: "y"},
}
ev = event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
if got := p.Update(ev); got {
t.Fatalf("Update() = %v, want false", got)
}
})
t.Run("returns true when administrators length changed", func(t *testing.T) {
t.Parallel()
oldObj := &capsulev1beta2.CapsuleConfiguration{}
newObj := &capsulev1beta2.CapsuleConfiguration{}
oldObj.Spec.Administrators = []api.UserSpec{
{Name: "a"},
}
newObj.Spec.Administrators = []api.UserSpec{
{Name: "a"},
{Name: "b"},
}
ev := event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}
if got := p.Update(ev); !got {
t.Fatalf("Update() = %v, want true", got)
}
})
}

View File

@@ -0,0 +1,57 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import (
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
)
type LabelsMatchingPredicate struct {
Match map[string]string
}
func (p LabelsMatchingPredicate) Create(e event.CreateEvent) bool {
return p.matches(e.Object)
}
func (p LabelsMatchingPredicate) Delete(e event.DeleteEvent) bool {
return p.matches(e.Object)
}
func (p LabelsMatchingPredicate) Generic(e event.GenericEvent) bool {
return p.matches(e.Object)
}
func (p LabelsMatchingPredicate) Update(e event.UpdateEvent) bool {
return p.matches(e.ObjectNew)
}
func (p LabelsMatchingPredicate) matches(obj client.Object) bool {
if obj == nil {
return false
}
if len(p.Match) == 0 {
return true
}
labels := obj.GetLabels()
if labels == nil {
return false
}
for k, v := range p.Match {
if labels[k] != v {
return false
}
}
return true
}
func LabelsMatching(match map[string]string) builder.Predicates {
return builder.WithPredicates(LabelsMatchingPredicate{Match: match})
}

View File

@@ -0,0 +1,88 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestLabelsMatchingPredicate_Matches(t *testing.T) {
t.Parallel()
mk := func(lbl map[string]string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ConfigMap")
u.SetName("cm")
u.SetNamespace("ns")
u.SetLabels(lbl)
return u
}
t.Run("empty match map matches everything (including nil labels)", func(t *testing.T) {
t.Parallel()
p := predicates.LabelsMatchingPredicate{Match: map[string]string{}}
if !p.Create(event.CreateEvent{Object: mk(nil)}) {
t.Fatalf("Create should match when Match is empty")
}
if !p.Update(event.UpdateEvent{ObjectNew: mk(nil)}) {
t.Fatalf("Update should match when Match is empty")
}
if !p.Delete(event.DeleteEvent{Object: mk(nil)}) {
t.Fatalf("Delete should match when Match is empty")
}
if !p.Generic(event.GenericEvent{Object: mk(nil)}) {
t.Fatalf("Generic should match when Match is empty")
}
})
t.Run("non-empty match requires all key/value pairs", func(t *testing.T) {
t.Parallel()
p := predicates.LabelsMatchingPredicate{Match: map[string]string{"app": "x", "tier": "backend"}}
// Missing labels
if p.Create(event.CreateEvent{Object: mk(nil)}) {
t.Fatalf("expected no match when labels are nil")
}
// Partial match
if p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x"})}) {
t.Fatalf("expected no match when one label missing")
}
// Wrong value
if p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x", "tier": "frontend"})}) {
t.Fatalf("expected no match when value differs")
}
// Full match
if !p.Create(event.CreateEvent{Object: mk(map[string]string{"app": "x", "tier": "backend"})}) {
t.Fatalf("expected match when all labels match")
}
})
t.Run("Update checks new object only", func(t *testing.T) {
t.Parallel()
p := predicates.LabelsMatchingPredicate{Match: map[string]string{"app": "x"}}
// Old matches, new doesn't => false
if p.Update(event.UpdateEvent{ObjectOld: mk(map[string]string{"app": "x"}), ObjectNew: mk(map[string]string{"app": "y"})}) {
t.Fatalf("expected false when new object does not match")
}
// Old doesn't match, new matches => true
if !p.Update(event.UpdateEvent{ObjectOld: mk(map[string]string{"app": "y"}), ObjectNew: mk(map[string]string{"app": "x"})}) {
t.Fatalf("expected true when new object matches")
}
})
}

View File

@@ -0,0 +1,33 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import (
"slices"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
)
type NamesMatchingPredicate struct {
Names []string
}
func (p NamesMatchingPredicate) Create(e event.CreateEvent) bool { return p.matches(e.Object) }
func (p NamesMatchingPredicate) Delete(e event.DeleteEvent) bool { return p.matches(e.Object) }
func (p NamesMatchingPredicate) Generic(e event.GenericEvent) bool { return p.matches(e.Object) }
func (p NamesMatchingPredicate) Update(e event.UpdateEvent) bool { return p.matches(e.ObjectNew) }
func (p NamesMatchingPredicate) matches(obj client.Object) bool {
if obj == nil {
return false
}
return slices.Contains(p.Names, obj.GetName())
}
func NamesMatching(names ...string) builder.Predicates {
return builder.WithPredicates(NamesMatchingPredicate{Names: names})
}

View File

@@ -0,0 +1,67 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestNamesMatchingPredicate_Matches(t *testing.T) {
t.Parallel()
mk := func(name string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ConfigMap")
u.SetName(name)
u.SetNamespace("ns")
return u
}
p := predicates.NamesMatchingPredicate{Names: []string{"a", "b"}}
t.Run("Create/Delete/Generic match by name", func(t *testing.T) {
t.Parallel()
if !p.Create(event.CreateEvent{Object: mk("a")}) {
t.Fatalf("expected Create match for name a")
}
if p.Create(event.CreateEvent{Object: mk("c")}) {
t.Fatalf("expected no Create match for name c")
}
if !p.Delete(event.DeleteEvent{Object: mk("b")}) {
t.Fatalf("expected Delete match for name b")
}
if p.Delete(event.DeleteEvent{Object: mk("c")}) {
t.Fatalf("expected no Delete match for name c")
}
if !p.Generic(event.GenericEvent{Object: mk("a")}) {
t.Fatalf("expected Generic match for name a")
}
if p.Generic(event.GenericEvent{Object: mk("c")}) {
t.Fatalf("expected no Generic match for name c")
}
})
t.Run("Update checks new object only", func(t *testing.T) {
t.Parallel()
// Old matches, new doesn't => false
if p.Update(event.UpdateEvent{ObjectOld: mk("a"), ObjectNew: mk("c")}) {
t.Fatalf("expected false when new name does not match")
}
// Old doesn't match, new matches => true
if !p.Update(event.UpdateEvent{ObjectOld: mk("c"), ObjectNew: mk("b")}) {
t.Fatalf("expected true when new name matches")
}
})
}

View File

@@ -0,0 +1,45 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import (
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
type PromotedServiceaccountPredicate struct{}
func (PromotedServiceaccountPredicate) Generic(event.GenericEvent) bool { return false }
func (PromotedServiceaccountPredicate) Create(e event.CreateEvent) bool {
if e.Object == nil {
return false
}
v, ok := e.Object.GetLabels()[meta.OwnerPromotionLabel]
return ok && v == meta.OwnerPromotionLabelTrigger
}
func (PromotedServiceaccountPredicate) Delete(e event.DeleteEvent) bool {
if e.Object == nil {
return false
}
v, ok := e.Object.GetLabels()[meta.OwnerPromotionLabel]
return ok && v == meta.OwnerPromotionLabelTrigger
}
func (PromotedServiceaccountPredicate) Update(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
oldVal, oldOK := e.ObjectOld.GetLabels()[meta.OwnerPromotionLabel]
newVal, newOK := e.ObjectNew.GetLabels()[meta.OwnerPromotionLabel]
return oldOK != newOK || oldVal != newVal
}

View File

@@ -0,0 +1,117 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestPromotedServiceaccountPredicate_StaticFuncs(t *testing.T) {
t.Parallel()
p := predicates.PromotedServiceaccountPredicate{}
if got := p.Generic(event.GenericEvent{}); got {
t.Fatalf("Generic() = %v, want false", got)
}
}
func TestPromotedServiceaccountPredicate_CreateDelete(t *testing.T) {
t.Parallel()
p := predicates.PromotedServiceaccountPredicate{}
mk := func(lbl map[string]string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ServiceAccount")
u.SetName("sa")
u.SetNamespace("ns")
u.SetLabels(lbl)
return u
}
t.Run("Create returns true only when trigger label present and equals trigger value", func(t *testing.T) {
t.Parallel()
if got := p.Create(event.CreateEvent{Object: mk(nil)}); got {
t.Fatalf("Create() = %v, want false (no labels)", got)
}
if got := p.Create(event.CreateEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: "nope"})}); got {
t.Fatalf("Create() = %v, want false (wrong value)", got)
}
if got := p.Create(event.CreateEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger})}); !got {
t.Fatalf("Create() = %v, want true (trigger)", got)
}
})
t.Run("Delete returns true only when trigger label present and equals trigger value", func(t *testing.T) {
t.Parallel()
if got := p.Delete(event.DeleteEvent{Object: mk(nil)}); got {
t.Fatalf("Delete() = %v, want false (no labels)", got)
}
if got := p.Delete(event.DeleteEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: "nope"})}); got {
t.Fatalf("Delete() = %v, want false (wrong value)", got)
}
if got := p.Delete(event.DeleteEvent{Object: mk(map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger})}); !got {
t.Fatalf("Delete() = %v, want true (trigger)", got)
}
})
}
func TestPromotedServiceaccountPredicate_Update(t *testing.T) {
t.Parallel()
p := predicates.PromotedServiceaccountPredicate{}
mk := func(lbl map[string]string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ServiceAccount")
u.SetName("sa")
u.SetNamespace("ns")
u.SetLabels(lbl)
return u
}
tests := []struct {
name string
old map[string]string
new map[string]string
want bool
}{
{"no label in either", nil, nil, false},
{"label added", nil, map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger}, true},
{"label removed", map[string]string{meta.OwnerPromotionLabel: meta.OwnerPromotionLabelTrigger}, nil, true},
{"label value changed", map[string]string{meta.OwnerPromotionLabel: "a"}, map[string]string{meta.OwnerPromotionLabel: "b"}, true},
{"label unchanged", map[string]string{meta.OwnerPromotionLabel: "a"}, map[string]string{meta.OwnerPromotionLabel: "a"}, false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ev := event.UpdateEvent{
ObjectOld: mk(tt.old),
ObjectNew: mk(tt.new),
}
if got := p.Update(ev); got != tt.want {
t.Fatalf("Update() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,38 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import (
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// Only Trigger a Reconcile when the requested annotation has changed value or was added.
type ReconcileRequestedPredicate struct{}
func (ReconcileRequestedPredicate) Create(e event.CreateEvent) bool { return false }
func (ReconcileRequestedPredicate) Delete(e event.DeleteEvent) bool { return false }
func (ReconcileRequestedPredicate) Generic(e event.GenericEvent) bool { return false }
func (ReconcileRequestedPredicate) Update(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
oldA := e.ObjectOld.GetAnnotations()
newA := e.ObjectNew.GetAnnotations()
oldV := ""
if oldA != nil {
oldV = oldA[meta.ReconcileAnnotation]
}
newV := ""
if newA != nil {
newV = newA[meta.ReconcileAnnotation]
}
return newV != "" && newV != oldV
}

View File

@@ -0,0 +1,123 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestReconcileRequestedPredicate_StaticFuncs(t *testing.T) {
t.Parallel()
p := predicates.ReconcileRequestedPredicate{}
if got := p.Create(event.CreateEvent{}); got {
t.Fatalf("Create() = %v, want false", got)
}
if got := p.Delete(event.DeleteEvent{}); got {
t.Fatalf("Delete() = %v, want false", got)
}
if got := p.Generic(event.GenericEvent{}); got {
t.Fatalf("Generic() = %v, want false", got)
}
}
func TestReconcileRequestedPredicate_Update(t *testing.T) {
t.Parallel()
p := predicates.ReconcileRequestedPredicate{}
mkObj := func(ann map[string]string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("capsule.clastix.io/v1beta2")
u.SetKind("GlobalTenantResource")
u.SetName("x")
// Important: nil vs empty map both behave the same for lookups,
// but we keep this as-is to match real objects.
u.SetAnnotations(ann)
return u
}
type tc struct {
name string
old map[string]string
new map[string]string
want bool
}
tests := []tc{
{
name: "annotation added triggers true",
old: map[string]string{},
new: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
want: true,
},
{
name: "annotation value changed triggers true",
old: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
new: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:24:14.111111+01:00"},
want: true,
},
{
name: "annotation unchanged does not trigger",
old: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
new: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
want: false,
},
{
name: "annotation removed does not trigger",
old: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
new: map[string]string{}, // removed
want: false,
},
{
name: "annotation absent in both does not trigger",
old: map[string]string{},
new: map[string]string{},
want: false,
},
{
name: "annotation set to empty string does not trigger",
old: map[string]string{},
new: map[string]string{meta.ReconcileAnnotation: ""},
want: false,
},
{
name: "annotation changed to empty string (effectively removed) does not trigger",
old: map[string]string{meta.ReconcileAnnotation: "2026-01-13T06:23:14.333872+01:00"},
new: map[string]string{meta.ReconcileAnnotation: ""},
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var oldObj, newObj *unstructured.Unstructured
oldObj = mkObj(tt.old)
newObj = mkObj(tt.new)
ev := event.UpdateEvent{
ObjectOld: client.Object(oldObj),
ObjectNew: client.Object(newObj),
}
got := p.Update(ev)
if got != tt.want {
t.Fatalf("Update() = %v, want %v (old=%v new=%v)", got, tt.want, tt.old, tt.new)
}
})
}
}

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
import "sigs.k8s.io/controller-runtime/pkg/event"
type UpdatedLabelsPredicate struct{}
func (UpdatedLabelsPredicate) Create(event.CreateEvent) bool { return true }
func (UpdatedLabelsPredicate) Delete(event.DeleteEvent) bool { return true }
func (UpdatedLabelsPredicate) Generic(event.GenericEvent) bool { return false }
func (UpdatedLabelsPredicate) Update(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
return !LabelsEqual(e.ObjectOld.GetLabels(), e.ObjectNew.GetLabels())
}

View File

@@ -0,0 +1,72 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestUpdatedMetadataPredicate_StaticFuncs(t *testing.T) {
t.Parallel()
p := predicates.UpdatedLabelsPredicate{}
if got := p.Generic(event.GenericEvent{}); got {
t.Fatalf("Generic() = %v, want false", got)
}
if got := p.Create(event.CreateEvent{}); !got {
t.Fatalf("Create() = %v, want true", got)
}
if got := p.Delete(event.DeleteEvent{}); !got {
t.Fatalf("Delete() = %v, want true", got)
}
}
func TestUpdatedMetadataPredicate_Update(t *testing.T) {
t.Parallel()
p := predicates.UpdatedLabelsPredicate{}
mk := func(lbl map[string]string) *unstructured.Unstructured {
u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind("ConfigMap")
u.SetName("cm")
u.SetNamespace("ns")
u.SetLabels(lbl)
return u
}
tests := []struct {
name string
old map[string]string
new map[string]string
want bool
}{
{"both nil", nil, nil, false},
{"nil to empty", nil, map[string]string{}, false},
{"same labels", map[string]string{"a": "1"}, map[string]string{"a": "1"}, false},
{"label added", nil, map[string]string{"a": "1"}, true},
{"label removed", map[string]string{"a": "1"}, nil, true},
{"label value changed", map[string]string{"a": "1"}, map[string]string{"a": "2"}, true},
{"label key changed", map[string]string{"a": "1"}, map[string]string{"b": "1"}, true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ev := event.UpdateEvent{ObjectOld: mk(tt.old), ObjectNew: mk(tt.new)}
if got := p.Update(ev); got != tt.want {
t.Fatalf("Update() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,31 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates
func LabelsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if bv, ok := b[k]; !ok || bv != v {
return false
}
}
return true
}
func LabelsChanged(keys []string, oldLabels, newLabels map[string]string) bool {
for _, key := range keys {
oldVal, oldOK := oldLabels[key]
newVal, newOK := newLabels[key]
if oldOK != newOK || oldVal != newVal {
return true
}
}
return false
}

View File

@@ -0,0 +1,210 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package predicates_test
import (
"testing"
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
)
func TestLabelsEqual(t *testing.T) {
t.Parallel()
type tc struct {
name string
a map[string]string
b map[string]string
want bool
}
tests := []tc{
{
name: "both nil => equal",
a: nil,
b: nil,
want: true,
},
{
name: "nil vs empty => equal (len==0)",
a: nil,
b: map[string]string{},
want: true,
},
{
name: "empty vs nil => equal (len==0)",
a: map[string]string{},
b: nil,
want: true,
},
{
name: "same single entry => equal",
a: map[string]string{"a": "1"},
b: map[string]string{"a": "1"},
want: true,
},
{
name: "same entries different insertion order => equal",
a: map[string]string{"a": "1", "b": "2"},
b: map[string]string{"b": "2", "a": "1"},
want: true,
},
{
name: "different lengths => not equal",
a: map[string]string{"a": "1"},
b: map[string]string{"a": "1", "b": "2"},
want: false,
},
{
name: "missing key in b => not equal",
a: map[string]string{"a": "1", "b": "2"},
b: map[string]string{"a": "1", "c": "2"},
want: false,
},
{
name: "same keys but different value => not equal",
a: map[string]string{"a": "1"},
b: map[string]string{"a": "2"},
want: false,
},
{
name: "b has extra key (len differs) => not equal",
a: map[string]string{"a": "1"},
b: map[string]string{"a": "1", "x": "y"},
want: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := predicates.LabelsEqual(tt.a, tt.b)
if got != tt.want {
t.Fatalf("LabelsEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestLabelsChanged(t *testing.T) {
t.Parallel()
type tc struct {
name string
keys []string
oldLabels map[string]string
newLabels map[string]string
want bool
}
tests := []tc{
{
name: "no keys => unchanged (false)",
keys: nil,
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{"a": "2"},
want: false,
},
{
name: "key unchanged => false",
keys: []string{"a"},
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{"a": "1"},
want: false,
},
{
name: "value changed => true",
keys: []string{"a"},
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{"a": "2"},
want: true,
},
{
name: "key added => true",
keys: []string{"a"},
oldLabels: map[string]string{},
newLabels: map[string]string{"a": "1"},
want: true,
},
{
name: "key removed => true",
keys: []string{"a"},
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{},
want: true,
},
{
name: "old nil new has key => true",
keys: []string{"a"},
oldLabels: nil,
newLabels: map[string]string{"a": "1"},
want: true,
},
{
name: "old has key new nil => true",
keys: []string{"a"},
oldLabels: map[string]string{"a": "1"},
newLabels: nil,
want: true,
},
{
name: "both nil and key missing => false",
keys: []string{"a"},
oldLabels: nil,
newLabels: nil,
want: false,
},
{
name: "multiple keys: one changed => true",
keys: []string{"a", "b"},
oldLabels: map[string]string{"a": "1", "b": "2"},
newLabels: map[string]string{"a": "1", "b": "3"},
want: true,
},
{
name: "multiple keys: only non-watched key changed => false",
keys: []string{"a"},
oldLabels: map[string]string{"a": "1", "x": "old"},
newLabels: map[string]string{"a": "1", "x": "new"},
want: false,
},
{
name: "watched key absent in both even if other keys differ => false",
keys: []string{"a"},
oldLabels: map[string]string{"x": "1"},
newLabels: map[string]string{"x": "2"},
want: false,
},
{
name: "duplicate keys in keys slice still behaves correctly",
keys: []string{"a", "a"},
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{"a": "1"},
want: false,
},
{
name: "duplicate keys in keys slice with change => true",
keys: []string{"a", "a"},
oldLabels: map[string]string{"a": "1"},
newLabels: map[string]string{"a": "2"},
want: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := predicates.LabelsChanged(tt.keys, tt.oldLabels, tt.newLabels)
if got != tt.want {
t.Fatalf("LabelsChanged(keys=%v, old=%v, new=%v) = %v, want %v",
tt.keys, tt.oldLabels, tt.newLabels, got, tt.want,
)
}
})
}
}

View File

@@ -0,0 +1,75 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize
import (
"fmt"
apiMeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// SanitizeObject removes metadata (and optionally status) from a client.Object in-place.
// For StripStatus it converts to unstructured and back (generic, but only when needed).
//
//nolint:nestif
func SanitizeObject(obj client.Object, scheme *runtime.Scheme, opts SanitizeOptions) error {
if obj == nil {
return nil
}
if opts.StripUID {
obj.SetUID("")
}
if opts.StripManagedFields {
accessor, err := apiMeta.Accessor(obj)
if err == nil {
accessor.SetManagedFields(nil)
}
}
if opts.StripLastApplied {
anns := obj.GetAnnotations()
if len(anns) > 0 {
delete(anns, "kubectl.kubernetes.io/last-applied-configuration")
if len(anns) == 0 {
obj.SetAnnotations(nil)
} else {
obj.SetAnnotations(anns)
}
}
}
if opts.StripStatus {
if scheme == nil {
return fmt.Errorf("scheme is required to StripStatus on typed objects")
}
// Convert typed -> unstructured
u := &unstructured.Unstructured{}
if err := scheme.Convert(obj, u, nil); err != nil {
m, err2 := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err2 != nil {
return fmt.Errorf("failed converting object to unstructured for status stripping: %w", err)
}
u.Object = m
}
unstructured.RemoveNestedField(u.Object, "status")
// Convert back unstructured -> typed
if err := scheme.Convert(u, obj, nil); err != nil {
if err2 := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj); err2 != nil {
return fmt.Errorf("failed converting unstructured back to typed after status stripping: %w", err2)
}
}
}
return nil
}

View File

@@ -0,0 +1,322 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize_test
import (
"testing"
apiMeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
)
func TestSanitizeObject_Nil(t *testing.T) {
t.Parallel()
// Should not panic and should return nil.
if err := sanitize.SanitizeObject(nil, nil, sanitize.SanitizeOptions{
StripUID: true,
StripManagedFields: true,
StripLastApplied: true,
StripStatus: true,
}); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestSanitizeObject_MetadataFields_TypedObject(t *testing.T) {
t.Parallel()
pod := newPodWithMeta()
opts := sanitize.SanitizeOptions{
StripUID: true,
StripManagedFields: true,
StripLastApplied: true,
StripStatus: false, // metadata-only test
}
if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
// UID stripped
if got := pod.GetUID(); got != "" {
t.Fatalf("expected UID stripped, got %q", got)
}
// ManagedFields stripped
accessor, err := apiMeta.Accessor(pod)
if err != nil {
t.Fatalf("apiMeta.Accessor failed: %v", err)
}
if mf := accessor.GetManagedFields(); len(mf) != 0 {
t.Fatalf("expected managedFields stripped, got %#v", mf)
}
// last-applied stripped, other annotation preserved
anns := pod.GetAnnotations()
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
t.Fatalf("expected last-applied annotation stripped, still present: %#v", anns)
}
if anns["keep"] != "yes" {
t.Fatalf("expected other annotation preserved, got %#v", anns)
}
}
func TestSanitizeObject_LastApplied_AnnotationMapRemovedWhenEmpty(t *testing.T) {
t.Parallel()
pod := newPodWithMeta()
// Only last-applied exists.
pod.SetAnnotations(map[string]string{
"kubectl.kubernetes.io/last-applied-configuration": `{"x":"y"}`,
})
opts := sanitize.SanitizeOptions{
StripLastApplied: true,
}
if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if anns := pod.GetAnnotations(); len(anns) != 0 {
t.Fatalf("expected annotations cleared (nil or empty) after removing last-applied, got %#v", anns)
}
}
func TestSanitizeObject_NoOptions_NoChanges(t *testing.T) {
t.Parallel()
pod := newPodWithMeta()
// make a copy for comparison
orig := pod.DeepCopy()
opts := sanitize.SanitizeOptions{} // everything false
if err := sanitize.SanitizeObject(pod, nil, opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
// Verify important bits unchanged
if pod.GetUID() != orig.GetUID() {
t.Fatalf("UID changed unexpectedly: %q -> %q", orig.GetUID(), pod.GetUID())
}
if pod.GetAnnotations()["keep"] != "yes" {
t.Fatalf("annotations changed unexpectedly: %#v", pod.GetAnnotations())
}
// ManagedFields should still be present
accessor, err := apiMeta.Accessor(pod)
if err != nil {
t.Fatalf("apiMeta.Accessor failed: %v", err)
}
origAcc, _ := apiMeta.Accessor(orig)
if len(accessor.GetManagedFields()) != len(origAcc.GetManagedFields()) {
t.Fatalf("managedFields changed unexpectedly: %#v -> %#v", origAcc.GetManagedFields(), accessor.GetManagedFields())
}
}
func TestSanitizeObject_StripStatus_TypedObject(t *testing.T) {
t.Parallel()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("AddToScheme: %v", err)
}
pod := newPodWithMeta()
// Put something into status so we can confirm its removed.
pod.Status.Phase = corev1.PodRunning
pod.Status.HostIP = "10.0.0.1"
opts := sanitize.SanitizeOptions{
StripStatus: true,
}
if err := sanitize.SanitizeObject(pod, scheme, opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
// After stripping status, it should be zero value.
if pod.Status.Phase != "" || pod.Status.HostIP != "" {
t.Fatalf("expected pod status stripped to zero value, got %#v", pod.Status)
}
}
func TestSanitizeObject_StripStatus_RequiresScheme(t *testing.T) {
t.Parallel()
pod := newPodWithMeta()
pod.Status.Phase = corev1.PodRunning
opts := sanitize.SanitizeOptions{
StripStatus: true,
}
if err := sanitize.SanitizeObject(pod, nil, opts); err == nil {
t.Fatalf("expected error when StripStatus=true and scheme=nil, got nil")
}
}
func TestSanitizeObject_Unstructured_FastPathMetadataAndStatus(t *testing.T) {
t.Parallel()
u := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "p",
"namespace": "ns",
"uid": "abc",
"annotations": map[string]any{
"kubectl.kubernetes.io/last-applied-configuration": `{"x":"y"}`,
"keep": "yes",
},
"managedFields": []any{
map[string]any{"manager": "x"},
},
},
"status": map[string]any{
"phase": "Running",
},
},
}
// If your SanitizeObject supports unstructured directly (recommended),
// this should work. If you keep a separate SanitizeUnstructured, then
// call that instead in this test.
opts := sanitize.SanitizeOptions{
StripUID: true,
StripManagedFields: true,
StripLastApplied: true,
StripStatus: true,
}
// scheme not needed for unstructured if your implementation detects it and uses map operations.
// If your implementation requires scheme even for unstructured, pass a scheme.
if err := sanitize.SanitizeObject(u, runtime.NewScheme(), opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
// Verify uid removed
if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "metadata", "uid"); found {
t.Fatalf("expected metadata.uid stripped")
}
// Verify managedFields removed
if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "metadata", "managedFields"); found {
t.Fatalf("expected metadata.managedFields stripped")
}
// Verify last-applied removed, keep preserved
anns, found, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations")
if err != nil || !found {
t.Fatalf("expected annotations map to exist, err=%v found=%v", err, found)
}
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
t.Fatalf("expected last-applied stripped from annotations, got %#v", anns)
}
if anns["keep"] != "yes" {
t.Fatalf("expected keep annotation preserved, got %#v", anns)
}
// Verify status removed
if _, found, _ := unstructured.NestedFieldNoCopy(u.Object, "status"); found {
t.Fatalf("expected status stripped")
}
}
func TestSanitizeObject_AllOptions_TypedObject(t *testing.T) {
t.Parallel()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("AddToScheme: %v", err)
}
pod := newPodWithMeta()
pod.Status.Phase = corev1.PodRunning
pod.Status.PodIP = "10.0.0.2"
opts := sanitize.SanitizeOptions{
StripUID: true,
StripManagedFields: true,
StripLastApplied: true,
StripStatus: true,
}
if err := sanitize.SanitizeObject(pod, scheme, opts); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
// UID stripped
if pod.GetUID() != "" {
t.Fatalf("expected UID stripped, got %q", pod.GetUID())
}
// managedFields stripped
acc, err := apiMeta.Accessor(pod)
if err != nil {
t.Fatalf("apiMeta.Accessor failed: %v", err)
}
if len(acc.GetManagedFields()) != 0 {
t.Fatalf("expected managedFields stripped, got %#v", acc.GetManagedFields())
}
// last-applied stripped
anns := pod.GetAnnotations()
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
t.Fatalf("expected last-applied stripped, got %#v", anns)
}
if anns["keep"] != "yes" {
t.Fatalf("expected other annotations preserved, got %#v", anns)
}
if pod.Status.Phase != "" || pod.Status.PodIP != "" || pod.Status.HostIP != "" {
t.Fatalf("expected scalar status fields cleared, got %#v", pod.Status)
}
if len(pod.Status.Conditions) != 0 {
t.Fatalf("expected status.conditions empty, got %#v", pod.Status.Conditions)
}
if len(pod.Status.ContainerStatuses) != 0 {
t.Fatalf("expected status.containerStatuses empty, got %#v", pod.Status.ContainerStatuses)
}
}
// --- helpers ---
func newPodWithMeta() *corev1.Pod {
p := &corev1.Pod{}
p.SetName("p")
p.SetNamespace("ns")
p.SetUID(types.UID("uid-123"))
p.SetAnnotations(map[string]string{
"kubectl.kubernetes.io/last-applied-configuration": `{"a":"b"}`,
"keep": "yes",
})
// ManagedFields is on ObjectMeta; easiest to set via Accessor.
// Note: type is []metav1.ManagedFieldsEntry
acc, _ := apiMeta.Accessor(p)
acc.SetManagedFields([]metav1.ManagedFieldsEntry{
{
Manager: "test",
Operation: metav1.ManagedFieldsOperationApply,
APIVersion: "v1",
Time: &metav1.Time{},
},
})
// Implement client.Object assertion at compile-time for sanity.
var _ client.Object = p
return p
}

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize
type SanitizeOptions struct {
StripUID bool
StripManagedFields bool
StripLastApplied bool
StripStatus bool
}
func DefaultSanitizeOptions() SanitizeOptions {
return SanitizeOptions{
StripUID: true,
StripManagedFields: true,
StripLastApplied: true,
StripStatus: true,
}
}

View File

@@ -0,0 +1,27 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize_test
import (
"testing"
"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
)
func TestDefaultSanitizeOptions(t *testing.T) {
opts := sanitize.DefaultSanitizeOptions()
if !opts.StripManagedFields {
t.Fatalf("expected StripManagedFields=true")
}
if !opts.StripLastApplied {
t.Fatalf("expected StripLastApplied=true")
}
if !opts.StripStatus {
t.Fatalf("expected StripStatus=true")
}
if !opts.StripUID {
t.Fatalf("expected StripUID=true")
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize
import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
// SanitizeUnstructured Removes additional metadata we might not need when loading unstructured items into a context.
func SanitizeUnstructured(obj *unstructured.Unstructured, opts SanitizeOptions) {
if obj == nil {
return
}
if opts.StripUID {
unstructured.RemoveNestedField(obj.Object, "metadata", "uid")
}
if opts.StripManagedFields {
unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields")
}
if opts.StripLastApplied {
anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
if err == nil && found && len(anns) > 0 {
// kubectl apply annotation.
delete(anns, "kubectl.kubernetes.io/last-applied-configuration")
if len(anns) == 0 {
unstructured.RemoveNestedField(obj.Object, "metadata", "annotations")
} else {
_ = unstructured.SetNestedStringMap(obj.Object, anns, "metadata", "annotations")
}
}
}
if opts.StripStatus {
unstructured.RemoveNestedField(obj.Object, "status")
}
}

View File

@@ -0,0 +1,228 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package sanitize_test
import (
"testing"
"github.com/projectcapsule/capsule/pkg/runtime/sanitize"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestSanitizeUnstructured_NilObject_NoPanic(t *testing.T) {
// Just ensure it doesn't panic
sanitize.SanitizeUnstructured(nil, sanitize.DefaultSanitizeOptions())
}
func TestSanitizeUnstructured_StripManagedFields_RemovesOnlyWhenEnabled(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "x",
"managedFields": []any{
map[string]any{"manager": "foo"},
},
},
},
}
// Disabled: should remain
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: false,
StripStatus: false,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); !found {
t.Fatalf("expected managedFields to remain when StripManagedFields=false")
}
// Enabled: should be removed
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: true,
StripLastApplied: false,
StripStatus: false,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); found {
t.Fatalf("expected managedFields to be removed when StripManagedFields=true")
}
}
func TestSanitizeUnstructured_StripLastApplied_RemovesKeyButKeepsOtherAnnotations(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"annotations": map[string]any{
"kubectl.kubernetes.io/last-applied-configuration": "huge",
"keep": "me",
},
},
},
}
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: true,
StripStatus: false,
})
anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
if err != nil {
t.Fatalf("unexpected error reading annotations: %v", err)
}
if !found {
t.Fatalf("expected annotations to exist")
}
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
t.Fatalf("expected last-applied annotation to be removed")
}
if anns["keep"] != "me" {
t.Fatalf("expected other annotations to be preserved, got: %#v", anns)
}
}
func TestSanitizeUnstructured_StripLastApplied_RemovesAnnotationsFieldWhenItBecomesEmpty(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"annotations": map[string]any{
"kubectl.kubernetes.io/last-applied-configuration": "huge",
},
},
},
}
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: true,
StripStatus: false,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "annotations"); found {
t.Fatalf("expected metadata.annotations to be removed entirely when empty")
}
}
func TestSanitizeUnstructured_StripLastApplied_NoAnnotations_NoError(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"name": "x",
},
},
}
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: true,
StripStatus: false,
})
// Nothing to assert besides "doesn't crash" and metadata still present
if got := obj.GetName(); got != "x" {
t.Fatalf("expected name to stay unchanged, got %q", got)
}
}
func TestSanitizeUnstructured_StripLastApplied_AnnotationsNotStringMap_IsIgnored(t *testing.T) {
// NestedStringMap will return an error if annotations is not a map[string]string
// and SanitizeUnstructured should ignore it (no crash, no deletion).
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"annotations": []any{"not-a-map"},
},
},
}
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: true,
StripStatus: false,
})
// Still present because we ignored on error
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "annotations"); !found {
t.Fatalf("expected annotations to remain when annotations is malformed and cannot be parsed as string map")
}
}
func TestSanitizeUnstructured_StripStatus_RemovesStatusOnlyWhenEnabled(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{"name": "x"},
"status": map[string]any{
"phase": "Active",
},
},
}
// Disabled: should remain
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: false,
StripStatus: false,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); !found {
t.Fatalf("expected status to remain when StripStatus=false")
}
// Enabled: should be removed
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: false,
StripLastApplied: false,
StripStatus: true,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); found {
t.Fatalf("expected status to be removed when StripStatus=true")
}
}
func TestSanitizeUnstructured_AllOptionsEnabled_RemovesAllTargets(t *testing.T) {
obj := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"managedFields": []any{
map[string]any{"manager": "foo"},
},
"annotations": map[string]any{
"kubectl.kubernetes.io/last-applied-configuration": "huge",
"keep": "me",
},
},
"status": map[string]any{"foo": "bar"},
},
}
sanitize.SanitizeUnstructured(obj, sanitize.SanitizeOptions{
StripManagedFields: true,
StripLastApplied: true,
StripStatus: true,
})
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "metadata", "managedFields"); found {
t.Fatalf("expected managedFields removed")
}
anns, found, err := unstructured.NestedStringMap(obj.Object, "metadata", "annotations")
if err != nil {
t.Fatalf("unexpected error reading annotations: %v", err)
}
if !found {
t.Fatalf("expected annotations to still exist because 'keep' should remain")
}
if _, ok := anns["kubectl.kubernetes.io/last-applied-configuration"]; ok {
t.Fatalf("expected last-applied removed")
}
if anns["keep"] != "me" {
t.Fatalf("expected keep annotation preserved, got %#v", anns)
}
if _, found, _ := unstructured.NestedFieldNoCopy(obj.Object, "status"); found {
t.Fatalf("expected status removed")
}
}

View File

@@ -0,0 +1,28 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package selectors
import "k8s.io/apimachinery/pkg/labels"
func CombineSelectors(selectors ...labels.Selector) labels.Selector {
combined := labels.NewSelector()
for _, sel := range selectors {
if sel == nil {
continue
}
reqs, selectable := sel.Requirements()
if !selectable {
// Defensive: if selector can't be expressed as requirements, match nothing.
return labels.Nothing()
}
for _, r := range reqs {
combined = combined.Add(r)
}
}
return combined
}

View File

@@ -0,0 +1,113 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package selectors_test
import (
"testing"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
"k8s.io/apimachinery/pkg/labels"
)
func TestCombineSelectors(t *testing.T) {
t.Parallel()
t.Run("no selectors returns Everything (matches all)", func(t *testing.T) {
t.Parallel()
sel := selectors.CombineSelectors()
if !sel.Matches(labels.Set{}) {
t.Fatalf("expected combined selector to match empty label set")
}
// labels.NewSelector() string is typically "", which means "everything"
if got := sel.String(); got != "" {
t.Fatalf("expected empty selector string, got %q", got)
}
})
t.Run("nil selectors are ignored", func(t *testing.T) {
t.Parallel()
base := labels.SelectorFromSet(labels.Set{"a": "1"})
sel := selectors.CombineSelectors(nil, base, nil)
if !sel.Matches(labels.Set{"a": "1"}) {
t.Fatalf("expected to match labels a=1")
}
if sel.Matches(labels.Set{"a": "2"}) {
t.Fatalf("expected not to match labels a=2")
}
})
t.Run("combines selectors with AND semantics", func(t *testing.T) {
t.Parallel()
s1 := labels.SelectorFromSet(labels.Set{"a": "1"})
s2 := labels.SelectorFromSet(labels.Set{"b": "2"})
combined := selectors.CombineSelectors(s1, s2)
if !combined.Matches(labels.Set{"a": "1", "b": "2"}) {
t.Fatalf("expected to match when both requirements are satisfied")
}
if combined.Matches(labels.Set{"a": "1"}) {
t.Fatalf("expected not to match when b is missing")
}
if combined.Matches(labels.Set{"b": "2"}) {
t.Fatalf("expected not to match when a is missing")
}
if combined.Matches(labels.Set{"a": "1", "b": "3"}) {
t.Fatalf("expected not to match when b mismatches")
}
})
t.Run("conflicting selectors match nothing", func(t *testing.T) {
t.Parallel()
s1 := labels.SelectorFromSet(labels.Set{"a": "1"})
s2 := labels.SelectorFromSet(labels.Set{"a": "2"})
combined := selectors.CombineSelectors(s1, s2)
if combined.Matches(labels.Set{"a": "1"}) {
t.Fatalf("expected not to match due to conflict (a=1 AND a=2)")
}
if combined.Matches(labels.Set{"a": "2"}) {
t.Fatalf("expected not to match due to conflict (a=1 AND a=2)")
}
})
t.Run("non-selectable selector returns Nothing", func(t *testing.T) {
t.Parallel()
// labels.Nothing() is not selectable (Requirements() => selectable=false).
combined := selectors.CombineSelectors(labels.SelectorFromSet(labels.Set{"a": "1"}), labels.Nothing())
if combined.String() != labels.Nothing().String() {
t.Fatalf("expected labels.Nothing() selector, got %q", combined.String())
}
if combined.Matches(labels.Set{"a": "1"}) {
t.Fatalf("expected Nothing selector to match nothing")
}
if combined.Matches(labels.Set{}) {
t.Fatalf("expected Nothing selector to match nothing (even empty set)")
}
})
t.Run("output selector is independent from input mutation patterns", func(t *testing.T) {
t.Parallel()
// This is a light regression guard: we depend on CombineSelectors turning input
// selectors into requirements, not keeping references to the original selector objects.
in := labels.SelectorFromSet(labels.Set{"a": "1"})
out := selectors.CombineSelectors(in)
if !out.Matches(labels.Set{"a": "1"}) {
t.Fatalf("expected out to match a=1")
}
if out.Matches(labels.Set{"a": "2"}) {
t.Fatalf("expected out not to match a=2")
}
})
}