mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-02-14 18:09:58 +00:00
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:
201
pkg/runtime/cert/ca.go
Normal file
201
pkg/runtime/cert/ca.go
Normal 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
|
||||
}
|
||||
76
pkg/runtime/cert/ca_test.go
Normal file
76
pkg/runtime/cert/ca_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
pkg/runtime/cert/errors.go
Normal file
16
pkg/runtime/cert/errors.go
Normal 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"
|
||||
}
|
||||
28
pkg/runtime/cert/options.go
Normal file
28
pkg/runtime/cert/options.go
Normal 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}
|
||||
}
|
||||
90
pkg/runtime/client/apply.go
Normal file
90
pkg/runtime/client/apply.go
Normal 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
|
||||
}
|
||||
185
pkg/runtime/client/ignore.go
Normal file
185
pkg/runtime/client/ignore.go
Normal 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
|
||||
}
|
||||
424
pkg/runtime/client/ignore_test.go
Normal file
424
pkg/runtime/client/ignore_test.go
Normal 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
285
pkg/runtime/client/patch.go
Normal 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
|
||||
}
|
||||
424
pkg/runtime/client/patch_test.go
Normal file
424
pkg/runtime/client/patch_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
53
pkg/runtime/client/update.go
Normal file
53
pkg/runtime/client/update.go
Normal 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
|
||||
}
|
||||
37
pkg/runtime/client/zz_generated.deepcopy.go
Normal file
37
pkg/runtime/client/zz_generated.deepcopy.go
Normal 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
|
||||
}
|
||||
183
pkg/runtime/configuration/client.go
Normal file
183
pkg/runtime/configuration/client.go
Normal 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
|
||||
}
|
||||
41
pkg/runtime/configuration/configuration.go
Normal file
41
pkg/runtime/configuration/configuration.go
Normal 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
|
||||
}
|
||||
14
pkg/runtime/events/actions.go
Normal file
14
pkg/runtime/events/actions.go
Normal 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"
|
||||
)
|
||||
59
pkg/runtime/events/reasons.go
Normal file
59
pkg/runtime/events/reasons.go
Normal 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"
|
||||
)
|
||||
25
pkg/runtime/gvk/has_gvk.go
Normal file
25
pkg/runtime/gvk/has_gvk.go
Normal 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
|
||||
}
|
||||
133
pkg/runtime/gvk/has_gvk_test.go
Normal file
133
pkg/runtime/gvk/has_gvk_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
16
pkg/runtime/handlers/errors.go
Normal file
16
pkg/runtime/handlers/errors.go
Normal 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
|
||||
}
|
||||
34
pkg/runtime/handlers/handlers.go
Normal file
34
pkg/runtime/handlers/handlers.go
Normal 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
|
||||
}
|
||||
79
pkg/runtime/handlers/in_capsule_groups.go
Normal file
79
pkg/runtime/handlers/in_capsule_groups.go
Normal 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
|
||||
}
|
||||
}
|
||||
118
pkg/runtime/handlers/typed_tenant.go
Normal file
118
pkg/runtime/handlers/typed_tenant.go
Normal 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)
|
||||
}
|
||||
168
pkg/runtime/handlers/typed_tenant_ruleset.go
Normal file
168
pkg/runtime/handlers/typed_tenant_ruleset.go
Normal 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)
|
||||
}
|
||||
8
pkg/runtime/handlers/utils.go
Normal file
8
pkg/runtime/handlers/utils.go
Normal 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
|
||||
9
pkg/runtime/handlers/webhook.go
Normal file
9
pkg/runtime/handlers/webhook.go
Normal 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
|
||||
}
|
||||
59
pkg/runtime/indexers/indexer.go
Normal file
59
pkg/runtime/indexers/indexer.go
Normal 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
|
||||
}
|
||||
55
pkg/runtime/indexers/ingress/hostname_path.go
Normal file
55
pkg/runtime/indexers/ingress/hostname_path.go
Normal 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
|
||||
}
|
||||
}
|
||||
71
pkg/runtime/indexers/ingress/utils.go
Normal file
71
pkg/runtime/indexers/ingress/utils.go
Normal 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
|
||||
}
|
||||
42
pkg/runtime/indexers/namespace/namespaces.go
Normal file
42
pkg/runtime/indexers/namespace/namespaces.go
Normal 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
|
||||
}
|
||||
}
|
||||
33
pkg/runtime/indexers/resourcepool/claim.go
Normal file
33
pkg/runtime/indexers/resourcepool/claim.go
Normal 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)}
|
||||
}
|
||||
}
|
||||
34
pkg/runtime/indexers/resourcepool/namespaces.go
Normal file
34
pkg/runtime/indexers/resourcepool/namespaces.go
Normal 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
|
||||
}
|
||||
}
|
||||
29
pkg/runtime/indexers/tenant/namespaces.go
Normal file
29
pkg/runtime/indexers/tenant/namespaces.go
Normal 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()
|
||||
}
|
||||
}
|
||||
34
pkg/runtime/indexers/tenant/owner.go
Normal file
34
pkg/runtime/indexers/tenant/owner.go
Normal 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)
|
||||
}
|
||||
}
|
||||
8
pkg/runtime/indexers/tenantresource/constants.go
Normal file
8
pkg/runtime/indexers/tenantresource/constants.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenantresource
|
||||
|
||||
const (
|
||||
IndexerFieldName = "status.processedItems"
|
||||
)
|
||||
34
pkg/runtime/indexers/tenantresource/global.go
Normal file
34
pkg/runtime/indexers/tenantresource/global.go
Normal 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
|
||||
}
|
||||
}
|
||||
34
pkg/runtime/indexers/tenantresource/local.go
Normal file
34
pkg/runtime/indexers/tenantresource/local.go
Normal 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
|
||||
}
|
||||
}
|
||||
27
pkg/runtime/predicates/config_change.go
Normal file
27
pkg/runtime/predicates/config_change.go
Normal 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)
|
||||
}
|
||||
99
pkg/runtime/predicates/config_change_test.go
Normal file
99
pkg/runtime/predicates/config_change_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
57
pkg/runtime/predicates/labels_matching.go
Normal file
57
pkg/runtime/predicates/labels_matching.go
Normal 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})
|
||||
}
|
||||
88
pkg/runtime/predicates/labels_matching_test.go
Normal file
88
pkg/runtime/predicates/labels_matching_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
33
pkg/runtime/predicates/name_matching.go
Normal file
33
pkg/runtime/predicates/name_matching.go
Normal 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})
|
||||
}
|
||||
67
pkg/runtime/predicates/name_matching_test.go
Normal file
67
pkg/runtime/predicates/name_matching_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
45
pkg/runtime/predicates/promoted_serviceaccount.go
Normal file
45
pkg/runtime/predicates/promoted_serviceaccount.go
Normal 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
|
||||
}
|
||||
117
pkg/runtime/predicates/promoted_serviceaccount_test.go
Normal file
117
pkg/runtime/predicates/promoted_serviceaccount_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
pkg/runtime/predicates/reconcile_requested.go
Normal file
38
pkg/runtime/predicates/reconcile_requested.go
Normal 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
|
||||
}
|
||||
123
pkg/runtime/predicates/reconcile_requested_test.go
Normal file
123
pkg/runtime/predicates/reconcile_requested_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
pkg/runtime/predicates/updated_labels.go
Normal file
20
pkg/runtime/predicates/updated_labels.go
Normal 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())
|
||||
}
|
||||
72
pkg/runtime/predicates/updated_labels_test.go
Normal file
72
pkg/runtime/predicates/updated_labels_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
pkg/runtime/predicates/utils.go
Normal file
31
pkg/runtime/predicates/utils.go
Normal 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
|
||||
}
|
||||
210
pkg/runtime/predicates/utils_test.go
Normal file
210
pkg/runtime/predicates/utils_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
pkg/runtime/sanitize/object.go
Normal file
75
pkg/runtime/sanitize/object.go
Normal 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
|
||||
}
|
||||
322
pkg/runtime/sanitize/object_test.go
Normal file
322
pkg/runtime/sanitize/object_test.go
Normal 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 it’s 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
|
||||
}
|
||||
20
pkg/runtime/sanitize/options.go
Normal file
20
pkg/runtime/sanitize/options.go
Normal 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,
|
||||
}
|
||||
}
|
||||
27
pkg/runtime/sanitize/options_test.go
Normal file
27
pkg/runtime/sanitize/options_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
39
pkg/runtime/sanitize/unstructured.go
Normal file
39
pkg/runtime/sanitize/unstructured.go
Normal 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")
|
||||
}
|
||||
}
|
||||
228
pkg/runtime/sanitize/unstructured_test.go
Normal file
228
pkg/runtime/sanitize/unstructured_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
28
pkg/runtime/selectors/combine.go
Normal file
28
pkg/runtime/selectors/combine.go
Normal 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
|
||||
}
|
||||
113
pkg/runtime/selectors/combine_test.go
Normal file
113
pkg/runtime/selectors/combine_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user