mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-02-14 18:09:58 +00:00
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:
@@ -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"
|
||||
)
|
||||
58
pkg/api/meta/annotations.go
Normal file
58
pkg/api/meta/annotations.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
66
pkg/api/owner.go
Normal 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
63
pkg/api/owner_list.go
Normal 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]
|
||||
}
|
||||
98
pkg/api/owner_list_test.go
Normal file
98
pkg/api/owner_list_test.go
Normal 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
19
pkg/api/users.go
Normal 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
63
pkg/api/users_list.go
Normal 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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -29,4 +29,5 @@ type Configuration interface {
|
||||
IgnoreUserWithGroups() []string
|
||||
ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
|
||||
ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec
|
||||
Administrators() capsuleapi.UserListSpec
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package metrics
|
||||
|
||||
const (
|
||||
metricsPrefix = "capsule"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
183
pkg/utils/tenant/get_by.go
Normal 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
|
||||
}
|
||||
21
pkg/utils/tenant/metdata.go
Normal file
21
pkg/utils/tenant/metdata.go
Normal 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
35
pkg/utils/tenant/owned.go
Normal 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
|
||||
}
|
||||
58
pkg/utils/tenant/owner_reference.go
Normal file
58
pkg/utils/tenant/owner_reference.go
Normal 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()
|
||||
}
|
||||
323
pkg/utils/tenant/owner_reference_test.go
Normal file
323
pkg/utils/tenant/owner_reference_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
20
pkg/utils/tenant/types.go
Normal 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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
14
pkg/utils/users/is_admin_user.go
Normal file
14
pkg/utils/users/is_admin_user.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
59
pkg/utils/users/serviceaccounts.go
Normal file
59
pkg/utils/users/serviceaccounts.go
Normal 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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package utils
|
||||
package users
|
||||
|
||||
import (
|
||||
"sort"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package utils
|
||||
package users
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 "aHandler{}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ®istryClassForbiddenError{
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user