feat: Add additionalMetadataList Support for Conditional Metadata Assignment (#1339)

* feat: Add support for additionalMetadataList

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

* docs: change description

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

* fix: missing bracket

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

* fix: removed duplicated if statement

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

* chore: adjustments after review

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

* chore: Sync `syncNamespaceMetadata` method

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>

---------

Signed-off-by: Deofex <28751252+Deofex@users.noreply.github.com>
Signed-off-by: Deofex 28751252+Deofex@users.noreply.github.com
This commit is contained in:
Deofex
2025-05-08 08:45:05 +02:00
committed by GitHub
parent eb52eba944
commit 8e9b8adac9
8 changed files with 405 additions and 76 deletions

View File

@@ -13,6 +13,8 @@ type NamespaceOptions struct {
Quota *int32 `json:"quota,omitempty"`
// Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional.
AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"`
// Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant via a list. Optional.
AdditionalMetadataList []api.AdditionalMetadataSelectorSpec `json:"additionalMetadataList,omitempty"`
// Define the labels that a Tenant Owner cannot set for their Namespace resources.
ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitempty"`
// Define the annotations that a Tenant Owner cannot set for their Namespace resources.

View File

@@ -293,6 +293,13 @@ func (in *NamespaceOptions) DeepCopyInto(out *NamespaceOptions) {
*out = new(api.AdditionalMetadataSpec)
(*in).DeepCopyInto(*out)
}
if in.AdditionalMetadataList != nil {
in, out := &in.AdditionalMetadataList, &out.AdditionalMetadataList
*out = make([]api.AdditionalMetadataSelectorSpec, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
in.ForbiddenLabels.DeepCopyInto(&out.ForbiddenLabels)
in.ForbiddenAnnotations.DeepCopyInto(&out.ForbiddenAnnotations)
}

View File

@@ -1373,6 +1373,71 @@ spec:
type: string
type: object
type: object
additionalMetadataList:
description: Specifies additional labels and annotations the Capsule
operator places on any Namespace resource in the Tenant via
a list. Optional.
items:
properties:
annotations:
additionalProperties:
type: string
type: object
labels:
additionalProperties:
type: string
type: object
namespaceSelector:
description: |-
A label selector is a label query over a set of resources. The result of matchLabels and
matchExpressions are ANDed. An empty label selector matches all objects. A null
label selector matches no objects.
properties:
matchExpressions:
description: matchExpressions is a list of label selector
requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector
applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
type: object
type: array
forbiddenAnnotations:
description: Define the annotations that a Tenant Owner cannot
set for their Namespace resources.

View File

@@ -6,6 +6,7 @@ package tenant
import (
"context"
"fmt"
"maps"
"strings"
"golang.org/x/sync/errgroup"
@@ -42,36 +43,39 @@ func (r *Manager) syncNamespaces(ctx context.Context, tenant *capsulev1beta2.Ten
return
}
//nolint:gocognit,nakedret
func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, tnt *capsulev1beta2.Tenant) (err error) {
var res controllerutil.OperationResult
err = retry.RetryOnConflict(retry.DefaultBackoff, func() (conflictErr error) {
ns := &corev1.Namespace{}
if conflictErr = r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return
return conflictErr
}
capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
res, conflictErr = controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
annotations := make(map[string]string)
labels := map[string]string{
"kubernetes.io/metadata.name": namespace,
capsuleLabel: tnt.GetName(),
}
annotations := buildNamespaceAnnotationsForTenant(tnt)
labels := buildNamespaceLabelsForTenant(tnt)
if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil {
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Annotations {
annotations[k] = v
if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
for _, md := range opts.AdditionalMetadataList {
ok, err := utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
if err != nil {
return err
}
if !ok {
continue
}
maps.Copy(labels, md.Labels)
maps.Copy(annotations, md.Annotations)
}
}
if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil {
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels {
labels[k] = v
}
}
labels["kubernetes.io/metadata.name"] = namespace
labels[capsuleLabel] = tnt.GetName()
if tnt.Spec.Cordoned {
ns.Labels[utils.CordonedLabel] = "true"
@@ -79,76 +83,22 @@ func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, t
delete(ns.Labels, utils.CordonedLabel)
}
if tnt.Spec.NodeSelector != nil {
annotations = utils.BuildNodeSelector(tnt, annotations)
}
if tnt.Spec.IngressOptions.AllowedClasses != nil {
if len(tnt.Spec.IngressOptions.AllowedClasses.Exact) > 0 {
annotations[AvailableIngressClassesAnnotation] = strings.Join(tnt.Spec.IngressOptions.AllowedClasses.Exact, ",")
}
if len(tnt.Spec.IngressOptions.AllowedClasses.Regex) > 0 {
annotations[AvailableIngressClassesRegexpAnnotation] = tnt.Spec.IngressOptions.AllowedClasses.Regex
}
}
if tnt.Spec.StorageClasses != nil {
if len(tnt.Spec.StorageClasses.Exact) > 0 {
annotations[AvailableStorageClassesAnnotation] = strings.Join(tnt.Spec.StorageClasses.Exact, ",")
}
if len(tnt.Spec.StorageClasses.Regex) > 0 {
annotations[AvailableStorageClassesRegexpAnnotation] = tnt.Spec.StorageClasses.Regex
}
}
if tnt.Spec.ContainerRegistries != nil {
if len(tnt.Spec.ContainerRegistries.Exact) > 0 {
annotations[AllowedRegistriesAnnotation] = strings.Join(tnt.Spec.ContainerRegistries.Exact, ",")
}
if len(tnt.Spec.ContainerRegistries.Regex) > 0 {
annotations[AllowedRegistriesRegexpAnnotation] = tnt.Spec.ContainerRegistries.Regex
}
}
if value, ok := tnt.Annotations[api.ForbiddenNamespaceLabelsAnnotation]; ok {
annotations[api.ForbiddenNamespaceLabelsAnnotation] = value
}
if value, ok := tnt.Annotations[api.ForbiddenNamespaceLabelsRegexpAnnotation]; ok {
annotations[api.ForbiddenNamespaceLabelsRegexpAnnotation] = value
}
if value, ok := tnt.Annotations[api.ForbiddenNamespaceAnnotationsAnnotation]; ok {
annotations[api.ForbiddenNamespaceAnnotationsAnnotation] = value
}
if value, ok := tnt.Annotations[api.ForbiddenNamespaceAnnotationsRegexpAnnotation]; ok {
annotations[api.ForbiddenNamespaceAnnotationsRegexpAnnotation] = value
}
if ns.Annotations == nil {
ns.SetAnnotations(annotations)
} else {
for k, v := range annotations {
ns.Annotations[k] = v
}
maps.Copy(ns.Annotations, annotations)
}
if ns.Labels == nil {
ns.SetLabels(labels)
} else {
for k, v := range labels {
ns.Labels[k] = v
}
maps.Copy(ns.Labels, labels)
}
return nil
})
return
return conflictErr
})
r.emitEvent(tnt, namespace, res, "Ensuring Namespace metadata", err)
@@ -156,6 +106,71 @@ func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, t
return err
}
func buildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]string {
annotations := make(map[string]string)
if md := tnt.Spec.NamespaceOptions; md != nil && md.AdditionalMetadata != nil {
maps.Copy(annotations, md.AdditionalMetadata.Annotations)
}
if tnt.Spec.NodeSelector != nil {
annotations = utils.BuildNodeSelector(tnt, annotations)
}
if ic := tnt.Spec.IngressOptions.AllowedClasses; ic != nil {
if len(ic.Exact) > 0 {
annotations[AvailableIngressClassesAnnotation] = strings.Join(ic.Exact, ",")
}
if len(ic.Regex) > 0 {
annotations[AvailableIngressClassesRegexpAnnotation] = ic.Regex
}
}
if sc := tnt.Spec.StorageClasses; sc != nil {
if len(sc.Exact) > 0 {
annotations[AvailableStorageClassesAnnotation] = strings.Join(sc.Exact, ",")
}
if len(sc.Regex) > 0 {
annotations[AvailableStorageClassesRegexpAnnotation] = sc.Regex
}
}
if cr := tnt.Spec.ContainerRegistries; cr != nil {
if len(cr.Exact) > 0 {
annotations[AllowedRegistriesAnnotation] = strings.Join(cr.Exact, ",")
}
if len(cr.Regex) > 0 {
annotations[AllowedRegistriesRegexpAnnotation] = cr.Regex
}
}
for _, key := range []string{
api.ForbiddenNamespaceLabelsAnnotation,
api.ForbiddenNamespaceLabelsRegexpAnnotation,
api.ForbiddenNamespaceAnnotationsAnnotation,
api.ForbiddenNamespaceAnnotationsRegexpAnnotation,
} {
if value, ok := tnt.Annotations[key]; ok {
annotations[key] = value
}
}
return annotations
}
func buildNamespaceLabelsForTenant(tnt *capsulev1beta2.Tenant) map[string]string {
labels := make(map[string]string)
if md := tnt.Spec.NamespaceOptions; md != nil && md.AdditionalMetadata != nil {
maps.Copy(labels, md.AdditionalMetadata.Labels)
}
return labels
}
func (r *Manager) ensureNamespaceCount(ctx context.Context, tenant *capsulev1beta2.Tenant) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
tenant.Status.Size = uint(len(tenant.Status.Namespaces))

View File

@@ -38,8 +38,10 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", f
NamespaceOptions: &capsulev1beta2.NamespaceOptions{
AdditionalMetadata: &api.AdditionalMetadataSpec{
Labels: map[string]string{
"k8s.io/custom-label": "foo",
"clastix.io/custom-label": "bar",
"k8s.io/custom-label": "foo",
"clastix.io/custom-label": "bar",
"capsule.clastix.io/tenant": "tenan-override",
"kubernetes.io/metadata.name": "namespace-override",
},
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
@@ -68,6 +70,9 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", f
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels {
if k == "capsule.clastix.io/tenant" || k == "kubernetes.io/metadata.name" {
continue // this label is managed and shouldn't be set by the user
}
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Labels); !ok {
return
}
@@ -75,6 +80,18 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", f
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking managed labels", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
if ok, _ = HaveKeyWithValue("capsule.clastix.io/tenant", tnt.GetName()).Match(ns.Labels); !ok {
return
}
if ok, _ = HaveKeyWithValue("kubernetes.io/metadata.name", ns.GetName()).Match(ns.Labels); !ok {
return
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional annotations", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
@@ -88,3 +105,161 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", f
})
})
})
var _ = Describe("creating a Namespace for a Tenant with additional metadata list", func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-metadata",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "cap",
Kind: "dummy",
Name: "tenant-metadata",
UID: "tenant-metadata",
},
},
},
Spec: capsulev1beta2.TenantSpec{
Owners: capsulev1beta2.OwnerListSpec{
{
Name: "gatsby",
Kind: "User",
},
},
NamespaceOptions: &capsulev1beta2.NamespaceOptions{
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
{
Labels: map[string]string{
"k8s.io/custom-label": "foo",
"clastix.io/custom-label": "bar",
},
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
"clastix.io/custom-annotation": "buzz",
},
},
{
NamespaceSelector: &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "matching_namespace_label",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"matching_namespace_label_value"},
},
},
},
Labels: map[string]string{
"k8s.io/custom-label_2": "foo",
},
Annotations: map[string]string{
"k8s.io/custom-annotation_2": "bizz",
},
},
{
NamespaceSelector: &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "nonmatching_namespace_label",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"nonmatching_namespace_label_value"},
},
},
},
Labels: map[string]string{
"k8s.io/custom-label_3": "foo",
},
Annotations: map[string]string{
"k8s.io/custom-annotation_3": "bizz",
},
},
},
},
},
}
JustBeforeEach(func() {
EventuallyCreation(func() error {
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
It("should contain additional Namespace metadata", func() {
labels := map[string]string{
"matching_namespace_label": "matching_namespace_label_value",
}
ns := NewNamespace("", labels)
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
By("checking additional labels from entry without node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[0].Labels {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Labels); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional labels from entry with matching node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[1].Labels {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Labels); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional labels from entry with non-matching node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[2].Labels {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Labels); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeFalse())
})
By("checking additional annotations from entry without node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[0].Annotations {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Annotations); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional annotations from entry with matching node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[1].Annotations {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Annotations); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional annotations from entry with non-matching node selector", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadataList[2].Annotations {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Annotations); !ok {
return
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeFalse())
})
})
})

View File

@@ -3,9 +3,19 @@
package api
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// +kubebuilder:object:generate=true
type AdditionalMetadataSpec struct {
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
// +kubebuilder:object:generate=true
type AdditionalMetadataSelectorSpec struct {
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}

View File

@@ -10,9 +10,44 @@ package api
import (
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AdditionalMetadataSelectorSpec) DeepCopyInto(out *AdditionalMetadataSelectorSpec) {
*out = *in
if in.NamespaceSelector != nil {
in, out := &in.NamespaceSelector, &out.NamespaceSelector
*out = new(v1.LabelSelector)
(*in).DeepCopyInto(*out)
}
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdditionalMetadataSelectorSpec.
func (in *AdditionalMetadataSelectorSpec) DeepCopy() *AdditionalMetadataSelectorSpec {
if in == nil {
return nil
}
out := new(AdditionalMetadataSelectorSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AdditionalMetadataSpec) DeepCopyInto(out *AdditionalMetadataSpec) {
*out = *in
@@ -47,7 +82,7 @@ func (in *AdditionalRoleBindingsSpec) DeepCopyInto(out *AdditionalRoleBindingsSp
*out = *in
if in.Subjects != nil {
in, out := &in.Subjects, &out.Subjects
*out = make([]v1.Subject, len(*in))
*out = make([]rbacv1.Subject, len(*in))
copy(*out, *in)
}
}

View File

@@ -0,0 +1,20 @@
package utils
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
)
func IsNamespaceSelectedBySelector(ns *corev1.Namespace, selector *metav1.LabelSelector) (bool, error) {
if selector == nil {
return true, nil // If selector is nil, all namespaces match
}
labelSelector, err := metav1.LabelSelectorAsSelector(selector)
if err != nil {
return false, err
}
return labelSelector.Matches(labels.Set(ns.Labels)), nil
}