Files
Oliver Bähler f1b760a87a feat(rules): improve metadata enforcement and add ingress rules (#2050)
* feat: implement namespace metadata enforcement

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
2026-07-22 22:24:54 +02:00

383 lines
10 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rulestatus
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/rules"
apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
)
const managedMetadataListPageSize int64 = 500
type managedMetadataTarget struct {
gvr schema.GroupVersionResource
gvk schema.GroupVersionKind
}
func (r Manager) reconcileManagedMetadata(ctx context.Context, instance *capsulev1beta2.RuleStatus, previous, current []*rules.NamespaceRuleBodyNamespace) error {
if r.RESTConfig == nil {
return fmt.Errorf("REST config is required for managed metadata reconciliation")
}
dynamicClient, err := dynamic.NewForConfig(r.RESTConfig)
if err != nil {
return err
}
manager := ruleStatusFieldManager(instance)
namespaceGVK := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}
previousLabels, previousAnnotations := managedMetadataForGVK(namespaceGVK, previous)
labels, annotations := managedMetadataForGVK(namespaceGVK, current)
if hasMetadata(previousLabels, previousAnnotations) || hasMetadata(labels, annotations) {
if err := reconcileObjectManagedMetadata(ctx, dynamicClient, schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, namespaceGVK, "", instance.GetNamespace(), previousLabels, previousAnnotations, labels, annotations, manager); err != nil {
return err
}
}
targets, err := managedMetadataTargets(r.RESTMapper, previous, current)
if err != nil {
return err
}
for _, target := range targets {
previousLabels, previousAnnotations := managedMetadataForGVK(target.gvk, previous)
labels, annotations := managedMetadataForGVK(target.gvk, current)
if err := reconcileManagedMetadataTarget(
ctx,
dynamicClient,
target,
instance.GetNamespace(),
previousLabels,
previousAnnotations,
labels,
annotations,
manager,
); err != nil {
return err
}
}
return nil
}
func reconcileManagedMetadataTarget(
ctx context.Context,
dynamicClient dynamic.Interface,
target managedMetadataTarget,
namespace string,
previousLabels, previousAnnotations, labels, annotations map[string]string,
manager string,
) error {
continueToken := ""
for {
items, err := dynamicClient.Resource(target.gvr).Namespace(namespace).List(ctx, metav1.ListOptions{
Limit: managedMetadataListPageSize,
Continue: continueToken,
})
if err != nil {
if isManagedMetadataObjectGone(err) {
return nil
}
return fmt.Errorf("list %s in namespace %q: %w", target.gvr.String(), namespace, err)
}
for i := range items.Items {
if err := reconcileObjectManagedMetadata(
ctx,
dynamicClient,
target.gvr,
target.gvk,
namespace,
items.Items[i].GetName(),
previousLabels,
previousAnnotations,
labels,
annotations,
manager,
); err != nil {
return err
}
}
continueToken = items.GetContinue()
if continueToken == "" {
return nil
}
}
}
func managedMetadataTargets(
mapper k8smeta.RESTMapper,
ruleSets ...[]*rules.NamespaceRuleBodyNamespace,
) ([]managedMetadataTarget, error) {
targets := make(map[schema.GroupVersionResource]managedMetadataTarget)
for _, bodies := range ruleSets {
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
if !metadataRuleHasManagedValues(rule) {
continue
}
if rule.HasWildcard() {
return nil, fmt.Errorf("managed metadata requires concrete apiGroups and kinds")
}
for _, kind := range rule.Kinds {
kind = strings.TrimSpace(kind)
for _, apiGroup := range rule.StatusAPIGroups() {
mapping, err := managedMetadataRESTMapping(mapper, apiGroup, kind)
if err != nil {
return nil, fmt.Errorf("resolve managed metadata target %q/%q: %w", apiGroup, kind, err)
}
if mapping.GroupVersionKind.Group == "" &&
mapping.GroupVersionKind.Version == apiruntime.CoreAPIVersion &&
mapping.GroupVersionKind.Kind == "Namespace" {
continue
}
if mapping.Scope.Name() != k8smeta.RESTScopeNameNamespace {
return nil, fmt.Errorf("managed metadata target %s is not namespaced", mapping.GroupVersionKind.String())
}
targets[mapping.Resource] = managedMetadataTarget{
gvr: mapping.Resource,
gvk: mapping.GroupVersionKind,
}
}
}
}
}
}
out := make([]managedMetadataTarget, 0, len(targets))
for _, target := range targets {
out = append(out, target)
}
sort.Slice(out, func(i, j int) bool {
return out[i].gvr.String() < out[j].gvr.String()
})
return out, nil
}
func managedMetadataRESTMapping(
mapper k8smeta.RESTMapper,
apiGroup string,
kind string,
) (*k8smeta.RESTMapping, error) {
if mapper == nil {
return nil, fmt.Errorf("REST mapper is required for managed metadata reconciliation")
}
apiGroup = strings.TrimSpace(apiGroup)
if apiGroup == "" || apiGroup == apiruntime.CoreAPIVersion {
return mapper.RESTMapping(schema.GroupKind{Kind: kind}, apiruntime.CoreAPIVersion)
}
if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") {
return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version)
}
return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind})
}
func reconcileObjectManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, previousLabels, previousAnnotations, labels, annotations map[string]string, manager string) error {
removedLabels := removedMetadataKeys(previousLabels, labels)
removedAnnotations := removedMetadataKeys(previousAnnotations, annotations)
if hasRemovedMetadata(removedLabels, removedAnnotations) {
if err := removeManagedMetadata(ctx, dynamicClient, gvr, namespace, name, removedLabels, removedAnnotations); err != nil {
if isManagedMetadataObjectGone(err) {
return nil
}
return err
}
}
return applyManagedMetadata(ctx, dynamicClient, gvr, gvk, namespace, name, labels, annotations, manager)
}
func removedMetadataKeys(previous, current map[string]string) map[string]any {
removed := map[string]any{}
for key := range previous {
if _, ok := current[key]; !ok {
removed[key] = nil
}
}
return removed
}
func hasRemovedMetadata(labels, annotations map[string]any) bool {
return len(labels) > 0 || len(annotations) > 0
}
func removeManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, namespace, name string, labels, annotations map[string]any) error {
metadata := map[string]any{}
if len(labels) > 0 {
metadata["labels"] = labels
}
if len(annotations) > 0 {
metadata["annotations"] = annotations
}
raw, err := json.Marshal(map[string]any{"metadata": metadata})
if err != nil {
return err
}
var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr)
if namespace != "" {
resource = dynamicClient.Resource(gvr).Namespace(namespace)
}
_, err = resource.Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
return err
}
func hasMetadata(labels, annotations map[string]string) bool {
return len(labels) > 0 || len(annotations) > 0
}
func metadataRuleHasManagedValues(rule rules.MetadataRule) bool {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
return false
}
func managedMetadataForGVK(gvk schema.GroupVersionKind, bodies []*rules.NamespaceRuleBodyNamespace) (map[string]string, map[string]string) {
labels, annotations := map[string]string{}, map[string]string{}
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
for key, policy := range rule.Labels {
if policy.Managed != nil {
labels[key] = *policy.Managed
}
}
for key, policy := range rule.Annotations {
if policy.Managed != nil {
annotations[key] = *policy.Managed
}
}
}
}
return labels, annotations
}
func hasManagedMetadata(bodies []*rules.NamespaceRuleBodyNamespace) bool {
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
}
}
return false
}
func applyManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, labels, annotations map[string]string, manager string) error {
metadata := map[string]any{"name": name, "labels": labels, "annotations": annotations}
var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr)
if namespace != "" {
metadata["namespace"] = namespace
resource = dynamicClient.Resource(gvr).Namespace(namespace)
}
payload := map[string]any{"apiVersion": gvk.GroupVersion().String(), "kind": gvk.Kind, "metadata": metadata}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
force := true
_, err = resource.Patch(ctx, name, types.ApplyPatchType, raw, metav1.PatchOptions{FieldManager: manager, Force: &force})
if isManagedMetadataObjectGone(err) {
return nil
}
return err
}
func isManagedMetadataObjectGone(err error) bool {
return apierrors.IsNotFound(err)
}
func ruleStatusFieldManager(instance *capsulev1beta2.RuleStatus) string {
sum := sha256.Sum256([]byte(instance.GetNamespace() + "/" + instance.GetName() + "/" + string(instance.GetUID())))
return "projectcapsule.dev/rulestatus-" + hex.EncodeToString(sum[:8])
}