Compare commits

..

6 Commits

Author SHA1 Message Date
renovate[bot]
1acf63c411 chore(deps): update all-ci-updates (#1795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 10:15:15 +02:00
Oliver Bähler
a42d910ba1 fix(controller): template concurrency (#1802)
Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
2025-12-19 08:14:37 +01:00
renovate[bot]
8eea90731c fix(deps): update k8s.io/utils digest to 61b37f7 (#1801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 01:27:33 +01:00
renovate[bot]
5bcfdd058d chore(deps): update all-ci-updates (#1791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 16:10:32 +02:00
renovate[bot]
cd0675e8a3 chore(deps): update securego/gosec action to v2.22.11 (#1788)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 17:04:07 +01:00
Oliver Bähler
e19575bcbd fix(controller): allow no spaces in template references (#1789)
* 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(controller): allow no spaces in template references

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

* fix(controller): allow no spaces in template references

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

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
2025-12-11 17:03:52 +01:00
13 changed files with 644 additions and 107 deletions

View File

@@ -17,7 +17,7 @@ jobs:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Ensure SHA pinned actions
uses: zgosalvez/github-actions-ensure-sha-pinned-actions@9e9574ef04ea69da568d6249bd69539ccc704e74 # v4.0.0
uses: zgosalvez/github-actions-ensure-sha-pinned-actions@6124774845927d14c601359ab8138699fa5b70c3 # v4.0.1
with:
# slsa-github-generator requires using a semver tag for reusable workflows.
# See: https://github.com/slsa-framework/slsa-github-generator#referencing-slsa-builders-and-generators

View File

@@ -52,7 +52,7 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Run Gosec Security Scanner
uses: securego/gosec@6be2b51fd78feca86af91f5186b7964d76cb1256 # v2.22.10
uses: securego/gosec@424fc4cd9c82ea0fd6bee9cd49c2db2c3cc0c93f # v2.22.11
with:
args: '-no-fail -fmt sarif -out gosec.sarif ./...'
- name: Upload SARIF file

View File

@@ -37,6 +37,6 @@ jobs:
path: results.sarif
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: results.sarif

View File

@@ -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

2
go.mod
View File

@@ -20,7 +20,7 @@ require (
k8s.io/apiserver v0.34.3
k8s.io/client-go v0.34.3
k8s.io/dynamic-resource-allocation v0.34.3
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
k8s.io/utils v0.0.0-20251218160917-61b37f7a4624
sigs.k8s.io/cluster-api v1.11.3
sigs.k8s.io/controller-runtime v0.22.4
sigs.k8s.io/gateway-api v1.4.1

2
go.sum
View File

@@ -340,6 +340,8 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20251218160917-61b37f7a4624 h1:wadElzGW3vTZ1Et18CImPEErLaXvMSU5369b0to32+0=
k8s.io/utils v0.0.0-20251218160917-61b37f7a4624/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/cluster-api v1.11.3 h1:apxfugbP1X8AG7THCM74CTarCOW4H2oOc6hlbm1hY80=

View File

@@ -13,7 +13,7 @@ spec:
chart:
spec:
chart: argo-cd
version: "9.1.7"
version: "9.1.9"
sourceRef:
kind: HelmRepository
name: argocd

View File

@@ -10,7 +10,6 @@ import (
"maps"
"sync"
"github.com/valyala/fasttemplate"
corev1 "k8s.io/api/core/v1"
apierr "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -25,6 +24,7 @@ import (
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
tpl "github.com/projectcapsule/capsule/pkg/template"
)
const (
@@ -243,12 +243,7 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant
for rawIndex, item := range spec.RawItems {
template := string(item.Raw)
t := fasttemplate.New(template, "{{ ", " }}")
tmplString := t.ExecuteString(map[string]any{
"tenant.name": tnt.Name,
"namespace": ns.Name,
})
tmplString := tpl.TemplateForTenantAndNamespace(template, &tnt, &ns)
obj, keysAndValues := unstructured.Unstructured{}, []any{"index", rawIndex}

View File

@@ -4,6 +4,8 @@
package template
import (
"io"
"maps"
"strings"
"github.com/valyala/fasttemplate"
@@ -12,19 +14,39 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
// TemplateForTenantAndNamespace applies templating to all values in the provided map in place.
func TemplateForTenantAndNamespace(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) {
for k, v := range m {
if !strings.Contains(v, "{{ ") && !strings.Contains(v, " }}") {
continue
// TemplateForTenantAndNamespace applies templatingto the provided string.
func TemplateForTenantAndNamespace(template string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) string {
if !strings.Contains(template, "{{") && !strings.Contains(template, "}}") {
return template
}
t := fasttemplate.New(template, "{{", "}}")
values := map[string]string{
"tenant.name": tnt.Name,
"namespace": ns.Name,
}
return t.ExecuteFuncString(func(w io.Writer, tag string) (int, error) {
key := strings.TrimSpace(tag)
if v, ok := values[key]; ok {
return w.Write([]byte(v))
}
t := fasttemplate.New(v, "{{ ", " }}")
tmplString := t.ExecuteString(map[string]any{
"tenant.name": tnt.Name,
"namespace": ns.Name,
})
m[k] = tmplString
}
return 0, nil
})
}
// TemplateForTenantAndNamespace applies templating to all values in the provided map in place.
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
}

View File

@@ -1,29 +1,28 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package template
package template_test
import (
"sync"
"testing"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
tpl "github.com/projectcapsule/capsule/pkg/template"
)
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},
}
}
@@ -31,65 +30,67 @@ func TestTemplateForTenantAndNamespace_ReplacesPlaceholders(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
m := map[string]string{
"key1": "tenant={{ tenant.name }}, ns={{ namespace }}",
"key2": "plain-value",
}
got := tpl.TemplateForTenantAndNamespace(
"tenant={{tenant.name}}, ns={{namespace}}",
tnt,
ns,
)
TemplateForTenantAndNamespace(m, tnt, ns)
if got := m["key1"]; got != "tenant=tenant-a, ns=ns-1" {
t.Fatalf("key1: expected %q, got %q", "tenant=tenant-a, ns=ns-1", got)
}
if got := m["key2"]; got != "plain-value" {
t.Fatalf("key2: expected %q to remain unchanged, got %q", "plain-value", got)
want := "tenant=tenant-a, ns=ns-1"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_SkipsValuesWithoutDelimiters(t *testing.T) {
func TestTemplateForTenantAndNamespace_ReplacesPlaceholdersSpaces(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}}",
}
got := tpl.TemplateForTenantAndNamespace(
"tenant={{ tenant.name }}, ns={{ namespace }}",
tnt,
ns,
)
original1 := m["noTemplate1"]
original2 := m["noTemplate2"]
TemplateForTenantAndNamespace(m, tnt, ns)
if got := m["noTemplate1"]; got != original1 {
t.Fatalf("noTemplate1: expected %q to remain unchanged, got %q", original1, got)
}
if got := m["noTemplate2"]; got != original2 {
t.Fatalf("noTemplate2: expected %q to remain unchanged, got %q", original2, got)
want := "tenant=tenant-a, ns=ns-1"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespace_MixedKeys(t *testing.T) {
func TestTemplateForTenantAndNamespace_OnlyTenant(t *testing.T) {
tnt := newTenant("tenant-x")
ns := newNamespace("ns-x")
ns := newNamespace("ns-y")
m := map[string]string{
"onlyTenant": "T={{ tenant.name }}",
"onlyNS": "N={{ namespace }}",
"none": "static",
}
got := tpl.TemplateForTenantAndNamespace("T={{tenant.name}}", tnt, ns)
want := "T=tenant-x"
TemplateForTenantAndNamespace(m, tnt, ns)
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
if got := m["onlyTenant"]; got != "T=tenant-x" {
t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", got)
func TestTemplateForTenantAndNamespace_OnlyNamespace(t *testing.T) {
tnt := newTenant("tenant-x")
ns := newNamespace("ns-y")
got := tpl.TemplateForTenantAndNamespace("N={{namespace}}", tnt, ns)
want := "N=ns-y"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
if got := m["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)
}
func TestTemplateForTenantAndNamespace_NoDelimiters_ReturnsInput(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
in := "plain-value-without-templates"
got := tpl.TemplateForTenantAndNamespace(in, tnt, ns)
if got != in {
t.Fatalf("expected %q, got %q", in, got)
}
}
@@ -97,14 +98,193 @@ func TestTemplateForTenantAndNamespace_UnknownKeyBecomesEmpty(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
m := map[string]string{
got := tpl.TemplateForTenantAndNamespace("X={{unknown.key}}", tnt, ns)
want := "X="
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholders(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
orig := map[string]string{
"key1": "tenant={{tenant.name}}, ns={{namespace}}",
"key2": "plain-value",
}
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
// 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)
}
// 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)
}
}
func TestTemplateForTenantAndNamespaceMap_ReplacesPlaceholdersSpaces(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
orig := map[string]string{
"key1": "tenant={{ tenant.name }}, ns={{ namespace }}",
"key2": "plain-value",
}
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
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)
}
// 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_TransformsValuesWithDelimiters(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
orig := map[string]string{
"t1": "hello {{tenant.name}}",
"t2": "namespace {{namespace}}",
"t3": "static",
}
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
if got := out["t1"]; got != "hello tenant-a" {
t.Fatalf("t1: expected %q, got %q", "hello tenant-a", 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)
}
}
func TestTemplateForTenantAndNamespaceMap_MixedKeys(t *testing.T) {
tnt := newTenant("tenant-x")
ns := newNamespace("ns-x")
orig := map[string]string{
"onlyTenant": "T={{ tenant.name }}",
"onlyNS": "N={{ namespace }}",
"none": "static",
}
out := tpl.TemplateForTenantAndNamespaceMap(orig, tnt, ns)
if got := out["onlyTenant"]; got != "T=tenant-x" {
t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", got)
}
if got := out["onlyNS"]; got != "N=ns-x" {
t.Fatalf("onlyNS: expected %q, got %q", "N=ns-x", got)
}
if got := out["none"]; got != "static" {
t.Fatalf("none: expected %q, got %q", "static", got)
}
}
func TestTemplateForTenantAndNamespaceMap_UnknownKeyBecomesEmpty(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
orig := map[string]string{
"unknown": "X={{ unknown.key }}",
}
TemplateForTenantAndNamespace(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"])
}
}

View 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) {
// Dont 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)
}
}

View File

@@ -59,11 +59,11 @@ func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.T
continue
}
template.TemplateForTenantAndNamespace(md.Labels, tnt, ns)
template.TemplateForTenantAndNamespace(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)
}
}

View File

@@ -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)
}