From c17e4fa812c1564b26f9d39652d54fe61ad8adab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:44:43 +0200 Subject: [PATCH] feat: add leader-election tuning (#2008) * feat: add leader-election tuning Signed-off-by: Oliver Baehler * feat: add leader-election tuning Signed-off-by: Oliver Baehler * feat: add tracing for admission Signed-off-by: Oliver Baehler * feat: add tracing for admission Signed-off-by: Oliver Baehler * add tenant --------- Signed-off-by: Oliver Baehler --- Makefile | 4 + charts/capsule/README.md | 20 +- charts/capsule/templates/_pod.tpl | 57 ++- charts/capsule/values.schema.json | 102 ++++- charts/capsule/values.yaml | 53 ++- cmd/controller/main.go | 163 +++++++- cmd/controller/tracing.go | 152 ++++++++ go.mod | 8 +- .../kube-prometheus-stack/release.flux.yaml | 21 ++ hack/distro/monitoring/kustomization.yaml | 1 + .../monitoring/tempo/kustomization.yaml | 4 + .../distro/monitoring/tempo/release.flux.yaml | 58 +++ .../tenant/resourcequotas_quota.go | 56 ++- .../tenant/resourcequotas_quota_test.go | 161 ++++++++ internal/webhook/router.go | 66 +++- pkg/api/allowed_list_test.go | 70 ++++ pkg/api/errors/errors_test.go | 183 +++++++++ pkg/api/errors/evented_test.go | 115 ++++++ pkg/api/meta/helpers_test.go | 225 +++++++++++ pkg/api/processor/accumulator_test.go | 42 +++ pkg/api/rbac/string_helpers_test.go | 67 ++++ pkg/api/rules/enforce_func_test.go | 60 +++ .../runtime/expression_description_test.go | 62 +++ pkg/api/scope_test.go | 28 ++ pkg/runtime/admission/accumulation_test.go | 53 +++ .../admission/response_helpers_test.go | 48 +++ .../configuration/configuration_test.go | 283 ++++++++++++++ pkg/runtime/events/recorder_test.go | 157 ++++++++ pkg/runtime/gvk/resources_test.go | 82 ++++ pkg/runtime/handlers/handlers_test.go | 322 ++++++++++++++++ .../indexers/customquota/customquota_test.go | 79 ++++ pkg/runtime/indexers/indexer_test.go | 129 +++++++ .../indexers/ingress/hostname_path_test.go | 74 ++++ .../indexers/namespace/namespaces_test.go | 31 ++ .../resourcepool/resourcepool_test.go | 36 ++ pkg/runtime/indexers/tenant/tenant_test.go | 35 ++ pkg/runtime/indexers/tenantowner/name_test.go | 31 ++ .../tenantresource/more_indexers_test.go | 90 +++++ pkg/runtime/predicates/tenant_change_test.go | 96 +++++ pkg/runtime/quota/conditions_test.go | 100 +++++ pkg/runtime/selectors/selectors_test.go | 355 ++++++++++++++++++ pkg/template/fast_validation_test.go | 138 +++++++ pkg/template/functions/funcmap_age_test.go | 67 ++++ pkg/template/reference_context_test.go | 202 ++++++++++ pkg/tenant/get_by_test.go | 301 +++++++++++++++ pkg/tenant/namespaces_context_test.go | 213 +++++++++++ pkg/tenant/rules_promotions_test.go | 157 ++++++++ pkg/users/users_test.go | 236 ++++++++++++ pkg/utils/errors_test.go | 28 +- pkg/utils/maps_test.go | 67 ++++ pkg/utils/tenant_labels_test.go | 53 +++ 51 files changed, 5197 insertions(+), 44 deletions(-) create mode 100644 cmd/controller/tracing.go create mode 100644 hack/distro/monitoring/tempo/kustomization.yaml create mode 100644 hack/distro/monitoring/tempo/release.flux.yaml create mode 100644 internal/controllers/tenant/resourcequotas_quota_test.go create mode 100644 pkg/api/errors/errors_test.go create mode 100644 pkg/api/errors/evented_test.go create mode 100644 pkg/api/meta/helpers_test.go create mode 100644 pkg/api/processor/accumulator_test.go create mode 100644 pkg/api/rbac/string_helpers_test.go create mode 100644 pkg/api/rules/enforce_func_test.go create mode 100644 pkg/api/runtime/expression_description_test.go create mode 100644 pkg/api/scope_test.go create mode 100644 pkg/runtime/admission/accumulation_test.go create mode 100644 pkg/runtime/admission/response_helpers_test.go create mode 100644 pkg/runtime/configuration/configuration_test.go create mode 100644 pkg/runtime/events/recorder_test.go create mode 100644 pkg/runtime/gvk/resources_test.go create mode 100644 pkg/runtime/handlers/handlers_test.go create mode 100644 pkg/runtime/indexers/customquota/customquota_test.go create mode 100644 pkg/runtime/indexers/indexer_test.go create mode 100644 pkg/runtime/indexers/ingress/hostname_path_test.go create mode 100644 pkg/runtime/indexers/namespace/namespaces_test.go create mode 100644 pkg/runtime/indexers/resourcepool/resourcepool_test.go create mode 100644 pkg/runtime/indexers/tenant/tenant_test.go create mode 100644 pkg/runtime/indexers/tenantowner/name_test.go create mode 100644 pkg/runtime/indexers/tenantresource/more_indexers_test.go create mode 100644 pkg/runtime/predicates/tenant_change_test.go create mode 100644 pkg/runtime/quota/conditions_test.go create mode 100644 pkg/runtime/selectors/selectors_test.go create mode 100644 pkg/template/fast_validation_test.go create mode 100644 pkg/template/functions/funcmap_age_test.go create mode 100644 pkg/template/reference_context_test.go create mode 100644 pkg/tenant/get_by_test.go create mode 100644 pkg/tenant/namespaces_context_test.go create mode 100644 pkg/tenant/rules_promotions_test.go create mode 100644 pkg/users/users_test.go create mode 100644 pkg/utils/tenant_labels_test.go diff --git a/Makefile b/Makefile index 3a2ea3e6..43c89ac2 100644 --- a/Makefile +++ b/Makefile @@ -486,6 +486,8 @@ e2e-install: helm-controller-version ko-build-all dev-install-gw-api-crds --set 'manager.options.workers=4' \ --set 'manager.options.clientConnectionQPS=2000' \ --set 'manager.options.clientConnectionBurst=1000' \ + --set 'manager.options.leaderElection.leaseDuration=60s' \ + --set 'manager.options.leaderElection.renewDeadline=40s' \ --set 'manager.rbac.minimal=true' \ --set 'webhooks.hooks.nodes.enabled=true' \ --set "webhooks.exclusive=true"\ @@ -516,6 +518,8 @@ e2e-install-openshift: helm-controller-version ko-build-all --set 'manager.resources=null'\ --set "manager.image.tag=$(VERSION)" \ --set 'manager.livenessProbe.failureThreshold=10' \ + --set 'manager.options.leaderElection.leaseDuration=60s' \ + --set 'manager.options.leaderElection.renewDeadline=40s' \ --set 'webhooks.hooks.nodes.enabled=true' \ --set "webhooks.exclusive=true"\ --set "manager.options.logLevel=debug"\ diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 6a705a58..8830b600 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -112,7 +112,7 @@ The following Values have changed key or Value: | manager.daemonsetStrategy | object | `{"type":"RollingUpdate"}` | [Daemonset Strategy](https://kubernetes.io/docs/tasks/manage-daemon/update-daemon-set/#creating-a-daemonset-with-rollingupdate-update-strategy) | | manager.deploymentStrategy | object | `{"type":"RollingUpdate"}` | [Deployment Strategy](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy) | | manager.env | list | `[]` | Additional Environment Variables | -| manager.extraArgs | list | `["--enable-leader-election=true"]` | A list of extra arguments for the capsule controller | +| manager.extraArgs | list | `[]` | A list of extra arguments for the capsule controller | | manager.hostNetwork | bool | `false` | Specifies if the container should be started in hostNetwork mode. Required for use in some managed kubernetes clusters (such as AWS EKS) with custom CNI (such as calico), because control-plane managed by AWS cannot communicate with pods' IP CIDR and admission webhooks are not working | | manager.hostPID | bool | `false` | Specifies if the container should be started in hostPID mode. | | manager.hostUsers | bool | `true` | Don't use Host Users (User Namespaces) | @@ -137,6 +137,10 @@ The following Values have changed key or Value: | manager.options.ignoreUserWithGroups | list | `[]` | Define groups which when found in the request of a user will be ignored by the Capsule this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups. | | manager.options.impersonation | object | `{}` | Impersonation | | manager.options.labels | object | `{}` | Additional labels to add to the CapsuleConfiguration resource | +| manager.options.leaderElection.enabled | bool | `true` | Enable Leader Election for capsule controller | +| manager.options.leaderElection.leaseDuration | string | `""` | Leader election lease duration. Empty uses controller-runtime's default. | +| manager.options.leaderElection.renewDeadline | string | `""` | Leader election renew deadline. Empty uses controller-runtime's default. | +| manager.options.leaderElection.retryPeriod | string | `""` | Leader election retry period. Empty uses controller-runtime's default. | | manager.options.logLevel | string | `"info"` | Set the log verbosity of the capsule with a value from 1 to 5 | | manager.options.nodeMetadata | object | `{"forbiddenAnnotations":{"denied":[],"deniedRegex":""},"forbiddenLabels":{"denied":[],"deniedRegex":""}}` | Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant | | manager.options.protectedNamespaceRegex | string | `""` | If specified, disallows creation of namespaces matching the passed regexp | @@ -145,6 +149,20 @@ The following Values have changed key or Value: | manager.options.rbac.deleter | string | `"capsule-namespace-deleter"` | Name for the ClusterRole required to grant Namespace Deletion permissions. | | manager.options.rbac.promotionClusterRoles | list | `["capsule-namespace-provisioner","capsule-namespace-deleter"]` | The ClusterRoles applied for ServiceAccounts which had owner Promotion | | manager.options.rbac.provisioner | string | `"capsule-namespace-provisioner"` | Name for the ClusterRole required to grant Namespace Provision permissions. | +| manager.options.tracing.basicAuth.existingSecret.name | string | `""` | Existing Secret containing OTLP basic auth credentials. | +| manager.options.tracing.basicAuth.existingSecret.passwordKey | string | `"password"` | Secret key containing the basic auth password. | +| manager.options.tracing.basicAuth.existingSecret.usernameKey | string | `"username"` | Secret key containing the basic auth username. | +| manager.options.tracing.basicAuth.password | string | `""` | Basic auth password for the OTLP gRPC trace exporter. Prefer existingSecret for production. | +| manager.options.tracing.basicAuth.username | string | `""` | Basic auth username for the OTLP gRPC trace exporter. Prefer existingSecret for production. | +| manager.options.tracing.compression | string | `""` | Compression for OTLP gRPC trace exports. Supported value: gzip. Empty disables compression. | +| manager.options.tracing.enabled | bool | `false` | Enable OpenTelemetry tracing for admission webhook requests. | +| manager.options.tracing.endpoint | string | `""` | OTLP gRPC endpoint for exporting traces. If empty, OpenTelemetry environment variables are used. | +| manager.options.tracing.headers | object | `{}` | OTLP gRPC metadata headers to send with each trace export request. | +| manager.options.tracing.insecure | bool | `true` | Disable transport security for the OTLP gRPC trace exporter. | +| manager.options.tracing.sampleRatio | float | `1` | Trace sampling ratio for admission webhook requests. Must be between 0 and 1. | +| manager.options.tracing.timeout | string | `""` | Timeout for exporting a batch of spans. Empty uses OpenTelemetry's default. | +| manager.options.tracing.tls.insecureSkipVerify | bool | `false` | Skip OTLP gRPC trace exporter TLS certificate verification. Not recommended for production. | +| manager.options.tracing.tls.serverName | string | `""` | TLS server name override for the OTLP gRPC trace exporter. | | manager.options.userNames | list | `[]` | DEPRECATED: use users properties. Names of the users considered as Capsule users. | | manager.options.users | list | `[{"kind":"Group","name":"projectcapsule.dev"}]` | Define entities which are considered part of the Capsule construct. Users not mentioned here will be ignored by Capsule | | manager.options.workers | int | `1` | Workers (MaxConcurrentReconciles) is the maximum number of concurrent Reconciles which can be run (ALPHA). | diff --git a/charts/capsule/templates/_pod.tpl b/charts/capsule/templates/_pod.tpl index 936e4d6c..373a5ee6 100644 --- a/charts/capsule/templates/_pod.tpl +++ b/charts/capsule/templates/_pod.tpl @@ -70,6 +70,37 @@ spec: {{- with .Values.manager.options.cacheSyncTimeout }} - --cache-sync-timeout={{ . }} {{- end }} + - --enable-leader-election={{ .Values.manager.options.leaderElection.enabled }} + {{- with .Values.manager.options.leaderElection.leaseDuration }} + - --leader-election-lease-duration={{ . }} + {{- end }} + {{- with .Values.manager.options.leaderElection.renewDeadline }} + - --leader-election-renew-deadline={{ . }} + {{- end }} + {{- with .Values.manager.options.leaderElection.retryPeriod }} + - --leader-election-retry-period={{ . }} + {{- end }} + {{- if .Values.manager.options.tracing.enabled }} + - --enable-tracing=true + {{- with .Values.manager.options.tracing.endpoint }} + - --tracing-otlp-endpoint={{ . }} + {{- end }} + - --tracing-otlp-insecure={{ .Values.manager.options.tracing.insecure }} + - --tracing-sample-ratio={{ .Values.manager.options.tracing.sampleRatio }} + {{- range $key, $value := .Values.manager.options.tracing.headers }} + - --tracing-otlp-header={{ $key }}={{ $value }} + {{- end }} + {{- with .Values.manager.options.tracing.timeout }} + - --tracing-otlp-timeout={{ . }} + {{- end }} + {{- with .Values.manager.options.tracing.compression }} + - --tracing-otlp-compression={{ . }} + {{- end }} + {{- with .Values.manager.options.tracing.tls.serverName }} + - --tracing-otlp-tls-server-name={{ . }} + {{- end }} + - --tracing-otlp-tls-insecure-skip-verify={{ .Values.manager.options.tracing.tls.insecureSkipVerify }} + {{- end }} {{- with .Values.manager.extraArgs }} {{- toYaml . | nindent 8 }} {{- end }} @@ -84,8 +115,32 @@ spec: valueFrom: fieldRef: fieldPath: spec.serviceAccountName + {{- if .Values.manager.options.tracing.enabled }} + {{- if or .Values.manager.options.tracing.basicAuth.username .Values.manager.options.tracing.basicAuth.existingSecret.name }} + - name: CAPSULE_TRACING_OTLP_BASIC_AUTH_USERNAME + {{- if .Values.manager.options.tracing.basicAuth.existingSecret.name }} + valueFrom: + secretKeyRef: + name: {{ .Values.manager.options.tracing.basicAuth.existingSecret.name }} + key: {{ .Values.manager.options.tracing.basicAuth.existingSecret.usernameKey }} + {{- else }} + value: {{ .Values.manager.options.tracing.basicAuth.username | quote }} + {{- end }} + {{- end }} + {{- if or .Values.manager.options.tracing.basicAuth.password .Values.manager.options.tracing.basicAuth.existingSecret.name }} + - name: CAPSULE_TRACING_OTLP_BASIC_AUTH_PASSWORD + {{- if .Values.manager.options.tracing.basicAuth.existingSecret.name }} + valueFrom: + secretKeyRef: + name: {{ .Values.manager.options.tracing.basicAuth.existingSecret.name }} + key: {{ .Values.manager.options.tracing.basicAuth.existingSecret.passwordKey }} + {{- else }} + value: {{ .Values.manager.options.tracing.basicAuth.password | quote }} + {{- end }} + {{- end }} + {{- end }} {{- with .Values.manager.env }} - {{- toYaml . | nindent 6 }} + {{- toYaml . | nindent 6 }} {{- end }} ports: {{- if not (.Values.manager.hostNetwork) }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index c8bfb124..c72502b6 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -357,10 +357,7 @@ }, "extraArgs": { "description": "A list of extra arguments for the capsule controller", - "type": "array", - "items": { - "type": "string" - } + "type": "array" }, "hostNetwork": { "description": "Specifies if the container should be started in hostNetwork mode. Required for use in some managed kubernetes clusters (such as AWS EKS) with custom CNI (such as calico), because control-plane managed by AWS cannot communicate with pods' IP CIDR and admission webhooks are not working", @@ -480,6 +477,27 @@ "description": "Additional labels to add to the CapsuleConfiguration resource", "type": "object" }, + "leaderElection": { + "type": "object", + "properties": { + "enabled": { + "description": "Enable Leader Election for capsule controller", + "type": "boolean" + }, + "leaseDuration": { + "description": "Leader election lease duration. Empty uses controller-runtime's default.", + "type": "string" + }, + "renewDeadline": { + "description": "Leader election renew deadline. Empty uses controller-runtime's default.", + "type": "string" + }, + "retryPeriod": { + "description": "Leader election retry period. Empty uses controller-runtime's default.", + "type": "string" + } + } + }, "logLevel": { "description": "Set the log verbosity of the capsule with a value from 1 to 5", "type": "string" @@ -544,6 +562,82 @@ } } }, + "tracing": { + "type": "object", + "properties": { + "basicAuth": { + "type": "object", + "properties": { + "existingSecret": { + "type": "object", + "properties": { + "name": { + "description": "Existing Secret containing OTLP basic auth credentials.", + "type": "string" + }, + "passwordKey": { + "description": "Secret key containing the basic auth password.", + "type": "string" + }, + "usernameKey": { + "description": "Secret key containing the basic auth username.", + "type": "string" + } + } + }, + "password": { + "description": "Basic auth password for the OTLP gRPC trace exporter. Prefer existingSecret for production.", + "type": "string" + }, + "username": { + "description": "Basic auth username for the OTLP gRPC trace exporter. Prefer existingSecret for production.", + "type": "string" + } + } + }, + "compression": { + "description": "Compression for OTLP gRPC trace exports. Supported value: gzip. Empty disables compression.", + "type": "string" + }, + "enabled": { + "description": "Enable OpenTelemetry tracing for admission webhook requests.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP gRPC endpoint for exporting traces. If empty, OpenTelemetry environment variables are used.", + "type": "string" + }, + "headers": { + "description": "OTLP gRPC metadata headers to send with each trace export request.", + "type": "object" + }, + "insecure": { + "description": "Disable transport security for the OTLP gRPC trace exporter.", + "type": "boolean" + }, + "sampleRatio": { + "description": "Trace sampling ratio for admission webhook requests. Must be between 0 and 1.", + "type": "number" + }, + "timeout": { + "description": "Timeout for exporting a batch of spans. Empty uses OpenTelemetry's default.", + "type": "string" + }, + "tls": { + "type": "object", + "properties": { + "insecureSkipVerify": { + "description": "Skip OTLP gRPC trace exporter TLS certificate verification. Not recommended for production.", + "type": "boolean" + }, + "serverName": { + "description": "TLS server name override for the OTLP gRPC trace exporter.", + "type": "string" + } + } + } + } + }, "userNames": { "description": "DEPRECATED: use users properties. Names of the users considered as Capsule users.", "type": "array" diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 0eb842b4..56f4e0d8 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -240,6 +240,18 @@ manager: clientConnectionBurst: 30 # -- Timeout used when waiting for controller cache synchronization. Empty uses controller-runtime's default. cacheSyncTimeout: "4m" + # -- Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server + cacheInvalidation: 0h30m0s + # Leader Election + leaderElection: + # -- Enable Leader Election for capsule controller + enabled: true + # -- Leader election lease duration. Empty uses controller-runtime's default. + leaseDuration: "" + # -- Leader election renew deadline. Empty uses controller-runtime's default. + renewDeadline: "" + # -- Leader election retry period. Empty uses controller-runtime's default. + retryPeriod: "" # -- Define entities which are considered part of the Capsule construct. # Users not mentioned here will be ignored by Capsule users: @@ -275,10 +287,6 @@ manager: forbiddenAnnotations: denied: [] deniedRegex: "" - - # -- Duration after which the in-memory cache is invalidated (based on usaage) and re-fetched from the API server - cacheInvalidation: 0h30m0s - # -- Managed RBAC configuration for the controller rbac: # -- The ClusterRoles applied for Administrators @@ -292,9 +300,41 @@ manager: deleter: capsule-namespace-deleter # -- Name for the ClusterRole required to grant Namespace Provision permissions. provisioner: capsule-namespace-provisioner - # -- Impersonation impersonation: {} + # Tracing Configuration + tracing: + # -- Enable OpenTelemetry tracing for admission webhook requests. + enabled: false + # -- OTLP gRPC endpoint for exporting traces. If empty, OpenTelemetry environment variables are used. + endpoint: "" + # -- Disable transport security for the OTLP gRPC trace exporter. + insecure: true + # -- Trace sampling ratio for admission webhook requests. Must be between 0 and 1. + sampleRatio: 1.0 + # -- OTLP gRPC metadata headers to send with each trace export request. + headers: {} + basicAuth: + # -- Basic auth username for the OTLP gRPC trace exporter. Prefer existingSecret for production. + username: "" + # -- Basic auth password for the OTLP gRPC trace exporter. Prefer existingSecret for production. + password: "" + existingSecret: + # -- Existing Secret containing OTLP basic auth credentials. + name: "" + # -- Secret key containing the basic auth username. + usernameKey: username + # -- Secret key containing the basic auth password. + passwordKey: password + # -- Timeout for exporting a batch of spans. Empty uses OpenTelemetry's default. + timeout: "" + # -- Compression for OTLP gRPC trace exports. Supported value: gzip. Empty disables compression. + compression: "" + tls: + # -- TLS server name override for the OTLP gRPC trace exporter. + serverName: "" + # -- Skip OTLP gRPC trace exporter TLS certificate verification. Not recommended for production. + insecureSkipVerify: false # -- DEPRECATED: use users properties. # Names of the users considered as Capsule users. @@ -304,8 +344,7 @@ manager: capsuleUserGroups: [] # -- A list of extra arguments for the capsule controller - extraArgs: - - "--enable-leader-election=true" + extraArgs: [] # -- Additional Environment Variables env: [] diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 3dd67bf8..ea2e13f9 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -4,6 +4,7 @@ package main import ( + "context" "crypto/tls" goflag "flag" "fmt" @@ -122,16 +123,32 @@ func main() { enableLeaderElection bool enablePprof bool + enableTracing bool version bool secureMetrics bool enableHTTP2 bool + tracingEndpoint string + tracingSampleRatio float64 + tracingInsecure bool + tracingHeaders = tracingHeadersFlag{} + tracingBasicAuthUsername string + tracingBasicAuthPassword string + tracingTimeout time.Duration + tracingCompression string + tracingTLSServerName string + tracingTLSInsecureSkipVerify bool + clientConnectionQPS float32 clientConnectionBurst int32 webhookPort int cacheSyncTimeout time.Duration + + leaderElectionLeaseDuration time.Duration + leaderElectionRenewDeadline time.Duration + leaderElectionRetryPeriod time.Duration ) var goFlagSet goflag.FlagSet @@ -152,6 +169,71 @@ func main() { "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.", ) + flag.BoolVar( + &enableTracing, + "enable-tracing", + false, + "Enable OpenTelemetry tracing for admission webhook requests.", + ) + flag.StringVar( + &tracingEndpoint, + "tracing-otlp-endpoint", + "", + "OTLP gRPC endpoint for exporting traces, for example otel-collector.observability.svc:4317. If unset, OpenTelemetry environment variables are used.", + ) + flag.BoolVar( + &tracingInsecure, + "tracing-otlp-insecure", + true, + "Disable transport security for the OTLP gRPC trace exporter.", + ) + flag.Float64Var( + &tracingSampleRatio, + "tracing-sample-ratio", + 1.0, + "Trace sampling ratio for admission webhook requests. Must be between 0 and 1.", + ) + flag.Var( + tracingHeaders, + "tracing-otlp-header", + "OTLP gRPC metadata header in key=value format. Can be set multiple times.", + ) + flag.StringVar( + &tracingBasicAuthUsername, + "tracing-otlp-basic-auth-username", + "", + "Basic auth username for the OTLP gRPC trace exporter. Can also be set with CAPSULE_TRACING_OTLP_BASIC_AUTH_USERNAME.", + ) + flag.StringVar( + &tracingBasicAuthPassword, + "tracing-otlp-basic-auth-password", + "", + "Basic auth password for the OTLP gRPC trace exporter. Can also be set with CAPSULE_TRACING_OTLP_BASIC_AUTH_PASSWORD.", + ) + flag.DurationVar( + &tracingTimeout, + "tracing-otlp-timeout", + 0, + "Timeout for exporting a batch of spans. Empty or 0 uses OpenTelemetry's default.", + ) + flag.StringVar( + &tracingCompression, + "tracing-otlp-compression", + "", + "Compression for OTLP gRPC trace exports. Supported value: gzip. Empty disables compression.", + ) + flag.StringVar( + &tracingTLSServerName, + "tracing-otlp-tls-server-name", + "", + "TLS server name override for the OTLP gRPC trace exporter.", + ) + flag.BoolVar( + &tracingTLSInsecureSkipVerify, + "tracing-otlp-tls-insecure-skip-verify", + false, + "Skip OTLP gRPC trace exporter TLS certificate verification. Not recommended for production.", + ) flag.IntVar( &controllerConfig.Runtime.MaxConcurrentReconciles, "workers", @@ -164,6 +246,24 @@ func main() { 0, "The timeout used when waiting for controller cache synchronization. If unset or 0, the controller-runtime default is used.", ) + flag.DurationVar( + &leaderElectionLeaseDuration, + "leader-election-lease-duration", + 0, + "The duration that non-leader candidates wait to force acquire leadership. If unset or 0, the controller-runtime default is used.", + ) + flag.DurationVar( + &leaderElectionRenewDeadline, + "leader-election-renew-deadline", + 0, + "The duration that the acting leader retries refreshing leadership before giving up. If unset or 0, the controller-runtime default is used.", + ) + flag.DurationVar( + &leaderElectionRetryPeriod, + "leader-election-retry-period", + 0, + "The duration that leader election clients wait between retries. If unset or 0, the controller-runtime default is used.", + ) flag.StringVar( &metricsAddr, "metrics-addr", @@ -464,6 +564,18 @@ func main() { }, } + if leaderElectionLeaseDuration > 0 { + ctrlOpts.LeaseDuration = &leaderElectionLeaseDuration + } + + if leaderElectionRenewDeadline > 0 { + ctrlOpts.RenewDeadline = &leaderElectionRenewDeadline + } + + if leaderElectionRetryPeriod > 0 { + ctrlOpts.RetryPeriod = &leaderElectionRetryPeriod + } + if enablePprof { ctrlOpts.PprofBindAddress = ":8082" } @@ -704,7 +816,11 @@ func main() { ctrl.Log.WithName("capsule.ctrl").WithName("events"), manager.GetEventRecorder("tenant-controller"), cfg, - ), webhooksList...); err != nil { + ), + webhook.RegistrationOptions{ + EnableTracing: enableTracing, + }, + webhooksList...); err != nil { setupLog.Error(err, "unable to setup webhooks") os.Exit(1) } @@ -845,8 +961,49 @@ func main() { setupLog.Info("starting manager") - if err = manager.Start(ctx); err != nil { - setupLog.Error(err, "problem running manager") + if tracingBasicAuthUsername == "" { + tracingBasicAuthUsername = os.Getenv("CAPSULE_TRACING_OTLP_BASIC_AUTH_USERNAME") + } + + if tracingBasicAuthPassword == "" { + tracingBasicAuthPassword = os.Getenv("CAPSULE_TRACING_OTLP_BASIC_AUTH_PASSWORD") + } + + tracingShutdown, err := setupTracing(ctx, tracingOptions{ + enabled: enableTracing, + endpoint: tracingEndpoint, + insecure: tracingInsecure, + sampleRatio: tracingSampleRatio, + headers: tracingHeaders, + basicAuthUsername: tracingBasicAuthUsername, + basicAuthPassword: tracingBasicAuthPassword, + timeout: tracingTimeout, + compression: tracingCompression, + tlsServerName: tracingTLSServerName, + tlsInsecureSkipVerify: tracingTLSInsecureSkipVerify, + }) + if err != nil { + setupLog.Error(err, "unable to initialize tracing") os.Exit(1) } + + if err = manager.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if shutdownErr := tracingShutdown(shutdownCtx); shutdownErr != nil { + setupLog.Error(shutdownErr, "unable to shutdown tracing") + } + + cancel() + + os.Exit(1) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := tracingShutdown(shutdownCtx); err != nil { + setupLog.Error(err, "unable to shutdown tracing") + } + + cancel() } diff --git a/cmd/controller/tracing.go b/cmd/controller/tracing.go new file mode 100644 index 00000000..ebf21271 --- /dev/null +++ b/cmd/controller/tracing.go @@ -0,0 +1,152 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "maps" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "google.golang.org/grpc/credentials" + + capsuleversion "github.com/projectcapsule/capsule/internal/version" +) + +type tracingOptions struct { + enabled bool + endpoint string + insecure bool + sampleRatio float64 + headers map[string]string + basicAuthUsername string + basicAuthPassword string + timeout time.Duration + compression string + tlsServerName string + tlsInsecureSkipVerify bool +} + +func setupTracing(ctx context.Context, options tracingOptions) (func(context.Context) error, error) { + if !options.enabled { + return func(context.Context) error { return nil }, nil + } + + if options.sampleRatio < 0 || options.sampleRatio > 1 { + return nil, fmt.Errorf("tracing sample ratio must be between 0 and 1, got %v", options.sampleRatio) + } + + if (options.basicAuthUsername == "") != (options.basicAuthPassword == "") { + return nil, fmt.Errorf("tracing basic auth username and password must be configured together") + } + + headers := make(map[string]string, len(options.headers)+1) + maps.Copy(headers, options.headers) + + if options.basicAuthUsername != "" { + token := base64.StdEncoding.EncodeToString([]byte(options.basicAuthUsername + ":" + options.basicAuthPassword)) + headers["authorization"] = "Basic " + token + } + + exporterOptions := make([]otlptracegrpc.Option, 0, 6) + + if options.endpoint != "" { + exporterOptions = append(exporterOptions, otlptracegrpc.WithEndpoint(options.endpoint)) + } + + if options.insecure { + exporterOptions = append(exporterOptions, otlptracegrpc.WithInsecure()) + } else if options.tlsServerName != "" || options.tlsInsecureSkipVerify { + exporterOptions = append(exporterOptions, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(&tls.Config{ + ServerName: options.tlsServerName, + InsecureSkipVerify: options.tlsInsecureSkipVerify, //nolint:gosec + }))) + } + + if len(headers) > 0 { + exporterOptions = append(exporterOptions, otlptracegrpc.WithHeaders(headers)) + } + + if options.timeout > 0 { + exporterOptions = append(exporterOptions, otlptracegrpc.WithTimeout(options.timeout)) + } + + if options.compression != "" { + if options.compression != "gzip" { + return nil, fmt.Errorf("unsupported tracing compression %q, supported values: gzip", options.compression) + } + + exporterOptions = append(exporterOptions, otlptracegrpc.WithCompressor(options.compression)) + } + + exporter, err := otlptracegrpc.New(ctx, exporterOptions...) + if err != nil { + return nil, fmt.Errorf("create OTLP trace exporter: %w", err) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName("capsule"), + semconv.ServiceVersion(capsuleversion.GitTag), + ), + ) + if err != nil { + return nil, fmt.Errorf("create OpenTelemetry resource: %w", err) + } + + provider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(options.sampleRatio))), + ) + + otel.SetTracerProvider(provider) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return provider.Shutdown, nil +} + +type tracingHeadersFlag map[string]string + +func (f tracingHeadersFlag) String() string { + if len(f) == 0 { + return "" + } + + items := make([]string, 0, len(f)) + for key, value := range f { + items = append(items, key+"="+value) + } + + return strings.Join(items, ",") +} + +func (f tracingHeadersFlag) Type() string { + return "key=value" +} + +func (f tracingHeadersFlag) Set(value string) error { + key, headerValue, found := strings.Cut(value, "=") + if !found || key == "" { + return fmt.Errorf("tracing header must use key=value format") + } + + f[key] = headerValue + + return nil +} diff --git a/go.mod b/go.mod index d31f7a98..d56dc3fd 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,10 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.28.0 go.yaml.in/yaml/v2 v2.4.3 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 golang.org/x/sync v0.20.0 gomodules.xyz/jsonpatch/v2 v2.5.0 gomodules.xyz/jsonpatch/v3 v3.0.1 @@ -104,12 +108,8 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/hack/distro/monitoring/kube-prometheus-stack/release.flux.yaml b/hack/distro/monitoring/kube-prometheus-stack/release.flux.yaml index 511cadb2..32b8e795 100644 --- a/hack/distro/monitoring/kube-prometheus-stack/release.flux.yaml +++ b/hack/distro/monitoring/kube-prometheus-stack/release.flux.yaml @@ -34,6 +34,24 @@ spec: type: grafana-pyroscope-datasource uid: pyroscope url: http://pyroscope.{{ $.Release.Namespace }}.svc.cluster.local.:4040/ + - name: Tempo + type: tempo + uid: tempo + url: http://tempo.{{ $.Release.Namespace }}.svc.cluster.local.:3200/ + jsonData: + tracesToMetrics: + datasourceUid: prometheus + spanStartTimeShift: -10m + spanEndTimeShift: 10m + tags: + - key: service.name + value: service + - key: job + queries: + - name: Span latency + query: 'sum(rate(traces_spanmetrics_latency_bucket{$$__tags}[5m]))' + - name: Span calls + query: 'sum(rate(traces_spanmetrics_calls_total{$$__tags}[5m]))' adminPassword: admin global: dnsService: "kube-dns" @@ -88,6 +106,9 @@ spec: auto_assign_org: true server: root_url: "http://localhost:9090" + prometheus: + prometheusSpec: + enableRemoteWriteReceiver: true --- apiVersion: source.toolkit.fluxcd.io/v1 kind: HelmRepository diff --git a/hack/distro/monitoring/kustomization.yaml b/hack/distro/monitoring/kustomization.yaml index 52ba0044..0b4d4ace 100644 --- a/hack/distro/monitoring/kustomization.yaml +++ b/hack/distro/monitoring/kustomization.yaml @@ -4,3 +4,4 @@ resources: - ns.yaml - kube-prometheus-stack/ - pyroscope/ + - tempo/ diff --git a/hack/distro/monitoring/tempo/kustomization.yaml b/hack/distro/monitoring/tempo/kustomization.yaml new file mode 100644 index 00000000..7fcbf108 --- /dev/null +++ b/hack/distro/monitoring/tempo/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml diff --git a/hack/distro/monitoring/tempo/release.flux.yaml b/hack/distro/monitoring/tempo/release.flux.yaml new file mode 100644 index 00000000..f69bfcb7 --- /dev/null +++ b/hack/distro/monitoring/tempo/release.flux.yaml @@ -0,0 +1,58 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: tempo + namespace: flux-system +spec: + serviceAccountName: kustomize-controller + interval: 30s + timeout: 10m + targetNamespace: monitoring-system + releaseName: "tempo" + chart: + spec: + chart: tempo + version: "2.2.3" + sourceRef: + kind: HelmRepository + name: tempo + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + retries: -1 + driftDetection: + mode: enabled + values: + global: + dnsService: "kube-dns" + dnsNamespace: "kube-system" + clusterLabelOverride: "kind" + tempo: + metricsGenerator: + enabled: true + remoteWriteUrl: http://kube-prometheus-stack-prometheus.monitoring-system.svc.cluster.local:9090/api/v1/write + overrides: + defaults: + metrics_generator: + processors: + - service-graphs + - span-metrics + podAnnotations: + profiles.grafana.com/memory.scrape: "false" + profiles.grafana.com/goroutine.scrape: "false" + profiles.grafana.com/cpu.scrape: "false" +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: tempo + namespace: flux-system +spec: + interval: 24h0m0s + url: https://grafana-community.github.io/helm-charts/ diff --git a/internal/controllers/tenant/resourcequotas_quota.go b/internal/controllers/tenant/resourcequotas_quota.go index 455ca02e..50dfad34 100644 --- a/internal/controllers/tenant/resourcequotas_quota.go +++ b/internal/controllers/tenant/resourcequotas_quota.go @@ -7,8 +7,11 @@ import ( "context" "fmt" "strings" + "sync" "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -17,10 +20,15 @@ import ( "k8s.io/client-go/util/retry" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + caperrors "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/api/meta" ) func (r *Manager) syncCustomResourceQuotaUsages(ctx context.Context, tenant *capsulev1beta2.Tenant) error { + if tenant.DeletionTimestamp != nil { + return nil + } + type resource struct { kind string group string @@ -63,9 +71,14 @@ func (r *Manager) syncCustomResourceQuotaUsages(ctx context.Context, tenant *cap }) } + if len(resourceList) == 0 { + return nil + } + errGroup := new(errgroup.Group) usedMap := make(map[string]int) + usedMapMu := sync.Mutex{} defer func() { for gvk, used := range usedMap { @@ -89,30 +102,41 @@ func (r *Manager) syncCustomResourceQuotaUsages(ctx context.Context, tenant *cap } }() + dynamicClient := r.DynamicClient + if dynamicClient == nil { + dynamicClient = dynamic.NewForConfigOrDie(r.RESTConfig) + } + + namespaces := readyTenantNamespaces(tenant) + for _, item := range resourceList { res := item + key := fmt.Sprintf("%s.%s_%s", res.kind, res.group, res.version) errGroup.Go(func() (scopeErr error) { - dynamicClient := dynamic.NewForConfigOrDie(r.RESTConfig) + var used int - for _, ns := range tenant.Status.Namespaces { + for _, ns := range namespaces { var list *unstructured.UnstructuredList - list, scopeErr = dynamicClient.Resource(schema.GroupVersionResource{Group: res.group, Version: res.version, Resource: res.kind}).List(ctx, metav1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.namespace==%s", ns), - }) + list, scopeErr = dynamicClient. + Resource(schema.GroupVersionResource{ + Group: res.group, + Version: res.version, + Resource: res.kind, + }). + Namespace(ns). + List(ctx, metav1.ListOptions{}) if scopeErr != nil { + if caperrors.IgnoreGone(scopeErr) || apierrors.HasStatusCause(scopeErr, corev1.NamespaceTerminatingCause) { + scopeErr = nil + + continue + } + return scopeErr } - key := fmt.Sprintf("%s.%s_%s", res.kind, res.group, res.version) - - if _, ok := usedMap[key]; !ok { - usedMap[key] = 0 - } - - var used int - for _, k := range list.Items { if k.GetDeletionTimestamp() != nil { continue @@ -120,10 +144,12 @@ func (r *Manager) syncCustomResourceQuotaUsages(ctx context.Context, tenant *cap used++ } - - usedMap[key] += used } + usedMapMu.Lock() + usedMap[key] += used + usedMapMu.Unlock() + return scopeErr }) } diff --git a/internal/controllers/tenant/resourcequotas_quota_test.go b/internal/controllers/tenant/resourcequotas_quota_test.go new file mode 100644 index 00000000..11cdfc8e --- /dev/null +++ b/internal/controllers/tenant/resourcequotas_quota_test.go @@ -0,0 +1,161 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/testing" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" +) + +func TestSyncCustomResourceQuotaUsagesCountsReadyNamespacesOnly(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("cannot add capsule scheme: %v", err) + } + + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + Annotations: map[string]string{ + capsulev1beta2.LimitAnnotationForResource("widgets.example.com_v1"): "10", + }, + }, + Status: capsulev1beta2.TenantStatus{ + Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{ + { + Name: "ready", + Conditions: meta.ConditionList{{ + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }}, + }, + { + Name: "terminating", + Conditions: meta.ConditionList{{ + Type: meta.TerminatingCondition, + Status: metav1.ConditionTrue, + }}, + }, + }, + }, + } + + gvr := schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"} + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvr: "WidgetList", + }) + var listedNamespaces []string + dynamicClient.PrependReactor("list", "widgets", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + listedNamespaces = append(listedNamespaces, action.GetNamespace()) + + active := unstructured.Unstructured{} + active.SetName("active") + + deleting := unstructured.Unstructured{} + deleting.SetName("deleting") + now := metav1.Now() + deleting.SetDeletionTimestamp(&now) + + return true, &unstructured.UnstructuredList{Items: []unstructured.Unstructured{active, deleting}}, nil + }) + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tnt).Build() + manager := &Manager{ + Client: kubeClient, + DynamicClient: dynamicClient, + } + + if err := manager.syncCustomResourceQuotaUsages(ctx, tnt); err != nil { + t.Fatalf("syncCustomResourceQuotaUsages() unexpected error: %v", err) + } + + if len(listedNamespaces) != 1 { + t.Fatalf("expected one resource list call, got %d: %v", len(listedNamespaces), listedNamespaces) + } + + if listedNamespaces[0] != "ready" { + t.Fatalf("unexpected list namespace %q", listedNamespaces[0]) + } + + updated := &capsulev1beta2.Tenant{} + if err := kubeClient.Get(ctx, client.ObjectKey{Name: tnt.Name}, updated); err != nil { + t.Fatalf("cannot get updated tenant: %v", err) + } + + if got := updated.Annotations[capsulev1beta2.UsedAnnotationForResource("widgets.example.com_v1")]; got != "1" { + t.Fatalf("used annotation = %q, want 1", got) + } +} + +func TestSyncCustomResourceQuotaUsagesIgnoresMissingResource(t *testing.T) { + ctx := context.Background() + scheme := runtime.NewScheme() + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("cannot add capsule scheme: %v", err) + } + + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + Annotations: map[string]string{ + capsulev1beta2.LimitAnnotationForResource("widgets.example.com_v1"): "10", + }, + }, + Status: capsulev1beta2.TenantStatus{ + Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{{ + Name: "ready", + Conditions: meta.ConditionList{{ + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }}, + }}, + }, + } + + gvr := schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"} + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvr: "WidgetList", + }) + dynamicClient.PrependReactor("list", "widgets", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{Group: "example.com", Resource: "widgets"}, "") + }) + + manager := &Manager{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(tnt).Build(), + DynamicClient: dynamicClient, + } + + if err := manager.syncCustomResourceQuotaUsages(ctx, tnt); err != nil { + t.Fatalf("syncCustomResourceQuotaUsages() unexpected error: %v", err) + } +} + +func TestSyncCustomResourceQuotaUsagesSkipsTerminatingTenant(t *testing.T) { + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a", + DeletionTimestamp: &metav1.Time{}, + }, + } + + manager := &Manager{} + + if err := manager.syncCustomResourceQuotaUsages(context.Background(), tnt); err != nil { + t.Fatalf("syncCustomResourceQuotaUsages() unexpected error: %v", err) + } +} diff --git a/internal/webhook/router.go b/internal/webhook/router.go index 61937bad..a6c564b9 100644 --- a/internal/webhook/router.go +++ b/internal/webhook/router.go @@ -5,7 +5,11 @@ package webhook import ( "context" + "net/http" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" admissionv1 "k8s.io/api/admission/v1" controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,19 +20,36 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Register(manager controllerruntime.Manager, recorder events.EventRecorder, webhookList ...handlers.Webhook) error { +type RegistrationOptions struct { + EnableTracing bool +} + +func Register(manager controllerruntime.Manager, recorder events.EventRecorder, options RegistrationOptions, webhookList ...handlers.Webhook) error { server := manager.GetWebhookServer() for _, wh := range webhookList { - server.Register(wh.GetPath(), &webhook.Admission{ + handler := http.Handler(&webhook.Admission{ Handler: &handlerRouter{ client: manager.GetClient(), reader: manager.GetAPIReader(), decoder: admission.NewDecoder(manager.GetScheme()), recorder: recorder, handlers: wh.GetHandlers(), + path: wh.GetPath(), }, }) + + if options.EnableTracing { + handler = otelhttp.NewHandler( + handler, + "capsule.admission", + otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { + return "capsule.admission " + r.URL.Path + }), + ) + } + + server.Register(wh.GetPath(), handler) } return nil @@ -41,31 +62,62 @@ type handlerRouter struct { recorder events.EventRecorder handlers []handlers.Handler + path string } func (r *handlerRouter) Handle(ctx context.Context, req admission.Request) admission.Response { + span := trace.SpanFromContext(ctx) + span.SetAttributes( + attribute.String("admission.uid", string(req.UID)), + attribute.String("admission.operation", string(req.Operation)), + attribute.String("admission.kind.group", req.Kind.Group), + attribute.String("admission.kind.version", req.Kind.Version), + attribute.String("admission.kind.kind", req.Kind.Kind), + attribute.String("admission.resource.group", req.Resource.Group), + attribute.String("admission.resource.version", req.Resource.Version), + attribute.String("admission.resource.resource", req.Resource.Resource), + attribute.String("admission.subresource", req.SubResource), + attribute.String("admission.namespace", req.Namespace), + attribute.String("admission.name", req.Name), + attribute.String("admission.user", req.UserInfo.Username), + attribute.String("admission.webhook.path", r.path), + ) + switch req.Operation { case admissionv1.Create: for _, h := range r.handlers { if response := h.OnCreate(r.client, r.reader, r.decoder, r.recorder)(ctx, req); response != nil { - return *response + return r.recordResponse(span, *response) } } case admissionv1.Update: for _, h := range r.handlers { if response := h.OnUpdate(r.client, r.reader, r.decoder, r.recorder)(ctx, req); response != nil { - return *response + return r.recordResponse(span, *response) } } case admissionv1.Delete: for _, h := range r.handlers { if response := h.OnDelete(r.client, r.reader, r.decoder, r.recorder)(ctx, req); response != nil { - return *response + return r.recordResponse(span, *response) } } case admissionv1.Connect: - return admission.Allowed("") + return r.recordResponse(span, admission.Allowed("")) } - return admission.Allowed("") + return r.recordResponse(span, admission.Allowed("")) +} + +func (r *handlerRouter) recordResponse(span trace.Span, response admission.Response) admission.Response { + span.SetAttributes(attribute.Bool("admission.allowed", response.Allowed)) + + if response.Result != nil { + span.SetAttributes( + attribute.Int64("admission.response.code", int64(response.Result.Code)), + attribute.String("admission.response.reason", string(response.Result.Reason)), + ) + } + + return response } diff --git a/pkg/api/allowed_list_test.go b/pkg/api/allowed_list_test.go index af8b7784..d251deeb 100644 --- a/pkg/api/allowed_list_test.go +++ b/pkg/api/allowed_list_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/projectcapsule/capsule/pkg/api" ) @@ -74,3 +76,71 @@ func TestAllowedListSpec_RegexMatch(t *testing.T) { } } } + +func TestAllowedListSpec_Match(t *testing.T) { + t.Parallel() + + spec := api.AllowedListSpec{ + Exact: []string{"exact"}, + Regex: "regex-.*", + } + + assert.True(t, spec.Match("exact")) + assert.True(t, spec.Match("regex-value")) + assert.False(t, spec.Match("other")) +} + +func TestDefaultAllowedListSpec_MatchDefault(t *testing.T) { + t.Parallel() + + spec := api.DefaultAllowedListSpec{Default: "standard"} + + assert.True(t, spec.MatchDefault("standard")) + assert.False(t, spec.MatchDefault("premium")) +} + +func TestSelectorAllowedListSpec(t *testing.T) { + t.Parallel() + + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Name: "by-name", + Labels: map[string]string{"tier": "backend"}, + }} + + byName := api.SelectorAllowedListSpec{ + AllowedListSpec: api.AllowedListSpec{Exact: []string{"by-name"}}, + } + assert.True(t, byName.MatchSelectByName(obj)) + + bySelector := api.SelectorAllowedListSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"tier": "backend"}}, + } + assert.True(t, bySelector.SelectorMatch(obj)) + assert.True(t, bySelector.MatchSelectByName(obj)) + + invalidSelector := api.SelectorAllowedListSpec{ + LabelSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "invalid"}}, + }, + } + assert.False(t, invalidSelector.SelectorMatch(obj)) + assert.False(t, invalidSelector.MatchSelectByName(nil)) +} + +func TestSelectionListWithDefaultSpec(t *testing.T) { + t.Parallel() + + spec := api.SelectionListWithDefaultSpec{Default: "standard"} + assert.True(t, spec.MatchDefault("standard")) + assert.False(t, spec.MatchDefault("premium")) + + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"class": "gold"}, + }} + + selector := api.SelectionListWithSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"class": "gold"}}, + } + assert.True(t, selector.SelectorMatch(obj)) + assert.False(t, selector.SelectorMatch(nil)) +} diff --git a/pkg/api/errors/errors_test.go b/pkg/api/errors/errors_test.go new file mode 100644 index 00000000..a713e8a7 --- /dev/null +++ b/pkg/api/errors/errors_test.go @@ -0,0 +1,183 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package errors_test + +import ( + stderrors "errors" + "strings" + "testing" + + "github.com/projectcapsule/capsule/pkg/api" + apierrors "github.com/projectcapsule/capsule/pkg/api/errors" + "github.com/projectcapsule/capsule/pkg/runtime/events" + corev1 "k8s.io/api/core/v1" + k8sapierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestAllowedValuesErrorMessages(t *testing.T) { + t.Parallel() + + allowed := api.SelectorAllowedListSpec{ + AllowedListSpec: api.AllowedListSpec{ + Exact: []string{"fast", "slow"}, + Regex: "premium-.*", + }, + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"class": "gold"}}, + } + + got := apierrors.AllowedValuesErrorMessage(allowed, "prefix: ") + for _, want := range []string{ + "prefix:", + "use one from the following list (fast, slow)", + "use one matching the following regex (premium-.*)", + "matching the label selector defined in the Tenant", + } { + if !strings.Contains(got, want) { + t.Fatalf("AllowedValuesErrorMessage() = %q, missing %q", got, want) + } + } + + defaultMsg := apierrors.DefaultAllowedValuesErrorMessage(api.DefaultAllowedListSpec{SelectorAllowedListSpec: allowed}, "default: ") + if !strings.Contains(defaultMsg, "premium-.*") { + t.Fatalf("DefaultAllowedValuesErrorMessage() = %q, want regex detail", defaultMsg) + } + + selectionMsg := apierrors.SelectionListWithDefaultErrorMessage(api.SelectionListWithDefaultSpec{ + SelectionListWithSpec: api.SelectionListWithSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"class": "gold"}}, + }, + }, "selection: ") + if !strings.Contains(selectionMsg, "matching the label selector defined in the Tenant") { + t.Fatalf("SelectionListWithDefaultErrorMessage() = %q", selectionMsg) + } +} + +func TestErrorConstructors(t *testing.T) { + t.Parallel() + + allowed := api.DefaultAllowedListSpec{SelectorAllowedListSpec: api.SelectorAllowedListSpec{ + AllowedListSpec: api.AllowedListSpec{Exact: []string{"allowed"}, Regex: "allowed-.*"}, + }} + selectorAllowed := api.SelectorAllowedListSpec{AllowedListSpec: api.AllowedListSpec{Exact: []string{"allowed"}}} + + tests := []struct { + name string + err error + want string + }{ + {name: "custom quota", err: apierrors.NewCustomResourceQuotaError("pods.v1", 3), want: "pods.v1"}, + {name: "device forbidden", err: apierrors.NewDeviceClassForbidden("gpu", selectorAllowed), want: "Device Class gpu is forbidden"}, + {name: "device undefined", err: apierrors.NewDeviceClassUndefined(selectorAllowed), want: "Selected DeviceClass is forbidden"}, + {name: "gateway class", err: apierrors.NewGatewayClassError("public", stderrors.New("missing")), want: "Failed to resolve Gateway Class public"}, + {name: "gateway", err: apierrors.NewGatewayError(gatewayv1.ObjectName("gw"), stderrors.New("missing")), want: "Failed to resolve Gateway gw"}, + {name: "gateway forbidden", err: apierrors.NewGatewayClassForbidden("public", allowed), want: "Gateway Class public is forbidden"}, + {name: "gateway undefined", err: apierrors.NewGatewayClassUndefined(allowed), want: "No gateway Class is forbidden"}, + {name: "ingress class", err: apierrors.NewIngressClassError("nginx", stderrors.New("missing")), want: "Failed to resolve Ingress Class nginx"}, + {name: "ingress forbidden", err: apierrors.NewIngressClassForbidden("nginx", allowed), want: "Ingress Class nginx is forbidden"}, + {name: "ingress collision", err: apierrors.NewIngressHostnameCollision("example.com"), want: "example.com is already used"}, + {name: "empty ingress hostname", err: apierrors.NewEmptyIngressHostname(api.AllowedListSpec{Exact: []string{"example.com"}, Regex: ".*\\.example\\.com"}), want: "empty hostname is not allowed"}, + {name: "ingress hostnames invalid", err: apierrors.NewIngressHostnamesNotValid([]string{"bad_host"}, []string{"other.com"}, api.AllowedListSpec{Exact: []string{"example.com"}}), want: "Hostnames [bad_host] are not valid"}, + {name: "ingress undefined", err: apierrors.NewIngressClassUndefined(allowed), want: "No Ingress Class is forbidden"}, + {name: "ingress not valid", err: apierrors.NewIngressClassNotValid("nginx", allowed), want: "Ingress Class nginx is forbidden"}, + {name: "namespace quota", err: apierrors.NewNamespaceQuotaExceededError(), want: "Cannot exceed Namespace quota"}, + {name: "node labels", err: apierrors.NewNodeLabelForbiddenError(&api.ForbiddenListSpec{Exact: []string{"node-role"}, Regex: "forbidden.*"}), want: "some labels are marked as forbidden"}, + {name: "node annotations", err: apierrors.NewNodeAnnotationForbiddenError(&api.ForbiddenListSpec{Exact: []string{"internal"}}), want: "some annotations are marked as forbidden"}, + {name: "priority class", err: apierrors.NewPriorityClassError("high", stderrors.New("missing")), want: "Failed to resolve Priority Class high"}, + {name: "pod metadata", err: apierrors.NewNoPodMetadata("pod"), want: "Skipping labels sync for pod"}, + {name: "missing registry", err: apierrors.NewMissingContainerRegistryError("nginx"), want: "missing repository"}, + {name: "registry forbidden", err: apierrors.NewContainerRegistryForbidden("docker.io/nginx", api.AllowedListSpec{Exact: []string{"ghcr.io"}, Regex: "registry.*"}), want: "registry is forbidden"}, + {name: "pull policy", err: apierrors.NewImagePullPolicyForbidden("Always", "app", []string{"IfNotPresent"}), want: "ImagePullPolicy Always"}, + {name: "pod priority forbidden", err: apierrors.NewPodPriorityClassForbidden("high", allowed), want: "Pod Priority Class high is forbidden"}, + {name: "pod runtime forbidden", err: apierrors.NewPodRuntimeClassForbidden("kata", allowed), want: "Pod Runtime Class kata is forbidden"}, + {name: "services metadata", err: apierrors.NewNoServicesMetadata("service"), want: "Skipping labels sync for service"}, + {name: "external service IP forbidden empty", err: apierrors.NewExternalServiceIPForbidden(nil), want: "does not allow the use of Service with external IPs"}, + {name: "external service IP forbidden cidr", err: apierrors.NewExternalServiceIPForbidden([]api.AllowedIP{"10.0.0.0/8"}), want: "10.0.0.0/8"}, + {name: "nodeport disabled", err: apierrors.NewNodePortDisabledError(), want: "NodePort service types are forbidden"}, + {name: "external name disabled", err: apierrors.NewExternalNameDisabledError(), want: "ExternalName service types are forbidden"}, + {name: "loadbalancer disabled", err: apierrors.NewLoadBalancerDisabled(), want: "LoadBalancer service types are forbidden"}, + {name: "storage class", err: apierrors.NewStorageClassError("fast", stderrors.New("missing")), want: "Failed to resolve Storage Class fast"}, + {name: "storage class not valid", err: apierrors.NewStorageClassNotValid(allowed), want: "A valid Storage Class must be used"}, + {name: "storage forbidden", err: apierrors.NewStorageClassForbidden("slow", allowed), want: "Storage Class slow is forbidden"}, + {name: "tenant object", err: apierrors.NewNonTenantObject("obj"), want: "doesn't belong to tenant"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.err.Error(); !strings.Contains(got, tt.want) { + t.Fatalf("Error() = %q, want substring %q", got, tt.want) + } + }) + } +} + +func TestEventedStorageErrors(t *testing.T) { + t.Parallel() + + tests := []error{ + apierrors.NewMissingTenantPVLabelsError("pv-a", events.ActionValidationDenied), + apierrors.NewCrossTenantPVMountError("pv-a", events.ActionValidationDenied), + apierrors.NewPVSelectorError(events.ActionValidationDenied), + apierrors.NewPvNotFoundError("pv-a", events.ActionValidationDenied), + } + + for _, err := range tests { + evented, ok := err.(apierrors.EventedError) + if !ok { + t.Fatalf("%T does not implement EventedError", err) + } + if evented.Reason() != events.ReasonCrossTenantReference { + t.Fatalf("Reason() = %q, want %q", evented.Reason(), events.ReasonCrossTenantReference) + } + if evented.Action() != events.ActionValidationDenied { + t.Fatalf("Action() = %q, want %q", evented.Action(), events.ActionValidationDenied) + } + if err.Error() == "" { + t.Fatalf("Error() is empty") + } + } +} + +func TestMiscErrors(t *testing.T) { + t.Parallel() + + for _, err := range []error{ + apierrors.RunningInOutOfClusterModeError{}, + apierrors.CaNotYetValidError{}, + apierrors.CaExpiredError{}, + } { + if err.Error() == "" { + t.Fatalf("%T Error() is empty", err) + } + } +} + +func TestIgnoreGone(t *testing.T) { + t.Parallel() + + if !apierrors.IgnoreGone(nil) { + t.Fatalf("IgnoreGone(nil) = false, want true") + } + if !apierrors.IgnoreGone(k8sapierrors.NewNotFound(schema.GroupResource{Group: "", Resource: "pods"}, "missing")) { + t.Fatalf("IgnoreGone(NotFound) = false, want true") + } + if !apierrors.IgnoreGone(&k8sapierrors.StatusError{ErrStatus: metav1.Status{ + Reason: metav1.StatusReasonForbidden, + Details: &metav1.StatusDetails{Causes: []metav1.StatusCause{{ + Type: corev1.NamespaceTerminatingCause, + }}}, + }}) { + t.Fatalf("IgnoreGone(namespace terminating) = false, want true") + } + if !apierrors.IgnoreGone(stderrors.New("widget not found")) { + t.Fatalf("IgnoreGone(string not found) = false, want true") + } + if apierrors.IgnoreGone(stderrors.New("boom")) { + t.Fatalf("IgnoreGone(other error) = true, want false") + } +} diff --git a/pkg/api/errors/evented_test.go b/pkg/api/errors/evented_test.go new file mode 100644 index 00000000..f9a87511 --- /dev/null +++ b/pkg/api/errors/evented_test.go @@ -0,0 +1,115 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package errors_test + +import ( + "errors" + "fmt" + "testing" + + apierrors "github.com/projectcapsule/capsule/pkg/api/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type fakeEventRecorder struct { + calls []recordedEvent + panic bool +} + +type recordedEvent struct { + regarding runtime.Object + related runtime.Object + eventType string + reason string + action string + note string + args []interface{} +} + +func (f *fakeEventRecorder) Eventf( + regarding runtime.Object, + related runtime.Object, + eventType string, + reason string, + action string, + note string, + args ...interface{}, +) { + if f.panic { + panic("recorder failed") + } + + f.calls = append(f.calls, recordedEvent{ + regarding: regarding, + related: related, + eventType: eventType, + reason: reason, + action: action, + note: note, + args: args, + }) +} + +type fakeEventedError struct{} + +func (fakeEventedError) Error() string { return "typed failure" } +func (fakeEventedError) Reason() string { return "TypedFailure" } +func (fakeEventedError) Action() string { return "Validating" } + +func TestRecordTypedErrorEvent(t *testing.T) { + t.Parallel() + + regarding := &corev1.ConfigMap{} + related := &corev1.Secret{} + + for _, tt := range []struct { + name string + recorder *fakeEventRecorder + regarding runtime.Object + err error + }{ + {name: "nil recorder", recorder: nil, regarding: regarding, err: fakeEventedError{}}, + {name: "nil regarding", recorder: &fakeEventRecorder{}, regarding: nil, err: fakeEventedError{}}, + {name: "nil error", recorder: &fakeEventRecorder{}, regarding: regarding, err: nil}, + {name: "plain error", recorder: &fakeEventRecorder{}, regarding: regarding, err: errors.New("plain")}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + apierrors.RecordTypedErrorEvent(tt.recorder, tt.regarding, related, tt.err) + if tt.recorder != nil && len(tt.recorder.calls) != 0 { + t.Fatalf("RecordTypedErrorEvent() recorded %#v, want none", tt.recorder.calls) + } + }) + } + + recorder := &fakeEventRecorder{} + apierrors.RecordTypedErrorEvent(recorder, regarding, related, fmt.Errorf("wrapped: %w", fakeEventedError{})) + if len(recorder.calls) != 1 { + t.Fatalf("RecordTypedErrorEvent() calls = %d, want 1", len(recorder.calls)) + } + + call := recorder.calls[0] + if call.regarding != regarding || call.related != related { + t.Fatalf("RecordTypedErrorEvent() objects = %#v/%#v", call.regarding, call.related) + } + if call.eventType != corev1.EventTypeWarning || call.reason != "TypedFailure" || call.action != "Validating" { + t.Fatalf("RecordTypedErrorEvent() event fields = %#v", call) + } + if call.note != "%s" || len(call.args) != 1 || call.args[0] != "wrapped: typed failure" { + t.Fatalf("RecordTypedErrorEvent() note/args = %q %#v", call.note, call.args) + } +} + +func TestRecordTypedErrorEventRecoversRecorderPanic(t *testing.T) { + t.Parallel() + + apierrors.RecordTypedErrorEvent( + &fakeEventRecorder{panic: true}, + &corev1.ConfigMap{}, + nil, + fakeEventedError{}, + ) +} diff --git a/pkg/api/meta/helpers_test.go b/pkg/api/meta/helpers_test.go new file mode 100644 index 00000000..ff99b7e4 --- /dev/null +++ b/pkg/api/meta/helpers_test.go @@ -0,0 +1,225 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package meta_test + +import ( + "context" + "reflect" + "testing" + + "github.com/projectcapsule/capsule/pkg/api/meta" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestReleaseAndReconcileAnnotations(t *testing.T) { + t.Parallel() + + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + meta.ReleaseAnnotation: "TRUE", + meta.ReconcileAnnotation: "now", + "keep": "value", + }}} + + if !meta.ReleaseAnnotationTriggers(obj) { + t.Fatalf("ReleaseAnnotationTriggers() = false, want true") + } + meta.ReleaseAnnotationRemove(obj) + if _, ok := obj.Annotations[meta.ReleaseAnnotation]; ok { + t.Fatalf("ReleaseAnnotationRemove() did not remove annotation") + } + + meta.RemoveReconcileTriggerAnnotation(obj) + if _, ok := obj.Annotations[meta.ReconcileAnnotation]; ok { + t.Fatalf("RemoveReconcileTriggerAnnotation() did not remove reconcile annotation") + } + if obj.Annotations["keep"] != "value" { + t.Fatalf("RemoveReconcileTriggerAnnotation() removed unrelated annotation") + } + + onlyReconcile := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{meta.ReconcileAnnotation: "now"}}} + meta.RemoveReconcileTriggerAnnotation(onlyReconcile) + if onlyReconcile.Annotations != nil { + t.Fatalf("RemoveReconcileTriggerAnnotation() = %#v, want nil when last annotation removed", onlyReconcile.Annotations) + } +} + +func TestTriggerRequestReconcileAnnotation(t *testing.T) { + t.Parallel() + + ctx := context.Background() + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetName("settings") + obj.SetNamespace("default") + + cl := fake.NewClientBuilder().WithObjects(obj).Build() + if err := meta.TriggerRequestReconcileAnnotation(ctx, cl, gvk, types.NamespacedName{Namespace: "default", Name: "settings"}); err != nil { + t.Fatalf("TriggerRequestReconcileAnnotation() unexpected error: %v", err) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(gvk) + if err := cl.Get(ctx, client.ObjectKey{Namespace: "default", Name: "settings"}, got); err != nil { + t.Fatalf("getting patched object: %v", err) + } + if got.GetAnnotations()[meta.ReconcileAnnotation] == "" { + t.Fatalf("TriggerRequestReconcileAnnotation() did not set annotation") + } +} + +func TestConditionHelpers(t *testing.T) { + t.Parallel() + + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Generation: 7}} + + conditions := meta.ConditionList{meta.NewReadyCondition(obj)} + if !meta.IsStatusConditionTrue(conditions, meta.ReadyCondition) { + t.Fatalf("IsStatusConditionTrue() = false, want true") + } + if meta.IsStatusConditionTrue(conditions, meta.CordonedCondition) { + t.Fatalf("IsStatusConditionTrue() = true for missing condition") + } + + var list meta.ConditionList + if updated := list.UpdateConditionByTypeWithStatus(meta.NewCordonedCondition(obj)); !updated { + t.Fatalf("UpdateConditionByTypeWithStatus() = false for appended condition") + } + if updated := list.UpdateConditionByTypeWithStatus(meta.NewCordonedCondition(obj)); updated { + t.Fatalf("UpdateConditionByTypeWithStatus() = true for identical condition") + } + + tests := []struct { + name string + cond meta.Condition + typ string + }{ + {name: "cordoned", cond: meta.NewCordonedCondition(obj), typ: meta.CordonedCondition}, + {name: "exhausted", cond: meta.NewExhaustedCondition(obj), typ: meta.ExhaustedCondition}, + {name: "bound", cond: meta.NewBoundCondition(obj), typ: meta.BoundCondition}, + {name: "assigned", cond: meta.NewAssignedCondition(obj), typ: meta.AssignedCondition}, + {name: "ready reconciling", cond: meta.NewReadyConditionReconcilingReason(obj), typ: meta.ReadyCondition}, + {name: "terminating", cond: meta.NewTerminatingConditionReason(obj), typ: meta.TerminatingCondition}, + } + + for _, tt := range tests { + if tt.cond.Type != tt.typ { + t.Fatalf("%s condition type = %q, want %q", tt.name, tt.cond.Type, tt.typ) + } + if tt.cond.Type != meta.CordonedCondition && tt.cond.ObservedGeneration != 0 && tt.cond.ObservedGeneration != obj.Generation { + t.Fatalf("%s observed generation = %d", tt.name, tt.cond.ObservedGeneration) + } + } +} + +func TestManagerAndNameHelpers(t *testing.T) { + t.Parallel() + + if got := meta.ControllerFieldOwnerPrefix("webhook"); got != "projectcapsule.dev/webhook" { + t.Fatalf("ControllerFieldOwnerPrefix() = %q", got) + } + if got := meta.ControllerFieldOwner(); got != meta.FieldManagerCapsuleController { + t.Fatalf("ControllerFieldOwner() = %q", got) + } + if got := meta.ResourceControllerFieldOwnerPrefix(); got != "projectcapsule.dev/resource/controller" { + t.Fatalf("ResourceControllerFieldOwnerPrefix() = %q", got) + } + if got := meta.NameForManagedRuleStatus(); got != "capsule-managed-rules" { + t.Fatalf("NameForManagedRuleStatus() = %q", got) + } + if got := meta.NameForManagedRoleBindings("hash"); got != "capsule:managed:hash" { + t.Fatalf("NameForManagedRoleBindings() = %q", got) + } + if got := meta.NameForManagedPoolResourceQuota("pool-a"); got != "capsule-pool-pool-a" { + t.Fatalf("NameForManagedPoolResourceQuota() = %q", got) + } +} + +func TestCapsuleFieldOwners(t *testing.T) { + t.Parallel() + + obj := &unstructured.Unstructured{} + obj.SetManagedFields([]metav1.ManagedFieldsEntry{ + {Manager: "projectcapsule.dev/controller"}, + {Manager: "projectcapsule.dev/resource/controller"}, + {Manager: "kubectl"}, + {Manager: ""}, + }) + + got := meta.CapsuleFieldOwners(obj, meta.FieldManagerCapsulePrefix) + want := map[string]struct{}{ + "projectcapsule.dev/controller": {}, + "projectcapsule.dev/resource/controller": {}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("CapsuleFieldOwners() = %#v, want %#v", got, want) + } + if !meta.HasExactlyCapsuleOwners(obj, meta.FieldManagerCapsulePrefix, []string{ + "projectcapsule.dev/controller", + "projectcapsule.dev/resource/controller", + }) { + t.Fatalf("HasExactlyCapsuleOwners() = false, want true") + } + if meta.HasExactlyCapsuleOwners(obj, meta.FieldManagerCapsulePrefix, []string{"projectcapsule.dev/controller"}) { + t.Fatalf("HasExactlyCapsuleOwners() = true, want false for missing allowed owner") + } + if owners := meta.CapsuleFieldOwners(nil, meta.FieldManagerCapsulePrefix); len(owners) != 0 { + t.Fatalf("CapsuleFieldOwners(nil) = %#v, want empty", owners) + } +} + +func TestReferenceStrings(t *testing.T) { + t.Parallel() + + if got := meta.RFC1123Name("name").String(); got != "name" { + t.Fatalf("RFC1123Name.String() = %q", got) + } + if got := meta.RFC1123SubdomainName("namespace").String(); got != "namespace" { + t.Fatalf("RFC1123SubdomainName.String() = %q", got) + } +} + +func TestLabelsChangedUnstructuredAndSelectorKeys(t *testing.T) { + t.Parallel() + + oldObj := &unstructured.Unstructured{} + oldObj.SetLabels(map[string]string{"app": "api"}) + newObj := oldObj.DeepCopy() + newObj.SetLabels(map[string]string{"app": "worker"}) + + if !meta.LabelsChangedUnstructured(*oldObj, *newObj) { + t.Fatalf("LabelsChangedUnstructured() = false, want true") + } + + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "api"}, + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "tier", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"backend"}, + }}, + } + if got := meta.LabelSelectorKeys(selector); !reflect.DeepEqual(got, map[string]struct{}{"app": {}, "tier": {}}) { + t.Fatalf("LabelSelectorKeys() = %#v, want app and tier", got) + } + if got := meta.LabelSelectorKeys(nil); len(got) != 0 { + t.Fatalf("LabelSelectorKeys(nil) = %#v, want empty", got) + } +} + +func TestHelpersSchemeImport(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } +} diff --git a/pkg/api/processor/accumulator_test.go b/pkg/api/processor/accumulator_test.go new file mode 100644 index 00000000..ba5c749b --- /dev/null +++ b/pkg/api/processor/accumulator_test.go @@ -0,0 +1,42 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package processor_test + +import ( + "testing" + + "github.com/projectcapsule/capsule/pkg/api/processor" + "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestAccumulatorAdd(t *testing.T) { + t.Parallel() + + resource := gvk.ResourceID{Version: "v1", Kind: "ConfigMap", Namespace: "tenant-a", Name: "settings"} + obj := processor.AccumulatorObject{ + Origin: gvk.TenantResourceIDWithOrigin{TenantResourceID: gvk.TenantResourceID{Tenant: "tenant-a"}, Origin: "resource-a"}, + Object: &unstructured.Unstructured{}, + } + + processor.AccumulatorAdd(nil, resource, obj) + + acc := processor.Accumulator{} + processor.AccumulatorAdd(acc, resource, obj) + processor.AccumulatorAdd(acc, resource, obj) + + entry := acc[resource.GetKey("")] + if entry == nil { + t.Fatalf("AccumulatorAdd() did not create entry") + } + if entry.Objects == nil || len(*entry.Objects) != 2 { + t.Fatalf("AccumulatorAdd() objects = %#v, want two objects", entry.Objects) + } + + entry.Objects = nil + processor.AccumulatorAdd(acc, resource, obj) + if entry.Objects == nil || len(*entry.Objects) != 1 { + t.Fatalf("AccumulatorAdd() did not initialize nil object slice") + } +} diff --git a/pkg/api/rbac/string_helpers_test.go b/pkg/api/rbac/string_helpers_test.go new file mode 100644 index 00000000..dc276c7e --- /dev/null +++ b/pkg/api/rbac/string_helpers_test.go @@ -0,0 +1,67 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rbac_test + +import ( + "reflect" + "testing" + + "github.com/projectcapsule/capsule/pkg/api/rbac" +) + +func TestRBACStringHelpers(t *testing.T) { + t.Parallel() + + if got := rbac.OwnerKind("CustomOwner").String(); got != "CustomOwner" { + t.Fatalf("OwnerKind.String() = %q", got) + } + if got := rbac.UserKind("CustomUser").String(); got != "CustomUser" { + t.Fatalf("UserKind.String() = %q", got) + } + if got := rbac.ProxyOperation("Watch").String(); got != "Watch" { + t.Fatalf("ProxyOperation.String() = %q", got) + } + if got := rbac.ProxyServiceKind("Tenants").String(); got != "Tenants" { + t.Fatalf("ProxyServiceKind.String() = %q", got) + } +} + +func TestOwnerListSpecOwnershipAndStatusConversion(t *testing.T) { + t.Parallel() + + owners := rbac.OwnerListSpec{ + {CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}}}, + {CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Kind: rbac.GroupOwner, Name: "team-a"}}}, + {CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}}}, + } + + for _, tt := range []struct { + name string + user string + groups []string + want bool + }{ + {name: "direct user", user: "alice", want: true}, + {name: "group", user: "bob", groups: []string{"team-a"}, want: true}, + {name: "service account", user: "system:serviceaccount:tenant-a:builder", want: true}, + {name: "not owner", user: "mallory", groups: []string{"team-b"}, want: false}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := owners.IsOwner(tt.user, tt.groups); got != tt.want { + t.Fatalf("IsOwner() = %t, want %t", got, tt.want) + } + }) + } + + wantStatus := rbac.OwnerStatusListSpec{ + {UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}}, + {UserSpec: rbac.UserSpec{Kind: rbac.GroupOwner, Name: "team-a"}}, + {UserSpec: rbac.UserSpec{Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}}, + } + if got := owners.ToStatusOwners(); !reflect.DeepEqual(got, wantStatus) { + t.Fatalf("ToStatusOwners() = %#v, want %#v", got, wantStatus) + } +} diff --git a/pkg/api/rules/enforce_func_test.go b/pkg/api/rules/enforce_func_test.go new file mode 100644 index 00000000..770cd9b0 --- /dev/null +++ b/pkg/api/rules/enforce_func_test.go @@ -0,0 +1,60 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules_test + +import ( + "testing" + + "github.com/projectcapsule/capsule/pkg/api/rules" + corev1 "k8s.io/api/core/v1" +) + +func TestActionTypeOrDefault(t *testing.T) { + t.Parallel() + + if got := rules.ActionType("").OrDefault(); got != rules.ActionTypeDeny { + t.Fatalf("empty action OrDefault() = %q, want deny", got) + } + if got := rules.ActionTypeAllow.OrDefault(); got != rules.ActionTypeAllow { + t.Fatalf("allow OrDefault() = %q, want allow", got) + } +} + +func TestNamespaceRuleEnforceBodyWorkloadTargets(t *testing.T) { + t.Parallel() + + empty := rules.NamespaceRuleEnforceBody{} + if !empty.GetWorkloadTargets(rules.ValidateContainers) { + t.Fatalf("empty workload targets should match any target") + } + if !empty.WorkloadTargetsAny(rules.ValidateContainers, rules.ValidateVolumes) { + t.Fatalf("empty workload targets should match any candidate") + } + + enforce := rules.NamespaceRuleEnforceBody{ + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: []rules.WorkloadValidationTarget{rules.ValidateContainers}, + }, + } + if !enforce.GetWorkloadTargets(rules.ValidateContainers) { + t.Fatalf("GetWorkloadTargets(containers) = false, want true") + } + if enforce.GetWorkloadTargets(rules.ValidateVolumes) { + t.Fatalf("GetWorkloadTargets(volumes) = true, want false") + } + if !enforce.WorkloadTargetsAny(rules.ValidateVolumes, rules.ValidateContainers) { + t.Fatalf("WorkloadTargetsAny() = false, want true when one target matches") + } + if enforce.WorkloadTargetsAny(rules.ValidateVolumes, rules.ValidateInitContainers) { + t.Fatalf("WorkloadTargetsAny() = true, want false when no target matches") + } +} + +func TestImagePullPolicySpecString(t *testing.T) { + t.Parallel() + + if got := rules.ImagePullPolicySpec(corev1.PullAlways).String(); got != "Always" { + t.Fatalf("String() = %q, want Always", got) + } +} diff --git a/pkg/api/runtime/expression_description_test.go b/pkg/api/runtime/expression_description_test.go new file mode 100644 index 00000000..eefb4b68 --- /dev/null +++ b/pkg/api/runtime/expression_description_test.go @@ -0,0 +1,62 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtime_test + +import ( + "testing" + + capsuleruntime "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestDescribeExpressionMatch(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + match capsuleruntime.ExpressionMatch + want string + }{ + { + name: "empty", + match: capsuleruntime.ExpressionMatch{}, + want: "", + }, + { + name: "negated empty", + match: capsuleruntime.ExpressionMatch{ExpressionRegex: capsuleruntime.ExpressionRegex{Negate: true}}, + want: "not ", + }, + { + name: "exact", + match: capsuleruntime.ExpressionMatch{Exact: []string{"a", "b"}}, + want: "exact: a, b", + }, + { + name: "expression", + match: capsuleruntime.ExpressionMatch{ + ExpressionRegex: capsuleruntime.ExpressionRegex{Expression: "^team-"}, + }, + want: "exp: ^team-", + }, + { + name: "exact and expression negated", + match: capsuleruntime.ExpressionMatch{ + Exact: []string{"a"}, + ExpressionRegex: capsuleruntime.ExpressionRegex{Expression: "^team-", Negate: true}, + }, + want: "not exact: a; not exp: ^team-", + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := capsuleruntime.DescribeExpressionMatch(tt.match); got != tt.want { + t.Fatalf("DescribeExpressionMatch() = %q, want %q", got, tt.want) + } + if got := tt.match.Describe(); got != tt.want { + t.Fatalf("ExpressionMatch.Describe() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/api/scope_test.go b/pkg/api/scope_test.go new file mode 100644 index 00000000..7f7bed70 --- /dev/null +++ b/pkg/api/scope_test.go @@ -0,0 +1,28 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package api_test + +import ( + "testing" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func TestResourceScopeString(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + scope api.ResourceScope + want string + }{ + {scope: api.ResourceScopeNamespace, want: "Namespace"}, + {scope: api.ResourceScopeTenant, want: "Tenant"}, + {scope: api.ResourceScopeNone, want: "None"}, + {scope: api.ResourceScope("Custom"), want: "Custom"}, + } { + if got := tt.scope.String(); got != tt.want { + t.Fatalf("ResourceScope.String() = %q, want %q", got, tt.want) + } + } +} diff --git a/pkg/runtime/admission/accumulation_test.go b/pkg/runtime/admission/accumulation_test.go new file mode 100644 index 00000000..0292b63b --- /dev/null +++ b/pkg/runtime/admission/accumulation_test.go @@ -0,0 +1,53 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package admission_test + +import ( + "reflect" + "testing" + + capsuleadmission "github.com/projectcapsule/capsule/pkg/runtime/admission" + jsonpatch "gomodules.xyz/jsonpatch/v2" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestAccumulateAdmissionResponse(t *testing.T) { + t.Parallel() + + seed := []jsonpatch.JsonPatchOperation{ + jsonpatch.NewOperation("add", "/metadata/labels/existing", "true"), + } + + got, response := capsuleadmission.AccumulateAdmissionResponse(seed, nil) + if response != nil { + t.Fatalf("nil response returned admission response %#v", response) + } + if !reflect.DeepEqual(got, seed) { + t.Fatalf("nil response patches = %#v, want %#v", got, seed) + } + + denied := admission.Denied("stop") + got, response = capsuleadmission.AccumulateAdmissionResponse(seed, &denied) + if response != &denied { + t.Fatalf("denied response = %#v, want original response", response) + } + if !reflect.DeepEqual(got, seed) { + t.Fatalf("denied patches = %#v, want %#v", got, seed) + } + + patches := []jsonpatch.JsonPatchOperation{ + jsonpatch.NewOperation("add", "/metadata/labels/new", "value"), + jsonpatch.NewOperation("replace", "/metadata/name", "renamed"), + } + allowed := admission.Patched("mutated", patches...) + got, response = capsuleadmission.AccumulateAdmissionResponse(seed, &allowed) + if response != nil { + t.Fatalf("allowed response returned admission response %#v", response) + } + + want := append(append([]jsonpatch.JsonPatchOperation{}, seed...), patches...) + if !reflect.DeepEqual(got, want) { + t.Fatalf("allowed patches = %#v, want %#v", got, want) + } +} diff --git a/pkg/runtime/admission/response_helpers_test.go b/pkg/runtime/admission/response_helpers_test.go new file mode 100644 index 00000000..2d287fde --- /dev/null +++ b/pkg/runtime/admission/response_helpers_test.go @@ -0,0 +1,48 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package admission + +import ( + "errors" + "strings" + "testing" +) + +func TestAdmissionResponseHelpers(t *testing.T) { + t.Parallel() + + denied := Deny("blocked") + if denied == nil || denied.Allowed { + t.Fatalf("Deny() = %#v, want denied response", denied) + } + if denied.Result == nil || denied.Result.Message != "blocked" { + t.Fatalf("Deny() message = %#v", denied.Result) + } + + denied = Denyf("blocked %s", "tenant") + if denied == nil || denied.Allowed || denied.Result == nil || denied.Result.Message != "blocked tenant" { + t.Fatalf("Denyf() = %#v", denied) + } + + allowed := Allow("ok") + if allowed == nil || !allowed.Allowed { + t.Fatalf("Allow() = %#v, want allowed response", allowed) + } + if allowed.Result == nil || allowed.Result.Message != "ok" { + t.Fatalf("Allow() message = %#v", allowed.Result) + } + + allowed = Allowf("ok %s", "tenant") + if allowed == nil || !allowed.Allowed || allowed.Result == nil || allowed.Result.Message != "ok tenant" { + t.Fatalf("Allowf() = %#v", allowed) + } + + errResponse := ErroredResponse(errors.New("boom")) + if errResponse == nil || errResponse.Allowed || errResponse.Result == nil { + t.Fatalf("ErroredResponse() = %#v", errResponse) + } + if errResponse.Result.Code != 500 || !strings.Contains(errResponse.Result.Message, "boom") { + t.Fatalf("ErroredResponse() result = %#v", errResponse.Result) + } +} diff --git a/pkg/runtime/configuration/configuration_test.go b/pkg/runtime/configuration/configuration_test.go new file mode 100644 index 00000000..d6d9115f --- /dev/null +++ b/pkg/runtime/configuration/configuration_test.go @@ -0,0 +1,283 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package configuration_test + +import ( + "context" + "reflect" + "testing" + "time" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + capsulemeta "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestDefaultCapsuleConfiguration(t *testing.T) { + t.Parallel() + + got := configuration.DefaultCapsuleConfiguration() + + if !reflect.DeepEqual(got.Users, rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "projectcapsule.dev"}}) { + t.Fatalf("DefaultCapsuleConfiguration().Users = %#v", got.Users) + } + if got.CacheInvalidation.Duration != time.Hour { + t.Fatalf("CacheInvalidation = %s, want 1h", got.CacheInvalidation.Duration) + } + if got.RBAC == nil || got.RBAC.DeleterClusterRole != "capsule-namespace-deleter" { + t.Fatalf("RBAC defaults = %#v, want namespace deleter defaults", got.RBAC) + } +} + +func TestEnvironmentHelpers(t *testing.T) { + t.Setenv(configuration.EnvironmentServiceaccountName, "capsule-controller") + t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") + + if got := configuration.ControllerNamespace(); got != "capsule-system" { + t.Fatalf("ControllerNamespace() = %q, want capsule-system", got) + } + + name, namespace := configuration.ControllerServiceAccount() + if name != "capsule-controller" || namespace != "capsule-system" { + t.Fatalf("ControllerServiceAccount() = %q/%q, want capsule-controller/capsule-system", namespace, name) + } + + if !configuration.IsControllerServiceAccount("capsule-controller", "capsule-system") { + t.Fatalf("IsControllerServiceAccount() = false, want true") + } + if configuration.IsControllerServiceAccount("other", "capsule-system") { + t.Fatalf("IsControllerServiceAccount() = true, want false") + } +} + +func TestNewCapsuleConfigurationCreatesDefaultWhenMissing(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := configurationFakeClient(t) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{Host: "https://kubernetes.default"}, "capsule") + + got := cfg.GetConfigObject() + if got.Name != "capsule" { + t.Fatalf("GetConfigObject().Name = %q, want capsule", got.Name) + } + if !reflect.DeepEqual(got.Spec.Users, configuration.DefaultCapsuleConfiguration().Users) { + t.Fatalf("default config users = %#v, want %#v", got.Spec.Users, configuration.DefaultCapsuleConfiguration().Users) + } + + var stored capsulev1beta2.CapsuleConfiguration + if err := cl.Get(ctx, client.ObjectKey{Name: "capsule"}, &stored); err != nil { + t.Fatalf("created configuration was not stored: %v", err) + } +} + +func TestCapsuleConfigurationGetters(t *testing.T) { + t.Parallel() + + ctx := context.Background() + stored := &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + ProtectedNamespaceRegexpString: "^(kube|openshift)-", + ForceTenantPrefix: true, + EnableTLSReconciler: true, + AllowServiceAccountPromotion: true, + CapsuleResources: capsulev1beta2.CapsuleResources{ + TLSSecretName: "capsule-tls", + }, + Users: rbac.UserListSpec{ + {Kind: rbac.UserOwner, Name: "alice"}, + {Kind: rbac.GroupOwner, Name: "developers"}, + {Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}, + }, + UserNames: []string{"legacy-user"}, + UserGroups: []string{"legacy-group"}, + IgnoreUserWithGroups: []string{"ignored"}, + Administrators: rbac.UserListSpec{{Kind: rbac.UserOwner, Name: "admin"}}, + NodeMetadata: &capsulev1beta2.NodeMetadata{ + ForbiddenLabels: capsuleapi.ForbiddenListSpec{Exact: []string{"node-role.kubernetes.io/control-plane"}}, + ForbiddenAnnotations: capsuleapi.ForbiddenListSpec{Regex: "internal/.*"}, + }, + RBAC: &capsulev1beta2.RBACConfiguration{DeleterClusterRole: "delete-role"}, + CacheInvalidation: metav1.Duration{ + Duration: 2 * time.Hour, + }, + Impersonation: capsulev1beta2.ServiceAccountClient{ + Endpoint: "https://impersonation.example", + SkipTLSVerify: true, + }, + Events: capsulev1beta2.EventsConfiguration{ClusterEventNamespace: "audit"}, + }, + Status: capsulev1beta2.CapsuleConfigurationStatus{ + Users: rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "status-group"}}, + }, + } + cl := configurationFakeClient(t, stored) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{ + Host: "https://kubernetes.default", + TLSClientConfig: rest.TLSClientConfig{ + CAData: []byte("ca"), + CAFile: "ca-file", + }, + }, "capsule") + + regex, err := cfg.ProtectedNamespaceRegexp() + if err != nil { + t.Fatalf("ProtectedNamespaceRegexp() unexpected error: %v", err) + } + if !regex.MatchString("kube-system") { + t.Fatalf("ProtectedNamespaceRegexp() did not match kube-system") + } + + if !cfg.ForceTenantPrefix() || !cfg.EnableTLSConfiguration() || !cfg.AllowServiceAccountPromotion() { + t.Fatalf("boolean getters did not return configured true values") + } + if cfg.TLSSecretName() != "capsule-tls" { + t.Fatalf("TLSSecretName() = %q, want capsule-tls", cfg.TLSSecretName()) + } + if cfg.TenantCRDName() != configuration.TenantCRDName { + t.Fatalf("TenantCRDName() = %q, want %q", cfg.TenantCRDName(), configuration.TenantCRDName) + } + if !reflect.DeepEqual(cfg.IgnoreUserWithGroups(), []string{"ignored"}) { + t.Fatalf("IgnoreUserWithGroups() = %#v", cfg.IgnoreUserWithGroups()) + } + if !reflect.DeepEqual(cfg.GetUsersByStatus(), rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "status-group"}}) { + t.Fatalf("GetUsersByStatus() = %#v", cfg.GetUsersByStatus()) + } + if cfg.ForbiddenUserNodeLabels() == nil || cfg.ForbiddenUserNodeLabels().Exact[0] != "node-role.kubernetes.io/control-plane" { + t.Fatalf("ForbiddenUserNodeLabels() = %#v", cfg.ForbiddenUserNodeLabels()) + } + if cfg.ForbiddenUserNodeAnnotations() == nil || cfg.ForbiddenUserNodeAnnotations().Regex != "internal/.*" { + t.Fatalf("ForbiddenUserNodeAnnotations() = %#v", cfg.ForbiddenUserNodeAnnotations()) + } + if cfg.Administrators()[0].Name != "admin" { + t.Fatalf("Administrators() = %#v", cfg.Administrators()) + } + if cfg.Events().ClusterEventNamespace != "audit" { + t.Fatalf("Events() = %#v", cfg.Events()) + } + if cfg.RBAC().DeleterClusterRole != "delete-role" { + t.Fatalf("RBAC() = %#v", cfg.RBAC()) + } + if cfg.CacheInvalidation().Duration != 2*time.Hour { + t.Fatalf("CacheInvalidation() = %s", cfg.CacheInvalidation().Duration) + } + + wantUsers := rbac.UserListSpec{ + {Kind: rbac.GroupOwner, Name: "developers"}, + {Kind: rbac.GroupOwner, Name: "legacy-group"}, + {Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}, + {Kind: rbac.UserOwner, Name: "alice"}, + {Kind: rbac.UserOwner, Name: "legacy-user"}, + } + if got := cfg.Users(); !reflect.DeepEqual(got, wantUsers) { + t.Fatalf("Users() = %#v, want %#v", got, wantUsers) + } + + saClient, err := cfg.ServiceAccountClient(ctx) + if err != nil { + t.Fatalf("ServiceAccountClient() unexpected error: %v", err) + } + if saClient.Host != "https://impersonation.example" || + !saClient.Insecure || + len(saClient.TLSClientConfig.CAData) != 0 || + saClient.TLSClientConfig.CAFile != "" { + t.Fatalf("ServiceAccountClient() = %#v, want endpoint with insecure TLS", saClient) + } +} + +func TestProtectedNamespaceRegexpErrors(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := configurationFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + ProtectedNamespaceRegexpString: "[", + }, + }) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + + if _, err := cfg.ProtectedNamespaceRegexp(); err == nil { + t.Fatalf("ProtectedNamespaceRegexp() expected error") + } +} + +func TestServiceAccountClientLoadsCASecret(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := configurationFakeClient(t, + &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Impersonation: capsulev1beta2.ServiceAccountClient{ + Endpoint: "https://impersonation.example", + CASecretNamespace: capsulemeta.RFC1123SubdomainName("capsule-system"), + CASecretName: capsulemeta.RFC1123Name("ca"), + CASecretKey: "ca.crt", + }, + }, + }, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "capsule-system", Name: "ca"}, Data: map[string][]byte{"ca.crt": []byte("secret-ca")}}, + ) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{ + Host: "https://kubernetes.default", + TLSClientConfig: rest.TLSClientConfig{CAFile: "old"}, + }, "capsule") + + got, err := cfg.ServiceAccountClient(ctx) + if err != nil { + t.Fatalf("ServiceAccountClient() unexpected error: %v", err) + } + if got.Host != "https://impersonation.example" || string(got.TLSClientConfig.CAData) != "secret-ca" || got.TLSClientConfig.CAFile != "" { + t.Fatalf("ServiceAccountClient() = %#v, want CA data from secret", got) + } +} + +func TestServiceAccountClientMissingCASecretKey(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := configurationFakeClient(t, + &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Impersonation: capsulev1beta2.ServiceAccountClient{ + CASecretNamespace: capsulemeta.RFC1123SubdomainName("capsule-system"), + CASecretName: capsulemeta.RFC1123Name("ca"), + CASecretKey: "missing", + }, + }, + }, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "capsule-system", Name: "ca"}, Data: map[string][]byte{"ca.crt": []byte("secret-ca")}}, + ) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + + if _, err := cfg.ServiceAccountClient(ctx); err == nil { + t.Fatalf("ServiceAccountClient() expected error for missing CA key") + } +} + +func configurationFakeClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} diff --git a/pkg/runtime/events/recorder_test.go b/pkg/runtime/events/recorder_test.go new file mode 100644 index 00000000..9890d1e6 --- /dev/null +++ b/pkg/runtime/events/recorder_test.go @@ -0,0 +1,157 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package events_test + +import ( + "context" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/klog/v2/klogr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestLabeledEventAccessorsAndCopies(t *testing.T) { + t.Parallel() + + recorder := events.NewEventRecorder(nil, klogr.New(), nil, nil) + pod := podObject("tenant-a", "api") + related := podObject("tenant-a", "sidecar") + + event := recorder.LabeledEvent(pod, corev1.EventTypeWarning, events.ReasonForbiddenMetadata, events.ActionValidationDenied, "denied"). + WithRelated(related). + WithLabels(map[string]string{"existing": "label"}). + WithAnnotations(map[string]string{"existing": "annotation"}). + WithTenantLabel(&capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: "tenant-a"}}). + WithRequestAnnotations(admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + UID: types.UID("request-uid"), + UserInfo: authenticationv1.UserInfo{ + Username: "alice", + }, + }}) + + if event.Reason() != events.ReasonForbiddenMetadata || event.Action() != events.ActionValidationDenied { + t.Fatalf("unexpected reason/action: %s/%s", event.Reason(), event.Action()) + } + if event.Regarding() != pod || event.Related() != related { + t.Fatalf("unexpected regarding/related objects") + } + if event.Note() != "denied" || event.EventType() != corev1.EventTypeWarning { + t.Fatalf("unexpected note/type: %q/%q", event.Note(), event.EventType()) + } + + labels := event.Labels() + labels["existing"] = "changed" + if event.Labels()["existing"] != "label" { + t.Fatalf("Labels() did not return a copy") + } + if event.Labels()[meta.NewTenantLabel] != "tenant-a" { + t.Fatalf("WithTenantLabel() did not set tenant label") + } + + annotations := event.Annotations() + annotations["existing"] = "changed" + if event.Annotations()["existing"] != "annotation" { + t.Fatalf("Annotations() did not return a copy") + } + if event.Annotations()[meta.AuditRequestUID] != "request-uid" || event.Annotations()[meta.AuditUsername] != "alice" { + t.Fatalf("WithRequestAnnotations() annotations = %#v", event.Annotations()) + } +} + +func TestLabeledEventEmitCreatesEvent(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := eventsFakeClient(t, + &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Events: capsulev1beta2.EventsConfiguration{ClusterEventNamespace: "audit"}, + }, + }, + ) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + recorder := events.NewEventRecorder(cl, klogr.New(), nil, cfg) + + recorder.LabeledEvent( + podObject("", "cluster-object"), + corev1.EventTypeWarning, + events.ReasonAdmissionFailure, + events.ActionValidationDenied, + "blocked", + ).WithLabels(map[string]string{"capsule.clastix.io/test": "true"}).Emit(ctx) + + var eventList eventsv1.EventList + if err := cl.List(ctx, &eventList, client.InNamespace("audit")); err != nil { + t.Fatalf("listing emitted events: %v", err) + } + if len(eventList.Items) != 1 { + t.Fatalf("emitted events = %d, want 1", len(eventList.Items)) + } + + got := eventList.Items[0] + if got.ReportingController != events.ReportingController || got.ReportingInstance != events.ReportingInstance { + t.Fatalf("reporting fields = %q/%q", got.ReportingController, got.ReportingInstance) + } + if got.Reason != events.ReasonAdmissionFailure || got.Action != events.ActionValidationDenied || got.Note != "blocked" { + t.Fatalf("event payload = reason %q action %q note %q", got.Reason, got.Action, got.Note) + } + if got.Namespace != "audit" || got.Regarding.Name != "cluster-object" || got.Labels["capsule.clastix.io/test"] != "true" { + t.Fatalf("event metadata = %#v", got) + } +} + +func TestLabeledEventEmitNoopsForInvalidInputs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + recorder := events.NewEventRecorder(nil, klogr.New(), nil, nil) + recorder.LabeledEvent(podObject("tenant-a", "api"), corev1.EventTypeWarning, "", events.ActionValidationDenied, "missing reason").Emit(ctx) +} + +func eventsFakeClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := eventsv1.AddToScheme(scheme); err != nil { + t.Fatalf("adding events scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func podObject(namespace, name string) *corev1.Pod { + return &corev1.Pod{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + UID: types.UID(name + "-uid"), + }, + } +} diff --git a/pkg/runtime/gvk/resources_test.go b/pkg/runtime/gvk/resources_test.go new file mode 100644 index 00000000..4ae97896 --- /dev/null +++ b/pkg/runtime/gvk/resources_test.go @@ -0,0 +1,82 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package gvk_test + +import ( + "reflect" + "testing" + + "github.com/projectcapsule/capsule/pkg/runtime/gvk" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestNamespacedListableResources(t *testing.T) { + t.Parallel() + + got, err := gvk.NamespacedListableResources([]*metav1.APIResourceList{{ + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + {Name: "deployments", Namespaced: true, Verbs: metav1.Verbs{"list", "patch"}}, + {Name: "deployments/status", Namespaced: true, Verbs: metav1.Verbs{"list", "patch"}}, + {Name: "daemonsets", Namespaced: true, Verbs: metav1.Verbs{"list"}}, + {Name: "statefulsets", Namespaced: true, Verbs: metav1.Verbs{"list", "update"}}, + {Name: "clusterthings", Namespaced: false, Verbs: metav1.Verbs{"list", "patch"}}, + {Name: "deployments", Namespaced: true, Verbs: metav1.Verbs{"list", "patch"}}, + }, + }}) + if err != nil { + t.Fatalf("NamespacedListableResources() unexpected error: %v", err) + } + + want := []schema.GroupVersionResource{ + {Group: "apps", Version: "v1", Resource: "deployments"}, + {Group: "apps", Version: "v1", Resource: "statefulsets"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("NamespacedListableResources() = %#v, want %#v", got, want) + } + + if _, err := gvk.NamespacedListableResources([]*metav1.APIResourceList{{GroupVersion: "not/a/group/version"}}); err == nil { + t.Fatalf("NamespacedListableResources() expected parse error") + } +} + +func TestSupportsVerb(t *testing.T) { + t.Parallel() + + if !gvk.SupportsVerb(metav1.Verbs{"get", "list"}, "list") { + t.Fatalf("SupportsVerb() = false, want true") + } + if gvk.SupportsVerb(metav1.Verbs{"get"}, "list") { + t.Fatalf("SupportsVerb() = true, want false") + } +} + +func TestResourceIDHelpers(t *testing.T) { + t.Parallel() + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}) + u.SetNamespace("tenant-a") + u.SetName("api") + + id := gvk.NewResourceID(u, "tenant", "origin") + if id.GetName() != "api" || id.GetNamespace() != "tenant-a" { + t.Fatalf("NewResourceID() identity = %#v", id) + } + if got := id.GetGVK(); got != (schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}) { + t.Fatalf("GetGVK() = %#v", got) + } + if got := id.GetGVKKey("/"); got != "apps/v1/Deployment/tenant-a/api/" { + t.Fatalf("GetGVKKey() = %q", got) + } + if got := id.GetKey("/"); got != "apps/v1/Deployment/tenant-a/api/tenant/origin/" { + t.Fatalf("GetKey() = %q", got) + } + if got := id.FieldOwner(""); got != "tenant-a/tenant/origin/" { + t.Fatalf("FieldOwner() = %q", got) + } +} diff --git a/pkg/runtime/handlers/handlers_test.go b/pkg/runtime/handlers/handlers_test.go new file mode 100644 index 00000000..a8a971a6 --- /dev/null +++ b/pkg/runtime/handlers/handlers_test.go @@ -0,0 +1,322 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package handlers_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestErroredResponse(t *testing.T) { + t.Parallel() + + resp := handlers.ErroredResponse(errors.New("boom")) + if resp == nil { + t.Fatalf("ErroredResponse() = nil") + } + if resp.Allowed { + t.Fatalf("ErroredResponse().Allowed = true, want false") + } + if resp.Result == nil || resp.Result.Code != 500 { + t.Fatalf("ErroredResponse().Result = %#v, want status 500", resp.Result) + } +} + +func TestResolveAdmissionUser(t *testing.T) { + ctx := context.Background() + cl := handlersFakeClient(t, + &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Administrators: rbac.UserListSpec{{Kind: rbac.UserOwner, Name: "admin"}}, + IgnoreUserWithGroups: []string{"ignored"}, + }, + Status: capsulev1beta2.CapsuleConfigurationStatus{ + Users: rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "capsule-users"}}, + }, + }, + ) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + + t.Run("controller service account is admin", func(t *testing.T) { + t.Setenv(configuration.EnvironmentServiceaccountName, "controller") + t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") + + user := handlers.ResolveAdmissionUser(ctx, cl, requestFor("system:serviceaccount:capsule-system:controller", nil), cfg) + if !user.IsAdmin() { + t.Fatalf("ResolveAdmissionUser() = %#v, want admin", user) + } + }) + + t.Run("ignored group remains unknown", func(t *testing.T) { + user := handlers.ResolveAdmissionUser(ctx, cl, requestFor("admin", []string{"ignored"}), cfg) + if !user.IsUnknown() { + t.Fatalf("ResolveAdmissionUser() = %#v, want unknown", user) + } + }) + + t.Run("configured administrator is admin", func(t *testing.T) { + user := handlers.ResolveAdmissionUser(ctx, cl, requestFor("admin", nil), cfg) + if !user.IsAdmin() { + t.Fatalf("ResolveAdmissionUser() = %#v, want admin", user) + } + }) + + t.Run("configured capsule group is capsule user", func(t *testing.T) { + user := handlers.ResolveAdmissionUser(ctx, cl, requestFor("alice", []string{"capsule-users"}), cfg) + if !user.IsCapsule() { + t.Fatalf("ResolveAdmissionUser() = %#v, want capsule user", user) + } + }) +} + +func TestInCapsuleGroupsWrapper(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := handlersFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Status: capsulev1beta2.CapsuleConfigurationStatus{ + Users: rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "capsule-users"}}, + }, + }) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + spy := &spyHandler{} + wrapped := handlers.InCapsuleGroups(cfg, spy) + + if resp := wrapped.OnCreate(cl, cl, nil, nil)(ctx, requestFor("alice", []string{"other"})); resp != nil { + t.Fatalf("OnCreate() = %#v, want nil for non-capsule user", resp) + } + if spy.createCalls != 0 { + t.Fatalf("inner handler called for non-capsule user") + } + + resp := wrapped.OnCreate(cl, cl, nil, nil)(ctx, requestFor("alice", []string{"capsule-users"})) + if resp == nil || resp.Allowed { + t.Fatalf("OnCreate() = %#v, want inner denial response", resp) + } + if spy.createCalls != 1 { + t.Fatalf("inner create calls = %d, want 1", spy.createCalls) + } +} + +func TestIsNotPrivilegedWrapper(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := handlersFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Administrators: rbac.UserListSpec{{Kind: rbac.UserOwner, Name: "admin"}}, + }, + }) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + spy := &spyHandler{} + wrapped := handlers.IsNotPrivileged(cfg, spy) + + if resp := wrapped.OnDelete(cl, cl, nil, nil)(ctx, requestFor("admin", nil)); resp != nil { + t.Fatalf("OnDelete() = %#v, want nil for admin user", resp) + } + if spy.deleteCalls != 0 { + t.Fatalf("inner handler called for admin user") + } + + resp := wrapped.OnDelete(cl, cl, nil, nil)(ctx, requestFor("alice", nil)) + if resp == nil || resp.Allowed { + t.Fatalf("OnDelete() = %#v, want inner denial response", resp) + } + if spy.deleteCalls != 1 { + t.Fatalf("inner delete calls = %d, want 1", spy.deleteCalls) + } +} + +func TestTypedTenantHandlerOnCreate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := handlersScheme(t) + cl := handlersFakeClientWithScheme(t, scheme, + &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: "tenant-a", UID: types.UID("tenant-uid")}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a-ns", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: "Tenant", + Name: "tenant-a", + UID: types.UID("tenant-uid"), + }}, + }}, + ) + decoder := admission.NewDecoder(scheme) + spy := &typedTenantSpy{} + handler := &handlers.TypedTenantHandler[*corev1.ConfigMap]{ + Factory: func() *corev1.ConfigMap { return &corev1.ConfigMap{} }, + Handlers: []handlers.TypedHandlerWithTenant[*corev1.ConfigMap]{spy}, + } + + resp := handler.OnCreate(cl, cl, decoder, nil)(ctx, admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Namespace: "tenant-a-ns", + Object: rawExtension(t, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "settings", Namespace: "tenant-a-ns"}}), + }, + }) + if resp != nil { + t.Fatalf("OnCreate() = %#v, want nil", resp) + } + if spy.createCalls != 1 || spy.lastObjectName != "settings" || spy.lastTenantName != "tenant-a" { + t.Fatalf("typed handler spy = %#v", spy) + } + + resp = handler.OnCreate(cl, cl, decoder, nil)(ctx, admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Object: rawExtension(t, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "settings"}}), + }, + }) + if resp != nil { + t.Fatalf("OnCreate() without namespace = %#v, want nil", resp) + } + if spy.createCalls != 1 { + t.Fatalf("typed handler was called for request without namespace") + } +} + +type spyHandler struct { + createCalls int + updateCalls int + deleteCalls int +} + +func (s *spyHandler) OnCreate(client.Client, client.Reader, admission.Decoder, events.EventRecorder) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + s.createCalls++ + + return deniedResponse() + } +} + +func (s *spyHandler) OnUpdate(client.Client, client.Reader, admission.Decoder, events.EventRecorder) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + s.updateCalls++ + + return deniedResponse() + } +} + +func (s *spyHandler) OnDelete(client.Client, client.Reader, admission.Decoder, events.EventRecorder) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + s.deleteCalls++ + + return deniedResponse() + } +} + +type typedTenantSpy struct { + createCalls int + lastObjectName string + lastTenantName string +} + +func (s *typedTenantSpy) OnCreate( + _ client.Client, + _ client.Reader, + obj *corev1.ConfigMap, + _ admission.Decoder, + _ events.EventRecorder, + tnt *capsulev1beta2.Tenant, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + s.createCalls++ + s.lastObjectName = obj.Name + s.lastTenantName = tnt.Name + + return nil + } +} + +func (s *typedTenantSpy) OnUpdate(client.Client, client.Reader, *corev1.ConfigMap, *corev1.ConfigMap, admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func (s *typedTenantSpy) OnDelete(client.Client, client.Reader, *corev1.ConfigMap, admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func requestFor(username string, groups []string) admission.Request { + return admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + UserInfo: authenticationv1.UserInfo{Username: username, Groups: groups}, + }} +} + +func deniedResponse() *admission.Response { + resp := admission.Denied("denied") + + return &resp +} + +func rawExtension(t *testing.T, obj client.Object) runtime.RawExtension { + t.Helper() + + data, err := json.Marshal(obj) + if err != nil { + t.Fatalf("marshalling object: %v", err) + } + + return runtime.RawExtension{ + Raw: data, + Object: &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: schema.GroupVersion{Version: "v1"}.String(), + Kind: "ConfigMap", + }, + }, + } +} + +func handlersFakeClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + return handlersFakeClientWithScheme(t, handlersScheme(t), objects...) +} + +func handlersFakeClientWithScheme(t *testing.T, scheme *runtime.Scheme, objects ...client.Object) client.Client { + t.Helper() + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() +} + +func handlersScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + return scheme +} diff --git a/pkg/runtime/indexers/customquota/customquota_test.go b/pkg/runtime/indexers/customquota/customquota_test.go new file mode 100644 index 00000000..1818148a --- /dev/null +++ b/pkg/runtime/indexers/customquota/customquota_test.go @@ -0,0 +1,79 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package customquota_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/customquota" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestCustomQuotaIndexers(t *testing.T) { + t.Parallel() + + target := capsulev1beta2.CustomQuotaStatusTarget{ + GroupVersionKind: metav1.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, + } + claim := capsulev1beta2.CustomQuotaClaimItem{ + NamespacedObjectWithUIDReference: meta.NamespacedObjectWithUIDReference{UID: types.UID("claim-uid")}, + } + + tests := []struct { + name string + field string + got []string + want []string + }{ + { + name: "namespaced target", + field: customquota.NamespacedTargetReference{}.Field(), + got: customquota.NamespacedTargetReference{}.Func()(&capsulev1beta2.CustomQuota{ + Status: capsulev1beta2.CustomQuotaStatus{Targets: []capsulev1beta2.CustomQuotaStatusTarget{target}}, + }), + want: []string{target.String()}, + }, + { + name: "namespaced uid", + field: customquota.NamespacedObjectUIDReference{}.Field(), + got: customquota.NamespacedObjectUIDReference{}.Func()(&capsulev1beta2.CustomQuota{ + Status: capsulev1beta2.CustomQuotaStatus{Claims: []capsulev1beta2.CustomQuotaClaimItem{claim}}, + }), + want: []string{"claim-uid"}, + }, + { + name: "global target", + field: customquota.GlobalTargetReference{}.Field(), + got: customquota.GlobalTargetReference{}.Func()(&capsulev1beta2.GlobalCustomQuota{ + Status: capsulev1beta2.GlobalCustomQuotaStatus{ + CustomQuotaStatus: capsulev1beta2.CustomQuotaStatus{Targets: []capsulev1beta2.CustomQuotaStatusTarget{target}}, + }, + }), + want: []string{target.String()}, + }, + { + name: "global uid", + field: customquota.GlobalObjectUIDReference{}.Field(), + got: customquota.GlobalObjectUIDReference{}.Func()(&capsulev1beta2.GlobalCustomQuota{ + Status: capsulev1beta2.GlobalCustomQuotaStatus{ + CustomQuotaStatus: capsulev1beta2.CustomQuotaStatus{Claims: []capsulev1beta2.CustomQuotaClaimItem{claim}}, + }, + }), + want: []string{"claim-uid"}, + }, + } + + for _, tt := range tests { + if tt.field == "" { + t.Fatalf("%s field is empty", tt.name) + } + if !reflect.DeepEqual(tt.got, tt.want) { + t.Fatalf("%s got %#v, want %#v", tt.name, tt.got, tt.want) + } + } +} diff --git a/pkg/runtime/indexers/indexer_test.go b/pkg/runtime/indexers/indexer_test.go new file mode 100644 index 00000000..8916ea77 --- /dev/null +++ b/pkg/runtime/indexers/indexer_test.go @@ -0,0 +1,129 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package indexers_test + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/go-logr/logr" + "github.com/projectcapsule/capsule/pkg/runtime/indexers" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" + "k8s.io/client-go/tools/record" + ctrlcache "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + ctrlconfig "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/conversion" +) + +func TestAddToManagerRegistersIndexers(t *testing.T) { + t.Parallel() + + mgr := &fakeManager{indexer: &recordingFieldIndexer{}} + + if err := indexers.AddToManager(context.Background(), logr.Discard(), mgr); err != nil { + t.Fatalf("AddToManager() unexpected error: %v", err) + } + + if got, want := len(mgr.indexer.calls), 20; got != want { + t.Fatalf("registered indexers = %d, want %d", got, want) + } + + fields := map[string]bool{} + for _, call := range mgr.indexer.calls { + if call.object == nil { + t.Fatalf("registered nil object for field %q", call.field) + } + if call.field == "" { + t.Fatalf("registered empty field for object %T", call.object) + } + if call.fn == nil { + t.Fatalf("registered nil indexer func for field %q", call.field) + } + fields[call.field] = true + } + + for _, field := range []string{ + ".spec.name", + ".status.namespaces", + "spec.serviceaccount", + "hostnamePathPair", + } { + if !fields[field] { + t.Fatalf("expected field %q to be registered; got %#v", field, fields) + } + } +} + +func TestAddToManagerReturnsRegistrationError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("index failed") + mgr := &fakeManager{indexer: &recordingFieldIndexer{err: wantErr}} + + if err := indexers.AddToManager(context.Background(), logr.Discard(), mgr); !errors.Is(err, wantErr) { + t.Fatalf("AddToManager() error = %v, want %v", err, wantErr) + } +} + +type indexCall struct { + object client.Object + field string + fn client.IndexerFunc +} + +type recordingFieldIndexer struct { + calls []indexCall + err error +} + +func (r *recordingFieldIndexer) IndexField(_ context.Context, obj client.Object, field string, fn client.IndexerFunc) error { + r.calls = append(r.calls, indexCall{object: obj, field: field, fn: fn}) + + return r.err +} + +type fakeManager struct { + indexer *recordingFieldIndexer +} + +func (f *fakeManager) GetFieldIndexer() client.FieldIndexer { return f.indexer } +func (f *fakeManager) GetEventRecorderFor(string) record.EventRecorder { + return nil +} +func (f *fakeManager) GetEventRecorder(string) events.EventRecorder { return nil } +func (f *fakeManager) GetHTTPClient() *http.Client { return nil } +func (f *fakeManager) GetConfig() *rest.Config { return nil } +func (f *fakeManager) GetCache() ctrlcache.Cache { return nil } +func (f *fakeManager) GetScheme() *runtime.Scheme { return nil } +func (f *fakeManager) GetClient() client.Client { return nil } +func (f *fakeManager) GetRESTMapper() meta.RESTMapper { return nil } +func (f *fakeManager) GetAPIReader() client.Reader { return nil } +func (f *fakeManager) Start(context.Context) error { return nil } +func (f *fakeManager) Add(manager.Runnable) error { return nil } +func (f *fakeManager) Elected() <-chan struct{} { return nil } +func (f *fakeManager) AddMetricsServerExtraHandler(string, http.Handler) error { + return nil +} +func (f *fakeManager) AddHealthzCheck(string, healthz.Checker) error { return nil } +func (f *fakeManager) AddReadyzCheck(string, healthz.Checker) error { return nil } +func (f *fakeManager) GetWebhookServer() webhook.Server { return nil } +func (f *fakeManager) GetLogger() logr.Logger { return logr.Discard() } +func (f *fakeManager) GetControllerOptions() ctrlconfig.Controller { + return ctrlconfig.Controller{} +} +func (f *fakeManager) GetConverterRegistry() conversion.Registry { return nil } + +var _ manager.Manager = (*fakeManager)(nil) +var _ client.FieldIndexer = (*recordingFieldIndexer)(nil) +var _ cluster.Cluster = (*fakeManager)(nil) diff --git a/pkg/runtime/indexers/ingress/hostname_path_test.go b/pkg/runtime/indexers/ingress/hostname_path_test.go new file mode 100644 index 00000000..79e0ac94 --- /dev/null +++ b/pkg/runtime/indexers/ingress/hostname_path_test.go @@ -0,0 +1,74 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ingress_test + +import ( + "reflect" + "sort" + "testing" + + "github.com/projectcapsule/capsule/pkg/runtime/indexers/ingress" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + networkingv1 "k8s.io/api/networking/v1" + networkingv1beta1 "k8s.io/api/networking/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestHostnamePathIndexers(t *testing.T) { + t.Parallel() + + want := []string{"example.com;/", "example.com;/api"} + + tests := []struct { + name string + idx ingress.HostnamePath + obj client.Object + }{ + { + name: "networking v1", + idx: ingress.HostnamePath{Obj: &networkingv1.Ingress{}}, + obj: &networkingv1.Ingress{Spec: networkingv1.IngressSpec{Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{Paths: []networkingv1.HTTPIngressPath{ + {Path: "/"}, {Path: "/api"}, + }}}, + }}}}, + }, + { + name: "networking v1beta1", + idx: ingress.HostnamePath{Obj: &networkingv1beta1.Ingress{}}, + obj: &networkingv1beta1.Ingress{Spec: networkingv1beta1.IngressSpec{Rules: []networkingv1beta1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1beta1.IngressRuleValue{HTTP: &networkingv1beta1.HTTPIngressRuleValue{Paths: []networkingv1beta1.HTTPIngressPath{ + {Path: "/"}, {Path: "/api"}, + }}}, + }}}}, + }, + { + name: "extensions v1beta1", + idx: ingress.HostnamePath{Obj: &extensionsv1beta1.Ingress{}}, + obj: &extensionsv1beta1.Ingress{Spec: extensionsv1beta1.IngressSpec{Rules: []extensionsv1beta1.IngressRule{{ + Host: "example.com", + IngressRuleValue: extensionsv1beta1.IngressRuleValue{HTTP: &extensionsv1beta1.HTTPIngressRuleValue{Paths: []extensionsv1beta1.HTTPIngressPath{ + {Path: "/"}, {Path: "/api"}, + }}}, + }}}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.idx.Object() == nil || tt.idx.Field() != ingress.HostPathPair { + t.Fatalf("unexpected object/field") + } + got := tt.idx.Func()(tt.obj) + sort.Strings(got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("Func() = %#v, want %#v", got, want) + } + }) + } +} diff --git a/pkg/runtime/indexers/namespace/namespaces_test.go b/pkg/runtime/indexers/namespace/namespaces_test.go new file mode 100644 index 00000000..d7362d9a --- /dev/null +++ b/pkg/runtime/indexers/namespace/namespaces_test.go @@ -0,0 +1,31 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package namespace_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/namespace" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestOwnerReferenceIndexer(t *testing.T) { + t.Parallel() + + idx := namespace.OwnerReference{} + got := idx.Func()(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "ConfigMap", Name: "ignored"}, + {APIVersion: capsulev1beta2.GroupVersion.String(), Kind: "Tenant", Name: "tenant-a"}, + }}}) + + if idx.Object() == nil || idx.Field() != namespace.OwnerReferenceIndex { + t.Fatalf("unexpected object/field") + } + if !reflect.DeepEqual(got, []string{"tenant-a"}) { + t.Fatalf("Func() = %#v, want tenant-a", got) + } +} diff --git a/pkg/runtime/indexers/resourcepool/resourcepool_test.go b/pkg/runtime/indexers/resourcepool/resourcepool_test.go new file mode 100644 index 00000000..d9d46b16 --- /dev/null +++ b/pkg/runtime/indexers/resourcepool/resourcepool_test.go @@ -0,0 +1,36 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package resourcepool_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/resourcepool" + "k8s.io/apimachinery/pkg/types" +) + +func TestResourcePoolIndexers(t *testing.T) { + t.Parallel() + + namespaces := resourcepool.NamespacesReference{Obj: &capsulev1beta2.ResourcePool{}} + if namespaces.Object() == nil || namespaces.Field() != ".status.namespaces" { + t.Fatalf("unexpected namespace indexer object/field") + } + if got := namespaces.Func()(&capsulev1beta2.ResourcePool{Status: capsulev1beta2.ResourcePoolStatus{Namespaces: []string{"a", "b"}}}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("NamespacesReference.Func() = %#v", got) + } + + poolUID := resourcepool.PoolUIDReference{Obj: &capsulev1beta2.ResourcePoolClaim{}} + if poolUID.Object() == nil || poolUID.Field() != ".status.pool.uid" { + t.Fatalf("unexpected pool UID indexer object/field") + } + if got := poolUID.Func()(&capsulev1beta2.ResourcePoolClaim{Status: capsulev1beta2.ResourcePoolClaimStatus{ + Pool: meta.LocalRFC1123ObjectReferenceWithUID{UID: types.UID("pool-uid")}, + }}); !reflect.DeepEqual(got, []string{"pool-uid"}) { + t.Fatalf("PoolUIDReference.Func() = %#v", got) + } +} diff --git a/pkg/runtime/indexers/tenant/tenant_test.go b/pkg/runtime/indexers/tenant/tenant_test.go new file mode 100644 index 00000000..ad5485a4 --- /dev/null +++ b/pkg/runtime/indexers/tenant/tenant_test.go @@ -0,0 +1,35 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + tenantindexer "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant" +) + +func TestTenantIndexers(t *testing.T) { + t.Parallel() + + namespaces := tenantindexer.NamespacesReference{Obj: &capsulev1beta2.Tenant{}} + if namespaces.Object() == nil || namespaces.Field() != tenantindexer.NamespaceIndexerFieldName { + t.Fatalf("unexpected namespace indexer object/field") + } + if got := namespaces.Func()(&capsulev1beta2.Tenant{Status: capsulev1beta2.TenantStatus{Namespaces: []string{"a", "b"}}}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("NamespacesReference.Func() = %#v", got) + } + + owners := tenantindexer.OwnerReference{} + if owners.Object() == nil || owners.Field() != tenantindexer.OwnerKindIndexerFieldName { + t.Fatalf("unexpected owner indexer object/field") + } + if got := owners.Func()(&capsulev1beta2.Tenant{Status: capsulev1beta2.TenantStatus{Owners: rbac.OwnerStatusListSpec{{ + UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}, + }}}}); !reflect.DeepEqual(got, []string{"User:alice"}) { + t.Fatalf("OwnerReference.Func() = %#v", got) + } +} diff --git a/pkg/runtime/indexers/tenantowner/name_test.go b/pkg/runtime/indexers/tenantowner/name_test.go new file mode 100644 index 00000000..53f9f60a --- /dev/null +++ b/pkg/runtime/indexers/tenantowner/name_test.go @@ -0,0 +1,31 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantowner_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantowner" +) + +func TestOwnerNameReference(t *testing.T) { + t.Parallel() + + idx := tenantowner.OwnerNameReference{} + if idx.Object() == nil || idx.Field() != tenantowner.NameIndexerFieldName { + t.Fatalf("unexpected object/field") + } + + if got := idx.Func()(&capsulev1beta2.TenantOwner{Spec: capsulev1beta2.TenantOwnerSpec{ + CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice"}}, + }}); !reflect.DeepEqual(got, []string{"alice"}) { + t.Fatalf("Func() = %#v, want alice", got) + } + if got := idx.Func()(&capsulev1beta2.TenantOwner{}); got != nil { + t.Fatalf("Func() = %#v, want nil for empty name", got) + } +} diff --git a/pkg/runtime/indexers/tenantresource/more_indexers_test.go b/pkg/runtime/indexers/tenantresource/more_indexers_test.go new file mode 100644 index 00000000..cdd2cb99 --- /dev/null +++ b/pkg/runtime/indexers/tenantresource/more_indexers_test.go @@ -0,0 +1,90 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource_test + +import ( + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/gvk" + "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNamespacedTenantResourceIndexers(t *testing.T) { + t.Parallel() + + item := meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{ + Version: "v1", + Kind: "ConfigMap", + Namespace: "tenant-a", + Name: "settings", + }, + } + wantItemKey := item.GetGVKKey("") + + tr := &capsulev1beta2.TenantResource{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a"}, + Status: capsulev1beta2.TenantResourceStatus{ + TenantResourceCommonStatus: capsulev1beta2.TenantResourceCommonStatus{ + ServiceAccount: &meta.NamespacedRFC1123ObjectReferenceWithNamespace{Name: "builder"}, + ProcessedItems: meta.ProcessedItems{ + item, + {ResourceID: gvk.ResourceID{Version: "v1", Kind: "Secret", Namespace: "tenant-a", Name: "token"}}, + }, + }, + }, + } + + if idx := (tenantresource.NamespacedServiceAccount{}); idx.Object() == nil || idx.Field() != tenantresource.ServiceAccountIndexerFieldName { + t.Fatalf("unexpected namespaced service account object/field") + } else if got := idx.Func()(tr); !reflect.DeepEqual(got, []string{"tenant-a/builder"}) { + t.Fatalf("NamespacedServiceAccount.Func() = %#v", got) + } + + if idx := (tenantresource.NamespacedResourceNamespace{}); idx.Object() == nil || idx.Field() != tenantresource.NamespaceIndexerFieldName { + t.Fatalf("unexpected namespace object/field") + } else if got := idx.Func()(tr); !reflect.DeepEqual(got, []string{"tenant-a"}) { + t.Fatalf("NamespacedResourceNamespace.Func() = %#v", got) + } + + if idx := (tenantresource.NamespacedProcessedItems{}); idx.Object() == nil || idx.Field() != tenantresource.ProcessedIndexerFieldName { + t.Fatalf("unexpected processed items object/field") + } else if got := idx.Func()(tr); len(got) != 2 || got[0] != wantItemKey { + t.Fatalf("NamespacedProcessedItems.Func() = %#v", got) + } + + if got := (tenantresource.NamespacedServiceAccount{}).Func()(&capsulev1beta2.TenantResource{}); got != nil { + t.Fatalf("NamespacedServiceAccount empty = %#v, want nil", got) + } + if got := (tenantresource.NamespacedResourceNamespace{}).Func()(&capsulev1beta2.TenantResource{}); got != nil { + t.Fatalf("NamespacedResourceNamespace empty = %#v, want nil", got) + } +} + +func TestGlobalProcessedItemsIndexer(t *testing.T) { + t.Parallel() + + item := meta.ObjectReferenceStatus{ + ResourceID: gvk.ResourceID{Version: "v1", Kind: "ConfigMap", Namespace: "tenant-a", Name: "settings"}, + } + gtr := &capsulev1beta2.GlobalTenantResource{ + Status: capsulev1beta2.GlobalTenantResourceStatus{ + TenantResourceCommonStatus: capsulev1beta2.TenantResourceCommonStatus{ + ProcessedItems: meta.ProcessedItems{item}, + }, + }, + } + + idx := tenantresource.GlobalProcessedItems{} + if idx.Object() == nil || idx.Field() != tenantresource.ProcessedIndexerFieldName { + t.Fatalf("unexpected global processed items object/field") + } + if got := idx.Func()(gtr); !reflect.DeepEqual(got, []string{item.GetGVKKey("")}) { + t.Fatalf("GlobalProcessedItems.Func() = %#v", got) + } +} diff --git a/pkg/runtime/predicates/tenant_change_test.go b/pkg/runtime/predicates/tenant_change_test.go new file mode 100644 index 00000000..8f2ad466 --- /dev/null +++ b/pkg/runtime/predicates/tenant_change_test.go @@ -0,0 +1,96 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package predicates_test + +import ( + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/predicates" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestTenantStatusOwnersChangedPredicate(t *testing.T) { + t.Parallel() + + p := predicates.TenantStatusOwnersChangedPredicate{} + withOwner := &capsulev1beta2.Tenant{Status: capsulev1beta2.TenantStatus{ + Owners: rbac.OwnerStatusListSpec{{UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}}}, + }} + withoutOwner := &capsulev1beta2.Tenant{} + + if !p.Create(event.CreateEvent{Object: withOwner}) { + t.Fatalf("Create() = false, want true when owners are present") + } + if p.Create(event.CreateEvent{Object: withoutOwner}) { + t.Fatalf("Create() = true, want false without owners") + } + if p.Create(event.CreateEvent{Object: &corev1.ConfigMap{}}) { + t.Fatalf("Create() = true, want false for non-tenant") + } + if !p.Delete(event.DeleteEvent{Object: withOwner}) { + t.Fatalf("Delete() = false, want true when owners are present") + } + if !p.Generic(event.GenericEvent{Object: withOwner}) { + t.Fatalf("Generic() = false, want true when owners are present") + } + + if !p.Update(event.UpdateEvent{ObjectOld: withoutOwner, ObjectNew: withOwner}) { + t.Fatalf("Update() = false, want true when owners changed") + } + if p.Update(event.UpdateEvent{ObjectOld: withOwner, ObjectNew: withOwner.DeepCopy()}) { + t.Fatalf("Update() = true, want false when owners are unchanged") + } + if p.Update(event.UpdateEvent{ObjectOld: &corev1.ConfigMap{}, ObjectNew: withOwner}) { + t.Fatalf("Update() = true, want false for non-tenant") + } +} + +func TestTenantCountChangedPredicate(t *testing.T) { + t.Parallel() + + p := predicates.TenantCountChangedPredicate{} + if !p.Create(event.CreateEvent{}) { + t.Fatalf("Create() = false, want true") + } + if !p.Delete(event.DeleteEvent{}) { + t.Fatalf("Delete() = false, want true") + } + if p.Generic(event.GenericEvent{}) { + t.Fatalf("Generic() = true, want false") + } + if p.Update(event.UpdateEvent{}) { + t.Fatalf("Update() = true, want false") + } +} + +func TestNamesMatchingConstructor(t *testing.T) { + t.Parallel() + + _ = predicates.NamesMatching("a", "b") + + p := predicates.NamesMatchingPredicate{Names: []string{"a", "b"}} + matching := &corev1.ConfigMap{} + matching.Name = "a" + other := &corev1.ConfigMap{} + other.Name = "c" + + if !p.Create(event.CreateEvent{Object: matching}) { + t.Fatalf("Create() = false, want true for matching name") + } + if p.Create(event.CreateEvent{Object: other}) { + t.Fatalf("Create() = true, want false for non-matching name") + } + if p.Delete(event.DeleteEvent{}) { + t.Fatalf("Delete() = true, want false for nil object") + } + if !p.Update(event.UpdateEvent{ObjectNew: matching}) { + t.Fatalf("Update() = false, want true for matching new object") + } + if !p.Generic(event.GenericEvent{Object: matching}) { + t.Fatalf("Generic() = false, want true for matching name") + } +} diff --git a/pkg/runtime/quota/conditions_test.go b/pkg/runtime/quota/conditions_test.go new file mode 100644 index 00000000..4f1ab421 --- /dev/null +++ b/pkg/runtime/quota/conditions_test.go @@ -0,0 +1,100 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package quota_test + +import ( + "testing" + + "github.com/projectcapsule/capsule/pkg/runtime/jsonpath" + "github.com/projectcapsule/capsule/pkg/runtime/quota" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestCustomQuotaCacheKeys(t *testing.T) { + t.Parallel() + + if got := quota.MakeCustomQuotaCacheKey("tenant-a", "quota-a"); got != "tenant-a/quota-a" { + t.Fatalf("MakeCustomQuotaCacheKey() = %q", got) + } + if got := quota.MakeGlobalCustomQuotaCacheKey("quota-a"); got != "C/quota-a" { + t.Fatalf("MakeGlobalCustomQuotaCacheKey() = %q", got) + } +} + +func TestParseBoolFromUnstructured(t *testing.T) { + t.Parallel() + + compiled, err := jsonpath.CompileJSONPath(".spec.enabled") + if err != nil { + t.Fatalf("CompileJSONPath() unexpected error: %v", err) + } + empty, err := jsonpath.CompileJSONPath(".spec.empty") + if err != nil { + t.Fatalf("CompileJSONPath() unexpected error: %v", err) + } + + u := unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{ + "enabled": "true", + "empty": "", + }, + }} + + got, err := quota.ParseBoolFromUnstructured(u, compiled) + if err != nil { + t.Fatalf("ParseBoolFromUnstructured() unexpected error: %v", err) + } + if !got { + t.Fatalf("ParseBoolFromUnstructured() = false, want true") + } + + got, err = quota.ParseBoolFromUnstructured(u, empty) + if err != nil { + t.Fatalf("ParseBoolFromUnstructured(empty) unexpected error: %v", err) + } + if got { + t.Fatalf("ParseBoolFromUnstructured(empty) = true, want false") + } + + u.Object["spec"].(map[string]any)["enabled"] = "not-bool" + if _, err := quota.ParseBoolFromUnstructured(u, compiled); err == nil { + t.Fatalf("ParseBoolFromUnstructured(invalid) expected error") + } +} + +func TestConditionsMatch(t *testing.T) { + t.Parallel() + + enabled, err := jsonpath.CompileJSONPath(".spec.enabled") + if err != nil { + t.Fatalf("CompileJSONPath() unexpected error: %v", err) + } + ready, err := jsonpath.CompileJSONPath(".spec.ready") + if err != nil { + t.Fatalf("CompileJSONPath() unexpected error: %v", err) + } + + u := unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{ + "enabled": "true", + "ready": "false", + }, + }} + + got, err := quota.ConditionsMatch(u, []*jsonpath.CompiledJSONPath{enabled}) + if err != nil { + t.Fatalf("ConditionsMatch() unexpected error: %v", err) + } + if !got { + t.Fatalf("ConditionsMatch() = false, want true") + } + + got, err = quota.ConditionsMatch(u, []*jsonpath.CompiledJSONPath{enabled, ready}) + if err != nil { + t.Fatalf("ConditionsMatch() unexpected error: %v", err) + } + if got { + t.Fatalf("ConditionsMatch() = true, want false") + } +} diff --git a/pkg/runtime/selectors/selectors_test.go b/pkg/runtime/selectors/selectors_test.go new file mode 100644 index 00000000..588f7c6b --- /dev/null +++ b/pkg/runtime/selectors/selectors_test.go @@ -0,0 +1,355 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package selectors_test + +import ( + "context" + "reflect" + "testing" + + "github.com/projectcapsule/capsule/pkg/runtime/selectors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestMatchesSelectors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels labels.Set + selectors []metav1.LabelSelector + want bool + }{ + { + name: "empty selectors match", + want: true, + }, + { + name: "matching selector returns true", + labels: labels.Set{"env": "prod"}, + selectors: []metav1.LabelSelector{{ + MatchLabels: map[string]string{"env": "prod"}, + }}, + want: true, + }, + { + name: "any matching selector returns true", + labels: labels.Set{"env": "prod"}, + selectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"env": "dev"}}, + {MatchLabels: map[string]string{"env": "prod"}}, + }, + want: true, + }, + { + name: "invalid selector is skipped", + labels: labels.Set{"env": "prod"}, + selectors: []metav1.LabelSelector{ + {MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "env", Operator: "invalid"}}}, + {MatchLabels: map[string]string{"env": "prod"}}, + }, + want: true, + }, + { + name: "no selector matches", + labels: labels.Set{"env": "prod"}, + selectors: []metav1.LabelSelector{{ + MatchLabels: map[string]string{"env": "dev"}, + }}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := selectors.MatchesSelectors(tt.labels, tt.selectors); got != tt.want { + t.Fatalf("MatchesSelectors() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchesSelector(t *testing.T) { + t.Parallel() + + t.Run("matches", func(t *testing.T) { + t.Parallel() + + got, err := selectors.MatchesSelector(labels.Set{"tier": "frontend"}, metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "frontend"}, + }) + if err != nil { + t.Fatalf("MatchesSelector() unexpected error: %v", err) + } + if !got { + t.Fatalf("MatchesSelector() = false, want true") + } + }) + + t.Run("does not match", func(t *testing.T) { + t.Parallel() + + got, err := selectors.MatchesSelector(labels.Set{"tier": "backend"}, metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "frontend"}, + }) + if err != nil { + t.Fatalf("MatchesSelector() unexpected error: %v", err) + } + if got { + t.Fatalf("MatchesSelector() = true, want false") + } + }) + + t.Run("invalid selector returns error", func(t *testing.T) { + t.Parallel() + + got, err := selectors.MatchesSelector(labels.Set{"tier": "backend"}, metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "invalid"}}, + }) + if err == nil { + t.Fatalf("MatchesSelector() expected error") + } + if got { + t.Fatalf("MatchesSelector() = true, want false on error") + } + }) +} + +func TestNamespaceSelectorGetMatchingNamespaces(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := fakeClient( + namespace("alpha", map[string]string{"team": "a"}), + namespace("beta", map[string]string{"team": "b"}), + namespace("gamma", nil), + ) + + t.Run("nil selector returns nil", func(t *testing.T) { + t.Parallel() + + got, err := (&selectors.NamespaceSelector{}).GetMatchingNamespaces(ctx, cl) + if err != nil { + t.Fatalf("GetMatchingNamespaces() unexpected error: %v", err) + } + if got != nil { + t.Fatalf("GetMatchingNamespaces() = %#v, want nil", got) + } + }) + + t.Run("filters namespaces by labels", func(t *testing.T) { + t.Parallel() + + got, err := (&selectors.NamespaceSelector{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "a"}}, + }).GetMatchingNamespaces(ctx, cl) + if err != nil { + t.Fatalf("GetMatchingNamespaces() unexpected error: %v", err) + } + + if names := namespaceNames(got); !reflect.DeepEqual(names, []string{"alpha"}) { + t.Fatalf("GetMatchingNamespaces() names = %#v, want alpha", names) + } + }) + + t.Run("invalid selector returns error", func(t *testing.T) { + t.Parallel() + + _, err := (&selectors.NamespaceSelector{ + LabelSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "team", Operator: "invalid"}}, + }, + }).GetMatchingNamespaces(ctx, cl) + if err == nil { + t.Fatalf("GetMatchingNamespaces() expected error") + } + }) +} + +func TestGetNamespacesMatchingSelectors(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := fakeClient( + namespace("zeta", map[string]string{"team": "a", "region": "eu"}), + namespace("alpha", map[string]string{"team": "a", "region": "us"}), + namespace("beta", map[string]string{"team": "b", "region": "eu"}), + ) + + got, err := selectors.GetNamespacesMatchingSelectors(ctx, cl, []selectors.NamespaceSelector{ + {LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "a"}}}, + {LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"region": "eu"}}}, + }) + if err != nil { + t.Fatalf("GetNamespacesMatchingSelectors() unexpected error: %v", err) + } + + if names := namespaceNames(got); !reflect.DeepEqual(names, []string{"alpha", "beta", "zeta"}) { + t.Fatalf("GetNamespacesMatchingSelectors() names = %#v, want sorted unique names", names) + } + + gotNames, err := selectors.GetNamespacesMatchingSelectorsStrings(ctx, cl, []selectors.NamespaceSelector{ + {LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "a"}}}, + }) + if err != nil { + t.Fatalf("GetNamespacesMatchingSelectorsStrings() unexpected error: %v", err) + } + if !reflect.DeepEqual(gotNames, []string{"alpha", "zeta"}) { + t.Fatalf("GetNamespacesMatchingSelectorsStrings() = %#v, want alpha and zeta", gotNames) + } + + empty, err := selectors.GetNamespacesMatchingSelectors(ctx, cl, nil) + if err != nil { + t.Fatalf("GetNamespacesMatchingSelectors() unexpected error for nil selectors: %v", err) + } + if empty != nil { + t.Fatalf("GetNamespacesMatchingSelectors() = %#v, want nil for no selectors", empty) + } +} + +func TestSelectorWithNamespaceSelectorMatchObjects(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := fakeClient( + namespace("allowed", map[string]string{"tenant": "one"}), + namespace("denied", map[string]string{"tenant": "two"}), + ) + objects := []metav1.Object{ + configMap("allowed", "match", map[string]string{"app": "api"}), + configMap("allowed", "skip-label", map[string]string{"app": "worker"}), + configMap("denied", "skip-namespace", map[string]string{"app": "api"}), + } + + got, err := (&selectors.SelectorWithNamespaceSelector{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "api"}}, + NamespaceSelector: &selectors.NamespaceSelector{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "one"}}, + }, + }).MatchObjects(ctx, cl, objects) + if err != nil { + t.Fatalf("MatchObjects() unexpected error: %v", err) + } + + if names := objectNames(got); !reflect.DeepEqual(names, []string{"match"}) { + t.Fatalf("MatchObjects() names = %#v, want match", names) + } + + noNamespaceFilter, err := (&selectors.SelectorWithNamespaceSelector{ + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "api"}}, + }).MatchObjects(ctx, cl, objects) + if err != nil { + t.Fatalf("MatchObjects() unexpected error without namespace selector: %v", err) + } + if names := objectNames(noNamespaceFilter); !reflect.DeepEqual(names, []string{"match", "skip-namespace"}) { + t.Fatalf("MatchObjects() names = %#v, want both app=api objects", names) + } + + nilSelector, err := (*selectors.SelectorWithNamespaceSelector)(nil).MatchObjects(ctx, cl, objects) + if err != nil { + t.Fatalf("MatchObjects() unexpected error for nil selector: %v", err) + } + if nilSelector != nil { + t.Fatalf("MatchObjects() = %#v, want nil for nil receiver", nilSelector) + } +} + +func TestListBySelectors(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := fakeClient( + configMap("ns-a", "one", map[string]string{"app": "api"}), + configMap("ns-a", "two", map[string]string{"app": "worker"}), + configMap("ns-b", "three", nil), + ) + + got, err := selectors.ListBySelectors[*corev1.ConfigMap](ctx, cl, &corev1.ConfigMapList{}, []*metav1.LabelSelector{ + {MatchLabels: map[string]string{"app": "api"}}, + {MatchLabels: map[string]string{"app": "worker"}}, + }) + if err != nil { + t.Fatalf("ListBySelectors() unexpected error: %v", err) + } + if names := configMapKeys(got); !reflect.DeepEqual(names, []string{"ns-a/one", "ns-a/two"}) { + t.Fatalf("ListBySelectors() = %#v, want matching config maps", names) + } + + empty, err := selectors.ListBySelectors[*corev1.ConfigMap](ctx, cl, &corev1.ConfigMapList{}, nil) + if err != nil { + t.Fatalf("ListBySelectors() unexpected error for nil selectors: %v", err) + } + if empty != nil { + t.Fatalf("ListBySelectors() = %#v, want nil for nil selectors", empty) + } + + empty, err = selectors.ListBySelectors[*corev1.ConfigMap](ctx, cl, &corev1.ConfigMapList{}, []*metav1.LabelSelector{nil}) + if err != nil { + t.Fatalf("ListBySelectors() unexpected error for nil selector entry: %v", err) + } + if empty != nil { + t.Fatalf("ListBySelectors() = %#v, want nil when all selector entries are nil", empty) + } + + if _, err = selectors.ListBySelectors[*corev1.ConfigMap](ctx, cl, nil, []*metav1.LabelSelector{{}}); err == nil { + t.Fatalf("ListBySelectors() expected error for nil list") + } + + if _, err = selectors.ListBySelectors[*corev1.ConfigMap](ctx, cl, &corev1.ConfigMapList{}, []*metav1.LabelSelector{{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "app", Operator: "invalid"}}, + }}); err == nil { + t.Fatalf("ListBySelectors() expected error for invalid selector") + } +} + +func fakeClient(objects ...client.Object) client.Client { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + panic(err) + } + + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() +} + +func namespace(name string, lbls map[string]string) *corev1.Namespace { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls}} +} + +func configMap(namespace, name string, lbls map[string]string) *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Labels: lbls}} +} + +func namespaceNames(namespaces []corev1.Namespace) []string { + names := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, ns.Name) + } + + return names +} + +func objectNames(objects []metav1.Object) []string { + names := make([]string, 0, len(objects)) + for _, obj := range objects { + names = append(names, obj.GetName()) + } + + return names +} + +func configMapKeys(configMaps []*corev1.ConfigMap) []string { + names := make([]string, 0, len(configMaps)) + for _, configMap := range configMaps { + names = append(names, configMap.Namespace+"/"+configMap.Name) + } + + return names +} diff --git a/pkg/template/fast_validation_test.go b/pkg/template/fast_validation_test.go new file mode 100644 index 00000000..071f08e5 --- /dev/null +++ b/pkg/template/fast_validation_test.go @@ -0,0 +1,138 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template_test + +import ( + "reflect" + "strings" + "testing" + + tpl "github.com/projectcapsule/capsule/pkg/template" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAllowedNamespaceMetadataTemplatesString(t *testing.T) { + t.Parallel() + + if got := tpl.AllowedNamespaceMetadataTemplatesString(); got != "{{namespace}}, {{tenant.name}}" { + t.Fatalf("AllowedNamespaceMetadataTemplatesString() = %q", got) + } +} + +func TestContainsFastTemplateSyntax(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + value string + want bool + }{ + {name: "plain", value: "tenant-a", want: false}, + {name: "opening", value: "tenant-{{ namespace", want: true}, + {name: "closing", value: "tenant-namespace }}", want: true}, + {name: "complete", value: "tenant-{{ namespace }}", want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tpl.ContainsFastTemplateSyntax(tt.value); got != tt.want { + t.Fatalf("ContainsFastTemplateSyntax(%q) = %t, want %t", tt.value, got, tt.want) + } + }) + } +} + +func TestValidateAllowedTemplatesOnly(t *testing.T) { + t.Parallel() + + if errs := tpl.ValidateAllowedTemplatesOnly("metadata.name", "team-{{ namespace }}-{{ tenant.name }}"); len(errs) > 0 { + t.Fatalf("ValidateAllowedTemplatesOnly() errors = %#v, want none", errs) + } + + if errs := tpl.ValidateAllowedTemplatesOnly("metadata.name", "team-{{ owner }}"); len(errs) != 1 || + !strings.Contains(errs[0], "unsupported template") || + !strings.Contains(errs[0], "{{namespace}}, {{tenant.name}}") { + t.Fatalf("unsupported template errors = %#v", errs) + } + + for _, value := range []string{"team-{{ namespace", "team-{{ namespace }}-}}"} { + if errs := tpl.ValidateAllowedTemplatesOnly("metadata.name", value); len(errs) != 1 || + !strings.Contains(errs[0], "malformed template") { + t.Fatalf("malformed template %q errors = %#v", value, errs) + } + } +} + +func TestValidateKubernetesStringOrAllowedTemplates(t *testing.T) { + t.Parallel() + + validator := func(value string) []string { + if value == "team-template" || value == "team-template-template" { + return nil + } + + return []string{"invalid value"} + } + + if errs := tpl.ValidateKubernetesStringOrAllowedTemplates("metadata.name", "team-{{ namespace }}", validator); len(errs) > 0 { + t.Fatalf("ValidateKubernetesStringOrAllowedTemplates() errors = %#v, want none", errs) + } + + got := tpl.ValidateKubernetesStringOrAllowedTemplates("metadata.name", "bad", validator) + if want := []string{"metadata.name: invalid value"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ValidateKubernetesStringOrAllowedTemplates() = %#v, want %#v", got, want) + } +} + +func TestSelectorRequiresTemplating(t *testing.T) { + t.Parallel() + + if tpl.SelectorRequiresTemplating(nil) { + t.Fatalf("SelectorRequiresTemplating(nil) = true, want false") + } + + for _, tt := range []struct { + name string + sel *metav1.LabelSelector + }{ + { + name: "match label key", + sel: &metav1.LabelSelector{MatchLabels: map[string]string{"{{ namespace }}": "prod"}}, + }, + { + name: "match label value", + sel: &metav1.LabelSelector{MatchLabels: map[string]string{"environment": "{{ tenant.name }}"}}, + }, + { + name: "expression key", + sel: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "{{ namespace }}", Operator: metav1.LabelSelectorOpIn, Values: []string{"prod"}}, + }}, + }, + { + name: "expression value", + sel: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "environment", Operator: metav1.LabelSelectorOpIn, Values: []string{"{{ tenant.name }}"}}, + }}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if !tpl.SelectorRequiresTemplating(tt.sel) { + t.Fatalf("SelectorRequiresTemplating() = false, want true") + } + }) + } + + plain := &metav1.LabelSelector{ + MatchLabels: map[string]string{"environment": "prod"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tier", Operator: metav1.LabelSelectorOpIn, Values: []string{"frontend"}}, + }, + } + if tpl.SelectorRequiresTemplating(plain) { + t.Fatalf("SelectorRequiresTemplating(plain) = true, want false") + } +} diff --git a/pkg/template/functions/funcmap_age_test.go b/pkg/template/functions/funcmap_age_test.go new file mode 100644 index 00000000..bae423b4 --- /dev/null +++ b/pkg/template/functions/funcmap_age_test.go @@ -0,0 +1,67 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package functions + +import "testing" + +func TestFuncMaps(t *testing.T) { + t.Parallel() + + custom := CustomFuncMap() + for _, name := range []string{ + "toToml", + "fromToml", + "fromYamlArray", + "fromJsonArray", + "deterministicUUID", + "generateAgeKey", + "generateAgePQKey", + } { + if custom[name] == nil { + t.Fatalf("CustomFuncMap()[%q] is nil", name) + } + } + + extra := ExtraFuncMap() + if extra["env"] != nil || extra["expandEnv"] != nil { + t.Fatalf("ExtraFuncMap() should remove unsafe env functions") + } + if extra["generateAgeKey"] == nil { + t.Fatalf("ExtraFuncMap() missing custom function") + } +} + +func TestFromYAMLArray(t *testing.T) { + t.Parallel() + + got := fromYAMLArray("- a\n- b\n") + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("fromYAMLArray() = %#v", got) + } + + got = fromYAMLArray(":\n") + if len(got) != 1 { + t.Fatalf("fromYAMLArray(invalid) = %#v, want single error item", got) + } +} + +func TestGenerateAgeKeys(t *testing.T) { + t.Parallel() + + key, ok := generateAgeKey().(AgeKeyPair) + if !ok { + t.Fatalf("generateAgeKey() did not return AgeKeyPair") + } + if key.Identity == "" || key.Recipient == "" { + t.Fatalf("generateAgeKey() = %#v, want identity and recipient", key) + } + + pqKey, ok := generateAgePQKey().(AgeKeyPair) + if !ok { + t.Fatalf("generateAgePQKey() did not return AgeKeyPair") + } + if pqKey.Identity == "" || pqKey.Recipient == "" { + t.Fatalf("generateAgePQKey() = %#v, want identity and recipient", pqKey) + } +} diff --git a/pkg/template/reference_context_test.go b/pkg/template/reference_context_test.go new file mode 100644 index 00000000..711f21bd --- /dev/null +++ b/pkg/template/reference_context_test.go @@ -0,0 +1,202 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template_test + +import ( + "context" + "reflect" + "strings" + "testing" + + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" + tpl "github.com/projectcapsule/capsule/pkg/template" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestResourceReferenceTemplating(t *testing.T) { + t.Parallel() + + ref := tpl.ResourceReference{ + Name: "{{ tenant.name }}-config", + Namespace: "{{ namespace }}", + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "{{ tenant.name }}"}}, + } + if !ref.RequiresTemplating() { + t.Fatalf("RequiresTemplating() = false, want true") + } + + got, err := ref.LoadTemplated(map[string]string{"tenant.name": "team-a", "namespace": "team-a-dev"}) + if err != nil { + t.Fatalf("LoadTemplated() unexpected error: %v", err) + } + if got.Name != "team-a-config" || got.Namespace != "team-a-dev" || got.Selector.MatchLabels["app"] != "team-a" { + t.Fatalf("LoadTemplated() = %#v", got) + } + if ref.Name == got.Name { + t.Fatalf("LoadTemplated() mutated original reference") + } +} + +func TestResourceReferenceLoadResources(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + configMap("team-a", "settings", map[string]string{"app": "api", "tenant": "team-a"}), + configMap("team-a", "other", map[string]string{"app": "worker", "tenant": "team-a"}), + configMap("team-b", "settings", map[string]string{"app": "api", "tenant": "team-b"}), + ).Build() + mapper := templateRESTMapper() + + t.Run("loads named object", func(t *testing.T) { + t.Parallel() + + got, err := (tpl.ResourceReference{ + VersionKind: tplVersionKind("v1", "ConfigMap"), + Name: "settings", + Optional: true, + }).LoadResources(ctx, cl, mapper, "team-a", nil, nil, false, nil) + if err != nil { + t.Fatalf("LoadResources() unexpected error: %v", err) + } + if len(got) != 1 || got[0].GetName() != "settings" || got[0].GetNamespace() != "team-a" { + t.Fatalf("LoadResources() = %#v", got) + } + }) + + t.Run("optional missing named object returns nil", func(t *testing.T) { + t.Parallel() + + got, err := (tpl.ResourceReference{ + VersionKind: tplVersionKind("v1", "ConfigMap"), + Name: "missing", + Optional: true, + }).LoadResources(ctx, cl, mapper, "team-a", nil, nil, false, nil) + if err != nil { + t.Fatalf("LoadResources() unexpected error for optional missing object: %v", err) + } + if got != nil { + t.Fatalf("LoadResources() = %#v, want nil for optional missing object", got) + } + }) + + t.Run("lists with combined selectors", func(t *testing.T) { + t.Parallel() + + additional := labels.SelectorFromSet(labels.Set{"tenant": "team-a"}) + got, err := (tpl.ResourceReference{ + VersionKind: tplVersionKind("v1", "ConfigMap"), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "api"}}, + }).LoadResources(ctx, cl, mapper, "team-a", []labels.Selector{additional}, nil, false, nil) + if err != nil { + t.Fatalf("LoadResources() unexpected error for list: %v", err) + } + if len(got) != 1 || got[0].GetName() != "settings" { + t.Fatalf("LoadResources() = %#v, want only team-a/settings", got) + } + }) + + t.Run("validates namespace", func(t *testing.T) { + t.Parallel() + + _, err := (tpl.ResourceReference{ + VersionKind: tplVersionKind("v1", "ConfigMap"), + }).LoadResources(ctx, cl, mapper, "team-a", nil, nil, false, func(ns string) error { + if ns != "team-a" { + t.Fatalf("validated namespace = %q", ns) + } + + return nil + }) + if err != nil { + t.Fatalf("LoadResources() unexpected validation error: %v", err) + } + }) + + t.Run("invalid apiVersion returns error", func(t *testing.T) { + t.Parallel() + + _, err := (tpl.ResourceReference{ + VersionKind: tplVersionKind("not/a/version", "ConfigMap"), + }).LoadResources(ctx, cl, mapper, "team-a", nil, nil, false, nil) + if err == nil { + t.Fatalf("LoadResources() expected error for invalid apiVersion") + } + }) +} + +func TestTemplateContextGatherContext(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + configMap("team-a", "settings", map[string]string{"app": "api"}), + ).Build() + + templateContext := tpl.TemplateContext{ + Resources: []*tpl.TemplateResourceReference{{ + ResourceReference: tpl.ResourceReference{ + VersionKind: tplVersionKind("v1", "ConfigMap"), + Name: "settings", + Optional: true, + }, + Index: "configs", + }}, + } + + got, err := templateContext.GatherContext(ctx, cl, templateRESTMapper(), nil, "team-a", nil, nil) + if err != nil { + t.Fatalf("GatherContext() unexpected error: %v", err) + } + items := got["configs"].([]map[string]any) + if len(items) != 1 || items[0]["metadata"].(map[string]any)["name"] != "settings" { + t.Fatalf("GatherContext() = %#v", got) + } + + asString, err := got.String() + if err != nil { + t.Fatalf("ReferenceContext.String() unexpected error: %v", err) + } + if !strings.Contains(asString, "settings") { + t.Fatalf("ReferenceContext.String() = %q, want settings", asString) + } + + empty, err := (&tpl.TemplateContext{}).GatherContext(ctx, cl, templateRESTMapper(), nil, "team-a", nil, nil) + if err != nil { + t.Fatalf("GatherContext(empty) unexpected error: %v", err) + } + if !reflect.DeepEqual(empty, tpl.ReferenceContext{}) { + t.Fatalf("GatherContext(empty) = %#v, want empty context", empty) + } +} + +func configMap(namespace, name string, lbls map[string]string) *corev1.ConfigMap { + return &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Labels: lbls}} +} + +func templateRESTMapper() meta.RESTMapper { + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Version: "v1"}}) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace) + + return mapper +} + +func tplVersionKind(apiVersion, kind string) apiruntime.VersionKind { + return apiruntime.VersionKind{APIVersion: apiVersion, Kind: kind} +} diff --git a/pkg/tenant/get_by_test.go b/pkg/tenant/get_by_test.go new file mode 100644 index 00000000..66ef8735 --- /dev/null +++ b/pkg/tenant/get_by_test.go @@ -0,0 +1,301 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant_test + +import ( + "context" + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestTenantByStatusNamespace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, + tenantObject("tenant-a", withStatusNamespaces("ns-a")), + ) + + got, err := tenant.TenantByStatusNamespace(ctx, cl, "ns-a") + if err != nil { + t.Fatalf("TenantByStatusNamespace() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("TenantByStatusNamespace() = %#v, want tenant-a", got) + } + + name, err := tenant.GetTenantNameByStatusNamespace(ctx, cl, "ns-a") + if err != nil { + t.Fatalf("GetTenantNameByStatusNamespace() unexpected error: %v", err) + } + if name != "tenant-a" { + t.Fatalf("GetTenantNameByStatusNamespace() = %q, want tenant-a", name) + } + + ok, err := tenant.IsNamespaceInTenant(ctx, cl, "ns-a") + if err != nil { + t.Fatalf("IsNamespaceInTenant() unexpected error: %v", err) + } + if !ok { + t.Fatalf("IsNamespaceInTenant() = false, want true") + } + + got, err = tenant.TenantByStatusNamespace(ctx, cl, "missing") + if err != nil { + t.Fatalf("TenantByStatusNamespace() unexpected error for missing namespace: %v", err) + } + if got != nil { + t.Fatalf("TenantByStatusNamespace() = %#v, want nil", got) + } +} + +func TestGetTenantByNamespace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tnt := tenantObject("tenant-a", withUID("tenant-uid")) + cl := tenantFakeClient(t, + tnt, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "ns-a", + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", "tenant-uid")}, + }}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "ns-mismatch", + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", "other-uid")}, + }}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-unowned"}}, + ) + + name, err := tenant.GetTenantNameByNamespace(ctx, cl, "ns-a") + if err != nil { + t.Fatalf("GetTenantNameByNamespace() unexpected error: %v", err) + } + if name != "tenant-a" { + t.Fatalf("GetTenantNameByNamespace() = %q, want tenant-a", name) + } + + got, err := tenant.GetTenantByNamespace(ctx, cl, "ns-a") + if err != nil { + t.Fatalf("GetTenantByNamespace() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("GetTenantByNamespace() = %#v, want tenant-a", got) + } + + got, err = tenant.GetTenantByNamespace(ctx, cl, "ns-unowned") + if err != nil { + t.Fatalf("GetTenantByNamespace() unexpected error for unowned namespace: %v", err) + } + if got != nil { + t.Fatalf("GetTenantByNamespace() = %#v, want nil for unowned namespace", got) + } + + got, err = tenant.GetTenantByNamespace(ctx, cl, "missing") + if err != nil { + t.Fatalf("GetTenantByNamespace() unexpected error for missing namespace: %v", err) + } + if got != nil { + t.Fatalf("GetTenantByNamespace() = %#v, want nil for missing namespace", got) + } + + if _, err = tenant.GetTenantByNamespace(ctx, cl, "ns-mismatch"); err == nil { + t.Fatalf("GetTenantByNamespace() expected UID mismatch error") + } +} + +func TestGetTenantByOwnerreferences(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, tenantObject("tenant-a")) + refs := []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "ConfigMap", Name: "ignored"}, + tenantOwnerReference("tenant-a", ""), + } + + name, ok := tenant.GetTenantNameByOwnerreferences(refs) + if !ok || name != "tenant-a" { + t.Fatalf("GetTenantNameByOwnerreferences() = %q, %v, want tenant-a true", name, ok) + } + + got, err := tenant.GetTenantByOwnerreferences(ctx, cl, refs) + if err != nil { + t.Fatalf("GetTenantByOwnerreferences() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("GetTenantByOwnerreferences() = %#v, want tenant-a", got) + } + + got, err = tenant.GetTenantByOwnerreferences(ctx, cl, nil) + if err != nil { + t.Fatalf("GetTenantByOwnerreferences() unexpected error for nil refs: %v", err) + } + if got != nil { + t.Fatalf("GetTenantByOwnerreferences() = %#v, want nil", got) + } +} + +func TestGetTenantByUserInfo(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, + tenantObject("short", withSpecOwner(rbac.UserOwner, "alice")), + tenantObject("very-long-name", withSpecOwner(rbac.GroupOwner, "developers")), + tenantObject("service", withSpecOwner(rbac.ServiceAccountOwner, users.ServiceAccountUsername("tenant-a", "builder"))), + ) + + got, err := tenant.GetTenantByUserInfo(ctx, cl, nil, nil, users.AdmissionUser{ + Username: users.ServiceAccountUsername("tenant-a", "builder"), + Groups: []string{"developers"}, + }) + if err != nil { + t.Fatalf("GetTenantByUserInfo() unexpected error: %v", err) + } + + names := make([]string, 0, len(got)) + for _, tnt := range got { + names = append(names, tnt.Name) + } + + if !reflect.DeepEqual(names, []string{"very-long-name", "service"}) { + t.Fatalf("GetTenantByUserInfo() names = %#v, want sorted matching tenants", names) + } +} + +func TestGetTenantByLabelsAndUser(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, + tenantObject("tenant-a", withStatusOwner(rbac.UserOwner, "alice")), + ) + + got, err := tenant.GetTenantByLabels(ctx, cl, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "tenant-a"}, + }}) + if err != nil { + t.Fatalf("GetTenantByLabels() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("GetTenantByLabels() = %#v, want tenant-a", got) + } + + got, err = tenant.GetTenantByLabelsAndUser(ctx, cl, nil, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "tenant-a"}, + }}, users.NewAdmissionUser(users.AdmissionUserUnknown, authenticationv1.UserInfo{Username: "alice"})) + if err != nil { + t.Fatalf("GetTenantByLabelsAndUser() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("GetTenantByLabelsAndUser() = %#v, want tenant-a", got) + } + + if _, err = tenant.GetTenantByLabelsAndUser(ctx, cl, nil, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "tenant-a"}, + }}, users.NewAdmissionUser(users.AdmissionUserUnknown, authenticationv1.UserInfo{Username: "bob"})); err == nil { + t.Fatalf("GetTenantByLabelsAndUser() expected non-owner error") + } + + got, err = tenant.GetTenantByLabels(ctx, cl, &corev1.Namespace{}) + if err != nil { + t.Fatalf("GetTenantByLabels() unexpected error without label: %v", err) + } + if got != nil { + t.Fatalf("GetTenantByLabels() = %#v, want nil without label", got) + } +} + +func tenantFakeClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&capsulev1beta2.Tenant{}, ".status.namespaces", func(obj client.Object) []string { + return obj.(*capsulev1beta2.Tenant).Status.Namespaces + }). + WithIndex(&capsulev1beta2.Tenant{}, ".spec.owner.ownerkind", func(obj client.Object) []string { + tnt := obj.(*capsulev1beta2.Tenant) + values := make([]string, 0, len(tnt.Spec.Owners)) + for _, owner := range tnt.Spec.Owners { + values = append(values, owner.Kind.String()+":"+owner.Name) + } + + return values + }). + Build() +} + +type tenantOption func(*capsulev1beta2.Tenant) + +func tenantObject(name string, opts ...tenantOption) *capsulev1beta2.Tenant { + tnt := &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: name}} + for _, opt := range opts { + opt(tnt) + } + + return tnt +} + +func withUID(uid types.UID) tenantOption { + return func(tnt *capsulev1beta2.Tenant) { + tnt.UID = uid + } +} + +func withStatusNamespaces(namespaces ...string) tenantOption { + return func(tnt *capsulev1beta2.Tenant) { + tnt.Status.Namespaces = namespaces + } +} + +func withSpecOwner(kind rbac.OwnerKind, name string) tenantOption { + return func(tnt *capsulev1beta2.Tenant) { + tnt.Spec.Owners = append(tnt.Spec.Owners, rbac.OwnerSpec{ + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{Kind: kind, Name: name}, + }, + }) + } +} + +func withStatusOwner(kind rbac.OwnerKind, name string) tenantOption { + return func(tnt *capsulev1beta2.Tenant) { + tnt.Status.Owners = append(tnt.Status.Owners, rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{Kind: kind, Name: name}, + }) + } +} + +func tenantOwnerReference(name string, uid types.UID) metav1.OwnerReference { + return metav1.OwnerReference{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: tenant.ObjectReferenceTenantKind, + Name: name, + UID: uid, + } +} diff --git a/pkg/tenant/namespaces_context_test.go b/pkg/tenant/namespaces_context_test.go new file mode 100644 index 00000000..72cb83de --- /dev/null +++ b/pkg/tenant/namespaces_context_test.go @@ -0,0 +1,213 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant_test + +import ( + "context" + "reflect" + "strings" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/sanitize" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestNamespaceTerminationHelpers(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: "busy", Name: "pod-a"}}, + ) + + pending, err := tenant.NamespaceIsPendingPodTerminating(ctx, cl, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "busy"}}) + if err != nil { + t.Fatalf("NamespaceIsPendingPodTerminating() unexpected error: %v", err) + } + if !pending { + t.Fatalf("NamespaceIsPendingPodTerminating() = false, want true") + } + + pending, err = tenant.NamespaceIsPendingPodTerminating(ctx, cl, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "empty"}}) + if err != nil { + t.Fatalf("NamespaceIsPendingPodTerminating(empty) unexpected error: %v", err) + } + if pending { + t.Fatalf("NamespaceIsPendingPodTerminating(empty) = true, want false") + } +} + +func TestNamespaceIsPendingUnmanagedTerminationByStatus(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tnt := tenantObject("tenant-a", withUID(types.UID("tenant-uid"))) + tnt.Status.UpdateInstance(&capsulev1beta2.TenantStatusNamespaceItem{ + Name: "tenant-a-ns", + Conditions: meta.ConditionList{{ + Type: meta.TerminatingCondition, + Status: metav1.ConditionTrue, + }}, + }) + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a-ns", + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", types.UID("tenant-uid"))}, + }} + cl := tenantFakeClient(t, tnt, ns) + + pending, err := tenant.NamespaceIsPendingUnmanagedTerminationByStatus(ctx, cl, ns) + if err != nil { + t.Fatalf("NamespaceIsPendingUnmanagedTerminationByStatus() unexpected error: %v", err) + } + if !pending { + t.Fatalf("NamespaceIsPendingUnmanagedTerminationByStatus() = false, want true") + } + + pending, err = tenant.NamespaceIsPendingUnmanagedTerminationByStatus(ctx, cl, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "unowned"}}) + if err != nil { + t.Fatalf("NamespaceIsPendingUnmanagedTerminationByStatus(unowned) unexpected error: %v", err) + } + if pending { + t.Fatalf("NamespaceIsPendingUnmanagedTerminationByStatus(unowned) = true, want false") + } +} + +func TestResolveNamespaceTenant(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tnt := tenantObject("tenant-a", withUID(types.UID("tenant-uid"))) + cl := tenantFakeClient(t, tnt) + + valid := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a-ns", + Labels: map[string]string{meta.TenantLabel: "tenant-a"}, + OwnerReferences: []metav1.OwnerReference{ + tenantOwnerReference("tenant-a", types.UID("tenant-uid")), + }, + }} + got, err := tenant.ResolveNamespaceTenant(ctx, cl, valid) + if err != nil { + t.Fatalf("ResolveNamespaceTenant() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("ResolveNamespaceTenant() = %#v, want tenant-a", got) + } + + if got, err := tenant.ResolveNamespaceTenant(ctx, cl, nil); err != nil || got != nil { + t.Fatalf("ResolveNamespaceTenant(nil) = %#v, %v, want nil nil", got, err) + } + + tests := []struct { + name string + ns *corev1.Namespace + want string + }{ + { + name: "label without owner reference", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "tenant-a"}, + }}, + want: "no Tenant ownerReference", + }, + { + name: "owner reference without label", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", types.UID("tenant-uid"))}, + }}, + want: "but no tenant label", + }, + { + name: "mismatched label", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "other"}, + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", types.UID("tenant-uid"))}, + }}, + want: "does not match owner reference", + }, + } + + for _, tt := range tests { + _, err := tenant.ResolveNamespaceTenant(ctx, cl, tt.ns) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("%s error = %v, want substring %q", tt.name, err, tt.want) + } + } +} + +func TestCollectTenantNamespaceByLabel(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := tenantFakeClient(t, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "a", Labels: map[string]string{meta.TenantLabel: "tenant-a", "env": "prod"}}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "b", Labels: map[string]string{meta.TenantLabel: "tenant-a", "env": "dev"}}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "c", Labels: map[string]string{meta.TenantLabel: "tenant-b", "env": "prod"}}}, + ) + + namespaces, err := tenant.CollectTenantNamespaceByLabel(ctx, cl, *tenantObject("tenant-a"), &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }) + if err != nil { + t.Fatalf("CollectTenantNamespaceByLabel() unexpected error: %v", err) + } + if names := collectedNamespaceNames(namespaces); !reflect.DeepEqual(names, []string{"a"}) { + t.Fatalf("CollectTenantNamespaceByLabel() = %#v, want namespace a", names) + } +} + +func TestNamespaceOwnershipAndContexts(t *testing.T) { + t.Parallel() + + tnt := tenantObject("tenant-a", withUID(types.UID("tenant-uid")), withStatusOwner(rbac.UserOwner, "alice")) + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-a-ns", + OwnerReferences: []metav1.OwnerReference{tenantOwnerReference("tenant-a", types.UID("tenant-uid"))}, + }} + + if !tenant.NamespaceIsOwned(context.Background(), nil, nil, ns, tnt, users.AdmissionUser{Username: "alice"}) { + t.Fatalf("NamespaceIsOwned() = false, want true for status owner") + } + if tenant.NamespaceIsOwned(context.Background(), nil, nil, ns, tnt, users.AdmissionUser{Username: "bob"}) { + t.Fatalf("NamespaceIsOwned() = true, want false for non-owner") + } + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + ctx, err := tenant.NewTenantNamespaceContext(tnt, ns, scheme, sanitize.DefaultSanitizeOptions()) + if err != nil { + t.Fatalf("NewTenantNamespaceContext() unexpected error: %v", err) + } + if ctx["tenant"] == nil || ctx["namespace"] == nil { + t.Fatalf("NewTenantNamespaceContext() = %#v, want tenant and namespace contexts", ctx) + } + + fast := tenant.FastContextForTenantAndNamespace(tnt, ns) + if !reflect.DeepEqual(fast, map[string]string{"tenant.name": "tenant-a", "namespace": "tenant-a-ns"}) { + t.Fatalf("FastContextForTenantAndNamespace() = %#v", fast) + } +} + +func collectedNamespaceNames(namespaces []corev1.Namespace) []string { + names := make([]string, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, ns.Name) + } + + return names +} diff --git a/pkg/tenant/rules_promotions_test.go b/pkg/tenant/rules_promotions_test.go new file mode 100644 index 00000000..37b7662a --- /dev/null +++ b/pkg/tenant/rules_promotions_test.go @@ -0,0 +1,157 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant_test + +import ( + "context" + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestGetManagedRuleStatus(t *testing.T) { + t.Parallel() + + ctx := context.Background() + rs := &capsulev1beta2.RuleStatus{ + ObjectMeta: metav1.ObjectMeta{Name: meta.NameForManagedRuleStatus(), Namespace: "tenant-a"}, + Status: capsulev1beta2.RuleStatusStatus{ + Rules: []*rules.NamespaceRuleBodyNamespace{{Enforce: &rules.NamespaceRuleEnforceBody{Action: rules.ActionTypeAudit}}}, + }, + } + cl := tenantFakeClient(t, rs) + + got, err := tenant.GetManagedRuleStatus(ctx, cl, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-a"}}) + if err != nil { + t.Fatalf("GetManagedRuleStatus() unexpected error: %v", err) + } + if got.Name != meta.NameForManagedRuleStatus() || len(got.Status.Rules) != 1 { + t.Fatalf("GetManagedRuleStatus() = %#v", got) + } +} + +func TestBuildNamespaceRuleBodyStatus(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + tnt := tenantObject("tenant-a") + matching := &rules.NamespaceRuleBodyNamespace{Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAudit, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []apiruntime.ExpressionMatch{{Exact: []string{"prod-scheduler"}}}, + }, + }} + unmatched := &rules.NamespaceRuleBodyNamespace{Enforce: &rules.NamespaceRuleEnforceBody{Action: rules.ActionTypeDeny}} + tnt.Spec.Rules = []*rules.NamespaceRuleBodyTenant{ + { + NamespaceRuleBodyNamespace: matching, + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}, + }, + { + NamespaceRuleBodyNamespace: unmatched, + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "dev"}}, + }, + nil, + {}, + } + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-a-prod", Labels: map[string]string{"env": "prod"}}} + + got, err := tenant.BuildNamespaceRuleBodyStatus(scheme, ns, tnt) + if err != nil { + t.Fatalf("BuildNamespaceRuleBodyStatus() unexpected error: %v", err) + } + if len(got) != 1 || got[0].Enforce.Action != rules.ActionTypeAudit { + t.Fatalf("BuildNamespaceRuleBodyStatus() = %#v, want one audit rule", got) + } + if got[0].Enforce.Workloads.Schedulers[0].Exact[0] != "prod-scheduler" { + t.Fatalf("BuildNamespaceRuleBodyStatus() scheduler = %#v", got[0].Enforce.Workloads.Schedulers) + } + + got[0].Enforce.Action = rules.ActionTypeDeny + if matching.Enforce.Action != rules.ActionTypeAudit { + t.Fatalf("BuildNamespaceRuleBodyStatus() returned shared rule body") + } + + got, err = tenant.BuildNamespaceRuleBodyStatus(scheme, nil, tnt) + if err != nil || got != nil { + t.Fatalf("BuildNamespaceRuleBodyStatus(nil namespace) = %#v, %v, want nil nil", got, err) + } +} + +func TestCollectPromotions(t *testing.T) { + t.Parallel() + + ctx := context.Background() + tnt := tenantObject("tenant-a", withStatusNamespaces("ns-a", "ns-b")) + tnt.Spec.Rules = []*rules.NamespaceRuleBodyTenant{{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}, + Permissions: rules.NamespaceRulePermissionBody{ + Promotions: []*rules.NamespaceRulePromotionRule{{ + ClusterRoles: []string{"admin"}, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "platform"}}, + }}, + }, + }} + + cl := tenantFakeClient(t, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-a", Labels: map[string]string{ + corev1.LabelMetadataName: "ns-a", + "env": "prod", + }}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-b", Labels: map[string]string{ + corev1.LabelMetadataName: "ns-b", + "env": "dev", + }}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "builder", Labels: map[string]string{ + meta.ServiceAccountPromotionLabel: meta.ValueTrue, + "team": "platform", + }}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "ignored", Labels: map[string]string{ + meta.ServiceAccountPromotionLabel: meta.ValueTrue, + "team": "other", + }}}, + ) + + got, err := tenant.CollectPromotions(ctx, cl, tnt, nil) + if err != nil { + t.Fatalf("CollectPromotions() unexpected error: %v", err) + } + want := rbac.PromotionStatusListSpec{{ + UserSpec: rbac.UserSpec{ + Kind: rbac.ServiceAccountOwner, + Name: users.ServiceAccountUsername("ns-a", "builder"), + }, + ClusterRoles: []string{"admin"}, + Targets: []string{"ns-a"}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("CollectPromotions() = %#v, want %#v", got, want) + } + + emptyTenant := tenantObject("empty") + got, err = tenant.CollectPromotions(ctx, cl, emptyTenant, nil) + if err != nil { + t.Fatalf("CollectPromotions(empty) unexpected error: %v", err) + } + if got != nil { + t.Fatalf("CollectPromotions(empty) = %#v, want nil", got) + } +} diff --git a/pkg/users/users_test.go b/pkg/users/users_test.go new file mode 100644 index 00000000..ecf0f399 --- /dev/null +++ b/pkg/users/users_test.go @@ -0,0 +1,236 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package users_test + +import ( + "context" + "reflect" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/users" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestAdmissionUser(t *testing.T) { + t.Setenv(configuration.EnvironmentServiceaccountName, "capsule-controller") + t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") + + info := users.ServiceAccountUserInfo("capsule-system", "capsule-controller") + admissionUser := users.NewAdmissionUser(users.AdmissionUserAdmin, info) + + if !admissionUser.IsAdmin() { + t.Fatalf("IsAdmin() = false, want true") + } + if admissionUser.IsCapsule() { + t.Fatalf("IsCapsule() = true, want false") + } + if admissionUser.IsUnknown() { + t.Fatalf("IsUnknown() = true, want false") + } + if !admissionUser.IsControllerServiceAccount() { + t.Fatalf("IsControllerServiceAccount() = false, want true") + } + if got := admissionUser.UserInfo(); !reflect.DeepEqual(got, info) { + t.Fatalf("UserInfo() = %#v, want %#v", got, info) + } + + if got := users.ToServiceAccount("regular-user"); got != nil { + t.Fatalf("ToServiceAccount() = %#v, want nil for regular user", got) + } + if got := users.ServiceAccountUsername("team-a", "builder"); got != "system:serviceaccount:team-a:builder" { + t.Fatalf("ServiceAccountUsername() = %q, want service account username", got) + } +} + +func TestServiceAccountGroups(t *testing.T) { + t.Parallel() + + want := []string{ + "system:serviceaccounts:team-a", + "system:serviceaccounts", + user.AllAuthenticated, + } + if got := users.ServiceAccountGroups("team-a"); !reflect.DeepEqual(got, want) { + t.Fatalf("ServiceAccountGroups() = %#v, want %#v", got, want) + } + + ref := meta.NamespacedRFC1123ObjectReferenceWithNamespace{ + Namespace: meta.RFC1123SubdomainName("team-a"), + Name: meta.RFC1123Name("builder"), + } + if got := users.GetServiceAccountFullName(ref); got != "system:serviceaccount:team-a:builder" { + t.Fatalf("GetServiceAccountFullName() = %q", got) + } +} + +func TestHasIgnoredGroup(t *testing.T) { + t.Parallel() + + if !users.HasIgnoredGroup([]string{"developers", "ignored"}, []string{"ignored"}) { + t.Fatalf("HasIgnoredGroup() = false, want true") + } + if users.HasIgnoredGroup([]string{"developers"}, []string{"ignored"}) { + t.Fatalf("HasIgnoredGroup() = true, want false") + } + if users.HasIgnoredGroup(nil, []string{"ignored"}) { + t.Fatalf("HasIgnoredGroup() = true, want false for nil user groups") + } + if users.HasIgnoredGroup([]string{"developers"}, nil) { + t.Fatalf("HasIgnoredGroup() = true, want false for nil ignored groups") + } +} + +func TestIsAdminUser(t *testing.T) { + t.Setenv(configuration.EnvironmentServiceaccountName, "capsule-controller") + t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") + + if !users.IsAdminUser(admission.Request{ + AdmissionRequest: admissionv1Request("system:serviceaccount:capsule-system:capsule-controller", nil), + }, nil) { + t.Fatalf("IsAdminUser() = false, want true for controller service account") + } + + admins := rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "capsule-admins"}} + if !users.IsAdminUser(admission.Request{ + AdmissionRequest: admissionv1Request("alice", []string{"capsule-admins"}), + }, admins) { + t.Fatalf("IsAdminUser() = false, want true for configured admin group") + } + + if users.IsAdminUser(admission.Request{ + AdmissionRequest: admissionv1Request("alice", []string{"developers"}), + }, admins) { + t.Fatalf("IsAdminUser() = true, want false") + } +} + +func TestResolveServiceAccountActor(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cl := usersFakeClient(t, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "builder"}}, + &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "tenant-a"}, + Status: capsulev1beta2.TenantStatus{Namespaces: []string{"tenant-a"}}, + }, + ) + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") + + got, err := users.ResolveServiceAccountActor( + ctx, + cl, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "target"}}, + users.ServiceAccountUsername("tenant-a", "builder"), + cfg, + ) + if err != nil { + t.Fatalf("ResolveServiceAccountActor() unexpected error: %v", err) + } + if got == nil || got.Name != "tenant-a" { + t.Fatalf("ResolveServiceAccountActor() = %#v, want tenant-a", got) + } + + got, err = users.ResolveServiceAccountActor( + ctx, + cl, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "target", + Labels: map[string]string{meta.OwnerPromotionLabel: meta.ValueTrue}, + }}, + users.ServiceAccountUsername("tenant-a", "builder"), + cfg, + ) + if err != nil { + t.Fatalf("ResolveServiceAccountActor() unexpected error for promoted namespace: %v", err) + } + if got != nil { + t.Fatalf("ResolveServiceAccountActor() = %#v, want nil when promotion label is present", got) + } + + got, err = users.ResolveServiceAccountActor( + ctx, + cl, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "target"}}, + users.ServiceAccountUsername("tenant-a", "missing"), + cfg, + ) + if err != nil { + t.Fatalf("ResolveServiceAccountActor() unexpected error for missing service account: %v", err) + } + if got != nil { + t.Fatalf("ResolveServiceAccountActor() = %#v, want nil for missing service account", got) + } + + if _, err = users.ResolveServiceAccountActor(ctx, cl, nil, "regular-user", cfg); err == nil { + t.Fatalf("ResolveServiceAccountActor() expected error for non service-account username") + } +} + +func TestIsTenantOwnerByStatus(t *testing.T) { + t.Parallel() + + tnt := &capsulev1beta2.Tenant{ + Status: capsulev1beta2.TenantStatus{ + Owners: rbac.OwnerStatusListSpec{{ + UserSpec: rbac.UserSpec{ + Name: "alice", + Kind: rbac.UserOwner, + }, + }}, + }, + } + + if !users.IsTenantOwnerByStatus(tnt, users.AdmissionUser{Type: users.AdmissionUserAdmin, Username: "admin"}) { + t.Fatalf("IsTenantOwnerByStatus() = false, want true for admin") + } + if !users.IsTenantOwnerByStatus(tnt, users.AdmissionUser{Type: users.AdmissionUserUnknown, Username: "alice"}) { + t.Fatalf("IsTenantOwnerByStatus() = false, want true for status owner") + } + if users.IsTenantOwnerByStatus(tnt, users.AdmissionUser{Type: users.AdmissionUserUnknown, Username: "bob"}) { + t.Fatalf("IsTenantOwnerByStatus() = true, want false") + } +} + +func usersFakeClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("adding core scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&capsulev1beta2.Tenant{}, ".status.namespaces", func(obj client.Object) []string { + return obj.(*capsulev1beta2.Tenant).Status.Namespaces + }). + Build() +} + +func admissionv1Request(username string, groups []string) admissionv1.AdmissionRequest { + return admissionv1.AdmissionRequest{ + UserInfo: authenticationv1.UserInfo{ + Username: username, + Groups: groups, + }, + } +} diff --git a/pkg/utils/errors_test.go b/pkg/utils/errors_test.go index 27b7958c..dad5b328 100644 --- a/pkg/utils/errors_test.go +++ b/pkg/utils/errors_test.go @@ -4,11 +4,14 @@ package utils_test import ( - "errors" + stderrors "errors" "fmt" "testing" + gherrors "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" @@ -49,7 +52,7 @@ func TestIsUnsupportedAPI_WrappedError(t *testing.T) { } func TestIsUnsupportedAPI_OtherError(t *testing.T) { - err := errors.New("some other error") + err := stderrors.New("some other error") if utils.IsUnsupportedAPI(err) { t.Fatalf("expected false for unrelated error") @@ -61,3 +64,24 @@ func TestIsUnsupportedAPI_NilError(t *testing.T) { t.Fatalf("expected false for nil error") } } + +func TestIgnoreWrappedNotFound(t *testing.T) { + t.Parallel() + + notFound := apierrors.NewNotFound(schema.GroupResource{Resource: "pods"}, "missing") + + if got := utils.IgnoreWrappedNotFound(nil); got != nil { + t.Fatalf("IgnoreWrappedNotFound(nil) = %v, want nil", got) + } + if got := utils.IgnoreWrappedNotFound(notFound); got != nil { + t.Fatalf("IgnoreWrappedNotFound(notFound) = %v, want nil", got) + } + if got := utils.IgnoreWrappedNotFound(gherrors.Wrap(notFound, "wrapped")); got != nil { + t.Fatalf("IgnoreWrappedNotFound(wrapped notFound) = %v, want nil", got) + } + + boom := stderrors.New("boom") + if got := utils.IgnoreWrappedNotFound(boom); got != boom { + t.Fatalf("IgnoreWrappedNotFound(boom) = %v, want original error", got) + } +} diff --git a/pkg/utils/maps_test.go b/pkg/utils/maps_test.go index f716a17f..1b7d0107 100644 --- a/pkg/utils/maps_test.go +++ b/pkg/utils/maps_test.go @@ -8,6 +8,7 @@ import ( "testing" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/pkg/utils" ) @@ -181,3 +182,69 @@ func TestMapEqual(t *testing.T) { }) } } + +func TestToUnstructuredMap(t *testing.T) { + t.Parallel() + + got, err := utils.ToUnstructuredMap(&corev1.ConfigMap{ + Data: map[string]string{"key": "value"}, + }) + if err != nil { + t.Fatalf("ToUnstructuredMap() unexpected error: %v", err) + } + if got["data"].(map[string]any)["key"] != "value" { + t.Fatalf("ToUnstructuredMap() data = %#v", got["data"]) + } +} + +func TestMapify(t *testing.T) { + t.Parallel() + + type nested struct { + Value string + } + type sample struct { + Name string + Nested nested + Pointer *nested + Items []nested + Labels map[string]string + hidden string + } + + got := utils.Mapify(sample{ + Name: "example", + Nested: nested{Value: "nested"}, + Pointer: &nested{Value: "pointer"}, + Items: []nested{{Value: "one"}}, + Labels: map[string]string{"app": "api"}, + hidden: "ignored", + }) + + if got["Name"] != "example" { + t.Fatalf("Mapify() Name = %#v", got["Name"]) + } + if _, ok := got["hidden"]; ok { + t.Fatalf("Mapify() included unexported field") + } + if got["Nested"].(map[string]any)["Value"] != "nested" { + t.Fatalf("Mapify() nested = %#v", got["Nested"]) + } + if got["Pointer"].(map[string]any)["Value"] != "pointer" { + t.Fatalf("Mapify() pointer = %#v", got["Pointer"]) + } + if got["Items"].([]any)[0].(map[string]any)["Value"] != "one" { + t.Fatalf("Mapify() slice = %#v", got["Items"]) + } + if got["Labels"].(map[string]any)["app"] != "api" { + t.Fatalf("Mapify() map = %#v", got["Labels"]) + } + + var nilSample *sample + if got := utils.Mapify(nilSample); len(got) != 0 { + t.Fatalf("Mapify(nil pointer) = %#v, want empty map", got) + } + if got := utils.Mapify("not-a-struct"); len(got) != 0 { + t.Fatalf("Mapify(non-struct) = %#v, want empty map", got) + } +} diff --git a/pkg/utils/tenant_labels_test.go b/pkg/utils/tenant_labels_test.go new file mode 100644 index 00000000..8058499f --- /dev/null +++ b/pkg/utils/tenant_labels_test.go @@ -0,0 +1,53 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + "testing" + + capsulev1beta1 "github.com/projectcapsule/capsule/api/v1beta1" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/utils" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestGetTypeLabel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj runtime.Object + want string + }{ + {name: "v1beta1 tenant", obj: &capsulev1beta1.Tenant{}, want: meta.TenantLabel}, + {name: "v1beta2 tenant", obj: &capsulev1beta2.Tenant{}, want: meta.TenantLabel}, + {name: "resource pool", obj: &capsulev1beta2.ResourcePool{}, want: meta.ResourcePoolLabel}, + {name: "limit range", obj: &corev1.LimitRange{}, want: meta.LimitRangeLabel}, + {name: "network policy", obj: &networkingv1.NetworkPolicy{}, want: meta.NetworkPolicyLabel}, + {name: "resource quota", obj: &corev1.ResourceQuota{}, want: meta.ResourceQuotaLabel}, + {name: "role binding", obj: &rbacv1.RoleBinding{}, want: meta.RolebindingLabel}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := utils.GetTypeLabel(tt.obj) + if err != nil { + t.Fatalf("GetTypeLabel() unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("GetTypeLabel() = %q, want %q", got, tt.want) + } + }) + } + + if _, err := utils.GetTypeLabel(&corev1.Pod{}); err == nil { + t.Fatalf("GetTypeLabel() expected error for unmapped type") + } +}