feat: refactor core webhooks (#1756)

* feat(webhook): add watchdog webhook to core

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

* fix(controller): ensure managed metadata for namespaces on update

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

* chore(controller): refactor core webhooks to generics

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

* chore: fix helm plugin installation

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

* chore: rename webhook to tenant-label

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

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2025-11-26 15:27:41 +01:00
committed by GitHub
parent 84b8c3e8e6
commit 6e8405d5f0
33 changed files with 1261 additions and 374 deletions

View File

@@ -54,6 +54,8 @@ release:
footer: |
**Full Changelog**: https://github.com/projectcapsule/{{ .ProjectName }}/compare/{{ .PreviousTag }}...{{ .Tag }}
[Check out what's new in this release](https://projectcapsule.dev/docs/whats-new/)
**Docker Images**
- `ghcr.io/projectcapsule/{{ .ProjectName }}:{{ .Version }}`
- `ghcr.io/projectcapsule/{{ .ProjectName }}:latest`

View File

@@ -337,7 +337,7 @@ $(LOCALBIN):
HELM_SCHEMA_VERSION := ""
helm-plugin-schema:
@$(HELM) plugin install https://github.com/losisin/helm-values-schema-json.git --version $(HELM_SCHEMA_VERSION) || true
@$(HELM) plugin install https://github.com/losisin/helm-values-schema-json.git --version $(HELM_SCHEMA_VERSION) --verify=false || true
HELM_DOCS := $(LOCALBIN)/helm-docs
HELM_DOCS_VERSION := v1.14.1

View File

@@ -253,6 +253,14 @@ The following Values have changed key or Value:
| webhooks.hooks.services.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.services.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
| webhooks.hooks.services.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
| webhooks.hooks.tenantLabel.enabled | bool | `true` | Enable the Hook |
| webhooks.hooks.tenantLabel.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
| webhooks.hooks.tenantLabel.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.tenantLabel.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.tenantLabel.namespaceSelector | object | `{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
| webhooks.hooks.tenantLabel.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
| webhooks.hooks.tenantLabel.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
| webhooks.hooks.tenantLabel.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE"],"resources":["*"],"scope":"Namespaced"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) |
| webhooks.hooks.tenantResourceObjects.enabled | bool | `true` | Enable the Hook |
| webhooks.hooks.tenantResourceObjects.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) |
| webhooks.hooks.tenantResourceObjects.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |

View File

@@ -313,5 +313,33 @@ webhooks:
timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- with .Values.webhooks.hooks.tenantLabel }}
{{- if .enabled }}
- name: assignment.misc.projectcapsule.dev
admissionReviewVersions:
- v1
- v1beta1
clientConfig:
{{- include "capsule.webhooks.service" (dict "path" "/misc/tenant-label" "ctx" $) | nindent 4 }}
failurePolicy: {{ .failurePolicy }}
matchPolicy: {{ .matchPolicy }}
reinvocationPolicy: {{ .reinvocationPolicy }}
{{- with .namespaceSelector }}
namespaceSelector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .objectSelector }}
objectSelector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .matchConditions }}
matchConditions:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- toYaml .rules | nindent 4 }}
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1338,6 +1338,91 @@
}
}
},
"tenantLabel": {
"type": "object",
"properties": {
"enabled": {
"description": "Enable the Hook",
"type": "boolean"
},
"failurePolicy": {
"description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)",
"type": "string"
},
"matchConditions": {
"description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)",
"type": "array"
},
"matchPolicy": {
"description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)",
"type": "string"
},
"namespaceSelector": {
"description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)",
"type": "object",
"properties": {
"matchExpressions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"operator": {
"type": "string"
}
}
}
}
}
},
"objectSelector": {
"description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)",
"type": "object"
},
"reinvocationPolicy": {
"description": "[ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)",
"type": "string"
},
"rules": {
"description": "[Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)",
"type": "array",
"items": {
"type": "object",
"properties": {
"apiGroups": {
"type": "array",
"items": {
"type": "string"
}
},
"apiVersions": {
"type": "array",
"items": {
"type": "string"
}
},
"operations": {
"type": "array",
"items": {
"type": "string"
}
},
"resources": {
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"type": "string"
}
}
}
}
}
},
"tenantResourceObjects": {
"type": "object",
"properties": {

View File

@@ -405,6 +405,36 @@ webhooks:
# Admission Webhook Configuration
hooks:
tenantLabel:
# -- Enable the Hook
enabled: true
# -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)
failurePolicy: Fail
# -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)
matchPolicy: Equivalent
# -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)
objectSelector: {}
# -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)
namespaceSelector:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists
# -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)
matchConditions: []
# -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)
reinvocationPolicy: Never
# -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules)
rules:
- apiGroups:
- '*'
apiVersions:
- '*'
operations:
- CREATE
- UPDATE
resources:
- '*'
scope: Namespaced
resourcepools:
pools:

View File

@@ -46,6 +46,7 @@ import (
"github.com/projectcapsule/capsule/internal/webhook/defaults"
"github.com/projectcapsule/capsule/internal/webhook/gateway"
"github.com/projectcapsule/capsule/internal/webhook/ingress"
"github.com/projectcapsule/capsule/internal/webhook/misc"
"github.com/projectcapsule/capsule/internal/webhook/namespace"
namespacemutation "github.com/projectcapsule/capsule/internal/webhook/namespace/mutation"
namespacevalidation "github.com/projectcapsule/capsule/internal/webhook/namespace/validation"
@@ -236,15 +237,35 @@ func main() {
// webhooks: the order matters, don't change it and just append
webhooksList := append(
make([]webhook.Webhook, 0),
route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(cfg), pod.PriorityClass(), pod.RuntimeClass()),
route.Pod(
pod.Handler(
pod.ImagePullPolicy(),
pod.ContainerRegistry(cfg),
pod.PriorityClass(),
pod.RuntimeClass(),
),
),
route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()),
route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()),
route.Service(service.Handler()),
route.PVC(
pvc.Handler(
pvc.Validating(),
pvc.PersistentVolumeReuse(),
),
),
route.Service(
service.Handler(
service.Validating(),
),
),
route.TenantResourceObjects(utils.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())),
route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())),
route.Cordoning(tenantvalidation.CordoningHandler(cfg)),
route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
route.ServiceAccounts(serviceaccounts.Handler(cfg)),
route.ServiceAccounts(
serviceaccounts.Handler(
serviceaccounts.Validating(cfg),
),
),
route.CustomResources(tenantvalidation.ResourceCounterHandler(manager.GetClient())),
route.Gateway(gateway.Class(cfg)),
route.Defaults(defaults.Handler(cfg, kubeVersion)),
@@ -262,6 +283,7 @@ func main() {
tenantvalidation.ServiceAccountNameHandler(),
tenantvalidation.ForbiddenAnnotationsRegexHandler(),
tenantvalidation.ProtectedHandler(),
tenantvalidation.WarningHandler(),
),
route.NamespaceValidation(
namespace.NamespaceHandler(
@@ -277,14 +299,17 @@ func main() {
namespace.NamespaceHandler(
cfg,
namespacemutation.OwnerReferenceHandler(cfg),
namespacemutation.CordoningLabelHandler(cfg),
namespacemutation.MetadataHandler(cfg),
namespacemutation.CordoningLabelHandler(cfg),
),
),
route.ResourcePoolMutation((resourcepool.PoolMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool")))),
route.ResourcePoolValidation((resourcepool.PoolValidationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool")))),
route.ResourcePoolClaimMutation((resourcepool.ClaimMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepoolclaims")))),
route.ResourcePoolClaimValidation((resourcepool.ClaimValidationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepoolclaims")))),
route.TenantAssignment(
misc.TenantAssignmentHandler(),
),
)
nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion)

View File

@@ -7,9 +7,7 @@ import (
"context"
"fmt"
"maps"
"strings"
"github.com/valyala/fasttemplate"
"golang.org/x/sync/errgroup"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -21,7 +19,7 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
// Ensuring all annotations are applied to each Namespace handled by the Tenant.
@@ -140,8 +138,6 @@ func (r *Manager) reconcileMetadata(
managed *capsulev1beta2.TenantStatusNamespaceMetadata,
err error,
) {
capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
originLabels := ns.GetLabels()
if originLabels == nil {
originLabels = make(map[string]string)
@@ -152,28 +148,9 @@ func (r *Manager) reconcileMetadata(
originAnnotations = make(map[string]string)
}
managedAnnotations := buildNamespaceAnnotationsForTenant(tnt)
managedLabels := buildNamespaceLabelsForTenant(tnt)
if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
for _, md := range opts.AdditionalMetadataList {
var ok bool
ok, err = utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
if err != nil {
return managed, err
}
if !ok {
continue
}
applyTemplateMap(md.Labels, tnt, ns)
applyTemplateMap(md.Annotations, tnt, ns)
utils.MapMergeNoOverrite(managedLabels, md.Labels)
utils.MapMergeNoOverrite(managedAnnotations, md.Annotations)
}
managedLabels, managedAnnotations, err := tenant.BuildNamespaceMetadataForTenant(ns, tnt)
if err != nil {
return nil, err
}
managedMetadataOnly := tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.ManagedMetadataOnly
@@ -217,8 +194,8 @@ func (r *Manager) reconcileMetadata(
originAnnotations = managedAnnotations
}
originLabels["kubernetes.io/metadata.name"] = ns.GetName()
originLabels[capsuleLabel] = tnt.GetName()
tenant.AddNamespaceNameLabels(originLabels, ns)
tenant.AddTenantNameLabel(originLabels, ns, tnt)
ns.SetLabels(originLabels)
ns.SetAnnotations(originAnnotations)
@@ -226,75 +203,6 @@ func (r *Manager) reconcileMetadata(
return managed, 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[meta.AvailableIngressClassesAnnotation] = strings.Join(ic.Exact, ",")
}
if len(ic.Regex) > 0 {
annotations[meta.AvailableIngressClassesRegexpAnnotation] = ic.Regex
}
}
if sc := tnt.Spec.StorageClasses; sc != nil {
if len(sc.Exact) > 0 {
annotations[meta.AvailableStorageClassesAnnotation] = strings.Join(sc.Exact, ",")
}
if len(sc.Regex) > 0 {
annotations[meta.AvailableStorageClassesRegexpAnnotation] = sc.Regex
}
}
if cr := tnt.Spec.ContainerRegistries; cr != nil {
if len(cr.Exact) > 0 {
annotations[meta.AllowedRegistriesAnnotation] = strings.Join(cr.Exact, ",")
}
if len(cr.Regex) > 0 {
annotations[meta.AllowedRegistriesRegexpAnnotation] = cr.Regex
}
}
for _, key := range []string{
meta.ForbiddenNamespaceLabelsAnnotation,
meta.ForbiddenNamespaceLabelsRegexpAnnotation,
meta.ForbiddenNamespaceAnnotationsAnnotation,
meta.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)
}
if tnt.Spec.Cordoned {
labels[meta.CordonedLabel] = "true"
}
return labels
}
func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) {
list := &corev1.NamespaceList{}
@@ -309,20 +217,3 @@ func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.
return err
}
// applyTemplateMap applies templating to all values in the provided map in place.
func applyTemplateMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) {
for k, v := range m {
if !strings.Contains(v, "{{ ") && !strings.Contains(v, " }}") {
continue
}
t := fasttemplate.New(v, "{{ ", " }}")
tmplString := t.ExecuteString(map[string]interface{}{
"tenant.name": tnt.Name,
"namespace": ns.Name,
})
m[k] = tmplString
}
}

View File

@@ -9,6 +9,8 @@ import (
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type Func func(ctx context.Context, req admission.Request) *admission.Response
@@ -19,8 +21,20 @@ type Handler interface {
OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
}
type HanderWithTenant interface {
OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
}
type TypedHandler[T client.Object] interface {
OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder) Func
OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder record.EventRecorder) Func
OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder) Func
}
type TypedHandlerWithTenant[T client.Object] interface {
OnCreate(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnUpdate(c client.Client, obj T, old T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
OnDelete(c client.Client, obj T, decoder admission.Decoder, recorder record.EventRecorder, tnt *capsulev1beta2.Tenant) Func
}

View File

@@ -0,0 +1,84 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package misc
import (
"context"
"encoding/json"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type tenantAssignmentHandler struct{}
func TenantAssignmentHandler() capsulewebhook.Handler {
return &tenantAssignmentHandler{}
}
func (r *tenantAssignmentHandler) OnCreate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handle(ctx, c, decoder, req)
}
}
func (r *tenantAssignmentHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *tenantAssignmentHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handle(ctx, c, decoder, req)
}
}
func (r *tenantAssignmentHandler) handle(ctx context.Context, c client.Client, decoder admission.Decoder, req admission.Request) *admission.Response {
if req.Namespace == "" {
return nil
}
obj := &unstructured.Unstructured{}
if err := decoder.Decode(req, obj); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
labels := obj.GetLabels()
if labels == nil {
labels = map[string]string{}
}
if currentValue, exists := labels[meta.ManagedByCapsuleLabel]; exists && currentValue == tnt.GetName() {
return nil
}
labels[meta.ManagedByCapsuleLabel] = tnt.GetName()
obj.SetLabels(labels)
marshaledObj, err := json.Marshal(obj)
if err != nil {
return utils.ErroredResponse(err)
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaledObj)
return &response
}

View File

@@ -13,10 +13,10 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type metadataHandler struct {
@@ -31,39 +31,32 @@ func MetadataHandler(cfg configuration.Configuration) capsulewebhook.TypedHandle
func (h *metadataHandler) OnCreate(client client.Client, ns *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tenant, errResponse := utils.GetNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
tnt, errResponse := utils.GetNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
if errResponse != nil {
return errResponse
}
if tenant == nil {
if tnt == nil {
response := admission.Denied("Unable to assign namespace to tenant.")
return &response
}
// sync namespace metadata
instance := tenant.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
if len(instance.Metadata.Labels) == 0 && len(instance.Metadata.Annotations) == 0 {
return nil
labels, annotations, err := tenant.BuildNamespaceMetadataForTenant(ns, tnt)
if err != nil {
return utils.ErroredResponse(err)
}
labels := ns.GetLabels()
for k, v := range instance.Metadata.Labels {
labels[k] = v
managedMetadataOnly := tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.ManagedMetadataOnly
if managedMetadataOnly {
labels = mergeStringMap(ns.GetLabels(), labels)
annotations = mergeStringMap(ns.GetAnnotations(), annotations)
}
tenant.AddNamespaceNameLabels(labels, ns)
tenant.AddTenantNameLabel(labels, ns, tnt)
ns.SetLabels(labels)
annotations := ns.GetAnnotations()
for k, v := range instance.Metadata.Annotations {
annotations[k] = v
}
ns.SetAnnotations(annotations)
marshaled, err := json.Marshal(ns)
@@ -85,8 +78,68 @@ func (h *metadataHandler) OnDelete(client.Client, *corev1.Namespace, admission.D
}
}
func (h *metadataHandler) OnUpdate(client.Client, *corev1.Namespace, *corev1.Namespace, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
func (h *metadataHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, errResponse := utils.GetNamespaceTenant(ctx, c, oldNs, req, h.cfg, recorder)
if errResponse != nil {
return errResponse
}
if tnt == nil {
response := admission.Denied("Unable to assign namespace to tenant.")
return &response
}
o, err := json.Marshal(newNs.DeepCopy())
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
labels, annotations, err := tenant.BuildNamespaceMetadataForTenant(newNs, tnt)
if err != nil {
return utils.ErroredResponse(err)
}
managedMetadataOnly := tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.ManagedMetadataOnly
if !managedMetadataOnly {
labels = mergeStringMap(newNs.GetLabels(), labels)
annotations = mergeStringMap(newNs.GetAnnotations(), annotations)
}
tenant.AddNamespaceNameLabels(labels, oldNs)
tenant.AddTenantNameLabel(labels, oldNs, tnt)
newNs.SetLabels(labels)
newNs.SetAnnotations(annotations)
obj, err := json.Marshal(newNs)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(o, obj)
return &response
}
}
func mergeStringMap(dst, src map[string]string) map[string]string {
if len(src) == 0 {
return dst
}
if dst == nil {
dst = make(map[string]string, len(src))
}
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
@@ -46,11 +47,10 @@ func (h *ownerReferenceHandler) OnCreate(c client.Client, ns *corev1.Namespace,
return &response
}
if err := tenant.AddTenantLabelForNamespace(ns, tnt); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
labels := ns.GetLabels()
tenant.AddNamespaceNameLabels(labels, ns)
tenant.AddTenantNameLabel(labels, ns, tnt)
ns.SetLabels(labels)
response := patchResponseForOwnerRef(c, tnt.DeepCopy(), ns, recorder)
@@ -66,7 +66,7 @@ func (h *ownerReferenceHandler) OnDelete(client.Client, *corev1.Namespace, admis
func (h *ownerReferenceHandler) OnUpdate(c client.Client, newNs *corev1.Namespace, oldNs *corev1.Namespace, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, oldNs.OwnerReferences)
tnt, err := resolveTenantForNamespaceUpdate(ctx, c, h.cfg, oldNs, newNs, req.UserInfo)
if err != nil {
return utils.ErroredResponse(err)
}
@@ -75,11 +75,8 @@ func (h *ownerReferenceHandler) OnUpdate(c client.Client, newNs *corev1.Namespac
return nil
}
o, err := json.Marshal(newNs.DeepCopy())
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
if err := assignToTenant(c, tnt, oldNs, recorder); err != nil {
return utils.ErroredResponse(err)
}
var refs []metav1.OwnerReference
@@ -98,28 +95,78 @@ func (h *ownerReferenceHandler) OnUpdate(c client.Client, newNs *corev1.Namespac
newNs.OwnerReferences = refs
if err := tenant.AddTenantLabelForNamespace(newNs, tnt); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
labels := newNs.GetLabels()
tenant.AddNamespaceNameLabels(labels, oldNs)
tenant.AddTenantNameLabel(labels, oldNs, tnt)
newNs.SetLabels(labels)
return &response
}
obj, err := json.Marshal(newNs)
marshaled, err := json.Marshal(newNs)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(o, obj)
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
}
func resolveTenantForNamespaceUpdate(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
oldNs, newNs *corev1.Namespace,
userInfo authenticationv1.UserInfo,
) (*capsulev1beta2.Tenant, error) {
// 1) try old ownerRefs
if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, oldNs.OwnerReferences); err != nil {
return nil, err
} else if tnt != nil {
return tnt, nil
}
// 2) try new ownerRefs
if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, newNs.OwnerReferences); err != nil {
return nil, err
} else if tnt != nil {
return tnt, nil
}
// 3) fall back to labels + user
return tenant.GetTenantByLabelsAndUser(ctx, c, cfg, newNs, userInfo)
}
func assignToTenant(
c client.Client,
tnt *capsulev1beta2.Tenant,
ns *corev1.Namespace,
recorder record.EventRecorder,
) error {
has, err := controllerutil.HasOwnerReference(ns.OwnerReferences, tnt, c.Scheme())
if err != nil {
return err
}
if has {
return nil
}
if err := controllerutil.SetOwnerReference(tnt, ns, c.Scheme()); err != nil {
recorder.Eventf(tnt, corev1.EventTypeWarning, "Error", "Namespace %s cannot be assigned to the desired Tenant", ns.GetName())
return err
}
recorder.Eventf(tnt, corev1.EventTypeNormal, "NamespaceCreationWebhook", "Namespace %s has been assigned to the desired Tenant", ns.GetName())
return nil
}
func patchResponseForOwnerRef(
c client.Client,
tenant *capsulev1beta2.Tenant,
tnt *capsulev1beta2.Tenant,
ns *corev1.Namespace,
recorder record.EventRecorder,
) admission.Response {
@@ -128,14 +175,10 @@ func patchResponseForOwnerRef(
return admission.Errored(http.StatusInternalServerError, err)
}
if err = controllerutil.SetOwnerReference(tenant, ns, c.Scheme()); err != nil {
recorder.Eventf(tenant, corev1.EventTypeWarning, "Error", "Namespace %s cannot be assigned to the desired Tenant", ns.GetName())
if err := assignToTenant(c, tnt, ns, recorder); err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
recorder.Eventf(tenant, corev1.EventTypeNormal, "NamespaceCreationWebhook", "Namespace %s has been assigned to the desired Tenant", ns.GetName())
obj, err := json.Marshal(ns)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)

View File

@@ -13,61 +13,62 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type containerRegistryHandler struct {
configuration configuration.Configuration
}
func ContainerRegistry(configuration configuration.Configuration) capsulewebhook.Handler {
func ContainerRegistry(configuration configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
return &containerRegistryHandler{
configuration: configuration,
}
}
func (h *containerRegistryHandler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *containerRegistryHandler) OnCreate(
c client.Client,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
return h.validate(req, pod, tnt, recorder)
}
}
func (h *containerRegistryHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *containerRegistryHandler) OnUpdate(
c client.Client,
old *corev1.Pod,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(req, pod, tnt, recorder)
}
}
func (h *containerRegistryHandler) OnDelete(
client.Client,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
// ust be validate on update events since updates to pods on spec.containers[*].image and spec.initContainers[*].image are allowed.
func (h *containerRegistryHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
}
}
func (h *containerRegistryHandler) validate(
ctx context.Context,
c client.Client,
decoder admission.Decoder,
recorder record.EventRecorder,
req admission.Request,
pod *corev1.Pod,
tnt *capsulev1beta2.Tenant,
recorder record.EventRecorder,
) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, pod.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
if tnt.Spec.ContainerRegistries == nil {
return nil
}

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
)
func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.Pod]) webhook.Handler {
return &utils.TypedTenantHandler[*corev1.Pod]{
Factory: func() *corev1.Pod {
return &corev1.Pod{}
},
Handlers: handlers,
}
}

View File

@@ -13,55 +13,57 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type imagePullPolicy struct{}
func ImagePullPolicy() capsulewebhook.Handler {
func ImagePullPolicy() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
return &imagePullPolicy{}
}
func (r *imagePullPolicy) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *imagePullPolicy) OnCreate(
c client.Client,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, decoder, recorder, req)
return h.validate(req, pod, tnt, recorder)
}
}
func (r *imagePullPolicy) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *imagePullPolicy) OnUpdate(
c client.Client,
old *corev1.Pod,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, decoder, recorder, req)
return h.validate(req, pod, tnt, recorder)
}
}
func (r *imagePullPolicy) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *imagePullPolicy) OnDelete(
client.Client,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *imagePullPolicy) validate(
ctx context.Context,
c client.Client,
decoder admission.Decoder,
recorder record.EventRecorder,
req admission.Request,
pod *corev1.Pod,
tnt *capsulev1beta2.Tenant,
recorder record.EventRecorder,
) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, pod.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
policy := NewPullPolicy(tnt)
if policy == nil {
return nil

View File

@@ -12,33 +12,25 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type priorityClass struct{}
func PriorityClass() capsulewebhook.Handler {
func PriorityClass() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
return &priorityClass{}
}
func (h *priorityClass) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *priorityClass) OnCreate(
c client.Client,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, pod.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.PriorityClasses
if allowed == nil {
@@ -85,13 +77,26 @@ func (h *priorityClass) OnCreate(c client.Client, decoder admission.Decoder, rec
}
}
func (h *priorityClass) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *priorityClass) OnUpdate(
client.Client,
*corev1.Pod,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *priorityClass) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *priorityClass) OnDelete(
client.Client,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}

View File

@@ -14,30 +14,48 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type runtimeClass struct{}
func RuntimeClass() capsulewebhook.Handler {
func RuntimeClass() capsulewebhook.TypedHandlerWithTenant[*corev1.Pod] {
return &runtimeClass{}
}
func (h *runtimeClass) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *runtimeClass) OnCreate(
c client.Client,
pod *corev1.Pod,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
return h.validate(ctx, c, recorder, req, pod, tnt)
}
}
func (h *runtimeClass) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *runtimeClass) OnUpdate(
client.Client,
*corev1.Pod,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *runtimeClass) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *runtimeClass) OnDelete(
client.Client,
*corev1.Pod,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
@@ -56,21 +74,14 @@ func (h *runtimeClass) class(ctx context.Context, c client.Client, name string)
return obj, nil
}
func (h *runtimeClass) validate(ctx context.Context, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, req admission.Request) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, pod.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
func (h *runtimeClass) validate(
ctx context.Context,
c client.Client,
recorder record.EventRecorder,
req admission.Request,
pod *corev1.Pod,
tnt *capsulev1beta2.Tenant,
) *admission.Response {
allowed := tnt.Spec.RuntimeClasses
runtimeClassName := ""

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pvc
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
)
func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim]) webhook.Handler {
return &utils.TypedTenantHandler[*corev1.PersistentVolumeClaim]{
Factory: func() *corev1.PersistentVolumeClaim {
return &corev1.PersistentVolumeClaim{}
},
Handlers: handlers,
}
}

View File

@@ -17,51 +17,37 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
type PV struct {
capsuleLabel string
type pv struct{}
func PersistentVolumeReuse() capsulewebhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
return &pv{}
}
func PersistentVolumeReuse() capsulewebhook.Handler {
value, err := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{})
if err != nil {
panic(fmt.Sprintf("this shouldn't happen: %s", err.Error()))
}
return &PV{
capsuleLabel: value,
}
}
func (p PV) OnCreate(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
func (h pv) OnCreate(
c client.Client,
pvc *corev1.PersistentVolumeClaim,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pvc := corev1.PersistentVolumeClaim{}
if err := decoder.Decode(req, &pvc); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, client, pvc.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
// PVC is not in a Tenant Namespace, skipping
if tnt == nil {
return nil
}
// A PersistentVolume selector cannot help in preventing a cross-tenant mount:
// thus, disallowing that in first place.
if pvc.Spec.Selector != nil {
return utils.ErroredResponse(NewPVSelectorError())
}
// The PVC hasn't any volumeName pre-claimed, it can be skipped
if len(pvc.Spec.VolumeName) == 0 {
return nil
}
// Checking if the PV is labelled with the Tenant name
pv := corev1.PersistentVolume{}
if err = client.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil {
if err := c.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil {
if errors.IsNotFound(err) {
err = fmt.Errorf("cannot create a PVC referring to a not yet existing PV")
}
@@ -73,7 +59,7 @@ func (p PV) OnCreate(client client.Client, decoder admission.Decoder, _ record.E
return utils.ErroredResponse(NewMissingPVLabelsError(pv.GetName()))
}
value, ok := pv.GetLabels()[p.capsuleLabel]
value, ok := pv.GetLabels()[meta.TenantLabel]
if !ok {
return utils.ErroredResponse(NewMissingTenantPVLabelsError(pv.GetName()))
}
@@ -86,13 +72,26 @@ func (p PV) OnCreate(client client.Client, decoder admission.Decoder, _ record.E
}
}
func (p PV) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h pv) OnUpdate(
client.Client,
*corev1.PersistentVolumeClaim,
*corev1.PersistentVolumeClaim,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (p PV) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h pv) OnDelete(
client.Client,
*corev1.PersistentVolumeClaim,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}

View File

@@ -13,33 +13,25 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type validating struct{}
func Validating() capsulewebhook.Handler {
func Validating() capsulewebhook.TypedHandlerWithTenant[*corev1.PersistentVolumeClaim] {
return &validating{}
}
func (h *validating) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnCreate(
c client.Client,
pvc *corev1.PersistentVolumeClaim,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pvc := &corev1.PersistentVolumeClaim{}
if err := decoder.Decode(req, pvc); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, c, pvc.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.StorageClasses
if allowed == nil {
@@ -88,13 +80,26 @@ func (h *validating) OnCreate(c client.Client, decoder admission.Decoder, record
}
}
func (h *validating) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnUpdate(
client.Client,
*corev1.PersistentVolumeClaim,
*corev1.PersistentVolumeClaim,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *validating) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnDelete(
client.Client,
*corev1.PersistentVolumeClaim,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}

View File

@@ -0,0 +1,24 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package route
import (
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
)
type miscTenantAssignment struct {
handlers []capsulewebhook.Handler
}
func TenantAssignment(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
return &miscTenantAssignment{handlers: handlers}
}
func (w miscTenantAssignment) GetPath() string {
return "/misc/tenant-label"
}
func (w miscTenantAssignment) GetHandlers() []capsulewebhook.Handler {
return w.handlers
}

View File

@@ -1,6 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package route
import (

View File

@@ -1,6 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package route
import (

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package service
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
)
func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.Service]) webhook.Handler {
return &utils.TypedTenantHandler[*corev1.Service]{
Factory: func() *corev1.Service {
return &corev1.Service{}
},
Handlers: handlers,
}
}

View File

@@ -14,51 +14,60 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type handler struct{}
type validating struct{}
func Handler() capsulewebhook.Handler {
return &handler{}
func Validating() capsulewebhook.TypedHandlerWithTenant[*corev1.Service] {
return &validating{}
}
func (r *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnCreate(
c client.Client,
svc *corev1.Service,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handleService(ctx, client, decoder, req, recorder)
return h.handle(req, recorder, svc, tnt)
}
}
func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnUpdate(
c client.Client,
old *corev1.Service,
svc *corev1.Service,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handleService(ctx, client, decoder, req, recorder)
return h.handle(req, recorder, svc, tnt)
}
}
func (r *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnDelete(
client.Client,
*corev1.Service,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *handler) handleService(ctx context.Context, clt client.Client, decoder admission.Decoder, req admission.Request, recorder record.EventRecorder) *admission.Response {
svc := &corev1.Service{}
if err := decoder.Decode(req, svc); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, clt, svc.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
func (h *validating) handle(
req admission.Request,
recorder record.EventRecorder,
svc *corev1.Service,
tnt *capsulev1beta2.Tenant,
) *admission.Response {
if svc.Spec.Type == corev1.ServiceTypeNodePort && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.NodePort {
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodePort", "Service %s/%s cannot be type of NodePort for the current Tenant", req.Namespace, req.Name)

View File

@@ -0,0 +1,20 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package serviceaccounts
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
)
func Handler(handlers ...webhook.TypedHandlerWithTenant[*corev1.ServiceAccount]) webhook.Handler {
return &utils.TypedTenantHandler[*corev1.ServiceAccount]{
Factory: func() *corev1.ServiceAccount {
return &corev1.ServiceAccount{}
},
Handlers: handlers,
}
}

View File

@@ -11,61 +11,72 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
"github.com/projectcapsule/capsule/pkg/utils/users"
)
type handler struct {
type validating struct {
cfg configuration.Configuration
}
func Handler(cfg configuration.Configuration) capsulewebhook.Handler {
return &handler{cfg: cfg}
func Validating(cfg configuration.Configuration) capsulewebhook.TypedHandlerWithTenant[*corev1.ServiceAccount] {
return &validating{cfg: cfg}
}
func (r *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnCreate(
c client.Client,
sa *corev1.ServiceAccount,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handle(ctx, client, decoder, req, recorder)
return h.handle(ctx, c, req, sa, tnt)
}
}
func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnUpdate(
c client.Client,
old *corev1.ServiceAccount,
sa *corev1.ServiceAccount,
decoder admission.Decoder,
recorder record.EventRecorder,
tnt *capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.handle(ctx, client, decoder, req, recorder)
return h.handle(ctx, c, req, sa, tnt)
}
}
func (r *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
func (h *validating) OnDelete(
client.Client,
*corev1.ServiceAccount,
admission.Decoder,
record.EventRecorder,
*capsulev1beta2.Tenant,
) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *handler) handle(ctx context.Context, clt client.Client, decoder admission.Decoder, req admission.Request, recorder record.EventRecorder) *admission.Response {
sa := &corev1.ServiceAccount{}
if err := decoder.Decode(req, sa); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := tenant.TenantByStatusNamespace(ctx, clt, sa.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
func (h *validating) handle(
ctx context.Context,
c client.Client,
req admission.Request,
sa *corev1.ServiceAccount,
tnt *capsulev1beta2.Tenant,
) *admission.Response {
_, hasOwnerPromotion := sa.Labels[meta.OwnerPromotionLabel]
if !hasOwnerPromotion {
return nil
}
if !r.cfg.AllowServiceAccountPromotion() {
if !h.cfg.AllowServiceAccountPromotion() {
response := admission.Denied(
"service account owner promotion is disabled. Contact your system administrators",
)
@@ -74,7 +85,7 @@ func (r *handler) handle(ctx context.Context, clt client.Client, decoder admissi
}
// We don't want to allow promoted serviceaccounts to promote other serviceaccounts
allowed, err := users.IsTenantOwner(ctx, clt, r.cfg, tnt, req.UserInfo)
allowed, err := users.IsTenantOwner(ctx, c, h.cfg, tnt, req.UserInfo)
if err != nil {
return utils.ErroredResponse(err)
}

View File

@@ -0,0 +1,85 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
admissionv1 "k8s.io/api/admission/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/internal/webhook/utils"
)
type warningHandler struct{}
func WarningHandler() capsulewebhook.Handler {
return &warningHandler{}
}
func (h *warningHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.handle(decoder, req)
}
}
func (h *warningHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *warningHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.handle(decoder, req)
}
}
func (h *warningHandler) handle(decoder admission.Decoder, req admission.Request) *admission.Response {
tenant := &capsulev1beta2.Tenant{}
if err := decoder.Decode(req, tenant); err != nil {
return utils.ErroredResponse(err)
}
response := &admission.Response{
AdmissionResponse: admissionv1.AdmissionResponse{
UID: req.UID,
Allowed: true,
},
}
if len(tenant.Spec.LimitRanges.Items) > 0 {
response.Warnings = append(response.Warnings, "Limitranges are deprecated and will be removed int the future. You need to consider to migrate to TenantReplications: https://projectcapsule.dev/docs/tenants/enforcement/#limitrange-distribution-with-tenantreplications.")
}
if len(tenant.Spec.NetworkPolicies.Items) > 0 {
response.Warnings = append(response.Warnings, "NetworkPolicies are deprecated and will be removed int the future. You need to consider to migrate to TenantReplications: https://projectcapsule.dev/docs/tenants/enforcement/#networkpolicy-distribution-with-tenantreplications.")
}
if tenant.Spec.NamespaceOptions != nil && tenant.Spec.NamespaceOptions.AdditionalMetadata != nil {
response.Warnings = append(response.Warnings, "additionalMetadata is deprecated and will be removed int the future. You need to consider to migrate to AdditionalMetadataList: https://projectcapsule.dev/docs/tenants/enforcement/#additionalmetadatalist.")
}
if tenant.Spec.StorageClasses != nil && tenant.Spec.StorageClasses.Regex != "" {
response.Warnings = append(response.Warnings, "Using the regex property to select StorageClasses is deprecated and will be removed int the future.")
}
if tenant.Spec.GatewayOptions.AllowedClasses != nil && tenant.Spec.GatewayOptions.AllowedClasses.Regex != "" {
response.Warnings = append(response.Warnings, "Using the regex property to select GatewayClasses is deprecated and will be removed int the future.")
}
if tenant.Spec.PriorityClasses != nil && tenant.Spec.PriorityClasses.Regex != "" {
response.Warnings = append(response.Warnings, "Using the regex property to select PriorityClasses is deprecated and will be removed int the future.")
}
if tenant.Spec.RuntimeClasses != nil && tenant.Spec.RuntimeClasses.Regex != "" {
response.Warnings = append(response.Warnings, "Using the regex property to select RuntimeClasses is deprecated and will be removed int the future.")
}
return response
}

View File

@@ -0,0 +1,115 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package utils
import (
"context"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/internal/webhook"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type newObjectFunc[T client.Object] func() T
type TypedTenantHandler[T client.Object] struct {
Factory newObjectFunc[T]
Handlers []webhook.TypedHandlerWithTenant[T]
}
func (h *TypedTenantHandler[T]) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnCreate(c, obj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
newObj := h.Factory()
if err := decoder.Decode(req, newObj); err != nil {
return ErroredResponse(err)
}
oldObj := h.Factory()
if err := decoder.DecodeRaw(req.OldObject, oldObj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnUpdate(c, oldObj, newObj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) OnDelete(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) webhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, c, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnDelete(c, obj, decoder, recorder, tnt)(ctx, req); response != nil {
return response
}
}
return nil
}
}
func (h *TypedTenantHandler[T]) resolveTenant(ctx context.Context, c client.Client, req admission.Request) (*capsulev1beta2.Tenant, error) {
if req.Namespace == "" {
return nil, nil
}
return tenant.TenantByStatusNamespace(ctx, c, req.Namespace)
}

16
pkg/api/meta/ownership.go Normal file
View File

@@ -0,0 +1,16 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package meta
const (
CapsuleFieldOwnerPrefix = "capsule"
)
func ControllerFieldOwner() string {
return ControllerFieldOwnerPrefix("controller")
}
func ControllerFieldOwnerPrefix(fieldowner string) string {
return CapsuleFieldOwnerPrefix + "/" + fieldowner
}

30
pkg/template/fast.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package template
import (
"strings"
"github.com/valyala/fasttemplate"
corev1 "k8s.io/api/core/v1"
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
}
t := fasttemplate.New(v, "{{ ", " }}")
tmplString := t.ExecuteString(map[string]interface{}{
"tenant.name": tnt.Name,
"namespace": ns.Name,
})
m[k] = tmplString
}
}

110
pkg/template/fast_test.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package template
import (
"testing"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func newTenant(name string) *capsulev1beta2.Tenant {
return &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
}
func newNamespace(name string) *v1.Namespace {
return &v1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
}
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",
}
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)
}
}
func TestTemplateForTenantAndNamespace_SkipsValuesWithoutDelimiters(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}}",
}
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)
}
}
func TestTemplateForTenantAndNamespace_MixedKeys(t *testing.T) {
tnt := newTenant("tenant-x")
ns := newNamespace("ns-x")
m := map[string]string{
"onlyTenant": "T={{ tenant.name }}",
"onlyNS": "N={{ namespace }}",
"none": "static",
}
TemplateForTenantAndNamespace(m, tnt, ns)
if got := m["onlyTenant"]; got != "T=tenant-x" {
t.Fatalf("onlyTenant: expected %q, got %q", "T=tenant-x", 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_UnknownKeyBecomesEmpty(t *testing.T) {
tnt := newTenant("tenant-a")
ns := newNamespace("ns-1")
m := map[string]string{
"unknown": "X={{ unknown.key }}",
}
TemplateForTenantAndNamespace(m, tnt, ns)
// fasttemplate with missing key returns an empty string for that placeholder
if got := m["unknown"]; got != "X=" {
t.Fatalf("unknown: expected %q, got %q", "X=", got)
}
}

View File

@@ -4,18 +4,137 @@
package tenant
import (
"maps"
"strings"
corev1 "k8s.io/api/core/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/template"
"github.com/projectcapsule/capsule/pkg/utils"
)
func AddTenantLabelForNamespace(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) error {
if ns.Labels == nil {
ns.Labels = make(map[string]string)
func AddNamespaceNameLabels(labels map[string]string, ns *corev1.Namespace) {
labels["kubernetes.io/metadata.name"] = ns.GetName()
}
func AddTenantNameLabel(labels map[string]string, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) {
labels[meta.TenantLabel] = tnt.GetName()
}
func BuildInstanceMetadataForNamespace(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) (labels map[string]string, annotations map[string]string) {
annotations = make(map[string]string)
labels = make(map[string]string)
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
if instance == nil {
return labels, annotations
}
ns.Labels[meta.TenantLabel] = tnt.GetName()
annotations = instance.Metadata.Annotations
labels = instance.Metadata.Labels
return nil
return labels, annotations
}
func BuildNamespaceMetadataForTenant(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) (labels map[string]string, annotations map[string]string, err error) {
annotations = BuildNamespaceAnnotationsForTenant(tnt)
labels = BuildNamespaceLabelsForTenant(tnt)
if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
for _, md := range opts.AdditionalMetadataList {
var ok bool
ok, err = utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
if err != nil {
return nil, nil, err
}
if !ok {
continue
}
template.TemplateForTenantAndNamespace(md.Labels, tnt, ns)
template.TemplateForTenantAndNamespace(md.Annotations, tnt, ns)
utils.MapMergeNoOverrite(labels, md.Labels)
utils.MapMergeNoOverrite(annotations, md.Annotations)
}
}
return labels, annotations, nil
}
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[meta.AvailableIngressClassesAnnotation] = strings.Join(ic.Exact, ",")
}
if len(ic.Regex) > 0 {
annotations[meta.AvailableIngressClassesRegexpAnnotation] = ic.Regex
}
}
if sc := tnt.Spec.StorageClasses; sc != nil {
if len(sc.Exact) > 0 {
annotations[meta.AvailableStorageClassesAnnotation] = strings.Join(sc.Exact, ",")
}
if len(sc.Regex) > 0 {
annotations[meta.AvailableStorageClassesRegexpAnnotation] = sc.Regex
}
}
if cr := tnt.Spec.ContainerRegistries; cr != nil {
if len(cr.Exact) > 0 {
annotations[meta.AllowedRegistriesAnnotation] = strings.Join(cr.Exact, ",")
}
if len(cr.Regex) > 0 {
annotations[meta.AllowedRegistriesRegexpAnnotation] = cr.Regex
}
}
for _, key := range []string{
meta.ForbiddenNamespaceLabelsAnnotation,
meta.ForbiddenNamespaceLabelsRegexpAnnotation,
meta.ForbiddenNamespaceAnnotationsAnnotation,
meta.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)
}
if tnt.Spec.Cordoned {
labels[meta.CordonedLabel] = "true"
}
return labels
}