feat(controller): administration persona (#1739)

* chore(refactor): project and api refactoring

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

* chore(refactor): project and api refactoring

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

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2025-11-18 16:27:16 +01:00
committed by GitHub
parent be99fc56b7
commit 581a8fe60e
254 changed files with 3725 additions and 2327 deletions

View File

@@ -1,12 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
const (
ForbiddenNamespaceLabelsAnnotation = "capsule.clastix.io/forbidden-namespace-labels"
ForbiddenNamespaceLabelsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-labels-regexp"
ForbiddenNamespaceAnnotationsAnnotation = "capsule.clastix.io/forbidden-namespace-annotations"
ForbiddenNamespaceAnnotationsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-annotations-regexp"
ProtectedTenantAnnotation = "capsule.clastix.io/protected"
)

View File

@@ -0,0 +1,58 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package meta
import (
"strings"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
ReleaseAnnotation = "projectcapsule.dev/release"
ReleaseAnnotationTrigger = "true"
AvailableIngressClassesAnnotation = "capsule.clastix.io/ingress-classes"
AvailableIngressClassesRegexpAnnotation = "capsule.clastix.io/ingress-classes-regexp"
AvailableStorageClassesAnnotation = "capsule.clastix.io/storage-classes"
AvailableStorageClassesRegexpAnnotation = "capsule.clastix.io/storage-classes-regexp"
AllowedRegistriesAnnotation = "capsule.clastix.io/allowed-registries"
AllowedRegistriesRegexpAnnotation = "capsule.clastix.io/allowed-registries-regexp"
ForbiddenNamespaceLabelsAnnotation = "capsule.clastix.io/forbidden-namespace-labels"
ForbiddenNamespaceLabelsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-labels-regexp"
ForbiddenNamespaceAnnotationsAnnotation = "capsule.clastix.io/forbidden-namespace-annotations"
ForbiddenNamespaceAnnotationsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-annotations-regexp"
ProtectedTenantAnnotation = "capsule.clastix.io/protected"
)
func ReleaseAnnotationTriggers(obj client.Object) bool {
return annotationTriggers(obj, ReleaseAnnotation, ReleaseAnnotationTrigger)
}
func ReleaseAnnotationRemove(obj client.Object) {
annotationRemove(obj, ReleaseAnnotation)
}
func annotationRemove(obj client.Object, anno string) {
annotations := obj.GetAnnotations()
if _, ok := annotations[anno]; ok {
delete(annotations, anno)
obj.SetAnnotations(annotations)
}
}
func annotationTriggers(obj client.Object, anno string, trigger string) bool {
annotations := obj.GetAnnotations()
if val, ok := annotations[anno]; ok {
if strings.ToLower(val) == trigger {
return true
}
}
return false
}

View File

@@ -10,6 +10,11 @@ import (
)
const (
TenantNameLabel = "kubernetes.io/metadata.name"
TenantLabel = "capsule.clastix.io/tenant"
ResourcePoolLabel = "projectcapsule.dev/pool"
FreezeLabel = "projectcapsule.dev/freeze"
FreezeLabelTrigger = "true"
@@ -20,6 +25,11 @@ const (
CordonedLabelTrigger = "true"
ManagedByCapsuleLabel = "capsule.clastix.io/managed-by"
LimitRangeLabel = "capsule.clastix.io/limit-range"
NetworkPolicyLabel = "capsule.clastix.io/network-policy"
ResourceQuotaLabel = "capsule.clastix.io/resource-quota"
RolebindingLabel = "capsule.clastix.io/role-binding"
)
func FreezeLabelTriggers(obj client.Object) bool {

View File

@@ -1,8 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
const (
TenantNameLabel = "kubernetes.io/metadata.name"
)

66
pkg/api/owner.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
// +kubebuilder:object:generate=true
type OwnerSpec struct {
UserSpec `json:",inline"`
// Defines additional cluster-roles for the specific Owner.
// +kubebuilder:default={admin,capsule-namespace-deleter}
ClusterRoles []string `json:"clusterRoles,omitempty"`
// Proxy settings for tenant owner.
ProxyOperations []ProxySettings `json:"proxySettings,omitempty"`
// Additional Labels for the synchronized rolebindings
Labels map[string]string `json:"labels,omitempty"`
// Additional Annotations for the synchronized rolebindings
Annotations map[string]string `json:"annotations,omitempty"`
}
// +kubebuilder:validation:Enum=User;Group;ServiceAccount
type OwnerKind string
func (k OwnerKind) String() string {
return string(k)
}
// +kubebuilder:object:generate=true
type ProxySettings struct {
Kind ProxyServiceKind `json:"kind"`
Operations []ProxyOperation `json:"operations"`
}
// +kubebuilder:validation:Enum=List;Update;Delete
type ProxyOperation string
func (p ProxyOperation) String() string {
return string(p)
}
// +kubebuilder:validation:Enum=Nodes;StorageClasses;IngressClasses;PriorityClasses;RuntimeClasses;PersistentVolumes
type ProxyServiceKind string
func (p ProxyServiceKind) String() string {
return string(p)
}
const (
NodesProxy ProxyServiceKind = "Nodes"
StorageClassesProxy ProxyServiceKind = "StorageClasses"
IngressClassesProxy ProxyServiceKind = "IngressClasses"
PriorityClassesProxy ProxyServiceKind = "PriorityClasses"
RuntimeClassesProxy ProxyServiceKind = "RuntimeClasses"
PersistentVolumesProxy ProxyServiceKind = "PersistentVolumes"
TenantProxy ProxyServiceKind = "Tenant"
ListOperation ProxyOperation = "List"
UpdateOperation ProxyOperation = "Update"
DeleteOperation ProxyOperation = "Delete"
UserOwner OwnerKind = "User"
GroupOwner OwnerKind = "Group"
ServiceAccountOwner OwnerKind = "ServiceAccount"
)

63
pkg/api/owner_list.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package api
import (
"sort"
)
// +kubebuilder:object:generate=true
type OwnerListSpec []OwnerSpec
func (o OwnerListSpec) IsOwner(name string, groups []string) bool {
for _, owner := range o {
switch owner.Kind {
case UserOwner, ServiceAccountOwner:
if name == owner.Name {
return true
}
case GroupOwner:
for _, group := range groups {
if group == owner.Name {
return true
}
}
}
}
return false
}
func (o OwnerListSpec) FindOwner(name string, kind OwnerKind) (owner OwnerSpec) {
sort.Sort(ByKindAndName(o))
i := sort.Search(len(o), func(i int) bool {
return o[i].Kind >= kind && o[i].Name >= name
})
if i < len(o) && o[i].Kind == kind && o[i].Name == name {
return o[i]
}
return owner
}
type ByKindAndName OwnerListSpec
func (b ByKindAndName) Len() int {
return len(b)
}
func (b ByKindAndName) Less(i, j int) bool {
if b[i].Kind.String() != b[j].Kind.String() {
return b[i].Kind.String() < b[j].Kind.String()
}
return b[i].Name < b[j].Name
}
func (b ByKindAndName) Swap(i, j int) {
b[i], b[j] = b[j], b[i]
}

View File

@@ -0,0 +1,98 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOwnerListSpec_FindOwner(t *testing.T) {
bla := OwnerSpec{
UserSpec: UserSpec{
Kind: UserOwner,
Name: "bla",
},
ProxyOperations: []ProxySettings{
{
Kind: IngressClassesProxy,
Operations: []ProxyOperation{"Delete"},
},
},
}
bar := OwnerSpec{
UserSpec: UserSpec{
Kind: GroupOwner,
Name: "bar",
},
ProxyOperations: []ProxySettings{
{
Kind: StorageClassesProxy,
Operations: []ProxyOperation{"Delete"},
},
},
}
baz := OwnerSpec{
UserSpec: UserSpec{
Kind: UserOwner,
Name: "baz",
},
ProxyOperations: []ProxySettings{
{
Kind: StorageClassesProxy,
Operations: []ProxyOperation{"Update"},
},
},
}
fim := OwnerSpec{
UserSpec: UserSpec{
Kind: ServiceAccountOwner,
Name: "fim",
},
ProxyOperations: []ProxySettings{
{
Kind: NodesProxy,
Operations: []ProxyOperation{"List"},
},
},
}
bom := OwnerSpec{
UserSpec: UserSpec{
Kind: GroupOwner,
Name: "bom",
},
ProxyOperations: []ProxySettings{
{
Kind: StorageClassesProxy,
Operations: []ProxyOperation{"Delete"},
},
{
Kind: NodesProxy,
Operations: []ProxyOperation{"Delete"},
},
},
}
qip := OwnerSpec{
UserSpec: UserSpec{
Kind: ServiceAccountOwner,
Name: "qip",
},
ProxyOperations: []ProxySettings{
{
Kind: StorageClassesProxy,
Operations: []ProxyOperation{"List", "Delete"},
},
},
}
owners := OwnerListSpec{bom, qip, bla, bar, baz, fim}
assert.Equal(t, owners.FindOwner("bom", GroupOwner), bom)
assert.Equal(t, owners.FindOwner("qip", ServiceAccountOwner), qip)
assert.Equal(t, owners.FindOwner("bla", UserOwner), bla)
assert.Equal(t, owners.FindOwner("bar", GroupOwner), bar)
assert.Equal(t, owners.FindOwner("baz", UserOwner), baz)
assert.Equal(t, owners.FindOwner("fim", ServiceAccountOwner), fim)
assert.Equal(t, owners.FindOwner("notfound", ServiceAccountOwner), OwnerSpec{})
}

19
pkg/api/users.go Normal file
View File

@@ -0,0 +1,19 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
// +kubebuilder:validation:Enum=User;Group;ServiceAccount
type UserKind string
func (k UserKind) String() string {
return string(k)
}
// +kubebuilder:object:generate=true
type UserSpec struct {
// Kind of entity. Possible values are "User", "Group", and "ServiceAccount"
Kind OwnerKind `json:"kind"`
// Name of the entity.
Name string `json:"name"`
}

63
pkg/api/users_list.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package api
import (
"sort"
)
// +kubebuilder:object:generate=true
type UserListSpec []UserSpec
func (u UserListSpec) IsPresent(name string, groups []string) bool {
for _, user := range u {
switch user.Kind {
case UserOwner, ServiceAccountOwner:
if name == user.Name {
return true
}
case GroupOwner:
for _, group := range groups {
if group == user.Name {
return true
}
}
}
}
return false
}
func (o UserListSpec) FindUser(name string, kind OwnerKind) (owner UserSpec) {
sort.Sort(ByKindName(o))
i := sort.Search(len(o), func(i int) bool {
return o[i].Kind >= kind && o[i].Name >= name
})
if i < len(o) && o[i].Kind == kind && o[i].Name == name {
return o[i]
}
return owner
}
type ByKindName UserListSpec
func (b ByKindName) Len() int {
return len(b)
}
func (b ByKindName) Less(i, j int) bool {
if b[i].Kind.String() != b[j].Kind.String() {
return b[i].Kind.String() < b[j].Kind.String()
}
return b[i].Name < b[j].Name
}
func (b ByKindName) Swap(i, j int) {
b[i], b[j] = b[j], b[i]
}

View File

@@ -281,6 +281,69 @@ func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in OwnerListSpec) DeepCopyInto(out *OwnerListSpec) {
{
in := &in
*out = make(OwnerListSpec, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OwnerListSpec.
func (in OwnerListSpec) DeepCopy() OwnerListSpec {
if in == nil {
return nil
}
out := new(OwnerListSpec)
in.DeepCopyInto(out)
return *out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OwnerSpec) DeepCopyInto(out *OwnerSpec) {
*out = *in
out.UserSpec = in.UserSpec
if in.ClusterRoles != nil {
in, out := &in.ClusterRoles, &out.ClusterRoles
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.ProxyOperations != nil {
in, out := &in.ProxyOperations, &out.ProxyOperations
*out = make([]ProxySettings, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OwnerSpec.
func (in *OwnerSpec) DeepCopy() *OwnerSpec {
if in == nil {
return nil
}
out := new(OwnerSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PodOptions) DeepCopyInto(out *PodOptions) {
*out = *in
@@ -318,6 +381,26 @@ func (in *PoolExhaustionResource) DeepCopy() *PoolExhaustionResource {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProxySettings) DeepCopyInto(out *ProxySettings) {
*out = *in
if in.Operations != nil {
in, out := &in.Operations, &out.Operations
*out = make([]ProxyOperation, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxySettings.
func (in *ProxySettings) DeepCopy() *ProxySettings {
if in == nil {
return nil
}
out := new(ProxySettings)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceQuotaSpec) DeepCopyInto(out *ResourceQuotaSpec) {
*out = *in
@@ -420,3 +503,37 @@ func (in *ServiceOptions) DeepCopy() *ServiceOptions {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in UserListSpec) DeepCopyInto(out *UserListSpec) {
{
in := &in
*out = make(UserListSpec, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserListSpec.
func (in UserListSpec) DeepCopy() UserListSpec {
if in == nil {
return nil
}
out := new(UserListSpec)
in.DeepCopyInto(out)
return *out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *UserSpec) DeepCopyInto(out *UserSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserSpec.
func (in *UserSpec) DeepCopy() *UserSpec {
if in == nil {
return nil
}
out := new(UserSpec)
in.DeepCopyInto(out)
return out
}

View File

@@ -113,3 +113,7 @@ func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.Forbid
return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations
}
func (c *capsuleConfiguration) Administrators() capsuleapi.UserListSpec {
return c.retrievalFn().Spec.Administrators
}

View File

@@ -29,4 +29,5 @@ type Configuration interface {
IgnoreUserWithGroups() []string
ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec
Administrators() capsuleapi.UserListSpec
}

View File

@@ -9,7 +9,7 @@ import (
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type OwnerReference struct{}
@@ -32,7 +32,7 @@ func (o OwnerReference) Func() client.IndexerFunc {
}
for _, or := range ns.OwnerReferences {
if or.APIVersion == capsulev1beta2.GroupVersion.String() {
if tenant.IsTenantOwnerReference(or) {
res = append(res, or.Name)
}
}

View File

@@ -9,7 +9,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/utils"
"github.com/projectcapsule/capsule/pkg/utils/tenant"
)
type OwnerReference struct{}
@@ -24,11 +24,11 @@ func (o OwnerReference) Field() string {
func (o OwnerReference) Func() client.IndexerFunc {
return func(object client.Object) []string {
tenant, ok := object.(*capsulev1beta2.Tenant)
tnt, ok := object.(*capsulev1beta2.Tenant)
if !ok {
panic(fmt.Errorf("expected type *capsulev1beta2.Tenant, got %T", tenant))
panic(fmt.Errorf("expected type *capsulev1beta2.Tenant, got %T", tnt))
}
return utils.GetOwnersWithKinds(tenant)
return tenant.GetOwnersWithKinds(tnt)
}
}

View File

@@ -1,45 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package meta
import (
"strings"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
ReleaseAnnotation = "projectcapsule.dev/release"
ReleaseAnnotationTrigger = "true"
)
func ReleaseAnnotationTriggers(obj client.Object) bool {
return annotationTriggers(obj, ReleaseAnnotation, ReleaseAnnotationTrigger)
}
func ReleaseAnnotationRemove(obj client.Object) {
annotationRemove(obj, ReleaseAnnotation)
}
func annotationRemove(obj client.Object, anno string) {
annotations := obj.GetAnnotations()
if _, ok := annotations[anno]; ok {
delete(annotations, anno)
obj.SetAnnotations(annotations)
}
}
func annotationTriggers(obj client.Object, anno string, trigger string) bool {
annotations := obj.GetAnnotations()
if val, ok := annotations[anno]; ok {
if strings.ToLower(val) == trigger {
return true
}
}
return false
}

View File

@@ -1,94 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type ClaimRecorder struct {
claimConditionGauge *prometheus.GaugeVec
claimResourcesGauge *prometheus.GaugeVec
}
func MustMakeClaimRecorder() *ClaimRecorder {
metricsRecorder := NewClaimRecorder()
crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...)
return metricsRecorder
}
func NewClaimRecorder() *ClaimRecorder {
return &ClaimRecorder{
claimConditionGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "claim_condition",
Help: "The current condition status of a claim.",
},
[]string{"name", "target_namespace", "condition", "reason", "pool"},
),
claimResourcesGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "claim_resource",
Help: "The given amount of resources from the claim",
},
[]string{"name", "target_namespace", "resource"},
),
}
}
func (r *ClaimRecorder) Collectors() []prometheus.Collector {
return []prometheus.Collector{
r.claimConditionGauge,
r.claimResourcesGauge,
}
}
// RecordCondition records the condition as given for the ref.
func (r *ClaimRecorder) RecordClaimCondition(claim *capsulev1beta2.ResourcePoolClaim) {
// Remove all Condition Metrics to avoid duplicates
r.claimConditionGauge.DeletePartialMatch(map[string]string{
"name": claim.Name,
"namespace": claim.Namespace,
})
value := 0
if claim.Status.Condition.Status == metav1.ConditionTrue {
value = 1
}
r.claimConditionGauge.WithLabelValues(
claim.Name,
claim.Namespace,
claim.Status.Condition.Type,
claim.Status.Condition.Reason,
claim.Status.Pool.Name.String(),
).Set(float64(value))
for resourceName, qt := range claim.Spec.ResourceClaims {
r.claimResourcesGauge.WithLabelValues(
claim.Name,
claim.Namespace,
resourceName.String(),
).Set(float64(qt.MilliValue()) / 1000)
}
}
// DeleteCondition deletes the condition metrics for the ref.
func (r *ClaimRecorder) DeleteClaimMetric(claim string, namespace string) {
r.claimConditionGauge.DeletePartialMatch(map[string]string{
"name": claim,
"namespace": namespace,
})
r.claimResourcesGauge.DeletePartialMatch(map[string]string{
"name": claim,
"namespace": namespace,
})
}

View File

@@ -1,257 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
)
type ResourcePoolRecorder struct {
poolResource *prometheus.GaugeVec
poolResourceLimit *prometheus.GaugeVec
poolResourceAvailable *prometheus.GaugeVec
poolResourceUsage *prometheus.GaugeVec
poolResourceUsagePercentage *prometheus.GaugeVec
poolResourceExhaustion *prometheus.GaugeVec
poolResourceExhaustionPercentage *prometheus.GaugeVec
poolNamespaceResourceUsage *prometheus.GaugeVec
poolNamespaceResourceUsagePercentage *prometheus.GaugeVec
}
func MustMakeResourcePoolRecorder() *ResourcePoolRecorder {
metricsRecorder := NewResourcePoolRecorder()
crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...)
return metricsRecorder
}
func NewResourcePoolRecorder() *ResourcePoolRecorder {
return &ResourcePoolRecorder{
poolResourceExhaustion: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_exhaustion",
Help: "Resources become exhausted, when there's not enough available for all claims and the claims get queued",
},
[]string{"pool", "resource"},
),
poolResourceExhaustionPercentage: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_exhaustion_percentage",
Help: "Resources become exhausted, when there's not enough available for all claims and the claims get queued (Percentage)",
},
[]string{"pool", "resource"},
),
poolResource: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_resource",
Help: "Type of resource being used in a resource pool",
},
[]string{"pool", "resource"},
),
poolResourceLimit: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_limit",
Help: "Current resource limit for a given resource in a resource pool",
},
[]string{"pool", "resource"},
),
poolResourceUsage: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_usage",
Help: "Current resource usage for a given resource in a resource pool",
},
[]string{"pool", "resource"},
),
poolResourceUsagePercentage: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_usage_percentage",
Help: "Current resource usage for a given resource in a resource pool (percentage)",
},
[]string{"pool", "resource"},
),
poolResourceAvailable: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_available",
Help: "Current resource availability for a given resource in a resource pool",
},
[]string{"pool", "resource"},
),
poolNamespaceResourceUsage: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_namespace_usage",
Help: "Current resources claimed on namespace basis for a given resource in a resource pool for a specific namespace",
},
[]string{"pool", "target_namespace", "resource"},
),
poolNamespaceResourceUsagePercentage: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "pool_namespace_usage_percentage",
Help: "Current resources claimed on namespace basis for a given resource in a resource pool for a specific namespace (percentage)",
},
[]string{"pool", "target_namespace", "resource"},
),
}
}
func (r *ResourcePoolRecorder) Collectors() []prometheus.Collector {
return []prometheus.Collector{
r.poolResource,
r.poolResourceLimit,
r.poolResourceUsage,
r.poolResourceUsagePercentage,
r.poolResourceAvailable,
r.poolResourceExhaustion,
r.poolResourceExhaustionPercentage,
r.poolNamespaceResourceUsage,
r.poolNamespaceResourceUsagePercentage,
}
}
// Emit current hard limits and usage for a resource pool.
func (r *ResourcePoolRecorder) ResourceUsageMetrics(pool *capsulev1beta2.ResourcePool) {
for resourceName, quantity := range pool.Status.Allocation.Hard {
r.poolResourceLimit.WithLabelValues(
pool.Name,
resourceName.String(),
).Set(float64(quantity.MilliValue()) / 1000)
r.poolResource.WithLabelValues(
pool.Name,
resourceName.String(),
).Set(float64(1))
claimed, exists := pool.Status.Allocation.Claimed[resourceName]
if !exists {
r.poolResourceUsage.DeletePartialMatch(map[string]string{
"pool": pool.Name,
"resource": resourceName.String(),
})
continue
}
r.poolResourceUsage.WithLabelValues(
pool.Name,
resourceName.String(),
).Set(float64(claimed.MilliValue()) / 1000)
available := pool.Status.Allocation.Available[resourceName]
r.poolResourceAvailable.WithLabelValues(
pool.Name,
resourceName.String(),
).Set(float64(available.MilliValue()) / 1000)
usagePercentage := float64(0)
if quantity.MilliValue() > 0 {
usagePercentage = (float64(claimed.MilliValue()) / float64(quantity.MilliValue())) * 100
}
r.poolResourceUsagePercentage.WithLabelValues(
pool.Name,
resourceName.String(),
).Set(usagePercentage)
}
r.resourceUsageMetricsByNamespace(pool)
}
// Emit exhaustion metrics.
func (r *ResourcePoolRecorder) CalculateExhaustions(
pool *capsulev1beta2.ResourcePool,
current map[string]api.PoolExhaustionResource,
) {
for resource := range pool.Status.Exhaustions {
if _, ok := current[resource]; ok {
continue
}
r.poolResourceExhaustion.DeleteLabelValues(pool.Name, resource)
r.poolResourceExhaustionPercentage.DeleteLabelValues(pool.Name, resource)
}
for resource, ex := range current {
available := float64(ex.Available.MilliValue()) / 1000
requesting := float64(ex.Requesting.MilliValue()) / 1000
r.poolResourceExhaustion.WithLabelValues(
pool.Name,
resource,
).Set(float64(ex.Requesting.MilliValue()) / 1000)
// Calculate and expose overprovisioning percentage
if available > 0 && requesting > available {
percent := ((requesting - available) / available) * 100
r.poolResourceExhaustionPercentage.WithLabelValues(
pool.Name,
resource,
).Set(percent)
} else {
r.poolResourceExhaustionPercentage.DeleteLabelValues(pool.Name, resource)
}
}
}
// Delete all metrics for a namespace in a resource pool.
func (r *ResourcePoolRecorder) DeleteResourcePoolNamespaceMetric(pool string, namespace string) {
r.poolNamespaceResourceUsage.DeletePartialMatch(map[string]string{"pool": pool, "namespace": namespace})
}
// Delete all metrics for a resource pool.
func (r *ResourcePoolRecorder) DeleteResourcePoolMetric(pool string) {
r.cleanupAllMetricForLabels(map[string]string{"pool": pool})
}
func (r *ResourcePoolRecorder) DeleteResourcePoolSingleResourceMetric(pool string, resourceName string) {
r.cleanupAllMetricForLabels(map[string]string{"pool": pool, "resource": resourceName})
}
func (r *ResourcePoolRecorder) cleanupAllMetricForLabels(labels map[string]string) {
r.poolResourceLimit.DeletePartialMatch(labels)
r.poolResourceAvailable.DeletePartialMatch(labels)
r.poolResourceUsage.DeletePartialMatch(labels)
r.poolResourceUsagePercentage.DeletePartialMatch(labels)
r.poolNamespaceResourceUsage.DeletePartialMatch(labels)
r.poolNamespaceResourceUsagePercentage.DeletePartialMatch(labels)
r.poolResource.DeletePartialMatch(labels)
r.poolResourceExhaustion.DeletePartialMatch(labels)
}
// Calculate allocation per namespace for metric.
func (r *ResourcePoolRecorder) resourceUsageMetricsByNamespace(pool *capsulev1beta2.ResourcePool) {
resources := pool.GetClaimedByNamespaceClaims()
for namespace, claims := range resources {
for resourceName, quantity := range claims {
r.poolNamespaceResourceUsage.WithLabelValues(
pool.Name,
namespace,
resourceName.String(),
).Set(float64(quantity.MilliValue()) / 1000)
available, ok := pool.Status.Allocation.Hard[resourceName]
if !ok {
continue
}
r.poolNamespaceResourceUsagePercentage.WithLabelValues(
pool.Name,
namespace,
resourceName.String(),
).Set((float64(quantity.MilliValue()) / float64(available.MilliValue())) * 100)
}
}
}

View File

@@ -1,152 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
)
type TenantRecorder struct {
TenantNamespaceRelationshipGauge *prometheus.GaugeVec
TenantNamespaceConditionGauge *prometheus.GaugeVec
TenantConditionGauge *prometheus.GaugeVec
TenantNamespaceCounterGauge *prometheus.GaugeVec
TenantResourceUsageGauge *prometheus.GaugeVec
TenantResourceLimitGauge *prometheus.GaugeVec
}
func MustMakeTenantRecorder() *TenantRecorder {
metricsRecorder := NewTenantRecorder()
crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...)
return metricsRecorder
}
func NewTenantRecorder() *TenantRecorder {
return &TenantRecorder{
TenantNamespaceRelationshipGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_namespace_relationship",
Help: "Mapping metric showing namespace to tenant relationships",
}, []string{"tenant", "target_namespace"},
),
TenantNamespaceConditionGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_namespace_condition",
Help: "Provides per namespace within a tenant condition status for each condition",
}, []string{"tenant", "target_namespace", "condition"},
),
TenantConditionGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_condition",
Help: "Provides per tenant condition status for each condition",
}, []string{"tenant", "condition"},
),
TenantNamespaceCounterGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_namespace_count",
Help: "Total number of namespaces currently owned by the tenant",
}, []string{"tenant"},
),
TenantResourceUsageGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_resource_usage",
Help: "Current resource usage for a given resource in a tenant",
}, []string{"tenant", "resource", "resourcequotaindex"},
),
TenantResourceLimitGauge: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: metricsPrefix,
Name: "tenant_resource_limit",
Help: "Current resource limit for a given resource in a tenant",
}, []string{"tenant", "resource", "resourcequotaindex"},
),
}
}
func (r *TenantRecorder) Collectors() []prometheus.Collector {
return []prometheus.Collector{
r.TenantNamespaceRelationshipGauge,
r.TenantNamespaceConditionGauge,
r.TenantConditionGauge,
r.TenantNamespaceCounterGauge,
r.TenantResourceUsageGauge,
r.TenantResourceLimitGauge,
}
}
func (r *TenantRecorder) DeleteAllMetricsForNamespace(namespace string) {
r.DeleteNamespaceRelationshipMetrics(namespace)
r.DeleteTenantNamespaceConditionMetrics(namespace)
}
// DeleteCondition deletes the condition metrics for the ref.
func (r *TenantRecorder) DeleteNamespaceRelationshipMetrics(namespace string) {
r.TenantNamespaceRelationshipGauge.DeletePartialMatch(map[string]string{
"target_namespace": namespace,
})
}
func (r *TenantRecorder) DeleteTenantNamespaceConditionMetrics(namespace string) {
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
"target_namespace": namespace,
})
}
func (r *TenantRecorder) DeleteTenantNamespaceConditionMetricByType(namespace string, condition string) {
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
"target_namespace": namespace,
"condition": condition,
})
}
func (r *TenantRecorder) DeleteAllMetricsForTenant(tenant string) {
r.DeleteTenantResourceMetrics(tenant)
r.DeleteTenantStatusMetrics(tenant)
r.DeleteTenantConditionMetrics(tenant)
r.DeleteTenantResourceMetrics(tenant)
}
func (r *TenantRecorder) DeleteTenantConditionMetrics(tenant string) {
r.TenantConditionGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
}
func (r *TenantRecorder) DeleteTenantConditionMetricByType(tenant string, condition string) {
r.TenantConditionGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
"condition": condition,
})
}
// DeleteCondition deletes the condition metrics for the ref.
func (r *TenantRecorder) DeleteTenantResourceMetrics(tenant string) {
r.TenantResourceUsageGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
r.TenantResourceLimitGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
}
// DeleteCondition deletes the condition metrics for the ref.
func (r *TenantRecorder) DeleteTenantStatusMetrics(tenant string) {
r.TenantNamespaceCounterGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
r.TenantNamespaceRelationshipGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
"tenant": tenant,
})
}

View File

@@ -1,8 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package metrics
const (
metricsPrefix = "capsule"
)

View File

@@ -1,14 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
import (
"fmt"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
func PoolResourceQuotaName(quota *capsulev1beta2.ResourcePool) string {
return fmt.Sprintf("capsule-pool-%s", quota.Name)
}

View File

@@ -1,27 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
import (
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
const (
ObjectReferenceTenantKind = "Tenant"
)
func IsTenantOwnerReference(or metav1.OwnerReference) bool {
parts := strings.Split(or.APIVersion, "/")
if len(parts) != 2 {
return false
}
group := parts[0]
return group == capsulev1beta2.GroupVersion.Group && or.Kind == ObjectReferenceTenantKind
}

183
pkg/utils/tenant/get_by.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"context"
"fmt"
"sort"
"strings"
authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/utils/users"
)
func TenantByStatusNamespace(
ctx context.Context,
c client.Client,
namespace string,
) (*capsulev1beta2.Tenant, error) {
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", namespace),
}); err != nil {
return nil, err
}
if len(tntList.Items) == 0 {
return nil, nil
}
tnt := &capsulev1beta2.Tenant{}
*tnt = tntList.Items[0]
return tnt, nil
}
// getNamespaceTenant returns namespace owner tenant.
func GetTenantByOwnerreferences(
ctx context.Context,
c client.Client,
refs []v1.OwnerReference,
) (tnt *capsulev1beta2.Tenant, err error) {
for _, or := range refs {
if !IsTenantOwnerReference(or) {
continue
}
tnt = &capsulev1beta2.Tenant{}
if err = c.Get(ctx, types.NamespacedName{Name: or.Name}, tnt); err != nil {
return nil, err
}
return tnt, nil
}
return nil, nil
}
//nolint:nestif
func GetTenantByUserInfo(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
ns *corev1.Namespace,
username string,
groups []string,
) (sortedTenants, error) {
var tenants sortedTenants
// User tenants.
userTntList := &capsulev1beta2.TenantList{}
fields := client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("User:%s", username),
}
err := c.List(ctx, userTntList, fields)
if err != nil {
return nil, err
}
tenants = userTntList.Items
// ServiceAccount tenants.
if strings.HasPrefix(username, "system:serviceaccount:") {
saTntList := &capsulev1beta2.TenantList{}
fields = client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("ServiceAccount:%s", username),
}
err = c.List(ctx, saTntList, fields)
if err != nil {
return nil, err
}
tenants = append(tenants, saTntList.Items...)
if cfg.AllowServiceAccountPromotion() {
if tnt, err := users.ResolveServiceAccountActor(ctx, c, ns, username, cfg); err != nil {
return nil, err
} else if tnt != nil {
tenants = append(tenants, *tnt)
}
}
}
// Group tenants.
groupTntList := &capsulev1beta2.TenantList{}
for _, group := range groups {
fields = client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("Group:%s", group),
}
err = c.List(ctx, groupTntList, fields)
if err != nil {
return nil, err
}
tenants = append(tenants, groupTntList.Items...)
}
sort.Sort(sort.Reverse(tenants))
return tenants, nil
}
// getTenantByLabels returns tenant from labels.
func GetTenantByLabels(
ctx context.Context,
c client.Client,
ns *corev1.Namespace,
) (*capsulev1beta2.Tenant, error) {
if label, ok := ns.Labels[meta.TenantLabel]; ok {
tnt := &capsulev1beta2.Tenant{}
if err := c.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
return nil, err
}
return tnt, nil
}
// Nothing found in the labels.
return nil, nil
}
func GetTenantByLabelsAndUser(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
ns *corev1.Namespace,
userInfo authenticationv1.UserInfo,
) (*capsulev1beta2.Tenant, error) {
tnt, err := GetTenantByLabels(ctx, c, ns)
if err != nil {
return nil, err
}
if tnt != nil {
ok, err := users.IsTenantOwner(ctx, c, cfg, tnt, userInfo)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("can not assign the desired namespace to a non-owned Tenant")
}
return tnt, nil
}
return nil, nil
}

View File

@@ -0,0 +1,21 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
corev1 "k8s.io/api/core/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
func AddTenantLabelForNamespace(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) error {
if ns.Labels == nil {
ns.Labels = make(map[string]string)
}
ns.Labels[meta.TenantLabel] = tnt.GetName()
return nil
}

35
pkg/utils/tenant/owned.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"context"
authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/utils/users"
)
func NamespaceIsOwned(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
ns *corev1.Namespace,
tnt *capsulev1beta2.Tenant,
userInfo authenticationv1.UserInfo,
) (bool, error) {
for _, ownerRef := range ns.OwnerReferences {
if !IsTenantOwnerReferenceForTenant(ownerRef, tnt) {
continue
}
return users.IsTenantOwner(ctx, c, cfg, tnt, userInfo)
}
return false, nil
}

View File

@@ -0,0 +1,58 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
const (
ObjectReferenceTenantKind = "Tenant"
)
func IsTenantOwnerReference(or metav1.OwnerReference) bool {
if or.Kind != ObjectReferenceTenantKind {
return false
}
if or.APIVersion == "" {
return false
}
parts := strings.Split(or.APIVersion, "/")
if len(parts) != 2 {
return false
}
group := parts[0]
return group == capsulev1beta2.GroupVersion.Group && or.Kind == ObjectReferenceTenantKind
}
func IsTenantOwnerReferenceForTenant(or metav1.OwnerReference, tnt *capsulev1beta2.Tenant) bool {
if tnt == nil {
return false
}
if or.Kind != ObjectReferenceTenantKind {
return false
}
if or.APIVersion == "" {
return false
}
parts := strings.Split(or.APIVersion, "/")
if len(parts) != 2 {
return false
}
group := parts[0]
return group == capsulev1beta2.GroupVersion.Group && or.Kind == ObjectReferenceTenantKind && or.Name == tnt.GetName() && or.UID == tnt.GetUID()
}

View File

@@ -0,0 +1,323 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
func TestIsTenantOwnerReference(t *testing.T) {
capsuleGroup := capsulev1beta2.GroupVersion.Group
tests := []struct {
name string
or metav1.OwnerReference
want bool
}{
{
name: "valid tenant ownerRef with exact group and version",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: true,
},
{
name: "valid tenant ownerRef with same group but different version",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: true, // we intentionally only check the group, not the version
},
{
name: "wrong group",
or: metav1.OwnerReference{
APIVersion: "other.group.io/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "wrong kind",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2",
Kind: "Namespace",
Name: "my-tenant",
},
want: false,
},
{
name: "empty APIVersion",
or: metav1.OwnerReference{
APIVersion: "",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "APIVersion without slash (only version)",
or: metav1.OwnerReference{
APIVersion: "v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "APIVersion with empty group",
or: metav1.OwnerReference{
APIVersion: "/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "APIVersion with empty version",
or: metav1.OwnerReference{
APIVersion: "",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "APIVersion with extra slash in version (still ok as long as group matches)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2/extra",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
want: false,
},
{
name: "completely unrelated ownerRef",
or: metav1.OwnerReference{
APIVersion: "v1",
Kind: "ConfigMap",
Name: "cm",
},
want: false,
},
}
for _, tt := range tests {
tt := tt // capture
t.Run(tt.name, func(t *testing.T) {
got := IsTenantOwnerReference(tt.or)
if got != tt.want {
t.Fatalf("IsTenantOwnerReference(%+v) = %v, want %v", tt.or, got, tt.want)
}
})
}
}
func TestIsTenantOwnerReferenceForTenant(t *testing.T) {
capsuleGroup := capsulev1beta2.GroupVersion.Group
tests := []struct {
name string
or metav1.OwnerReference
want bool
tenant *capsulev1beta2.Tenant
}{
{
name: "valid tenant ownerRef with exact group and version (same tenant)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: true,
},
{
name: "valid tenant ownerRef with exact group and version (different tenant)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant-2",
},
},
want: false,
},
{
name: "valid tenant ownerRef with same group but different version (same tenant)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: true, // we intentionally only check the group, not the version
},
{
name: "valid tenant ownerRef with same group but different version (different tenant)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant-2",
},
},
want: false, // we intentionally only check the group, not the version
},
{
name: "wrong group",
or: metav1.OwnerReference{
APIVersion: "other.group.io/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "wrong kind",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2",
Kind: "Namespace",
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "empty APIVersion",
or: metav1.OwnerReference{
APIVersion: "",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "empty tenant",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: nil,
want: false,
},
{
name: "APIVersion without slash (only version)",
or: metav1.OwnerReference{
APIVersion: "v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "APIVersion with empty group",
or: metav1.OwnerReference{
APIVersion: "/v1beta2",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "APIVersion with empty version",
or: metav1.OwnerReference{
APIVersion: "",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "APIVersion with extra slash in version (still ok as long as group matches)",
or: metav1.OwnerReference{
APIVersion: capsuleGroup + "/v1beta2/extra",
Kind: ObjectReferenceTenantKind,
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
{
name: "completely unrelated ownerRef",
or: metav1.OwnerReference{
APIVersion: "v1",
Kind: "ConfigMap",
Name: "my-tenant",
},
tenant: &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "my-tenant",
},
},
want: false,
},
}
for _, tt := range tests {
tt := tt // capture
t.Run(tt.name, func(t *testing.T) {
got := IsTenantOwnerReferenceForTenant(tt.or, tt.tenant)
if got != tt.want {
t.Fatalf("IsTenantOwnerReference(%+v) = %v, want %v", tt.or, got, tt.want)
}
})
}
}

View File

@@ -1,7 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
package tenant
import (
"fmt"

20
pkg/utils/tenant/types.go Normal file
View File

@@ -0,0 +1,20 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package tenant
import capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
type sortedTenants []capsulev1beta2.Tenant
func (s sortedTenants) Len() int {
return len(s)
}
func (s sortedTenants) Less(i, j int) bool {
return len(s[i].GetName()) < len(s[j].GetName())
}
func (s sortedTenants) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}

View File

@@ -13,22 +13,23 @@ import (
"github.com/projectcapsule/capsule/api/v1beta1"
"github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
func GetTypeLabel(t runtime.Object) (label string, err error) {
switch v := t.(type) {
case *v1beta1.Tenant, *v1beta2.Tenant:
return "capsule.clastix.io/tenant", nil
return meta.TenantLabel, nil
case *v1beta2.ResourcePool:
return "projectcapsule.dev/pool", nil
return meta.ResourcePoolLabel, nil
case *corev1.LimitRange:
return "capsule.clastix.io/limit-range", nil
return meta.LimitRangeLabel, nil
case *networkingv1.NetworkPolicy:
return "capsule.clastix.io/network-policy", nil
return meta.NetworkPolicyLabel, nil
case *corev1.ResourceQuota:
return "capsule.clastix.io/resource-quota", nil
return meta.ResourceQuotaLabel, nil
case *rbacv1.RoleBinding:
return "capsule.clastix.io/role-binding", nil
return meta.RolebindingLabel, nil
default:
err = fmt.Errorf("type %T is not mapped as Capsule label recognized", v)
}

View File

@@ -0,0 +1,14 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package users
import (
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/api"
)
func IsAdminUser(req admission.Request, administrators api.UserListSpec) bool {
return administrators.IsPresent(req.UserInfo.Username, req.UserInfo.Groups)
}

View File

@@ -1,7 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
package users
import (
"context"
@@ -11,14 +11,19 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/utils"
"github.com/projectcapsule/capsule/pkg/configuration"
)
func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client, users []string, userGroups []string, ignoreGroups []string) bool {
groupList := utils.NewUserGroupList(req.UserInfo.Groups)
func IsCapsuleUser(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
user string,
groups []string,
) bool {
groupList := NewUserGroupList(groups)
// if the user is a ServiceAccount belonging to the kube-system namespace, definitely, it's not a Capsule user
// and we can skip the check in case of Capsule user group assigned to system:authenticated
// (ref: https://github.com/projectcapsule/capsule/issues/234)
@@ -27,15 +32,15 @@ func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client
}
//nolint:nestif
if sets.NewString(req.UserInfo.Groups...).Has("system:serviceaccounts") {
namespace, name, err := serviceaccount.SplitUsername(req.UserInfo.Username)
if sets.NewString(groups...).Has("system:serviceaccounts") {
namespace, name, err := serviceaccount.SplitUsername(user)
if err == nil {
if namespace == os.Getenv("NAMESPACE") && name == os.Getenv("SERVICE_ACCOUNT") {
return false
}
tl := &capsulev1beta2.TenantList{}
if err := clt.List(ctx, tl, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", namespace)}); err != nil {
if err := c.List(ctx, tl, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", namespace)}); err != nil {
return false
}
@@ -45,10 +50,10 @@ func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client
}
}
for _, group := range userGroups {
for _, group := range cfg.UserGroups() {
if groupList.Find(group) {
if len(ignoreGroups) > 0 {
for _, ignoreGroup := range ignoreGroups {
if len(cfg.IgnoreUserWithGroups()) > 0 {
for _, ignoreGroup := range cfg.IgnoreUserWithGroups() {
if groupList.Find(ignoreGroup) {
return false
}
@@ -59,7 +64,7 @@ func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client
}
}
if len(users) > 0 && sets.New[string](users...).Has(req.UserInfo.Username) {
if len(cfg.UserNames()) > 0 && sets.New[string](cfg.UserNames()...).Has(user) {
return true
}

View File

@@ -1,7 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
package users
import (
"context"
@@ -13,32 +13,27 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/configuration"
)
func IsTenantOwner(
ctx context.Context,
c client.Client,
cfg configuration.Configuration,
tenant *capsulev1beta2.Tenant,
userInfo authenticationv1.UserInfo,
promotedServiceAccountOwners bool,
) (bool, error) {
for _, owner := range tenant.Spec.Owners {
switch owner.Kind {
case capsulev1beta2.UserOwner, capsulev1beta2.ServiceAccountOwner:
if userInfo.Username == owner.Name {
return true, nil
}
case capsulev1beta2.GroupOwner:
for _, group := range userInfo.Groups {
if group == owner.Name {
return true, nil
}
}
}
if isOwner := tenant.Spec.Owners.IsOwner(userInfo.Username, userInfo.Groups); isOwner {
return true, nil
}
if promotedServiceAccountOwners {
// Administrators are always Owners
if cfg.Administrators().IsPresent(userInfo.Username, userInfo.Groups) {
return true, nil
}
if cfg.AllowServiceAccountPromotion() {
parts := strings.Split(userInfo.Username, ":")
if len(parts) != 4 {

View File

@@ -0,0 +1,59 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package users
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/configuration"
)
// This function resolves the tenant based on the serviceaccount given via username
// if a serviceaccount is in a tenant namespace they will return the tenant.
func ResolveServiceAccountActor(
ctx context.Context,
c client.Client,
ns *corev1.Namespace,
username string,
cfg configuration.Configuration,
) (tnt *capsulev1beta2.Tenant, err error) {
namespace, name, err := serviceaccount.SplitUsername(username)
if err != nil {
return nil, err
}
sa := &corev1.ServiceAccount{}
if err = c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, sa); err != nil {
if apierrors.IsNotFound(err) {
return tnt, err
}
return tnt, err
}
if meta.OwnerPromotionLabelTriggers(ns) {
return tnt, err
}
tntList := &capsulev1beta2.TenantList{}
if err = c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", namespace),
}); err != nil {
return tnt, err
}
if len(tntList.Items) > 0 {
tnt = &tntList.Items[0]
}
return tnt, err
}

View File

@@ -1,7 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
package users
import (
"sort"

View File

@@ -1,7 +1,7 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package utils
package users
import (
"testing"

View File

@@ -1,91 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"fmt"
"reflect"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)
type StorageClassError struct {
storageClass string
msg error
}
func NewStorageClassError(class string, msg error) error {
return &StorageClassError{
storageClass: class,
msg: msg,
}
}
func (e StorageClassError) Error() string {
return fmt.Sprintf("Failed to resolve Storage Class %s: %s", e.storageClass, e.msg)
}
type IngressClassError struct {
ingressClass string
msg error
}
func NewIngressClassError(class string, msg error) error {
return &IngressClassError{
ingressClass: class,
msg: msg,
}
}
func (e IngressClassError) Error() string {
return fmt.Sprintf("Failed to resolve Ingress Class %s: %s", e.ingressClass, e.msg)
}
type GatewayClassError struct {
gatewayClass string
msg error
}
func NewGatewayClassError(class string, msg error) error {
return &GatewayClassError{
gatewayClass: class,
msg: msg,
}
}
func (e GatewayClassError) Error() string {
return fmt.Sprintf("Failed to resolve Gateway Class %s: %s", e.gatewayClass, e.msg)
}
type GatewayError struct {
gateway string
msg error
}
func NewGatewayError(gateway gatewayv1.ObjectName, msg error) error {
return &GatewayError{
gateway: reflect.ValueOf(gateway).String(),
msg: msg,
}
}
func (e GatewayError) Error() string {
return fmt.Sprintf("Failed to resolve Gateway %s: %s", e.gateway, e.msg)
}
type PriorityClassError struct {
priorityClass string
msg error
}
func NewPriorityClassError(class string, msg error) error {
return &PriorityClassError{
priorityClass: class,
msg: msg,
}
}
func (e PriorityClassError) Error() string {
return fmt.Sprintf("Failed to resolve Priority Class %s: %s", e.priorityClass, e.msg)
}

View File

@@ -1,87 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulegateway "github.com/projectcapsule/capsule/pkg/webhook/gateway"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespce string) *admission.Response {
gatewayObj := &gatewayv1.Gateway{}
if err := decoder.Decode(req, gatewayObj); err != nil {
return utils.ErroredResponse(err)
}
gatewayObj.SetNamespace(namespce)
tnt, err := capsulegateway.TenantFromGateway(ctx, c, gatewayObj)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.GatewayOptions.AllowedClasses
if allowed == nil || allowed.Default == "" {
return nil
}
var mutate bool
gatewayClass, err := utils.GetGatewayClassClassByObjectName(ctx, c, gatewayObj.Spec.GatewayClassName)
if gatewayClass == nil {
if gatewayObj.Spec.GatewayClassName == ("") {
mutate = true
} else {
response := admission.Denied(NewGatewayError(gatewayObj.Spec.GatewayClassName, err).Error())
return &response
}
}
if gatewayClass != nil && gatewayClass.Name != allowed.Default {
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Denied(NewGatewayClassError(gatewayClass.Name, err).Error())
return &response
}
} else {
mutate = true
}
if mutate = mutate || (gatewayClass.Name == allowed.Default); !mutate {
return nil
}
gatewayObj.Spec.GatewayClassName = gatewayv1.ObjectName(allowed.Default)
marshaled, err := json.Marshal(gatewayObj)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Gateway Class %s to %s/%s", allowed.Default, gatewayObj.Name, gatewayObj.Namespace)
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}

View File

@@ -1,70 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
)
type handler struct {
cfg configuration.Configuration
version *version.Version
}
func Handler(cfg configuration.Configuration, version *version.Version) capsulewebhook.Handler {
return &handler{
cfg: cfg,
version: version,
}
}
func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.mutate(ctx, req, client, decoder, recorder)
}
}
func (h *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *handler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.mutate(ctx, req, client, decoder, recorder)
}
}
func (h *handler) mutate(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
var response *admission.Response
switch req.Resource {
case metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"}:
response = mutatePodDefaults(ctx, req, c, decoder, recorder, req.Namespace)
case metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"}:
response = mutatePVCDefaults(ctx, req, c, decoder, recorder, req.Namespace)
case metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1beta1", Resource: "ingresses"}:
response = mutateIngressDefaults(ctx, req, h.version, c, decoder, recorder, req.Namespace)
case metav1.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"}:
response = mutateGatewayDefaults(ctx, req, c, decoder, recorder, req.Namespace)
}
if response == nil {
skip := admission.Allowed("Skipping Mutation")
response = &skip
}
return response
}

View File

@@ -1,80 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsuleingress "github.com/projectcapsule/capsule/pkg/webhook/ingress"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
func mutateIngressDefaults(ctx context.Context, req admission.Request, version *version.Version, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
ingress, err := capsuleingress.FromRequest(req, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
ingress.SetNamespace(namespace)
var tnt *capsulev1beta2.Tenant
tnt, err = capsuleingress.TenantFromIngress(ctx, c, ingress)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
// Validate Default Ingress
allowed := tnt.Spec.IngressOptions.AllowedClasses
if allowed == nil || allowed.Default == "" {
return nil
}
var mutate bool
var ingressClass client.Object
if ingressClassName := ingress.IngressClass(); ingressClassName != nil && *ingressClassName != allowed.Default {
if ingressClass, err = utils.GetIngressClassByName(ctx, version, c, ingressClassName); err != nil && !k8serrors.IsNotFound(err) {
response := admission.Denied(NewIngressClassError(*ingressClassName, err).Error())
return &response
}
} else {
mutate = true
}
if mutate = mutate || (utils.IsDefaultIngressClass(ingressClass) && ingressClass.GetName() != allowed.Default); !mutate {
return nil
}
ingress.SetIngressClass(allowed.Default)
// Marshal Manifest
marshaled, err := json.Marshal(ingress)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Ingress Class %s to %s/%s", allowed.Default, ingress.Name(), ingress.Namespace())
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}

View File

@@ -1,126 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
"fmt"
corev1 "k8s.io/api/core/v1"
schedulev1 "k8s.io/api/scheduling/v1"
"k8s.io/client-go/tools/record"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
func mutatePodDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
var pod corev1.Pod
if err := decoder.Decode(req, &pod); err != nil {
return utils.ErroredResponse(err)
}
pod.SetNamespace(namespace)
tnt, tErr := utils.TenantByStatusNamespace(ctx, c, pod.Namespace)
if tErr != nil {
return utils.ErroredResponse(tErr)
} else if tnt == nil {
return nil
}
var err error
pcMutated, pcErr := handlePriorityClassDefault(ctx, c, tnt.Spec.PriorityClasses, &pod)
if pcErr != nil {
return utils.ErroredResponse(pcErr)
} else if pcMutated {
defer func() {
if err == nil {
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Priority Class %s to %s/%s", tnt.Spec.PriorityClasses.Default, pod.Namespace, pod.Name)
}
}()
}
rcMutated := handleRuntimeClassDefault(tnt.Spec.RuntimeClasses, &pod)
if rcMutated {
defer func() {
if err == nil {
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Runtime Class %s to %s/%s", tnt.Spec.RuntimeClasses.Default, pod.Namespace, pod.Name)
}
}()
}
if !rcMutated && !pcMutated {
return nil
}
var marshaled []byte
if marshaled, err = json.Marshal(pod); err != nil {
return utils.ErroredResponse(err)
}
return ptr.To(admission.PatchResponseFromRaw(req.Object.Raw, marshaled))
}
func handleRuntimeClassDefault(allowed *api.DefaultAllowedListSpec, pod *corev1.Pod) (mutated bool) {
if allowed == nil || allowed.Default == "" {
return false
}
runtimeClass := pod.Spec.RuntimeClassName
switch {
case allowed.Default == "":
return false
case runtimeClass != nil && *runtimeClass != "":
return false
case runtimeClass != nil && *runtimeClass != allowed.Default:
return false
default:
pod.Spec.RuntimeClassName = &allowed.Default
return true
}
}
func handlePriorityClassDefault(ctx context.Context, c client.Client, allowed *api.DefaultAllowedListSpec, pod *corev1.Pod) (mutated bool, err error) {
if allowed == nil || allowed.Default == "" {
return false, nil
}
priorityClassPod := pod.Spec.PriorityClassName
var cpc *schedulev1.PriorityClass
// PriorityClass name is empty, if no GlobalDefault is set and no PriorityClass was given on pod
if len(priorityClassPod) > 0 && priorityClassPod != allowed.Default {
cpc, err = utils.GetPriorityClassByName(ctx, c, priorityClassPod)
// Should not happen, since API already checks if PC present
if err != nil {
return false, NewPriorityClassError(priorityClassPod, err)
}
} else {
mutated = true
}
if mutated = mutated || (utils.IsDefaultPriorityClass(cpc) && cpc.GetName() != allowed.Default); !mutated {
return false, nil
}
pc, err := utils.GetPriorityClassByName(ctx, c, allowed.Default)
if err != nil {
return false, fmt.Errorf("failed to assign tenant default Priority Class: %w", err)
}
pod.Spec.PreemptionPolicy = pc.PreemptionPolicy
pod.Spec.Priority = &pc.Value
pod.Spec.PriorityClassName = pc.Name
return true, nil
}

View File

@@ -1,79 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
func mutatePVCDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespace string) *admission.Response {
var err error
pvc := &corev1.PersistentVolumeClaim{}
if err = decoder.Decode(req, pvc); err != nil {
return utils.ErroredResponse(err)
}
pvc.SetNamespace(namespace)
var tnt *capsulev1beta2.Tenant
tnt, err = utils.TenantByStatusNamespace(ctx, c, pvc.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.StorageClasses
if allowed == nil || allowed.Default == "" {
return nil
}
var mutate bool
var csc *storagev1.StorageClass
if storageClassName := pvc.Spec.StorageClassName; storageClassName != nil && *storageClassName != allowed.Default {
csc, err = utils.GetStorageClassByName(ctx, c, *storageClassName)
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Denied(NewStorageClassError(*storageClassName, err).Error())
return &response
}
} else {
mutate = true
}
if mutate = mutate || (utils.IsDefaultStorageClass(csc) && csc.GetName() != allowed.Default); !mutate {
return nil
}
pvc.Spec.StorageClassName = &tnt.Spec.StorageClasses.Default
// Marshal Manifest
marshaled, err := json.Marshal(pvc)
if err != nil {
return utils.ErroredResponse(err)
}
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Storage Class %s to %s/%s", allowed.Default, pvc.Namespace, pvc.Name)
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}

View File

@@ -1,43 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"fmt"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type gatewayClassForbiddenError struct {
gatewayClassName string
spec api.SelectionListWithDefaultSpec
}
func NewGatewayClassForbidden(class string, spec api.SelectionListWithDefaultSpec) error {
return &gatewayClassForbiddenError{
gatewayClassName: class,
spec: spec,
}
}
func (i gatewayClassForbiddenError) Error() string {
err := fmt.Sprintf("Gateway Class %s is forbidden for the current Tenant: ", i.gatewayClassName)
return utils.SelectionListWithDefaultErrorMessage(i.spec, err)
}
type gatewayClassUndefinedError struct {
spec api.SelectionListWithDefaultSpec
}
func NewGatewayClassUndefined(spec api.SelectionListWithDefaultSpec) error {
return &gatewayClassUndefinedError{
spec: spec,
}
}
func (i gatewayClassUndefinedError) Error() string {
return utils.SelectionListWithDefaultErrorMessage(i.spec, "No gateway Class is forbidden for the current Tenant. Specify a gateway Class which is allowed within the Tenant: ")
}

View File

@@ -1,29 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"context"
"k8s.io/apimachinery/pkg/fields"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
func TenantFromGateway(ctx context.Context, c client.Client, gateway *v1.Gateway) (*capsulev1beta2.Tenant, error) {
tenantList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tenantList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", gateway.Namespace),
}); err != nil {
return nil, err
}
if len(tenantList.Items) == 0 {
return nil, nil //nolint:nilnil
}
return &tenantList.Items[0], nil
}

View File

@@ -1,115 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type class struct {
configuration configuration.Configuration
}
func Class(configuration configuration.Configuration) capsulewebhook.Handler {
return &class{
configuration: configuration,
}
}
func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *class) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *class) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
gatewayObj := &gatewayv1.Gateway{}
if err := decoder.Decode(req, gatewayObj); err != nil {
return utils.ErroredResponse(err)
}
var tnt *capsulev1beta2.Tenant
tnt, err := TenantFromGateway(ctx, client, gatewayObj)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.GatewayOptions.AllowedClasses
if allowed == nil {
return nil
}
gatewayClass, err := utils.GetGatewayClassClassByObjectName(ctx, client, gatewayObj.Spec.GatewayClassName)
if err != nil {
return utils.ErroredResponse(err)
}
if gatewayClass == nil {
recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingGatewayClass", "Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name)
response := admission.Denied(NewGatewayClassUndefined(*allowed).Error())
return &response
}
selector := false
// Verify if the GatewayClass exists and matches the label selector/expression
if len(allowed.MatchExpressions) > 0 || len(allowed.MatchLabels) > 0 {
gatewayClassObj, err := utils.GetGatewayClassClassByObjectName(ctx, client, gatewayObj.Spec.GatewayClassName)
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
// Gateway Class is present, check if it matches the selector
if gatewayClassObj != nil {
selector = allowed.SelectorMatch(gatewayClassObj)
}
}
switch {
case allowed.MatchDefault(gatewayClass.Name):
return nil
case selector:
return nil
default:
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenGatewaClass", "Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &gatewayClass)
response := admission.Denied(NewGatewayClassForbidden(gatewayObj.Name, *allowed).Error())
return &response
}
}

View File

@@ -1,20 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package webhook
import (
"context"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
type Func func(ctx context.Context, req admission.Request) *admission.Response
type Handler interface {
OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
OnDelete(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) Func
}

View File

@@ -1,116 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"fmt"
"strings"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type ingressClassForbiddenError struct {
ingressClassName string
spec api.DefaultAllowedListSpec
}
func NewIngressClassForbidden(class string, spec api.DefaultAllowedListSpec) error {
return &ingressClassForbiddenError{
ingressClassName: class,
spec: spec,
}
}
func (i ingressClassForbiddenError) Error() string {
err := fmt.Sprintf("Ingress Class %s is forbidden for the current Tenant: ", i.ingressClassName)
return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
}
type ingressHostnameNotValidError struct {
invalidHostnames []string
notMatchingHostnames []string
spec api.AllowedListSpec
}
type ingressHostnameCollisionError struct {
hostname string
}
func (i ingressHostnameCollisionError) Error() string {
return fmt.Sprintf("hostname %s is already used across the cluster: please, reach out to the system administrators", i.hostname)
}
func NewIngressHostnameCollision(hostname string) error {
return &ingressHostnameCollisionError{hostname: hostname}
}
func NewEmptyIngressHostname(spec api.AllowedListSpec) error {
return &emptyIngressHostnameError{
spec: spec,
}
}
type emptyIngressHostnameError struct {
spec api.AllowedListSpec
}
func (e emptyIngressHostnameError) Error() string {
return fmt.Sprintf("empty hostname is not allowed for the current Tenant%s", appendHostnameError(e.spec))
}
func NewIngressHostnamesNotValid(invalidHostnames []string, notMatchingHostnames []string, spec api.AllowedListSpec) error {
return &ingressHostnameNotValidError{invalidHostnames: invalidHostnames, notMatchingHostnames: notMatchingHostnames, spec: spec}
}
func (i ingressHostnameNotValidError) Error() string {
return fmt.Sprintf("Hostnames %s are not valid for the current Tenant. Hostnames %s not matching for the current Tenant%s",
i.invalidHostnames, i.notMatchingHostnames, appendHostnameError(i.spec))
}
type ingressClassUndefinedError struct {
spec api.DefaultAllowedListSpec
}
func NewIngressClassUndefined(spec api.DefaultAllowedListSpec) error {
return &ingressClassUndefinedError{
spec: spec,
}
}
func (i ingressClassUndefinedError) Error() string {
return utils.DefaultAllowedValuesErrorMessage(i.spec, "No Ingress Class is forbidden for the current Tenant. Specify a Ingress Class which is allowed within the Tenant: ")
}
type ingressClassNotValidError struct {
ingressClassName string
spec api.DefaultAllowedListSpec
}
func NewIngressClassNotValid(class string, spec api.DefaultAllowedListSpec) error {
return &ingressClassNotValidError{
ingressClassName: class,
spec: spec,
}
}
func (i ingressClassNotValidError) Error() string {
err := fmt.Sprintf("Ingress Class %s is forbidden for the current Tenant: ", i.ingressClassName)
return utils.DefaultAllowedValuesErrorMessage(i.spec, err)
}
//nolint:predeclared,revive
func appendHostnameError(spec api.AllowedListSpec) (append string) {
if len(spec.Exact) > 0 {
append = fmt.Sprintf(", specify one of the following (%s)", strings.Join(spec.Exact, ", "))
}
if len(spec.Regex) > 0 {
append += fmt.Sprintf(", or matching the regex %s", spec.Regex)
}
return append
}

View File

@@ -1,256 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"sort"
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
"k8s.io/apimachinery/pkg/util/sets"
)
const (
annotationName = "kubernetes.io/ingress.class"
)
type Ingress interface {
IngressClass() *string
Namespace() string
Name() string
HostnamePathsPairs() map[string]sets.Set[string]
SetIngressClass(string)
SetNamespace(string)
}
type NetworkingV1 struct {
*networkingv1.Ingress
}
func (n NetworkingV1) Name() string {
return n.GetName()
}
func (n NetworkingV1) IngressClass() (res *string) {
res = n.Spec.IngressClassName
if res == nil {
if a := n.GetAnnotations(); a != nil {
if v, ok := a[annotationName]; ok {
res = &v
}
}
}
return res
}
func (n NetworkingV1) SetIngressClass(ingressClassName string) {
if n.Spec.IngressClassName == nil {
if a := n.GetAnnotations(); a != nil {
if _, ok := a[annotationName]; ok {
a[annotationName] = ingressClassName
return
}
}
}
// Assign in case the IngressClassName property was not set
n.Spec.IngressClassName = &ingressClassName
}
func (n NetworkingV1) Namespace() string {
return n.GetNamespace()
}
func (n NetworkingV1) SetNamespace(ns string) {
n.Ingress.SetNamespace(ns)
}
//nolint:dupl
func (n NetworkingV1) HostnamePathsPairs() (pairs map[string]sets.Set[string]) {
pairs = make(map[string]sets.Set[string])
for _, rule := range n.Spec.Rules {
host := rule.Host
if _, ok := pairs[host]; !ok {
pairs[host] = sets.New[string]()
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
}
return pairs
}
type NetworkingV1Beta1 struct {
*networkingv1beta1.Ingress
}
func (n NetworkingV1Beta1) Name() string {
return n.GetName()
}
func (n NetworkingV1Beta1) IngressClass() (res *string) {
res = n.Spec.IngressClassName
if res == nil {
if a := n.GetAnnotations(); a != nil {
if v, ok := a[annotationName]; ok {
res = &v
}
}
}
return res
}
func (n NetworkingV1Beta1) SetIngressClass(ingressClassName string) {
if n.Spec.IngressClassName == nil {
if a := n.GetAnnotations(); a != nil {
if _, ok := a[annotationName]; ok {
a[annotationName] = ingressClassName
return
}
}
}
// Assign in case the IngressClassName property was not set
n.Annotations[annotationName] = ingressClassName
}
func (n NetworkingV1Beta1) Namespace() string {
return n.GetNamespace()
}
func (n NetworkingV1Beta1) SetNamespace(ns string) {
n.Ingress.SetNamespace(ns)
}
//nolint:dupl
func (n NetworkingV1Beta1) HostnamePathsPairs() (pairs map[string]sets.Set[string]) {
pairs = make(map[string]sets.Set[string])
for _, rule := range n.Spec.Rules {
host := rule.Host
if _, ok := pairs[host]; !ok {
pairs[host] = sets.New[string]()
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
}
return pairs
}
type Extension struct {
*extensionsv1beta1.Ingress
}
func (e Extension) Name() string {
return e.GetName()
}
func (e Extension) SetNamespace(ns string) {
e.Ingress.SetNamespace(ns)
}
func (e Extension) IngressClass() (res *string) {
res = e.Spec.IngressClassName
if res == nil {
if a := e.GetAnnotations(); a != nil {
if v, ok := a[annotationName]; ok {
res = &v
}
}
}
return res
}
func (e Extension) SetIngressClass(ingressClassName string) {
if a := e.GetAnnotations(); a != nil {
if _, ok := a[annotationName]; ok {
a[annotationName] = ingressClassName
return
}
}
// Assign in case the IngressClassName property was not set
e.Annotations[annotationName] = ingressClassName
}
func (e Extension) Namespace() string {
return e.GetNamespace()
}
//nolint:dupl
func (e Extension) HostnamePathsPairs() (pairs map[string]sets.Set[string]) {
pairs = make(map[string]sets.Set[string])
for _, rule := range e.Spec.Rules {
host := rule.Host
if _, ok := pairs[host]; !ok {
pairs[host] = sets.New[string]()
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
if http := rule.HTTP; http != nil {
for _, path := range http.Paths {
pairs[host].Insert(path.Path)
}
}
}
return pairs
}
type HostnamesList []string
func (h HostnamesList) Len() int {
return len(h)
}
func (h HostnamesList) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h HostnamesList) Less(i, j int) bool {
return h[i] < h[j]
}
func (h HostnamesList) IsStringInList(value string) (ok bool) {
sort.Sort(h)
i := sort.SearchStrings(h, value)
ok = i < h.Len() && h[i] == value
return ok
}

View File

@@ -1,67 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"context"
"fmt"
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
"k8s.io/apimachinery/pkg/fields"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
func TenantFromIngress(ctx context.Context, c client.Client, ingress Ingress) (*capsulev1beta2.Tenant, error) {
tenantList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tenantList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", ingress.Namespace()),
}); err != nil {
return nil, err
}
if len(tenantList.Items) == 0 {
return nil, nil //nolint:nilnil
}
return &tenantList.Items[0], nil
}
func FromRequest(req admission.Request, decoder admission.Decoder) (ingress Ingress, err error) {
switch req.Kind.Group {
case "networking.k8s.io":
if req.Kind.Version == "v1" {
ingressObj := &networkingv1.Ingress{}
if err = decoder.Decode(req, ingressObj); err != nil {
return ingress, err
}
ingress = NetworkingV1{Ingress: ingressObj}
break
}
ingressObj := &networkingv1beta1.Ingress{}
if err = decoder.Decode(req, ingressObj); err != nil {
return ingress, err
}
ingress = NetworkingV1Beta1{Ingress: ingressObj}
case "extensions":
ingressObj := &extensionsv1beta1.Ingress{}
if err = decoder.Decode(req, ingressObj); err != nil {
return ingress, err
}
ingress = Extension{Ingress: ingressObj}
default:
err = fmt.Errorf("cannot recognize type %s", req.Kind.Group)
}
return ingress, err
}

View File

@@ -1,115 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type class struct {
configuration configuration.Configuration
version *version.Version
}
func Class(configuration configuration.Configuration, version *version.Version) capsulewebhook.Handler {
return &class{
configuration: configuration,
version: version,
}
}
func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, r.version, client, req, decoder, recorder)
}
}
func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, r.version, client, req, decoder, recorder)
}
}
func (r *class) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *class) validate(ctx context.Context, version *version.Version, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
ingress, err := FromRequest(req, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
var tnt *capsulev1beta2.Tenant
tnt, err = TenantFromIngress(ctx, client, ingress)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.IngressOptions.AllowedClasses
if allowed == nil {
return nil
}
ingressClass := ingress.IngressClass()
if ingressClass == nil {
recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingIngressClass", "Ingress %s/%s is missing IngressClass", req.Namespace, req.Name)
response := admission.Denied(NewIngressClassUndefined(*allowed).Error())
return &response
}
selector := false
// Verify if the IngressClass exists and matches the label selector/expression
if len(allowed.MatchExpressions) > 0 || len(allowed.MatchLabels) > 0 {
ingressClassObj, err := utils.GetIngressClassByName(ctx, version, client, ingressClass)
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
// Ingress Class is present, check if it matches the selector
if ingressClassObj != nil {
selector = allowed.SelectorMatch(ingressClassObj)
}
}
switch {
case allowed.MatchDefault(*ingressClass):
return nil
case allowed.Match(*ingressClass) || selector:
return nil
default:
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenIngressClass", "Ingress %s/%s IngressClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &ingressClass)
response := admission.Denied(NewIngressClassForbidden(*ingressClass, *allowed).Error())
return &response
}
}

View File

@@ -1,199 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"context"
"fmt"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/indexer/ingress"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type collision struct {
configuration configuration.Configuration
}
func Collision(configuration configuration.Configuration) capsulewebhook.Handler {
return &collision{configuration: configuration}
}
func (r *collision) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *collision) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *collision) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *collision) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
ing, err := FromRequest(req, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
var tenant *capsulev1beta2.Tenant
tenant, err = TenantFromIngress(ctx, client, ing)
if err != nil {
return utils.ErroredResponse(err)
}
if tenant == nil || tenant.Spec.IngressOptions.HostnameCollisionScope == api.HostnameCollisionScopeDisabled {
return nil
}
if err = r.validateCollision(ctx, client, ing, tenant.Spec.IngressOptions.HostnameCollisionScope); err == nil {
return nil
}
var collisionErr *ingressHostnameCollisionError
if errors.As(err, &collisionErr) {
recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameCollision", "Ingress %s/%s hostname is colliding", ing.Namespace(), ing.Name())
}
response := admission.Denied(err.Error())
return &response
}
//nolint:gocognit,gocyclo,cyclop
func (r *collision) validateCollision(ctx context.Context, clt client.Client, ing Ingress, scope api.HostnameCollisionScope) error {
for hostname, paths := range ing.HostnamePathsPairs() {
for path := range paths {
var ingressObjList client.ObjectList
switch ing.(type) {
case Extension:
ingressObjList = &extensionsv1beta1.IngressList{}
case NetworkingV1:
ingressObjList = &networkingv1.IngressList{}
case NetworkingV1Beta1:
ingressObjList = &networkingv1beta1.IngressList{}
}
namespaces := sets.NewString()
//nolint:exhaustive
switch scope {
case api.HostnameCollisionScopeCluster:
tenantList := &capsulev1beta2.TenantList{}
if err := clt.List(ctx, tenantList); err != nil {
return err
}
for _, tenant := range tenantList.Items {
namespaces.Insert(tenant.Status.Namespaces...)
}
case api.HostnameCollisionScopeTenant:
selector := client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", ing.Namespace())}
tenantList := &capsulev1beta2.TenantList{}
if err := clt.List(ctx, tenantList, selector); err != nil {
return err
}
for _, tenant := range tenantList.Items {
namespaces.Insert(tenant.Status.Namespaces...)
}
case api.HostnameCollisionScopeNamespace:
namespaces.Insert(ing.Namespace())
}
fieldSelector := fields.OneTermEqualSelector(ingress.HostPathPair, fmt.Sprintf("%s;%s", hostname, path))
if err := clt.List(ctx, ingressObjList, client.MatchingFieldsSelector{Selector: fieldSelector}); err != nil {
return err
}
ingressList := sets.NewInt()
switch list := ingressObjList.(type) {
case *extensionsv1beta1.IngressList:
for index, item := range list.Items {
if namespaces.Has(item.GetNamespace()) {
ingressList.Insert(index)
}
}
switch len(ingressList) {
case 0:
break
case 1:
if index := ingressList.List()[0]; list.Items[index].GetName() == ing.Name() && list.Items[index].GetNamespace() == ing.Namespace() {
break
}
fallthrough
default:
return NewIngressHostnameCollision(hostname)
}
case *networkingv1.IngressList:
for index, item := range list.Items {
if namespaces.Has(item.GetNamespace()) {
ingressList.Insert(index)
}
}
switch len(ingressList) {
case 0:
break
case 1:
if index := ingressList.List()[0]; list.Items[index].GetName() == ing.Name() && list.Items[index].GetNamespace() == ing.Namespace() {
break
}
fallthrough
default:
return NewIngressHostnameCollision(hostname)
}
case *networkingv1beta1.IngressList:
for index, item := range list.Items {
if namespaces.Has(item.GetNamespace()) {
ingressList.Insert(index)
}
}
switch len(ingressList) {
case 0:
break
case 1:
if index := ingressList.List()[0]; list.Items[index].GetName() == ing.Name() && list.Items[index].GetNamespace() == ing.Namespace() {
break
}
fallthrough
default:
return NewIngressHostnameCollision(hostname)
}
}
}
}
return nil
}

View File

@@ -1,135 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"context"
"regexp"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type hostnames struct {
configuration configuration.Configuration
}
func Hostnames(configuration configuration.Configuration) capsulewebhook.Handler {
return &hostnames{configuration: configuration}
}
func (r *hostnames) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, req, decoder, recorder)
}
}
func (r *hostnames) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, req, decoder, recorder)
}
}
func (r *hostnames) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *hostnames) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
ingress, err := FromRequest(req, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
var tenant *capsulev1beta2.Tenant
tenant, err = TenantFromIngress(ctx, client, ingress)
if err != nil {
return utils.ErroredResponse(err)
}
if tenant == nil || tenant.Spec.IngressOptions.AllowedHostnames == nil {
return nil
}
hostnameList := sets.New[string]()
for hostname := range ingress.HostnamePathsPairs() {
if len(hostname) == 0 {
recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameEmpty", "Ingress %s/%s hostname is empty", ingress.Namespace(), ingress.Name())
return utils.ErroredResponse(NewEmptyIngressHostname(*tenant.Spec.IngressOptions.AllowedHostnames))
}
hostnameList.Insert(hostname)
}
if err = r.validateHostnames(*tenant, hostnameList); err == nil {
return nil
}
var hostnameNotValidErr *ingressHostnameNotValidError
if errors.As(err, &hostnameNotValidErr) {
recorder.Eventf(tenant, corev1.EventTypeWarning, "IngressHostnameNotValid", "Ingress %s/%s hostname is not valid", ingress.Namespace(), ingress.Name())
response := admission.Denied(err.Error())
return &response
}
return utils.ErroredResponse(err)
}
func (r *hostnames) validateHostnames(tenant capsulev1beta2.Tenant, hostnames sets.Set[string]) error {
if tenant.Spec.IngressOptions.AllowedHostnames == nil {
return nil
}
var valid, matched bool
tenantHostnameSet := sets.New[string](tenant.Spec.IngressOptions.AllowedHostnames.Exact...)
var invalidHostnames []string
if len(hostnames) > 0 {
if diff := hostnames.Difference(tenantHostnameSet); len(diff) > 0 {
invalidHostnames = append(invalidHostnames, diff.UnsortedList()...)
}
if len(invalidHostnames) == 0 {
valid = true
}
}
var notMatchingHostnames []string
if allowedRegex := tenant.Spec.IngressOptions.AllowedHostnames.Regex; len(allowedRegex) > 0 {
for currentHostname := range hostnames {
matched, _ = regexp.MatchString(allowedRegex, currentHostname)
if !matched {
notMatchingHostnames = append(notMatchingHostnames, currentHostname)
}
}
if len(notMatchingHostnames) == 0 {
matched = true
}
}
if !valid && !matched {
return NewIngressHostnamesNotValid(invalidHostnames, notMatchingHostnames, *tenant.Spec.IngressOptions.AllowedHostnames)
}
return nil
}

View File

@@ -1,83 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ingress
import (
"context"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type wildcard struct{}
func Wildcard() capsulewebhook.Handler {
return &wildcard{}
}
func (h *wildcard) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, client, req, recorder, decoder)
}
}
func (h *wildcard) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *wildcard) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, client, req, recorder, decoder)
}
}
func (h *wildcard) validate(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder, decoder admission.Decoder) *admission.Response {
tntList := &capsulev1beta2.TenantList{}
if err := clt.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", req.Namespace),
}); err != nil {
return utils.ErroredResponse(err)
}
// resource is not inside a Tenant namespace
if len(tntList.Items) == 0 {
return nil
}
tnt := tntList.Items[0]
if !tnt.Spec.IngressOptions.AllowWildcardHostnames {
// Retrieve ingress resource from request.
ingress, err := FromRequest(req, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
// Loop over all the hosts present on the ingress.
for host := range ingress.HostnamePathsPairs() {
// Check if one of the host has wildcard.
if strings.HasPrefix(host, "*") {
// In case of wildcard, generate an event and then return.
recorder.Eventf(&tnt, corev1.EventTypeWarning, "Wildcard denied", "%s %s/%s cannot be %s", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
response := admission.Denied(fmt.Sprintf("Wildcard denied for tenant %s\n", tnt.GetName()))
return &response
}
}
}
return nil
}

View File

@@ -1,105 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/meta"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type cordoningLabelHandler struct {
cfg configuration.Configuration
}
func CordoningLabelHandler(cfg configuration.Configuration) capsulewebhook.Handler {
return &cordoningLabelHandler{
cfg: cfg,
}
}
func (h *cordoningLabelHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *cordoningLabelHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *cordoningLabelHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
response := h.syncNamespaceCordonLabel(ctx, c, req, ns)
return response
}
}
func (h *cordoningLabelHandler) syncNamespaceCordonLabel(ctx context.Context, c client.Client, req admission.Request, ns *corev1.Namespace) *admission.Response {
tnt := &capsulev1beta2.Tenant{}
ln, err := capsuleutils.GetTypeLabel(tnt)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
if label, ok := ns.Labels[ln]; ok {
if err = c.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
}
condition := tnt.Status.Conditions.GetConditionByType(meta.CordonedCondition)
if condition == nil {
return nil
}
if condition.Status != metav1.ConditionTrue {
return nil
}
labels := ns.GetLabels()
if _, ok := labels[meta.CordonedLabel]; ok {
return nil
}
ns.Labels[meta.CordonedLabel] = "true"
marshaled, err := json.Marshal(ns)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}

View File

@@ -1,98 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
)
type metadataHandler struct {
cfg configuration.Configuration
}
func MetadataHandler(cfg configuration.Configuration) capsulewebhook.Handler {
return &metadataHandler{
cfg: cfg,
}
}
func (h *metadataHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
tenant, errResponse := getNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
if errResponse != nil {
return errResponse
}
if tenant == nil {
response := admission.Denied("Unable to assign namespace to tenant.")
return &response
}
// sync namespace metadata
instance := tenant.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
if len(instance.Metadata.Labels) == 0 && len(instance.Metadata.Annotations) == 0 {
return nil
}
labels := ns.GetLabels()
for k, v := range instance.Metadata.Labels {
labels[k] = v
}
ns.SetLabels(labels)
annotations := ns.GetAnnotations()
for k, v := range instance.Metadata.Annotations {
annotations[k] = v
}
ns.SetAnnotations(annotations)
marshaled, err := json.Marshal(ns)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
}
func (h *metadataHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *metadataHandler) OnUpdate(_ client.Client, _ admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, _ admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,193 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type ownerReferenceHandler struct {
cfg configuration.Configuration
}
func OwnerReferenceHandler(cfg configuration.Configuration) capsulewebhook.Handler {
return &ownerReferenceHandler{
cfg: cfg,
}
}
func (h *ownerReferenceHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.setOwnerRef(ctx, req, client, decoder, recorder)
}
}
func (h *ownerReferenceHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *ownerReferenceHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
oldNs := &corev1.Namespace{}
if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil {
return utils.ErroredResponse(err)
}
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", oldNs.Name),
}); err != nil {
return utils.ErroredResponse(err)
}
ok, err := h.namespaceIsOwned(ctx, c, oldNs, tntList, req)
if err != nil {
return utils.ErroredResponse(err)
}
if !ok {
recorder.Eventf(oldNs, corev1.EventTypeWarning, "OfflimitNamespace", "Namespace %s can not be patched", oldNs.GetName())
response := admission.Denied("Denied patch request for this namespace")
return &response
}
newNs := &corev1.Namespace{}
if err := decoder.Decode(req, newNs); err != nil {
return utils.ErroredResponse(err)
}
o, err := json.Marshal(newNs.DeepCopy())
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
var refs []metav1.OwnerReference
for _, ref := range oldNs.OwnerReferences {
if capsuleutils.IsTenantOwnerReference(ref) {
refs = append(refs, ref)
}
}
for _, ref := range newNs.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(ref) {
refs = append(refs, ref)
}
}
newNs.OwnerReferences = refs
c, err := json.Marshal(newNs)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(o, c)
return &response
}
}
func (h *ownerReferenceHandler) namespaceIsOwned(ctx context.Context, c client.Client, ns *corev1.Namespace, tenantList *capsulev1beta2.TenantList, req admission.Request) (bool, error) {
for _, tenant := range tenantList.Items {
for _, ownerRef := range ns.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(ownerRef) {
continue
}
ok, err := utils.IsTenantOwner(ctx, c, &tenant, req.UserInfo, h.cfg.AllowServiceAccountPromotion())
if err != nil {
return false, err
}
if ownerRef.UID == tenant.UID && ok {
return true, nil
}
}
}
return false, nil
}
func (h *ownerReferenceHandler) setOwnerRef(ctx context.Context, req admission.Request, client client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
ln, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{})
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
tnt, errResponse := getNamespaceTenant(ctx, client, ns, req, h.cfg, recorder)
if errResponse != nil {
return errResponse
}
if tnt == nil {
response := admission.Denied("Unable to assign namespace to tenant. Please use " + ln + " label when creating a namespace")
return &response
}
response := h.patchResponseForOwnerRef(tnt.DeepCopy(), ns, recorder)
return &response
}
func (h *ownerReferenceHandler) patchResponseForOwnerRef(tenant *capsulev1beta2.Tenant, ns *corev1.Namespace, recorder record.EventRecorder) admission.Response {
scheme := runtime.NewScheme()
_ = capsulev1beta2.AddToScheme(scheme)
_ = corev1.AddToScheme(scheme)
o, err := json.Marshal(ns.DeepCopy())
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
if err = controllerutil.SetOwnerReference(tenant, ns, scheme); err != nil {
recorder.Eventf(tenant, corev1.EventTypeWarning, "Error", "Namespace %s cannot be assigned to the desired Tenant", ns.GetName())
return admission.Errored(http.StatusInternalServerError, err)
}
recorder.Eventf(tenant, corev1.EventTypeNormal, "NamespaceCreationWebhook", "Namespace %s has been assigned to the desired Tenant", ns.GetName())
c, err := json.Marshal(ns)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
return admission.PatchResponseFromRaw(o, c)
}

View File

@@ -1,271 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
v1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
"github.com/projectcapsule/capsule/pkg/meta"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type sortedTenants []capsulev1beta2.Tenant
func (s sortedTenants) Len() int {
return len(s)
}
func (s sortedTenants) Less(i, j int) bool {
return len(s[i].GetName()) < len(s[j].GetName())
}
func (s sortedTenants) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// getNamespaceTenant returns namespace owner tenant.
func getNamespaceTenant(
ctx context.Context,
client client.Client,
ns *corev1.Namespace,
req admission.Request,
cfg configuration.Configuration,
recorder record.EventRecorder,
) (*capsulev1beta2.Tenant, *admission.Response) {
tenant, errResponse := getTenantByLabels(ctx, client, ns, req, cfg, recorder)
if errResponse != nil {
return nil, errResponse
}
if tenant == nil {
tenant, errResponse = getTenantByUserInfo(ctx, ns, req.UserInfo, client, cfg)
if errResponse != nil {
return nil, errResponse
}
}
return tenant, nil
}
// getTenantByLabels returns tenant from labels.
func getTenantByLabels(
ctx context.Context,
client client.Client,
ns *corev1.Namespace,
req admission.Request,
cfg configuration.Configuration,
recorder record.EventRecorder,
) (*capsulev1beta2.Tenant, *admission.Response) {
ln, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{})
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
// Get tenant from namespace labels.
if label, ok := ns.Labels[ln]; ok {
// retrieving the selected Tenant
tnt := &capsulev1beta2.Tenant{}
if err = client.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
ok, err := utils.IsTenantOwner(ctx, client, tnt, req.UserInfo, cfg.AllowServiceAccountPromotion())
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
if !ok {
recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName())
response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant")
return nil, &response
}
return tnt, nil
}
// Nothing found in the labels.
return nil, nil
}
// getTenantByUserInfo returns tenant list associated with admission request userinfo.
//
//nolint:nestif
func getTenantByUserInfo(
ctx context.Context,
ns *corev1.Namespace,
userInfo v1.UserInfo,
clt client.Client,
cfg configuration.Configuration,
) (*capsulev1beta2.Tenant, *admission.Response) {
var tenants sortedTenants
// User tenants.
userTntList := &capsulev1beta2.TenantList{}
fields := client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("User:%s", userInfo.Username),
}
err := clt.List(ctx, userTntList, fields)
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
tenants = userTntList.Items
// ServiceAccount tenants.
if strings.HasPrefix(userInfo.Username, "system:serviceaccount:") {
saTntList := &capsulev1beta2.TenantList{}
fields = client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("ServiceAccount:%s", userInfo.Username),
}
err = clt.List(ctx, saTntList, fields)
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
tenants = append(tenants, saTntList.Items...)
if cfg.AllowServiceAccountPromotion() {
if tnt, err := resolveServiceAccountActor(ctx, ns, userInfo, clt, cfg); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
} else if tnt != nil {
tenants = append(tenants, *tnt)
}
}
}
// Group tenants.
groupTntList := &capsulev1beta2.TenantList{}
for _, group := range userInfo.Groups {
fields = client.MatchingFields{
".spec.owner.ownerkind": fmt.Sprintf("Group:%s", group),
}
err = clt.List(ctx, groupTntList, fields)
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return nil, &response
}
tenants = append(tenants, groupTntList.Items...)
}
sort.Sort(sort.Reverse(tenants))
if len(tenants) == 0 {
response := admission.Denied("You do not have any Tenant assigned: please, reach out to the system administrators")
return nil, &response
}
if len(tenants) == 1 {
// Check if namespace needs Tenant name prefix
if !validateNamespacePrefix(ns, &tenants[0]) {
response := admission.Denied(fmt.Sprintf("The Namespace name must start with '%s-' when ForceTenantPrefix is enabled in the Tenant.", tenants[0].GetName()))
return nil, &response
}
return &tenants[0], nil
}
if cfg.ForceTenantPrefix() {
for _, tnt := range tenants {
if strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) {
return &tnt, nil
}
}
response := admission.Denied("The Namespace prefix used doesn't match any available Tenant")
return nil, &response
}
return nil, nil
}
func resolveServiceAccountActor(
ctx context.Context,
ns *corev1.Namespace,
userInfo v1.UserInfo,
clt client.Client,
cfg configuration.Configuration,
) (tnt *capsulev1beta2.Tenant, err error) {
parts := strings.Split(userInfo.Username, ":")
if len(parts) != 4 {
return tnt, err
}
namespace, saName := parts[2], parts[3]
sa := &corev1.ServiceAccount{}
if err = clt.Get(ctx, client.ObjectKey{Namespace: namespace, Name: saName}, sa); err != nil {
if apierrors.IsNotFound(err) {
return tnt, err
}
return tnt, err
}
if meta.OwnerPromotionLabelTriggers(ns) {
return tnt, err
}
tntList := &capsulev1beta2.TenantList{}
if err = clt.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", namespace),
}); err != nil {
return tnt, err
}
if len(tntList.Items) > 0 {
tnt = &tntList.Items[0]
}
return tnt, err
}
func validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) bool {
// Check if ForceTenantPrefix is true
if tenant.Spec.ForceTenantPrefix != nil && *tenant.Spec.ForceTenantPrefix {
if !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tenant.GetName())) {
return false
}
}
return true
}

View File

@@ -1,14 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
type namespaceQuotaExceededError struct{}
func NewNamespaceQuotaExceededError() error {
return &namespaceQuotaExceededError{}
}
func (namespaceQuotaExceededError) Error() string {
return "Cannot exceed Namespace quota: please, reach out to the system administrators"
}

View File

@@ -1,119 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type freezedHandler struct {
configuration configuration.Configuration
}
func FreezeHandler(configuration configuration.Configuration) capsulewebhook.Handler {
return &freezedHandler{configuration: configuration}
}
func (r *freezedHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
for _, objectRef := range ns.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(objectRef) {
continue
}
// retrieving the selected Tenant
tnt := &capsulev1beta2.Tenant{}
if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil {
return utils.ErroredResponse(err)
}
if tnt.Spec.Cordoned {
recorder.Eventf(tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be attached, the current Tenant is freezed", ns.GetName())
response := admission.Denied("the selected Tenant is freezed")
return &response
}
}
// creating NS that is not bounded to any Tenant
return nil
}
}
func (r *freezedHandler) OnDelete(c client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", req.Name),
}); err != nil {
return utils.ErroredResponse(err)
}
if len(tntList.Items) == 0 {
return nil
}
tnt := tntList.Items[0]
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserNames(), r.configuration.UserGroups(), r.configuration.IgnoreUserWithGroups()) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be deleted, the current Tenant is freezed", req.Name)
response := admission.Denied("the selected Tenant is freezed")
return &response
}
return nil
}
}
func (r *freezedHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", ns.Name),
}); err != nil {
return utils.ErroredResponse(err)
}
if len(tntList.Items) == 0 {
return nil
}
tnt := tntList.Items[0]
if tnt.Spec.Cordoned && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserNames(), r.configuration.UserGroups(), r.configuration.IgnoreUserWithGroups()) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be updated, the current Tenant is freezed", ns.GetName())
response := admission.Denied("the selected Tenant is freezed")
return &response
}
return nil
}
}

View File

@@ -1,89 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"fmt"
"net/http"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type patchHandler struct {
configuration configuration.Configuration
}
func PatchHandler(configuration configuration.Configuration) capsulewebhook.Handler {
return &patchHandler{configuration: configuration}
}
func (r *patchHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *patchHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *patchHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
// Decode Namespace
ns := &corev1.Namespace{}
if err := decoder.DecodeRaw(req.OldObject, ns); err != nil {
return utils.ErroredResponse(err)
}
// Get Tenant Label
ln, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{})
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
// Extract Tenant from namespace
e := fmt.Sprintf("namespace/%s can not be patched", ns.Name)
if label, ok := ns.Labels[ln]; ok {
// retrieving the selected Tenant
tnt := &capsulev1beta2.Tenant{}
if err = c.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
ok, err := utils.IsTenantOwner(ctx, c, tnt, req.UserInfo, r.configuration.AllowServiceAccountPromotion())
if err != nil {
response := admission.Errored(http.StatusBadRequest, err)
return &response
}
if ok {
return nil
}
}
recorder.Eventf(ns, corev1.EventTypeWarning, "NamespacePatch", e)
response := admission.Denied(e)
return &response
}
}

View File

@@ -1,91 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type prefixHandler struct {
configuration configuration.Configuration
}
func PrefixHandler(configuration configuration.Configuration) capsulewebhook.Handler {
return &prefixHandler{
configuration: configuration,
}
}
func (r *prefixHandler) OnCreate(clt client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
if exp, _ := r.configuration.ProtectedNamespaceRegexp(); exp != nil {
if matched := exp.MatchString(ns.GetName()); matched {
response := admission.Denied(fmt.Sprintf("Creating namespaces with name matching %s regexp is not allowed; please, reach out to the system administrators", exp.String()))
return &response
}
}
if r.configuration.ForceTenantPrefix() {
tnt := &capsulev1beta2.Tenant{}
for _, or := range ns.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(or) {
continue
}
// retrieving the selected Tenant
if err := clt.Get(ctx, types.NamespacedName{Name: or.Name}, tnt); err != nil {
return utils.ErroredResponse(err)
}
// Check for Tenant-level ForceTenantPrefix override
if tnt.Spec.ForceTenantPrefix != nil && !*tnt.Spec.ForceTenantPrefix {
return nil
}
if e := fmt.Sprintf("%s-%s", tnt.GetName(), ns.GetName()); !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) {
recorder.Eventf(tnt, corev1.EventTypeWarning, "InvalidTenantPrefix", "Namespace %s does not match the expected prefix for the current Tenant", ns.GetName())
response := admission.Denied(fmt.Sprintf("The namespace doesn't match the tenant prefix, expected %s", e))
return &response
}
}
}
return nil
}
}
func (r *prefixHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *prefixHandler) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,76 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type quotaHandler struct{}
func QuotaHandler() capsulewebhook.Handler {
return &quotaHandler{}
}
func (r *quotaHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
for _, objectRef := range ns.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(objectRef) {
continue
}
// retrieving the selected Tenant
tnt := &capsulev1beta2.Tenant{}
if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil {
return utils.ErroredResponse(err)
}
if tnt.IsFull() {
// Checking if the Namespace already exists.
// If this is the case, no need to return the quota exceeded error:
// the Kubernetes API Server will return an AlreadyExists error,
// adhering more to the native Kubernetes experience.
if err := client.Get(ctx, types.NamespacedName{Name: ns.Name}, &corev1.Namespace{}); err == nil {
return nil
}
recorder.Eventf(tnt, corev1.EventTypeWarning, "NamespaceQuotaExceded", "Namespace %s cannot be attached, quota exceeded for the current Tenant", ns.GetName())
response := admission.Denied(NewNamespaceQuotaExceededError().Error())
return &response
}
}
// creating NS that is not bounded to any Tenant
return nil
}
}
func (r *quotaHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *quotaHandler) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,185 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type userMetadataHandler struct{}
func UserMetadataHandler() capsulewebhook.Handler {
return &userMetadataHandler{}
}
func (r *userMetadataHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
ns := &corev1.Namespace{}
if err := decoder.Decode(req, ns); err != nil {
return utils.ErroredResponse(err)
}
tnt := &capsulev1beta2.Tenant{}
for _, objectRef := range ns.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(objectRef) {
continue
}
// retrieving the selected Tenant
if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil {
return utils.ErroredResponse(err)
}
}
if tnt.Spec.NamespaceOptions != nil {
err := api.ValidateForbidden(ns.Annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
if err != nil {
err = errors.Wrap(err, "namespace annotations validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
err = api.ValidateForbidden(ns.Labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
if err != nil {
err = errors.Wrap(err, "namespace labels validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
}
return nil
}
}
func (r *userMetadataHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *userMetadataHandler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
oldNs := &corev1.Namespace{}
if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil {
return utils.ErroredResponse(err)
}
newNs := &corev1.Namespace{}
if err := decoder.Decode(req, newNs); err != nil {
return utils.ErroredResponse(err)
}
tnt := &capsulev1beta2.Tenant{}
for _, objectRef := range newNs.OwnerReferences {
if !capsuleutils.IsTenantOwnerReference(objectRef) {
continue
}
// retrieving the selected Tenant
if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil {
return utils.ErroredResponse(err)
}
}
if len(tnt.Spec.NodeSelector) > 0 {
v, ok := newNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"]
if !ok {
response := admission.Denied("the node-selector annotation is enforced, cannot be removed")
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorDeletion", string(response.Result.Reason))
return &response
}
if v != oldNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"] {
response := admission.Denied("the node-selector annotation is enforced, cannot be updated")
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorUpdate", string(response.Result.Reason))
return &response
}
}
labels, annotations := oldNs.GetLabels(), oldNs.GetAnnotations()
if labels == nil {
labels = make(map[string]string)
}
if annotations == nil {
annotations = make(map[string]string)
}
for key, value := range newNs.GetLabels() {
v, ok := labels[key]
if !ok {
labels[key] = value
continue
}
if v != value {
continue
}
delete(labels, key)
}
for key, value := range newNs.GetAnnotations() {
v, ok := annotations[key]
if !ok {
annotations[key] = value
continue
}
if v != value {
continue
}
delete(annotations, key)
}
if tnt.Spec.NamespaceOptions != nil {
err := api.ValidateForbidden(annotations, tnt.Spec.NamespaceOptions.ForbiddenAnnotations)
if err != nil {
err = errors.Wrap(err, "namespace annotations validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenAnnotationReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
err = api.ValidateForbidden(labels, tnt.Spec.NamespaceOptions.ForbiddenLabels)
if err != nil {
err = errors.Wrap(err, "namespace labels validation failed")
recorder.Eventf(tnt, corev1.EventTypeWarning, api.ForbiddenLabelReason, err.Error())
response := admission.Denied(err.Error())
return &response
}
}
return nil
}
}

View File

@@ -1,85 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package networkpolicy
import (
"context"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type handler struct{}
func Handler() capsulewebhook.Handler {
return &handler{}
}
func (r *handler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *handler) OnDelete(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
allowed, err := r.handle(ctx, req, client, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
if !allowed {
response := admission.Denied("Capsule Network Policies cannot be deleted: please, reach out to the system administrators")
return &response
}
return nil
}
}
func (r *handler) OnUpdate(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
allowed, err := r.handle(ctx, req, client, decoder)
if err != nil {
return utils.ErroredResponse(err)
}
if !allowed {
response := admission.Denied("Capsule Network Policies cannot be updated: please, reach out to the system administrators")
return &response
}
return nil
}
}
func (r *handler) handle(ctx context.Context, req admission.Request, client client.Client, _ admission.Decoder) (allowed bool, err error) {
allowed = true
np := &networkingv1.NetworkPolicy{}
if err = client.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, np); err != nil {
return false, err
}
objectLabel, err := capsuleutils.GetTypeLabel(&networkingv1.NetworkPolicy{})
if err != nil {
return allowed, err
}
labels := np.GetLabels()
if _, ok := labels[objectLabel]; ok {
allowed = false
}
return allowed, err
}

View File

@@ -1,56 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package node
import (
"fmt"
"strings"
capsulev1beta2 "github.com/projectcapsule/capsule/pkg/api"
)
//nolint:predeclared,revive
func appendForbiddenError(spec *capsulev1beta2.ForbiddenListSpec) (append string) {
append += "Forbidden are "
if len(spec.Exact) > 0 {
append += fmt.Sprintf("one of the following (%s)", strings.Join(spec.Exact, ", "))
if len(spec.Regex) > 0 {
append += " or "
}
}
if len(spec.Regex) > 0 {
append += fmt.Sprintf("matching the regex %s", spec.Regex)
}
return append
}
type nodeLabelForbiddenError struct {
spec *capsulev1beta2.ForbiddenListSpec
}
func NewNodeLabelForbiddenError(forbiddenSpec *capsulev1beta2.ForbiddenListSpec) error {
return &nodeLabelForbiddenError{
spec: forbiddenSpec,
}
}
func (f nodeLabelForbiddenError) Error() string {
return fmt.Sprintf("Unable to update node as some labels are marked as forbidden by system administrator. %s", appendForbiddenError(f.spec))
}
type nodeAnnotationForbiddenError struct {
spec *capsulev1beta2.ForbiddenListSpec
}
func NewNodeAnnotationForbiddenError(forbiddenSpec *capsulev1beta2.ForbiddenListSpec) error {
return &nodeAnnotationForbiddenError{
spec: forbiddenSpec,
}
}
func (f nodeAnnotationForbiddenError) Error() string {
return fmt.Sprintf("Unable to update node as some annotations are marked as forbidden by system administrator. %s", appendForbiddenError(f.spec))
}

View File

@@ -1,129 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package node
import (
"context"
"reflect"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/version"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type userMetadataHandler struct {
configuration configuration.Configuration
version *version.Version
}
func UserMetadataHandler(configuration configuration.Configuration, ver *version.Version) capsulewebhook.Handler {
return &userMetadataHandler{
configuration: configuration,
version: ver,
}
}
func (r *userMetadataHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *userMetadataHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *userMetadataHandler) OnUpdate(_ client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
nodeWebhookSupported, _ := utils.NodeWebhookSupported(r.version)
if !nodeWebhookSupported {
return nil
}
oldNode := &corev1.Node{}
if err := decoder.DecodeRaw(req.OldObject, oldNode); err != nil {
return utils.ErroredResponse(err)
}
newNode := &corev1.Node{}
if err := decoder.Decode(req, newNode); err != nil {
return utils.ErroredResponse(err)
}
if r.configuration.ForbiddenUserNodeLabels() != nil {
oldNodeForbiddenLabels := r.getForbiddenNodeLabels(oldNode)
newNodeForbiddenLabels := r.getForbiddenNodeLabels(newNode)
if !reflect.DeepEqual(oldNodeForbiddenLabels, newNodeForbiddenLabels) {
recorder.Eventf(newNode, corev1.EventTypeWarning, "ForbiddenNodeLabel", "Denied modifying forbidden labels on node")
response := admission.Denied(NewNodeLabelForbiddenError(r.configuration.ForbiddenUserNodeLabels()).Error())
return &response
}
}
if r.configuration.ForbiddenUserNodeAnnotations() != nil {
oldNodeForbiddenAnnotations := r.getForbiddenNodeAnnotations(oldNode)
newNodeForbiddenAnnotations := r.getForbiddenNodeAnnotations(newNode)
if !reflect.DeepEqual(oldNodeForbiddenAnnotations, newNodeForbiddenAnnotations) {
recorder.Eventf(newNode, corev1.EventTypeWarning, "ForbiddenNodeLabel", "Denied modifying forbidden annotations on node")
response := admission.Denied(NewNodeAnnotationForbiddenError(r.configuration.ForbiddenUserNodeAnnotations()).Error())
return &response
}
}
return nil
}
}
func (r *userMetadataHandler) getForbiddenNodeLabels(node *corev1.Node) map[string]string {
forbiddenNodeLabels := make(map[string]string)
forbiddenLabels := r.configuration.ForbiddenUserNodeLabels()
for label, value := range node.GetLabels() {
var forbidden, matched bool
forbidden = forbiddenLabels.ExactMatch(label)
matched = forbiddenLabels.RegexMatch(label)
if forbidden || matched {
forbiddenNodeLabels[label] = value
}
}
return forbiddenNodeLabels
}
func (r *userMetadataHandler) getForbiddenNodeAnnotations(node *corev1.Node) map[string]string {
forbiddenNodeAnnotations := make(map[string]string)
forbiddenAnnotations := r.configuration.ForbiddenUserNodeAnnotations()
for annotation, value := range node.GetAnnotations() {
var forbidden, matched bool
forbidden = forbiddenAnnotations.ExactMatch(annotation)
matched = forbiddenAnnotations.RegexMatch(annotation)
if forbidden || matched {
forbiddenNodeAnnotations[annotation] = value
}
}
return forbiddenNodeAnnotations
}

View File

@@ -1,131 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type containerRegistryHandler struct {
configuration configuration.Configuration
}
func ContainerRegistry(configuration configuration.Configuration) capsulewebhook.Handler {
return &containerRegistryHandler{
configuration: configuration,
}
}
func (h *containerRegistryHandler) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
}
}
func (h *containerRegistryHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
// ust be validate on update events since updates to pods on spec.containers[*].image and spec.initContainers[*].image are allowed.
func (h *containerRegistryHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
}
}
func (h *containerRegistryHandler) validate(
ctx context.Context,
c client.Client,
decoder admission.Decoder,
recorder record.EventRecorder,
req admission.Request,
) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", pod.Namespace),
}); err != nil {
return utils.ErroredResponse(err)
}
if len(tntList.Items) == 0 {
return nil
}
tnt := tntList.Items[0]
if tnt.Spec.ContainerRegistries == nil {
return nil
}
for _, container := range pod.Spec.InitContainers {
if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
return response
}
}
for _, container := range pod.Spec.EphemeralContainers {
if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
return response
}
}
for _, container := range pod.Spec.Containers {
if response := h.verifyContainerRegistry(recorder, req, container.Image, tnt); response != nil {
return response
}
}
return nil
}
func (h *containerRegistryHandler) verifyContainerRegistry(
recorder record.EventRecorder,
req admission.Request,
image string,
tnt capsulev1beta2.Tenant,
) *admission.Response {
var valid, matched bool
reg := NewRegistry(image, h.configuration)
if len(reg.Registry()) == 0 {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "MissingFQCI", "Pod %s/%s is not using a fully qualified container image, cannot enforce registry the current Tenant", req.Namespace, req.Name, reg.Registry())
response := admission.Denied(NewContainerRegistryForbidden(image, *tnt.Spec.ContainerRegistries).Error())
return &response
}
valid = tnt.Spec.ContainerRegistries.ExactMatch(reg.Registry())
matched = tnt.Spec.ContainerRegistries.RegexMatch(reg.Registry())
if !valid && !matched {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "ForbiddenContainerRegistry", "Pod %s/%s is using a container hosted on registry %s that is forbidden for the current Tenant", req.Namespace, req.Name, reg.Registry())
response := admission.Denied(NewContainerRegistryForbidden(reg.FQCI(), *tnt.Spec.ContainerRegistries).Error())
return &response
}
return nil
}

View File

@@ -1,53 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"fmt"
"strings"
"github.com/projectcapsule/capsule/pkg/api"
)
type missingContainerRegistryError struct {
fqci string
}
func (m missingContainerRegistryError) Error() string {
return fmt.Sprintf("container image %s is missing repository, please, use a fully qualified container image name", m.fqci)
}
func NewMissingContainerRegistryError(image string) error {
return &missingContainerRegistryError{fqci: image}
}
type registryClassForbiddenError struct {
fqci string
spec api.AllowedListSpec
}
func NewContainerRegistryForbidden(image string, spec api.AllowedListSpec) error {
return &registryClassForbiddenError{
fqci: image,
spec: spec,
}
}
func (f registryClassForbiddenError) Error() (err string) {
err = fmt.Sprintf("Container image %s registry is forbidden for the current Tenant: ", f.fqci)
var extra []string
if len(f.spec.Exact) > 0 {
extra = append(extra, fmt.Sprintf("use one from the following list (%s)", strings.Join(f.spec.Exact, ", ")))
}
if len(f.spec.Regex) > 0 {
extra = append(extra, fmt.Sprintf(" use one matching the following regex (%s)", f.spec.Regex))
}
err += strings.Join(extra, " or ")
return err
}

View File

@@ -1,107 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"fmt"
"regexp"
"strings"
"github.com/projectcapsule/capsule/pkg/configuration"
)
type registry map[string]string
func (r registry) Registry() string {
res, ok := r["registry"]
if !ok {
return ""
}
return res
}
func (r registry) Repository() string {
res, ok := r["repository"]
if !ok {
return ""
}
return res
}
func (r registry) Image() string {
res, ok := r["image"]
if !ok {
return ""
}
return res
}
func (r registry) Tag() string {
res, ok := r["tag"]
if !ok {
return ""
}
if len(res) == 0 {
res = "latest"
}
return res
}
func (r registry) FQCI() string {
reg := r.Registry()
repo := r.Repository()
img := r.Image()
tag := r.Tag()
// If there's no image, nothing to return
if img == "" {
return ""
}
// ensure repo ends with "/" if set
if repo != "" && repo[len(repo)-1] != '/' {
repo += "/"
}
// always append tag to image (strip any trailing : from image just in case)
// but our Image() already includes the name:tag, so split carefully
name := img
if tag != "" && !strings.Contains(img, ":") {
name = fmt.Sprintf("%s:%s", img, tag)
}
// build: [registry/]repo+image
if reg != "" {
return fmt.Sprintf("%s/%s%s", reg, repo, name)
}
return fmt.Sprintf("%s%s", repo, name)
}
type Registry interface {
Registry() string
Repository() string
Image() string
Tag() string
FQCI() string
}
func NewRegistry(value string, cfg configuration.Configuration) Registry {
reg := make(registry)
r := regexp.MustCompile(`((?P<registry>[a-zA-Z0-9-._]+(:\d+)?)\/)?(?P<repository>.*\/)?(?P<image>[a-zA-Z0-9-._]+:(?P<tag>[a-zA-Z0-9-._]+))?`)
match := r.FindStringSubmatch(value)
for i, name := range r.SubexpNames() {
if i > 0 && i <= len(match) {
reg[name] = match[i]
}
}
return reg
}

View File

@@ -1,112 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type imagePullPolicy struct{}
func ImagePullPolicy() capsulewebhook.Handler {
return &imagePullPolicy{}
}
func (r *imagePullPolicy) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, decoder, recorder, req)
}
}
func (r *imagePullPolicy) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, c, decoder, recorder, req)
}
}
func (r *imagePullPolicy) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *imagePullPolicy) validate(
ctx context.Context,
c client.Client,
decoder admission.Decoder,
recorder record.EventRecorder,
req admission.Request,
) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tntList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tntList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", pod.Namespace),
}); err != nil {
return utils.ErroredResponse(err)
}
if len(tntList.Items) == 0 {
return nil
}
tnt := tntList.Items[0]
policy := NewPullPolicy(&tnt)
if policy == nil {
return nil
}
for _, container := range pod.Spec.InitContainers {
if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
return response
}
}
for _, container := range pod.Spec.EphemeralContainers {
if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
return response
}
}
for _, container := range pod.Spec.Containers {
if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil {
return response
}
}
return nil
}
func (h *imagePullPolicy) verifyPullPolicy(
recorder record.EventRecorder,
req admission.Request,
policy PullPolicy,
usedPullPolicy string,
container string,
tnt capsulev1beta2.Tenant,
) *admission.Response {
if !policy.IsPolicySupported(usedPullPolicy) {
recorder.Eventf(&tnt, corev1.EventTypeWarning, "ForbiddenPullPolicy", "Pod %s/%s pull policy %s is forbidden for the current Tenant", req.Namespace, req.Name, usedPullPolicy)
response := admission.Denied(NewImagePullPolicyForbidden(usedPullPolicy, container, policy.AllowedPullPolicies()).Error())
return &response
}
return nil
}

View File

@@ -1,27 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"fmt"
"strings"
)
type imagePullPolicyForbiddenError struct {
usedPullPolicy string
allowedPullPolicies []string
containerName string
}
func NewImagePullPolicyForbidden(usedPullPolicy, containerName string, allowedPullPolicies []string) error {
return &imagePullPolicyForbiddenError{
usedPullPolicy: usedPullPolicy,
containerName: containerName,
allowedPullPolicies: allowedPullPolicies,
}
}
func (f imagePullPolicyForbiddenError) Error() (err string) {
return fmt.Sprintf("ImagePullPolicy %s for container %s is forbidden, use one of the followings: %s", f.usedPullPolicy, f.containerName, strings.Join(f.allowedPullPolicies, ", "))
}

View File

@@ -1,50 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"strings"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
type PullPolicy interface {
IsPolicySupported(policy string) bool
AllowedPullPolicies() []string
}
type imagePullPolicyValidator struct {
allowedPolicies []string
}
func (i imagePullPolicyValidator) IsPolicySupported(policy string) bool {
for _, allowed := range i.allowedPolicies {
if strings.EqualFold(allowed, policy) {
return true
}
}
return false
}
func (i imagePullPolicyValidator) AllowedPullPolicies() []string {
return i.allowedPolicies
}
func NewPullPolicy(tenant *capsulev1beta2.Tenant) PullPolicy {
// the Tenant doesn't enforce the allowed image pull policy, returning nil
if len(tenant.Spec.ImagePullPolicies) == 0 {
return nil
}
allowedPolicies := make([]string, 0, len(tenant.Spec.ImagePullPolicies))
for _, policy := range tenant.Spec.ImagePullPolicies {
allowedPolicies = append(allowedPolicies, policy.String())
}
return &imagePullPolicyValidator{
allowedPolicies: allowedPolicies,
}
}

View File

@@ -1,97 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type priorityClass struct{}
func PriorityClass() capsulewebhook.Handler {
return &priorityClass{}
}
func (h *priorityClass) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := utils.TenantByStatusNamespace(ctx, c, pod.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.PriorityClasses
if allowed == nil {
return nil
}
priorityClassName := pod.Spec.PriorityClassName
if len(priorityClassName) == 0 {
// We don't have to force Pod to specify a Priority Class
return nil
}
selector := false
// Verify if the StorageClass exists and matches the label selector/expression
if len(allowed.MatchExpressions) > 0 || len(allowed.MatchLabels) > 0 {
priorityClassObj, err := utils.GetPriorityClassByName(ctx, c, priorityClassName)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
// Storage Class is present, check if it matches the selector
if priorityClassObj != nil {
selector = allowed.SelectorMatch(priorityClassObj)
}
}
switch {
case allowed.MatchDefault(priorityClassName):
// Allow if given Priority Class is equal tenant default (eventough it's not allowed by selector)
return nil
case allowed.Match(priorityClassName) || selector:
return nil
default:
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenPriorityClass", "Pod %s/%s is using Priority Class %s is forbidden for the current Tenant", pod.Namespace, pod.Name, priorityClassName)
response := admission.Denied(NewPodPriorityClassForbidden(priorityClassName, *allowed).Error())
return &response
}
}
}
func (h *priorityClass) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *priorityClass) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,29 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"fmt"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type podPriorityClassForbiddenError struct {
priorityClassName string
spec api.DefaultAllowedListSpec
}
func NewPodPriorityClassForbidden(priorityClassName string, spec api.DefaultAllowedListSpec) error {
return &podPriorityClassForbiddenError{
priorityClassName: priorityClassName,
spec: spec,
}
}
func (f podPriorityClassForbiddenError) Error() (err string) {
msg := fmt.Sprintf("Pod Priority Class %s is forbidden for the current Tenant: ", f.priorityClassName)
return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
}

View File

@@ -1,103 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
nodev1 "k8s.io/api/node/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type runtimeClass struct{}
func RuntimeClass() capsulewebhook.Handler {
return &runtimeClass{}
}
func (h *runtimeClass) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, decoder, recorder, req)
}
}
func (h *runtimeClass) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *runtimeClass) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *runtimeClass) class(ctx context.Context, c client.Client, name string) (client.Object, error) {
if len(name) == 0 {
return nil, nil
}
obj := &nodev1.RuntimeClass{}
if err := c.Get(ctx, types.NamespacedName{Name: name}, obj); err != nil {
return nil, err
}
return obj, nil
}
func (h *runtimeClass) validate(ctx context.Context, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, req admission.Request) *admission.Response {
pod := &corev1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := utils.TenantByStatusNamespace(ctx, c, pod.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.RuntimeClasses
runtimeClassName := ""
if pod.Spec.RuntimeClassName != nil {
runtimeClassName = *pod.Spec.RuntimeClassName
}
class, err := h.class(ctx, c, runtimeClassName)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
switch {
case allowed == nil:
// Enforcement is not in place, skipping it at all
return nil
case len(runtimeClassName) == 0 || runtimeClassName == allowed.Default:
// Delegating mutating webhook to specify a default RuntimeClass
return nil
case !allowed.MatchSelectByName(class):
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenRuntimeClass", "Pod %s/%s is using Runtime Class %s is forbidden for the current Tenant", pod.Namespace, pod.Name, runtimeClassName)
response := admission.Denied(NewPodRuntimeClassForbidden(runtimeClassName, *allowed).Error())
return &response
default:
return nil
}
}

View File

@@ -1,29 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pod
import (
"fmt"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type podRuntimeClassForbiddenError struct {
runtimeClassName string
spec api.DefaultAllowedListSpec
}
func NewPodRuntimeClassForbidden(runtimeClassName string, spec api.DefaultAllowedListSpec) error {
return &podRuntimeClassForbiddenError{
runtimeClassName: runtimeClassName,
spec: spec,
}
}
func (f podRuntimeClassForbiddenError) Error() (err string) {
err = fmt.Sprintf("Pod Runtime Class %s is forbidden for the current Tenant: ", f.runtimeClassName)
return utils.DefaultAllowedValuesErrorMessage(f.spec, err)
}

View File

@@ -1,93 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pvc
import (
"fmt"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type storageClassNotValidError struct {
spec api.DefaultAllowedListSpec
}
func NewStorageClassNotValid(storageClasses api.DefaultAllowedListSpec) error {
return &storageClassNotValidError{
spec: storageClasses,
}
}
func (s storageClassNotValidError) Error() (err string) {
msg := "A valid Storage Class must be used: "
return utils.DefaultAllowedValuesErrorMessage(s.spec, msg)
}
type storageClassForbiddenError struct {
className string
spec api.DefaultAllowedListSpec
}
func NewStorageClassForbidden(className string, storageClasses api.DefaultAllowedListSpec) error {
return &storageClassForbiddenError{
className: className,
spec: storageClasses,
}
}
func (f storageClassForbiddenError) Error() string {
msg := fmt.Sprintf("Storage Class %s is forbidden for the current Tenant ", f.className)
return utils.DefaultAllowedValuesErrorMessage(f.spec, msg)
}
type missingPVLabelsError struct {
name string
}
func NewMissingPVLabelsError(name string) error {
return &missingPVLabelsError{name: name}
}
func (m missingPVLabelsError) Error() string {
return fmt.Sprintf("PersistentVolume %s is missing any label, please, ask the Cluster Administrator to label it", m.name)
}
type missingPVTenantLabelsError struct {
name string
}
func NewMissingTenantPVLabelsError(name string) error {
return &missingPVTenantLabelsError{name: name}
}
func (m missingPVTenantLabelsError) Error() string {
return fmt.Sprintf("PersistentVolume %s is missing the Capsule Tenant label, preventing a potential cross-tenant mount", m.name)
}
type crossTenantPVMountError struct {
name string
}
func NewCrossTenantPVMountError(name string) error {
return &crossTenantPVMountError{
name: name,
}
}
func (m crossTenantPVMountError) Error() string {
return fmt.Sprintf("PersistentVolume %s cannot be used by the following Tenant, preventing a cross-tenant mount", m.name)
}
type pvSelectorError struct{}
func NewPVSelectorError() error {
return &pvSelectorError{}
}
func (m pvSelectorError) Error() string {
return "PersistentVolume selectors are not allowed since unable to prevent cross-tenant mount"
}

View File

@@ -1,98 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pvc
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type PV struct {
capsuleLabel string
}
func PersistentVolumeReuse() capsulewebhook.Handler {
value, err := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{})
if err != nil {
panic(fmt.Sprintf("this shouldn't happen: %s", err.Error()))
}
return &PV{
capsuleLabel: value,
}
}
func (p PV) OnCreate(client client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pvc := corev1.PersistentVolumeClaim{}
if err := decoder.Decode(req, &pvc); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := utils.TenantByStatusNamespace(ctx, client, pvc.GetNamespace())
if err != nil {
return utils.ErroredResponse(err)
}
// PVC is not in a Tenant Namespace, skipping
if tnt == nil {
return nil
}
// A PersistentVolume selector cannot help in preventing a cross-tenant mount:
// thus, disallowing that in first place.
if pvc.Spec.Selector != nil {
return utils.ErroredResponse(NewPVSelectorError())
}
// The PVC hasn't any volumeName pre-claimed, it can be skipped
if len(pvc.Spec.VolumeName) == 0 {
return nil
}
// Checking if the PV is labelled with the Tenant name
pv := corev1.PersistentVolume{}
if err = client.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil {
if errors.IsNotFound(err) {
err = fmt.Errorf("cannot create a PVC referring to a not yet existing PV")
}
return utils.ErroredResponse(err)
}
if pv.GetLabels() == nil {
return utils.ErroredResponse(NewMissingPVLabelsError(pv.GetName()))
}
value, ok := pv.GetLabels()[p.capsuleLabel]
if !ok {
return utils.ErroredResponse(NewMissingTenantPVLabelsError(pv.GetName()))
}
if value != tnt.Name {
return utils.ErroredResponse(NewCrossTenantPVMountError(pv.GetName()))
}
return nil
}
}
func (p PV) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (p PV) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,100 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package pvc
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type validating struct{}
func Validating() capsulewebhook.Handler {
return &validating{}
}
func (h *validating) OnCreate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
pvc := &corev1.PersistentVolumeClaim{}
if err := decoder.Decode(req, pvc); err != nil {
return utils.ErroredResponse(err)
}
tnt, err := utils.TenantByStatusNamespace(ctx, c, pvc.Namespace)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.StorageClasses
if allowed == nil {
return nil
}
storageClass := pvc.Spec.StorageClassName
if storageClass == nil {
recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingStorageClass", "PersistentVolumeClaim %s/%s is missing StorageClass", req.Namespace, req.Name)
response := admission.Denied(NewStorageClassNotValid(*tnt.Spec.StorageClasses).Error())
return &response
}
selector := false
// Verify if the StorageClass exists and matches the label selector/expression
if len(allowed.MatchExpressions) > 0 || len(allowed.MatchLabels) > 0 {
storageClassObj, err := utils.GetStorageClassByName(ctx, c, *storageClass)
if err != nil && !errors.IsNotFound(err) {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
// Storage Class is present, check if it matches the selector
if storageClassObj != nil {
selector = allowed.SelectorMatch(storageClassObj)
}
}
switch {
case allowed.MatchDefault(*storageClass):
return nil
case allowed.Match(*storageClass) || selector:
return nil
default:
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenStorageClass", "PersistentVolumeClaim %s/%s StorageClass %s is forbidden for the current Tenant", req.Namespace, req.Name, *storageClass)
response := admission.Denied(NewStorageClassForbidden(*pvc.Spec.StorageClassName, *tnt.Spec.StorageClasses).Error())
return &response
}
}
}
func (h *validating) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *validating) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}

View File

@@ -1,158 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type claimMutationHandler struct {
log logr.Logger
}
func ClaimMutationHandler(log logr.Logger) capsulewebhook.Handler {
return &claimMutationHandler{log: log}
}
func (h *claimMutationHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.handle(ctx, req, decoder, c)
}
}
func (h *claimMutationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *claimMutationHandler) OnCreate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.handle(ctx, req, decoder, c)
}
}
func (h *claimMutationHandler) handle(
ctx context.Context,
req admission.Request,
decoder admission.Decoder,
c client.Client,
) *admission.Response {
claim := &capsulev1beta2.ResourcePoolClaim{}
if err := decoder.Decode(req, claim); err != nil {
return utils.ErroredResponse(fmt.Errorf("failed to decode new object: %w", err))
}
if err := h.autoAssignPools(ctx, c, claim); err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
h.handleReleaseAnnotation(claim)
marshaled, err := json.Marshal(claim)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
// Only Adds release label when necessary.
func (h *claimMutationHandler) handleReleaseAnnotation(
claim *capsulev1beta2.ResourcePoolClaim,
) {
if !meta.ReleaseAnnotationTriggers(claim) {
return
}
if !claim.IsBoundToResourcePool() {
return
}
meta.ReleaseAnnotationRemove(claim)
}
func (h *claimMutationHandler) autoAssignPools(
ctx context.Context,
c client.Client,
claim *capsulev1beta2.ResourcePoolClaim,
) error {
if claim.Spec.Pool != "" {
return nil
}
poolList := &capsulev1beta2.ResourcePoolList{}
if err := c.List(ctx, poolList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", claim.Namespace),
}); err != nil {
return err
}
if len(poolList.Items) == 0 {
return nil
}
candidates := make([]*capsulev1beta2.ResourcePool, 0)
for _, pool := range poolList.Items {
assignable := true
allocatable := true
for resource, requested := range claim.Spec.ResourceClaims {
if _, ok := pool.Status.Allocation.Hard[resource]; !ok {
assignable = false
break
}
available, ok := pool.Status.Allocation.Available[resource]
if !ok || available.Cmp(requested) < 0 {
allocatable = false
break
}
}
if !assignable {
continue
}
if allocatable {
candidates = append([]*capsulev1beta2.ResourcePool{&pool}, candidates...)
continue
}
candidates = append(candidates, &pool)
}
if len(candidates) == 0 {
return nil // no eligible pools
}
claim.Spec.Pool = candidates[0].Name
return nil
}

View File

@@ -1,84 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"context"
"fmt"
"reflect"
"github.com/go-logr/logr"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type claimValidationHandler struct {
log logr.Logger
}
func ClaimValidationHandler(log logr.Logger) capsulewebhook.Handler {
return &claimValidationHandler{log: log}
}
func (h *claimValidationHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *claimValidationHandler) OnDelete(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
claim := &capsulev1beta2.ResourcePoolClaim{}
if err := decoder.DecodeRaw(req.OldObject, claim); err != nil {
return utils.ErroredResponse(fmt.Errorf("failed to decode old object: %w", err))
}
if claim.IsBoundToResourcePool() {
response := admission.Denied(fmt.Sprintf("cannot delete the pool while claim is bound to a resourcepool %s", claim.Status.Pool.Name))
return &response
}
return nil
}
}
func (h *claimValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
oldClaim := &capsulev1beta2.ResourcePoolClaim{}
newClaim := &capsulev1beta2.ResourcePoolClaim{}
if err := decoder.DecodeRaw(req.OldObject, oldClaim); err != nil {
return utils.ErroredResponse(fmt.Errorf("failed to decode old object: %w", err))
}
if err := decoder.Decode(req, newClaim); err != nil {
return utils.ErroredResponse(fmt.Errorf("failed to decode new object: %w", err))
}
if !reflect.DeepEqual(oldClaim.Spec.ResourceClaims, newClaim.Spec.ResourceClaims) {
if oldClaim.IsBoundToResourcePool() {
response := admission.Denied(fmt.Sprintf("cannot change the requested resources while claim is bound to a resourcepool %s", oldClaim.Status.Pool.Name))
return &response
}
}
if !reflect.DeepEqual(oldClaim.Spec.Pool, newClaim.Spec.Pool) {
if oldClaim.IsBoundToResourcePool() {
response := admission.Denied(fmt.Sprintf("cannot change the pool while claim is bound to a resourcepool %s", oldClaim.Status.Pool.Name))
return &response
}
}
return nil
}
}

View File

@@ -1,100 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type poolMutationHandler struct {
log logr.Logger
}
func PoolMutationHandler(log logr.Logger) capsulewebhook.Handler {
return &poolMutationHandler{log: log}
}
func (h *poolMutationHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.handle(req, decoder)
}
}
func (h *poolMutationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *poolMutationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
return h.handle(req, decoder)
}
}
func (h *poolMutationHandler) handle(
req admission.Request,
decoder admission.Decoder,
) *admission.Response {
pool := &capsulev1beta2.ResourcePool{}
if err := decoder.Decode(req, pool); err != nil {
return utils.ErroredResponse(fmt.Errorf("failed to decode object: %w", err))
}
// Correctly set the defaults
h.handleDefaults(pool)
// Marshal Manifest
marshaled, err := json.Marshal(pool)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
// Handles the Default Property. This is done at admission, to prevent and reconcile loops
// from gitops engines when ignores are not correctly set.
func (h *poolMutationHandler) handleDefaults(
pool *capsulev1beta2.ResourcePool,
) {
if !*pool.Spec.Config.DefaultsAssignZero {
return
}
if pool.Spec.Defaults == nil {
pool.Spec.Defaults = corev1.ResourceList{}
}
defaults := pool.Spec.Defaults
for resourceName := range pool.Spec.Quota.Hard {
amount, exists := pool.Spec.Defaults[resourceName]
if !exists {
amount = resource.MustParse("0")
}
defaults[resourceName] = amount
}
pool.Spec.Defaults = defaults
}

View File

@@ -1,91 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package resourcepool
import (
"context"
"fmt"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type poolValidationHandler struct {
log logr.Logger
}
func PoolValidationHandler(log logr.Logger) capsulewebhook.Handler {
return &poolValidationHandler{log: log}
}
func (h *poolValidationHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *poolValidationHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *poolValidationHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
oldPool := &capsulev1beta2.ResourcePool{}
if err := decoder.DecodeRaw(req.OldObject, oldPool); err != nil {
return utils.ErroredResponse(err)
}
pool := &capsulev1beta2.ResourcePool{}
if err := decoder.Decode(req, pool); err != nil {
return utils.ErroredResponse(err)
}
// Verify if resource decrease is allowed or no
if !equality.Semantic.DeepEqual(pool.Spec.Quota.Hard, oldPool.Spec.Quota.Hard) {
zeroValue := resource.MustParse("0")
for resourceName, qt := range oldPool.Status.Allocation.Claimed {
allocation, exists := pool.Spec.Quota.Hard[resourceName]
if !exists {
// May remove resources when unused
if zeroValue.Cmp(qt) == 0 {
continue
}
response := admission.Denied(fmt.Sprintf(
"can not remove resource %s as it is still being allocated. Remove corresponding claims or keep the resources in the pool",
resourceName,
))
return &response
}
if allocation.Cmp(qt) < 0 {
response := admission.Denied(
fmt.Sprintf(
"can not reduce %s usage to %s because quantity %s is claimed . Remove corresponding claims or keep the resources in the pool",
resourceName,
allocation.String(),
qt.String(),
))
return &response
}
}
}
return nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package route
import (
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
)
type namespace struct {
handlers []capsulewebhook.Handler
}
func Namespace(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
return &namespace{handlers: handler}
}
func (w *namespace) GetHandlers() []capsulewebhook.Handler {
return w.handlers
}
func (w *namespace) GetPath() string {
return "/namespaces"
}
type namespacePatch struct {
handlers []capsulewebhook.Handler
}
func NamespacePatch(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
return &namespacePatch{handlers: handlers}
}
func (w *namespacePatch) GetHandlers() []capsulewebhook.Handler {
return w.handlers
}
func (w *namespacePatch) GetPath() string {
return "/namespace-patch"
}

View File

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

View File

@@ -1,24 +0,0 @@
// Copyright 2020-2025 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package route
import (
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
)
type node struct {
handlers []capsulewebhook.Handler
}
func Node(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
return &node{handlers: handler}
}
func (n *node) GetHandlers() []capsulewebhook.Handler {
return n.handlers
}
func (n *node) GetPath() string {
return "/nodes"
}

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More