mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-02-14 09:59:57 +00:00
fix(controller): template concurrency (#1802)
Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
2
Makefile
2
Makefile
@@ -46,7 +46,7 @@ all: manager
|
||||
# Run tests
|
||||
.PHONY: test
|
||||
test: test-clean generate manifests test-clean
|
||||
@GO111MODULE=on go test -v $(shell go list ./... | grep -v "e2e") -coverprofile coverage.out
|
||||
@GO111MODULE=on go test -race -v $(shell go list ./... | grep -v "e2e") -coverprofile coverage.out
|
||||
|
||||
.PHONY: test-clean
|
||||
test-clean: ## Clean tests cache
|
||||
|
||||
@@ -5,6 +5,7 @@ package template
|
||||
|
||||
import (
|
||||
"io"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasttemplate"
|
||||
@@ -37,8 +38,15 @@ func TemplateForTenantAndNamespace(template string, tnt *capsulev1beta2.Tenant,
|
||||
}
|
||||
|
||||
// TemplateForTenantAndNamespace applies templating to all values in the provided map in place.
|
||||
func TemplateForTenantAndNamespaceMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) {
|
||||
for k, v := range m {
|
||||
m[k] = TemplateForTenantAndNamespace(v, tnt, ns)
|
||||
func TemplateForTenantAndNamespaceMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
out := maps.Clone(m)
|
||||
for k, v := range out {
|
||||
out[k] = TemplateForTenantAndNamespace(v, tnt, ns)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package template_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
@@ -15,17 +16,13 @@ import (
|
||||
|
||||
func newTenant(name string) *capsulev1beta2.Tenant {
|
||||
return &capsulev1beta2.Tenant{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
func newNamespace(name string) *v1.Namespace {
|
||||
return &v1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,13 +82,15 @@ func TestTemplateForTenantAndNamespace_OnlyNamespace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateForTenantAndNamespace_NoDelimitersReturnsEmpty(t *testing.T) {
|
||||
func TestTemplateForTenantAndNamespace_NoDelimiters_ReturnsInput(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
got := tpl.TemplateForTenantAndNamespace("plain-value-without-templates", tnt, ns)
|
||||
if got != "plain-value-without-templates" {
|
||||
t.Fatalf("expected empty string for input without delimiters, got %q", got)
|
||||
in := "plain-value-without-templates"
|
||||
got := tpl.TemplateForTenantAndNamespace(in, tnt, ns)
|
||||
|
||||
if got != in {
|
||||
t.Fatalf("expected %q, got %q", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +110,24 @@ func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholders(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
m := map[string]string{
|
||||
orig := map[string]string{
|
||||
"key1": "tenant={{tenant.name}}, ns={{namespace}}",
|
||||
"key2": "plain-value",
|
||||
}
|
||||
|
||||
tpl.TemplateForTenantAndNamespaceMap(m, tnt, ns)
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
|
||||
|
||||
if got := m["key1"]; got != "tenant=tenant-a, ns=ns-1" {
|
||||
// output is templated
|
||||
if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
|
||||
t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
|
||||
}
|
||||
if got := out["key2"]; got != "plain-value" {
|
||||
t.Fatalf("key2: expected %q, got %q", "plain-value", got)
|
||||
}
|
||||
|
||||
if got := m["key2"]; got != "plain-value" {
|
||||
t.Fatalf("key2: expected %q to remain unchanged, got %q", "plain-value", got)
|
||||
// input map must remain unchanged (new behavior)
|
||||
if got := orig["key1"]; got != "tenant={{tenant.name}}, ns={{namespace}}" {
|
||||
t.Fatalf("input map must not be mutated; key1 got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,41 +135,46 @@ func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholdersSpaces(t *testing.
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
m := map[string]string{
|
||||
orig := map[string]string{
|
||||
"key1": "tenant={{ tenant.name }}, ns={{ namespace }}",
|
||||
"key2": "plain-value",
|
||||
}
|
||||
|
||||
tpl.TemplateForTenantAndNamespaceMap(m, tnt, ns)
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
|
||||
|
||||
if got := m["key1"]; got != "tenant=tenant-a, ns=ns-1" {
|
||||
if got := out["key1"]; got != "tenant=tenant-a, ns=ns-1" {
|
||||
t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
|
||||
}
|
||||
if got := out["key2"]; got != "plain-value" {
|
||||
t.Fatalf("key2: expected %q, got %q", "plain-value", got)
|
||||
}
|
||||
|
||||
if got := m["key2"]; got != "plain-value" {
|
||||
t.Fatalf("key2: expected %q to remain unchanged, got %q", "plain-value", got)
|
||||
// input map must remain unchanged
|
||||
if got := orig["key1"]; got != "tenant={{ tenant.name }}, ns={{ namespace }}" {
|
||||
t.Fatalf("input map must not be mutated; key1 got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateForTenantAndNamespaceMap_SkipsValuesWithoutDelimiters(t *testing.T) {
|
||||
func TestTemplateForTenantAndNamespaceMap_TransformsValuesWithDelimiters(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
// Note: no space after '{{' and before '}}', so the guard should skip it
|
||||
m := map[string]string{
|
||||
"noTemplate1": "hello {{tenant.name}}",
|
||||
"noTemplate2": "namespace {{namespace}}",
|
||||
orig := map[string]string{
|
||||
"t1": "hello {{tenant.name}}",
|
||||
"t2": "namespace {{namespace}}",
|
||||
"t3": "static",
|
||||
}
|
||||
|
||||
original2 := m["noTemplate2"]
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
|
||||
|
||||
tpl.TemplateForTenantAndNamespaceMap(m, tnt, ns)
|
||||
|
||||
if got := m["noTemplate1"]; got != "hello tenant-a" {
|
||||
t.Fatalf("noTemplate1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
|
||||
if got := out["t1"]; got != "hello tenant-a" {
|
||||
t.Fatalf("t1: expected %q, got %q", "hello tenant-a", got)
|
||||
}
|
||||
if got := m["noTemplate2"]; got != "namespace ns-1" {
|
||||
t.Fatalf("noTemplate2: expected %q to remain unchanged, got %q", original2, got)
|
||||
if got := out["t2"]; got != "namespace ns-1" {
|
||||
t.Fatalf("t2: expected %q, got %q", "namespace ns-1", got)
|
||||
}
|
||||
if got := out["t3"]; got != "static" {
|
||||
t.Fatalf("t3: expected %q, got %q", "static", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,22 +182,22 @@ func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) {
|
||||
tnt := newTenant("tenant-x")
|
||||
ns := newNamespace("ns-x")
|
||||
|
||||
m := map[string]string{
|
||||
orig := map[string]string{
|
||||
"onlyTenant": "T={{ tenant.name }}",
|
||||
"onlyNS": "N={{ namespace }}",
|
||||
"none": "static",
|
||||
}
|
||||
|
||||
tpl.TemplateForTenantAndNamespaceMap(m, tnt, ns)
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
|
||||
|
||||
if got := m["onlyTenant"]; got != "T=tenant-x" {
|
||||
if got := out["onlyTenant"]; got != "T=tenant-x" {
|
||||
t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", got)
|
||||
}
|
||||
if got := m["onlyNS"]; got != "N=ns-x" {
|
||||
if got := out["onlyNS"]; got != "N=ns-x" {
|
||||
t.Fatalf("onlyNS: expected %q, got %q", "N=ns-x", got)
|
||||
}
|
||||
if got := m["none"]; got != "static" {
|
||||
t.Fatalf("none: expected %q to remain unchanged, got %q", "static", got)
|
||||
if got := out["none"]; got != "static" {
|
||||
t.Fatalf("none: expected %q, got %q", "static", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +205,86 @@ func TestTemplateForTenantAndNamespaceMap_UnknownKeyBecomesEmpty(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
m := map[string]string{
|
||||
orig := map[string]string{
|
||||
"unknown": "X={{ unknown.key }}",
|
||||
}
|
||||
|
||||
tpl.TemplateForTenantAndNamespaceMap(m, tnt, ns)
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
|
||||
|
||||
// fasttemplate with missing key returns an empty string for that placeholder
|
||||
if got := m["unknown"]; got != "X=" {
|
||||
if got := out["unknown"]; got != "X=" {
|
||||
t.Fatalf("unknown: expected %q, got %q", "X=", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateForTenantAndNamespaceMap_EmptyOrNilInput(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
// nil map
|
||||
outNil := tpl.TemplateForTenantAndNamespaceMap(nil, tnt, ns)
|
||||
if outNil == nil {
|
||||
t.Fatalf("expected non-nil map for nil input")
|
||||
}
|
||||
if len(outNil) != 0 {
|
||||
t.Fatalf("expected empty map for nil input, got %v", outNil)
|
||||
}
|
||||
|
||||
// empty map
|
||||
outEmpty := tpl.TemplateForTenantAndNamespaceMap(map[string]string{}, tnt, ns)
|
||||
if outEmpty == nil || len(outEmpty) != 0 {
|
||||
t.Fatalf("expected empty map, got %v", outEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrency test: should never panic with "concurrent map writes"
|
||||
// Run with: go test -race ./...
|
||||
func TestTemplateForTenantAndNamespaceMap_Concurrency(t *testing.T) {
|
||||
tnt := newTenant("tenant-a")
|
||||
ns := newNamespace("ns-1")
|
||||
|
||||
// Shared input map across goroutines (this used to be unsafe if the function mutated in-place)
|
||||
shared := map[string]string{
|
||||
"k1": "tenant={{tenant.name}}",
|
||||
"k2": "ns={{namespace}}",
|
||||
"k3": "static",
|
||||
}
|
||||
|
||||
const goroutines = 50
|
||||
const iterations = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
out := tpl.TemplateForTenantAndNamespaceMap(shared, tnt, ns)
|
||||
|
||||
// sanity checks
|
||||
if out["k1"] != "tenant=tenant-a" {
|
||||
t.Errorf("unexpected k1: %q", out["k1"])
|
||||
return
|
||||
}
|
||||
if out["k2"] != "ns=ns-1" {
|
||||
t.Errorf("unexpected k2: %q", out["k2"])
|
||||
return
|
||||
}
|
||||
if out["k3"] != "static" {
|
||||
t.Errorf("unexpected k3: %q", out["k3"])
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// verify input map was not mutated
|
||||
if shared["k1"] != "tenant={{tenant.name}}" {
|
||||
t.Fatalf("input map mutated under concurrency: k1=%q", shared["k1"])
|
||||
}
|
||||
if shared["k2"] != "ns={{namespace}}" {
|
||||
t.Fatalf("input map mutated under concurrency: k2=%q", shared["k2"])
|
||||
}
|
||||
}
|
||||
|
||||
337
pkg/utils/tenant/metadata_test.go
Normal file
337
pkg/utils/tenant/metadata_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
||||
"github.com/projectcapsule/capsule/pkg/api"
|
||||
"github.com/projectcapsule/capsule/pkg/api/meta"
|
||||
tenant "github.com/projectcapsule/capsule/pkg/utils/tenant"
|
||||
)
|
||||
|
||||
// Helpers
|
||||
|
||||
func ns(name string, uid types.UID) *corev1.Namespace {
|
||||
n := &corev1.Namespace{}
|
||||
n.SetName(name)
|
||||
n.SetUID(uid)
|
||||
return n
|
||||
}
|
||||
|
||||
func tenantWithName(name string) *capsulev1beta2.Tenant {
|
||||
t := &capsulev1beta2.Tenant{}
|
||||
t.SetName(name)
|
||||
if t.Annotations == nil {
|
||||
t.Annotations = map[string]string{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func mustInstance(t *capsulev1beta2.Tenant, name string, uid types.UID, labels, ann map[string]string) {
|
||||
// Ensure Status + instance storage exists in your impl;
|
||||
// if TenantStatus needs initialization in your project, do it here.
|
||||
|
||||
item := &capsulev1beta2.TenantStatusNamespaceItem{
|
||||
Name: name,
|
||||
UID: uid,
|
||||
Metadata: &capsulev1beta2.TenantStatusNamespaceMetadata{
|
||||
Labels: labels,
|
||||
Annotations: ann,
|
||||
},
|
||||
}
|
||||
|
||||
t.Status.UpdateInstance(item)
|
||||
}
|
||||
|
||||
// --- Tests
|
||||
|
||||
func TestAddNamespaceNameLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
labels := map[string]string{"keep": "me"}
|
||||
n := ns("myns", "u1")
|
||||
|
||||
tenant.AddNamespaceNameLabels(labels, n)
|
||||
|
||||
if got := labels["kubernetes.io/metadata.name"]; got != "myns" {
|
||||
t.Fatalf("expected kubernetes.io/metadata.name to be %q, got %q", "myns", got)
|
||||
}
|
||||
if got := labels["keep"]; got != "me" {
|
||||
t.Fatalf("expected existing key to remain, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTenantNameLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
labels := map[string]string{}
|
||||
n := ns("myns", "u1")
|
||||
tt := tenantWithName("mytenant")
|
||||
|
||||
tenant.AddTenantNameLabel(labels, n, tt)
|
||||
|
||||
if got := labels[meta.TenantLabel]; got != "mytenant" {
|
||||
t.Fatalf("expected %s to be %q, got %q", meta.TenantLabel, "mytenant", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstanceMetadataForNamespace_NoInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
n := ns("myns", "u1")
|
||||
tt := tenantWithName("t1")
|
||||
|
||||
labels, annotations := tenant.BuildInstanceMetadataForNamespace(n, tt)
|
||||
|
||||
if labels == nil || annotations == nil {
|
||||
t.Fatalf("expected non-nil maps")
|
||||
}
|
||||
if len(labels) != 0 || len(annotations) != 0 {
|
||||
t.Fatalf("expected empty maps, got labels=%v annotations=%v", labels, annotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstanceMetadataForNamespace_WithInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
n := ns("myns", "u1")
|
||||
tt := tenantWithName("t1")
|
||||
|
||||
origLabels := map[string]string{"a": "1"}
|
||||
origAnn := map[string]string{"x": "y"}
|
||||
mustInstance(tt, n.GetName(), n.GetUID(), origLabels, origAnn)
|
||||
|
||||
labels, annotations := tenant.BuildInstanceMetadataForNamespace(n, tt)
|
||||
|
||||
// Implementation returns instance.Metadata maps directly (not cloned)
|
||||
if labels["a"] != "1" || annotations["x"] != "y" {
|
||||
t.Fatalf("unexpected returned metadata: labels=%v annotations=%v", labels, annotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNamespaceLabelsForTenant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("additional labels copied + cordoned", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := tenantWithName("t1")
|
||||
tt.Spec.Cordoned = true
|
||||
tt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
|
||||
//nolint:staticcheck
|
||||
AdditionalMetadata: &api.AdditionalMetadataSpec{
|
||||
Labels: map[string]string{
|
||||
"base": "label",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
labels := tenant.BuildNamespaceLabelsForTenant(tt)
|
||||
|
||||
if labels["base"] != "label" {
|
||||
t.Fatalf("expected base label copied, got %v", labels)
|
||||
}
|
||||
if labels[meta.CordonedLabel] != "true" {
|
||||
t.Fatalf("expected cordoned label true, got %v", labels[meta.CordonedLabel])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildNamespaceAnnotationsForTenant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("copies additional annotations and forwards forbidden annotations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := tenantWithName("t1")
|
||||
tt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
|
||||
//nolint:staticcheck
|
||||
AdditionalMetadata: &api.AdditionalMetadataSpec{
|
||||
Annotations: map[string]string{
|
||||
"a": "b",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tt.Annotations[meta.ForbiddenNamespaceLabelsAnnotation] = "l1,l2"
|
||||
tt.Annotations[meta.ForbiddenNamespaceAnnotationsAnnotation] = "a1,a2"
|
||||
|
||||
ann := tenant.BuildNamespaceAnnotationsForTenant(tt)
|
||||
|
||||
if ann["a"] != "b" {
|
||||
t.Fatalf("expected additional annotation copied, got %v", ann)
|
||||
}
|
||||
if ann[meta.ForbiddenNamespaceLabelsAnnotation] != "l1,l2" {
|
||||
t.Fatalf("expected forbidden labels annotation forwarded, got %v", ann[meta.ForbiddenNamespaceLabelsAnnotation])
|
||||
}
|
||||
if ann[meta.ForbiddenNamespaceAnnotationsAnnotation] != "a1,a2" {
|
||||
t.Fatalf("expected forbidden annotations forwarded, got %v", ann[meta.ForbiddenNamespaceAnnotationsAnnotation])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ingress/storage/registry exact join", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := tenantWithName("t1")
|
||||
tt.Spec.IngressOptions.AllowedClasses = &api.DefaultAllowedListSpec{
|
||||
SelectorAllowedListSpec: api.SelectorAllowedListSpec{
|
||||
AllowedListSpec: api.AllowedListSpec{
|
||||
Exact: []string{"nginx", "traefik"},
|
||||
},
|
||||
},
|
||||
}
|
||||
tt.Spec.StorageClasses = &api.DefaultAllowedListSpec{
|
||||
SelectorAllowedListSpec: api.SelectorAllowedListSpec{
|
||||
AllowedListSpec: api.AllowedListSpec{
|
||||
Exact: []string{"fast", "slow"},
|
||||
},
|
||||
},
|
||||
}
|
||||
tt.Spec.ContainerRegistries = &api.AllowedListSpec{
|
||||
Exact: []string{"docker.io", "ghcr.io"},
|
||||
}
|
||||
|
||||
ann := tenant.BuildNamespaceAnnotationsForTenant(tt)
|
||||
|
||||
if ann[meta.AvailableIngressClassesAnnotation] != "nginx,traefik" {
|
||||
t.Fatalf("unexpected ingress exact annotation: %v", ann[meta.AvailableIngressClassesAnnotation])
|
||||
}
|
||||
if ann[meta.AvailableStorageClassesAnnotation] != "fast,slow" {
|
||||
t.Fatalf("unexpected storage exact annotation: %v", ann[meta.AvailableStorageClassesAnnotation])
|
||||
}
|
||||
if ann[meta.AllowedRegistriesAnnotation] != "docker.io,ghcr.io" {
|
||||
t.Fatalf("unexpected registries exact annotation: %v", ann[meta.AllowedRegistriesAnnotation])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildNamespaceMetadataForTenant_AppliesAdditionalMetadataAndDoesNotOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := tenantWithName("tenant-x")
|
||||
|
||||
// Base AdditionalMetadata (staticcheck path)
|
||||
tt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
|
||||
//nolint:staticcheck
|
||||
AdditionalMetadata: &api.AdditionalMetadataSpec{
|
||||
Labels: map[string]string{
|
||||
"base": "keep",
|
||||
"dup": "base-val",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"baseAnn": "keep",
|
||||
"dupAnn": "base-ann",
|
||||
},
|
||||
},
|
||||
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
|
||||
{
|
||||
// Use an “empty selector” that should match everything in your selector logic.
|
||||
// If your IsNamespaceSelectedBySelector behaves differently, set selector accordingly.
|
||||
NamespaceSelector: nil,
|
||||
Labels: map[string]string{
|
||||
"extra": "{{ tenant.name }}",
|
||||
"dup": "should-not-overwrite",
|
||||
"namespaceKey": "{{ namespace }}",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"extraAnn": "{{ tenant.name }}",
|
||||
"dupAnn": "should-not-overwrite",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
n := ns("ns-1", "u1")
|
||||
|
||||
labels, ann, err := tenant.BuildNamespaceMetadataForTenant(n, tt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
|
||||
// templating must apply
|
||||
if labels["extra"] != "tenant-x" {
|
||||
t.Fatalf("expected templated label extra=tenant-x, got %q", labels["extra"])
|
||||
}
|
||||
if labels["namespaceKey"] != "ns-1" {
|
||||
t.Fatalf("expected templated namespaceKey=ns-1, got %q", labels["namespaceKey"])
|
||||
}
|
||||
if ann["extraAnn"] != "tenant-x" {
|
||||
t.Fatalf("expected templated annotation extraAnn=tenant-x, got %q", ann["extraAnn"])
|
||||
}
|
||||
|
||||
// MapMergeNoOverrite means base wins on duplicates
|
||||
if labels["dup"] != "base-val" {
|
||||
t.Fatalf("expected duplicate label to remain base-val, got %q", labels["dup"])
|
||||
}
|
||||
if ann["dupAnn"] != "base-ann" {
|
||||
t.Fatalf("expected duplicate annotation to remain base-ann, got %q", ann["dupAnn"])
|
||||
}
|
||||
|
||||
// base keys remain
|
||||
if labels["base"] != "keep" || ann["baseAnn"] != "keep" {
|
||||
t.Fatalf("expected base metadata to remain, labels=%v ann=%v", labels, ann)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNamespaceMetadataForTenant_Concurrency_NoConcurrentMapWrites(t *testing.T) {
|
||||
// Don’t run this test in parallel with other tests if your package has global shared state.
|
||||
// Keep it isolated; this is meant to be run with: go test -race ./...
|
||||
tt := tenantWithName("tenant-race")
|
||||
n := ns("ns-race", "u-race")
|
||||
|
||||
// Critical: reuse the SAME maps inside AdditionalMetadataList across goroutines.
|
||||
// If TemplateForTenantAndNamespaceMap still mutates in-place, this can panic.
|
||||
sharedLabels := map[string]string{
|
||||
"l1": "{{ tenant.name }}",
|
||||
"l2": "{{ namespace }}",
|
||||
}
|
||||
sharedAnn := map[string]string{
|
||||
"a1": "{{ tenant.name }}",
|
||||
}
|
||||
|
||||
tt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
|
||||
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
|
||||
{
|
||||
NamespaceSelector: nil,
|
||||
Labels: sharedLabels,
|
||||
Annotations: sharedAnn,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const goroutines = 50
|
||||
const iterations = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
errCh := make(chan error, goroutines)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
_, _, err := tenant.BuildNamespaceMetadataForTenant(n, tt)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
for err := range errCh {
|
||||
t.Fatalf("unexpected error under concurrency: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,11 @@ func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.T
|
||||
continue
|
||||
}
|
||||
|
||||
template.TemplateForTenantAndNamespaceMap(md.Labels, tnt, ns)
|
||||
template.TemplateForTenantAndNamespaceMap(md.Annotations, tnt, ns)
|
||||
tLabels := template.TemplateForTenantAndNamespaceMap(md.Labels, tnt, ns)
|
||||
tAnnotations := template.TemplateForTenantAndNamespaceMap(md.Annotations, tnt, ns)
|
||||
|
||||
utils.MapMergeNoOverrite(labels, md.Labels)
|
||||
utils.MapMergeNoOverrite(annotations, md.Annotations)
|
||||
utils.MapMergeNoOverrite(labels, tLabels)
|
||||
utils.MapMergeNoOverrite(annotations, tAnnotations)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package tenant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
||||
tenant "github.com/projectcapsule/capsule/pkg/utils/tenant"
|
||||
)
|
||||
|
||||
func TestIsTenantOwnerReference(t *testing.T) {
|
||||
@@ -23,7 +24,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "valid tenant ownerRef with exact group and version",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: true,
|
||||
@@ -32,7 +33,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "valid tenant ownerRef with same group but different version",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: true, // we intentionally only check the group, not the version
|
||||
@@ -41,7 +42,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "wrong group",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "other.group.io/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -59,7 +60,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "empty APIVersion",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -68,7 +69,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "APIVersion without slash (only version)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -77,7 +78,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "APIVersion with empty group",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -86,7 +87,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "APIVersion with empty version",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -95,7 +96,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
name: "APIVersion with extra slash in version (still ok as long as group matches)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1beta2/extra",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
want: false,
|
||||
@@ -114,7 +115,7 @@ func TestIsTenantOwnerReference(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
tt := tt // capture
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsTenantOwnerReference(tt.or)
|
||||
got := tenant.IsTenantOwnerReference(tt.or)
|
||||
if got != tt.want {
|
||||
t.Fatalf("IsTenantOwnerReference(%+v) = %v, want %v", tt.or, got, tt.want)
|
||||
}
|
||||
@@ -135,7 +136,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "valid tenant ownerRef with exact group and version (same tenant)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -149,7 +150,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "valid tenant ownerRef with exact group and version (different tenant)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -163,7 +164,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "valid tenant ownerRef with same group but different version (same tenant)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -177,7 +178,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "valid tenant ownerRef with same group but different version (different tenant)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -191,7 +192,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "wrong group",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "other.group.io/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -219,7 +220,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "empty APIVersion",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -233,7 +234,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "empty tenant",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: nil,
|
||||
@@ -243,7 +244,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "APIVersion without slash (only version)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -257,7 +258,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "APIVersion with empty group",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "/v1beta2",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -271,7 +272,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "APIVersion with empty version",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: "",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -285,7 +286,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
name: "APIVersion with extra slash in version (still ok as long as group matches)",
|
||||
or: metav1.OwnerReference{
|
||||
APIVersion: capsuleGroup + "/v1beta2/extra",
|
||||
Kind: ObjectReferenceTenantKind,
|
||||
Kind: tenant.ObjectReferenceTenantKind,
|
||||
Name: "my-tenant",
|
||||
},
|
||||
tenant: &capsulev1beta2.Tenant{
|
||||
@@ -314,7 +315,7 @@ func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
tt := tt // capture
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsTenantOwnerReferenceForTenant(tt.or, tt.tenant)
|
||||
got := tenant.IsTenantOwnerReferenceForTenant(tt.or, tt.tenant)
|
||||
if got != tt.want {
|
||||
t.Fatalf("IsTenantOwnerReference(%+v) = %v, want %v", tt.or, got, tt.want)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user