mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-04-22 18:46:37 +00:00
feat(controller): refactor namespace core loop and state management (#1680)
* feat(controller): allow owners to promote serviceaccounts within tenant as owners Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * feat(controller): refactor status handling for tenants and owned namespaces (including metrics) Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> --------- Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ type AdditionalMetadataSpec struct {
|
||||
|
||||
type AdditionalMetadataSelectorSpec struct {
|
||||
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
const (
|
||||
// ReadyCondition indicates the resource is ready and fully reconciled.
|
||||
ReadyCondition string = "Ready"
|
||||
CordonedCondition string = "Cordoned"
|
||||
NotReadyCondition string = "NotReady"
|
||||
|
||||
AssignedCondition string = "Assigned"
|
||||
@@ -19,11 +20,105 @@ const (
|
||||
// FailedReason indicates a condition or event observed a failure (Claim Rejected).
|
||||
SucceededReason string = "Succeeded"
|
||||
FailedReason string = "Failed"
|
||||
ActiveReason string = "Active"
|
||||
CordonedReason string = "Cordoned"
|
||||
PoolExhaustedReason string = "PoolExhausted"
|
||||
QueueExhaustedReason string = "QueueExhausted"
|
||||
NamespaceExhaustedReason string = "NamespaceExhausted"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
|
||||
type ConditionList []Condition
|
||||
|
||||
// Adds a condition by type.
|
||||
func (c *ConditionList) GetConditionByType(conditionType string) *Condition {
|
||||
for i := range *c {
|
||||
if (*c)[i].Type == conditionType {
|
||||
return &(*c)[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Adds a condition by type.
|
||||
func (c *ConditionList) UpdateConditionByType(condition Condition) {
|
||||
for i, cond := range *c {
|
||||
if cond.Type == condition.Type {
|
||||
(*c)[i].UpdateCondition(condition)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
*c = append(*c, condition)
|
||||
}
|
||||
|
||||
// Removes a condition by type.
|
||||
func (c *ConditionList) RemoveConditionByType(condition Condition) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make(ConditionList, 0, len(*c))
|
||||
|
||||
for _, cond := range *c {
|
||||
if cond.Type != condition.Type {
|
||||
filtered = append(filtered, cond)
|
||||
}
|
||||
}
|
||||
|
||||
*c = filtered
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type Condition metav1.Condition
|
||||
|
||||
func NewReadyCondition(obj client.Object) Condition {
|
||||
return Condition{
|
||||
Type: ReadyCondition,
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: SucceededReason,
|
||||
Message: "reconciled",
|
||||
LastTransitionTime: metav1.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewCordonedCondition(obj client.Object) Condition {
|
||||
return Condition{
|
||||
Type: CordonedCondition,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: ActiveReason,
|
||||
Message: "not cordoned",
|
||||
LastTransitionTime: metav1.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Disregards fields like LastTransitionTime and Version, which are not relevant for the API.
|
||||
func (c *Condition) UpdateCondition(condition Condition) (updated bool) {
|
||||
if condition.Type == c.Type &&
|
||||
condition.Status == c.Status &&
|
||||
condition.Reason == c.Reason &&
|
||||
condition.Message == c.Message &&
|
||||
condition.ObservedGeneration == c.ObservedGeneration {
|
||||
return false
|
||||
}
|
||||
|
||||
if condition.Status != c.Status {
|
||||
c.LastTransitionTime = metav1.Now()
|
||||
}
|
||||
|
||||
c.Type = condition.Type
|
||||
c.Status = condition.Status
|
||||
c.Reason = condition.Reason
|
||||
c.Message = condition.Message
|
||||
c.ObservedGeneration = condition.ObservedGeneration
|
||||
c.LastTransitionTime = condition.LastTransitionTime
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func NewBoundCondition(obj client.Object) metav1.Condition {
|
||||
return metav1.Condition{
|
||||
Type: BoundCondition,
|
||||
|
||||
211
pkg/meta/conditions_test.go
Normal file
211
pkg/meta/conditions_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// helper
|
||||
func makeCond(tpe, status, reason, msg string, gen int64) Condition {
|
||||
return Condition{
|
||||
Type: tpe,
|
||||
Status: metav1.ConditionStatus(status),
|
||||
Reason: reason,
|
||||
Message: msg,
|
||||
ObservedGeneration: gen,
|
||||
LastTransitionTime: metav1.NewTime(time.Unix(0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionList_GetConditionByType(t *testing.T) {
|
||||
t.Run("returns matching condition", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("Ready", "False", "Init", "starting", 1),
|
||||
makeCond("Synced", "True", "Ok", "done", 2),
|
||||
}
|
||||
|
||||
got := list.GetConditionByType("Synced")
|
||||
assert.NotNil(t, got)
|
||||
assert.Equal(t, "Synced", got.Type)
|
||||
assert.Equal(t, metav1.ConditionTrue, got.Status)
|
||||
assert.Equal(t, "Ok", got.Reason)
|
||||
assert.Equal(t, "done", got.Message)
|
||||
})
|
||||
|
||||
t.Run("returns nil when not found", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("Ready", "False", "Init", "starting", 1),
|
||||
}
|
||||
assert.Nil(t, list.GetConditionByType("Missing"))
|
||||
})
|
||||
|
||||
t.Run("returned pointer refers to slice element (not copy)", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("Ready", "False", "Init", "starting", 1),
|
||||
makeCond("Synced", "True", "Ok", "done", 2),
|
||||
}
|
||||
ptr := list.GetConditionByType("Ready")
|
||||
assert.NotNil(t, ptr)
|
||||
|
||||
ptr.Message = "mutated"
|
||||
// This asserts GetConditionByType returns &list[i] (via index),
|
||||
// not &cond where cond is the range variable copy.
|
||||
assert.Equal(t, "mutated", list[0].Message)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConditionList_UpdateConditionByType(t *testing.T) {
|
||||
now := metav1.Now()
|
||||
|
||||
t.Run("updates existing condition in place", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("Ready", "False", "Init", "starting", 1),
|
||||
makeCond("Synced", "True", "Ok", "done", 2),
|
||||
}
|
||||
beforeLen := len(list)
|
||||
|
||||
list.UpdateConditionByType(Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: "Reconciled",
|
||||
Message: "ready now",
|
||||
ObservedGeneration: 3,
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
assert.Equal(t, beforeLen, len(list))
|
||||
got := list.GetConditionByType("Ready")
|
||||
assert.NotNil(t, got)
|
||||
assert.Equal(t, metav1.ConditionTrue, got.Status)
|
||||
assert.Equal(t, "Reconciled", got.Reason)
|
||||
assert.Equal(t, "ready now", got.Message)
|
||||
assert.Equal(t, int64(3), got.ObservedGeneration)
|
||||
})
|
||||
|
||||
t.Run("appends when condition type not present", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("Ready", "True", "Ok", "ready", 1),
|
||||
}
|
||||
beforeLen := len(list)
|
||||
|
||||
list.UpdateConditionByType(Condition{
|
||||
Type: "Synced",
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: "Done",
|
||||
Message: "synced",
|
||||
ObservedGeneration: 2,
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
assert.Equal(t, beforeLen+1, len(list))
|
||||
got := list.GetConditionByType("Synced")
|
||||
assert.NotNil(t, got)
|
||||
assert.Equal(t, metav1.ConditionTrue, got.Status)
|
||||
assert.Equal(t, "Done", got.Reason)
|
||||
assert.Equal(t, "synced", got.Message)
|
||||
assert.Equal(t, int64(2), got.ObservedGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConditionList_RemoveConditionByType(t *testing.T) {
|
||||
t.Run("removes all conditions with matching type", func(t *testing.T) {
|
||||
list := ConditionList{
|
||||
makeCond("A", "True", "x", "m1", 1),
|
||||
makeCond("B", "True", "y", "m2", 1),
|
||||
makeCond("A", "False", "z", "m3", 2),
|
||||
}
|
||||
list.RemoveConditionByType(Condition{Type: "A"})
|
||||
|
||||
assert.Len(t, list, 1)
|
||||
assert.Equal(t, "B", list[0].Type)
|
||||
})
|
||||
|
||||
t.Run("no-op when type not present", func(t *testing.T) {
|
||||
orig := ConditionList{
|
||||
makeCond("A", "True", "x", "m1", 1),
|
||||
}
|
||||
list := append(ConditionList{}, orig...) // copy
|
||||
|
||||
list.RemoveConditionByType(Condition{Type: "Missing"})
|
||||
|
||||
assert.Equal(t, orig, list)
|
||||
})
|
||||
|
||||
t.Run("nil receiver is safe", func(t *testing.T) {
|
||||
var list *ConditionList // nil receiver
|
||||
assert.NotPanics(t, func() {
|
||||
list.RemoveConditionByType(Condition{Type: "X"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateCondition(t *testing.T) {
|
||||
now := metav1.Now()
|
||||
|
||||
t.Run("no update when all relevant fields match", func(t *testing.T) {
|
||||
c := &Condition{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "All good",
|
||||
}
|
||||
|
||||
updated := c.UpdateCondition(Condition{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "All good",
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
assert.False(t, updated)
|
||||
})
|
||||
|
||||
t.Run("update occurs on message change", func(t *testing.T) {
|
||||
c := &Condition{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "Old message",
|
||||
}
|
||||
|
||||
updated := c.UpdateCondition(Condition{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "New message",
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
assert.True(t, updated)
|
||||
assert.Equal(t, "New message", c.Message)
|
||||
})
|
||||
|
||||
t.Run("update occurs on status change", func(t *testing.T) {
|
||||
c := &Condition{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "Pending",
|
||||
Message: "Not ready yet",
|
||||
}
|
||||
|
||||
updated := c.UpdateCondition(Condition{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "Ready",
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
assert.True(t, updated)
|
||||
assert.Equal(t, "True", string(c.Status))
|
||||
assert.Equal(t, "Success", c.Reason)
|
||||
assert.Equal(t, "Ready", c.Message)
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,9 @@ const (
|
||||
|
||||
OwnerPromotionLabel = "owner.projectcapsule.dev/promote"
|
||||
OwnerPromotionLabelTrigger = "true"
|
||||
|
||||
CordonedLabel = "projectcapsule.dev/cordoned"
|
||||
CordonedLabelTrigger = "true"
|
||||
)
|
||||
|
||||
func FreezeLabelTriggers(obj client.Object) bool {
|
||||
|
||||
47
pkg/meta/zz_generated.deepcopy.go
Normal file
47
pkg/meta/zz_generated.deepcopy.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
// Copyright 2020-2023 Project Capsule Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package meta
|
||||
|
||||
import ()
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Condition) DeepCopyInto(out *Condition) {
|
||||
*out = *in
|
||||
in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition.
|
||||
func (in *Condition) DeepCopy() *Condition {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Condition)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in ConditionList) DeepCopyInto(out *ConditionList) {
|
||||
{
|
||||
in := &in
|
||||
*out = make(ConditionList, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConditionList.
|
||||
func (in ConditionList) DeepCopy() ConditionList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(ConditionList)
|
||||
in.DeepCopyInto(out)
|
||||
return *out
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
type TenantRecorder struct {
|
||||
TenantNamespaceRelationshipGauge *prometheus.GaugeVec
|
||||
TenantNamespaceConditionGauge *prometheus.GaugeVec
|
||||
TenantConditionGauge *prometheus.GaugeVec
|
||||
TenantCordonedStatusGauge *prometheus.GaugeVec
|
||||
TenantNamespaceCounterGauge *prometheus.GaugeVec
|
||||
TenantResourceUsageGauge *prometheus.GaugeVec
|
||||
@@ -32,11 +34,26 @@ func NewTenantRecorder() *TenantRecorder {
|
||||
Help: "Mapping metric showing namespace to tenant relationships",
|
||||
}, []string{"tenant", "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", "namespace", "condition"},
|
||||
),
|
||||
|
||||
TenantConditionGauge: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: metricsPrefix,
|
||||
Name: "tenant_condition",
|
||||
Help: "Provides per tenant condition status for each condition",
|
||||
}, []string{"tenant", "condition"},
|
||||
),
|
||||
TenantCordonedStatusGauge: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: metricsPrefix,
|
||||
Name: "tenant_status",
|
||||
Help: "Tenant cordon state indicating if tenant operations are restricted (1) or allowed (0) for resource creation and modification",
|
||||
Help: "DEPRECATED: Tenant cordon state indicating if tenant operations are restricted (1) or allowed (0) for resource creation and modification",
|
||||
}, []string{"tenant"},
|
||||
),
|
||||
TenantNamespaceCounterGauge: prometheus.NewGaugeVec(
|
||||
@@ -66,6 +83,8 @@ func NewTenantRecorder() *TenantRecorder {
|
||||
func (r *TenantRecorder) Collectors() []prometheus.Collector {
|
||||
return []prometheus.Collector{
|
||||
r.TenantNamespaceRelationshipGauge,
|
||||
r.TenantNamespaceConditionGauge,
|
||||
r.TenantConditionGauge,
|
||||
r.TenantCordonedStatusGauge,
|
||||
r.TenantNamespaceCounterGauge,
|
||||
r.TenantResourceUsageGauge,
|
||||
@@ -73,6 +92,51 @@ func (r *TenantRecorder) Collectors() []prometheus.Collector {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"namespace": namespace,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *TenantRecorder) DeleteTenantNamespaceConditionMetrics(namespace string) {
|
||||
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
|
||||
"namespace": namespace,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *TenantRecorder) DeleteTenantNamespaceConditionMetricByType(namespace string, condition string) {
|
||||
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
|
||||
"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{
|
||||
@@ -85,25 +149,16 @@ func (r *TenantRecorder) DeleteTenantResourceMetrics(tenant string) {
|
||||
|
||||
// DeleteCondition deletes the condition metrics for the ref.
|
||||
func (r *TenantRecorder) DeleteTenantStatusMetrics(tenant string) {
|
||||
r.TenantNamespaceCounterGauge.DeletePartialMatch(map[string]string{
|
||||
"tenant": tenant,
|
||||
})
|
||||
r.TenantCordonedStatusGauge.DeletePartialMatch(map[string]string{
|
||||
"tenant": tenant,
|
||||
})
|
||||
r.TenantNamespaceRelationshipGauge.DeletePartialMatch(map[string]string{
|
||||
"tenant": tenant,
|
||||
})
|
||||
r.TenantResourceUsageGauge.DeletePartialMatch(map[string]string{
|
||||
"tenant": tenant,
|
||||
})
|
||||
r.TenantResourceLimitGauge.DeletePartialMatch(map[string]string{
|
||||
r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{
|
||||
"tenant": tenant,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCondition deletes the condition metrics for the ref.
|
||||
func (r *TenantRecorder) DeleteNamespaceRelationshipMetrics(namespace string) {
|
||||
r.TenantNamespaceRelationshipGauge.DeletePartialMatch(map[string]string{
|
||||
"namespace": namespace,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *TenantRecorder) DeleteAllMetrics(tenant string) {
|
||||
r.DeleteTenantResourceMetrics(tenant)
|
||||
r.DeleteTenantStatusMetrics(tenant)
|
||||
}
|
||||
|
||||
16
pkg/utils/maps.go
Normal file
16
pkg/utils/maps.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package utils
|
||||
|
||||
func MapMergeNoOverrite(dst, src map[string]string) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range src {
|
||||
if _, exists := dst[k]; !exists {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
98
pkg/utils/maps_test.go
Normal file
98
pkg/utils/maps_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMapMergeNoOverrite_AddsNonOverlapping(t *testing.T) {
|
||||
dst := map[string]string{"a": "1"}
|
||||
src := map[string]string{"b": "2"}
|
||||
|
||||
MapMergeNoOverrite(dst, src)
|
||||
|
||||
if got, want := dst["a"], "1"; got != want {
|
||||
t.Fatalf("dst[a] = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := dst["b"], "2"; got != want {
|
||||
t.Fatalf("dst[b] = %q, want %q", got, want)
|
||||
}
|
||||
if len(dst) != 2 {
|
||||
t.Fatalf("len(dst) = %d, want 2", len(dst))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMergeNoOverrite_DoesNotOverwriteExisting(t *testing.T) {
|
||||
dst := map[string]string{"a": "1"}
|
||||
src := map[string]string{"a": "X"} // overlapping key
|
||||
|
||||
MapMergeNoOverrite(dst, src)
|
||||
|
||||
if got, want := dst["a"], "1"; got != want {
|
||||
t.Fatalf("dst[a] overwritten: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMergeNoOverrite_EmptySrc_NoChange(t *testing.T) {
|
||||
dst := map[string]string{"a": "1"}
|
||||
src := map[string]string{} // empty
|
||||
|
||||
before := make(map[string]string, len(dst))
|
||||
for k, v := range dst {
|
||||
before[k] = v
|
||||
}
|
||||
|
||||
MapMergeNoOverrite(dst, src)
|
||||
|
||||
if !reflect.DeepEqual(dst, before) {
|
||||
t.Fatalf("dst changed with empty src: got %#v, want %#v", dst, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMergeNoOverrite_NilSrc_NoChange(t *testing.T) {
|
||||
dst := map[string]string{"a": "1"}
|
||||
var src map[string]string // nil
|
||||
|
||||
before := make(map[string]string, len(dst))
|
||||
for k, v := range dst {
|
||||
before[k] = v
|
||||
}
|
||||
|
||||
MapMergeNoOverrite(dst, src)
|
||||
|
||||
if !reflect.DeepEqual(dst, before) {
|
||||
t.Fatalf("dst changed with nil src: got %#v, want %#v", dst, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMergeNoOverrite_Idempotent(t *testing.T) {
|
||||
dst := map[string]string{"a": "1"}
|
||||
src := map[string]string{"b": "2"}
|
||||
|
||||
MapMergeNoOverrite(dst, src)
|
||||
first := map[string]string{}
|
||||
for k, v := range dst {
|
||||
first[k] = v
|
||||
}
|
||||
|
||||
// Call again; result should be identical
|
||||
MapMergeNoOverrite(dst, src)
|
||||
|
||||
if !reflect.DeepEqual(dst, first) {
|
||||
t.Fatalf("non-idempotent merge: after second merge got %#v, want %#v", dst, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMergeNoOverrite_NilDst_Panics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic when dst is nil, but did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
var dst map[string]string // nil destination map
|
||||
src := map[string]string{"a": "1"}
|
||||
|
||||
// Writing to a nil map panics; document current behavior via this test.
|
||||
MapMergeNoOverrite(dst, src)
|
||||
}
|
||||
@@ -15,10 +15,6 @@ import (
|
||||
"github.com/projectcapsule/capsule/api/v1beta2"
|
||||
)
|
||||
|
||||
const (
|
||||
CordonedLabel = "projectcapsule.dev/cordoned"
|
||||
)
|
||||
|
||||
func GetTypeLabel(t runtime.Object) (label string, err error) {
|
||||
switch v := t.(type) {
|
||||
case *v1beta1.Tenant, *v1beta2.Tenant:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
|
||||
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"
|
||||
@@ -74,16 +76,21 @@ func (h *cordoningLabelHandler) syncNamespaceCordonLabel(ctx context.Context, c
|
||||
}
|
||||
}
|
||||
|
||||
if !tnt.Spec.Cordoned {
|
||||
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[capsuleutils.CordonedLabel]; ok {
|
||||
if _, ok := labels[meta.CordonedLabel]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ns.Labels[capsuleutils.CordonedLabel] = "true"
|
||||
ns.Labels[meta.CordonedLabel] = "true"
|
||||
|
||||
marshaled, err := json.Marshal(ns)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
|
||||
|
||||
capsuletenant "github.com/projectcapsule/capsule/controllers/tenant"
|
||||
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
||||
"github.com/projectcapsule/capsule/pkg/configuration"
|
||||
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
|
||||
)
|
||||
@@ -49,12 +49,29 @@ func (h *metadataHandler) OnCreate(client client.Client, decoder admission.Decod
|
||||
}
|
||||
|
||||
// sync namespace metadata
|
||||
if err := capsuletenant.SyncNamespaceMetadata(tenant, ns); err != nil {
|
||||
response := admission.Errored(http.StatusInternalServerError, err)
|
||||
instance := tenant.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
|
||||
Name: ns.GetName(),
|
||||
UID: ns.GetUID(),
|
||||
})
|
||||
|
||||
return &response
|
||||
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)
|
||||
|
||||
@@ -13,14 +13,19 @@ import (
|
||||
"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{}
|
||||
type containerRegistryHandler struct {
|
||||
configuration configuration.Configuration
|
||||
}
|
||||
|
||||
func ContainerRegistry() capsulewebhook.Handler {
|
||||
return &containerRegistryHandler{}
|
||||
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 {
|
||||
@@ -42,7 +47,13 @@ func (h *containerRegistryHandler) OnUpdate(c client.Client, decoder admission.D
|
||||
}
|
||||
}
|
||||
|
||||
func (h *containerRegistryHandler) validate(ctx context.Context, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, req admission.Request) *admission.Response {
|
||||
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)
|
||||
@@ -61,34 +72,45 @@ func (h *containerRegistryHandler) validate(ctx context.Context, c client.Client
|
||||
|
||||
tnt := tntList.Items[0]
|
||||
|
||||
if tnt.Spec.ContainerRegistries != nil {
|
||||
// Evaluate init containers
|
||||
for _, container := range pod.Spec.InitContainers {
|
||||
if response := h.verifyContainerRegistry(recorder, req, container, tnt); response != nil {
|
||||
return response
|
||||
}
|
||||
}
|
||||
if tnt.Spec.ContainerRegistries == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Evaluate containers
|
||||
for _, container := range pod.Spec.Containers {
|
||||
if response := h.verifyContainerRegistry(recorder, req, container, tnt); response != nil {
|
||||
return response
|
||||
}
|
||||
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, container corev1.Container, tnt capsulev1beta2.Tenant) *admission.Response {
|
||||
func (h *containerRegistryHandler) verifyContainerRegistry(
|
||||
recorder record.EventRecorder,
|
||||
req admission.Request,
|
||||
image string,
|
||||
tnt capsulev1beta2.Tenant,
|
||||
) *admission.Response {
|
||||
var valid, matched bool
|
||||
|
||||
reg := NewRegistry(container.Image)
|
||||
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(container.Image, *tnt.Spec.ContainerRegistries).Error())
|
||||
response := admission.Denied(NewContainerRegistryForbidden(image, *tnt.Spec.ContainerRegistries).Error())
|
||||
|
||||
return &response
|
||||
}
|
||||
@@ -100,7 +122,7 @@ func (h *containerRegistryHandler) verifyContainerRegistry(recorder record.Event
|
||||
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(container.Image, *tnt.Spec.ContainerRegistries).Error())
|
||||
response := admission.Denied(NewContainerRegistryForbidden(reg.FQCI(), *tnt.Spec.ContainerRegistries).Error())
|
||||
|
||||
return &response
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
package pod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/configuration"
|
||||
)
|
||||
|
||||
type registry map[string]string
|
||||
@@ -49,14 +53,46 @@ func (r registry) Tag() string {
|
||||
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) Registry {
|
||||
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)
|
||||
|
||||
@@ -25,49 +25,13 @@ func ImagePullPolicy() capsulewebhook.Handler {
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
// the Pod is not running in a Namespace managed by a Tenant
|
||||
if len(tntList.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tnt := tntList.Items[0]
|
||||
|
||||
policy := NewPullPolicy(&tnt)
|
||||
// if Tenant doesn't enforce the pull policy, exit
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, container := range pod.Spec.Containers {
|
||||
usedPullPolicy := string(container.ImagePullPolicy)
|
||||
|
||||
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.Name, policy.AllowedPullPolicies()).Error())
|
||||
|
||||
return &response
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return r.validate(ctx, c, decoder, recorder, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *imagePullPolicy) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
|
||||
return func(context.Context, admission.Request) *admission.Response {
|
||||
return nil
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,3 +40,73 @@ func (r *imagePullPolicy) OnDelete(client.Client, admission.Decoder, record.Even
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,18 +7,34 @@ import (
|
||||
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
|
||||
)
|
||||
|
||||
type tenant struct {
|
||||
type tenantValidating struct {
|
||||
handlers []capsulewebhook.Handler
|
||||
}
|
||||
|
||||
func Tenant(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
|
||||
return &tenant{handlers: handler}
|
||||
func TenantValidating(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
|
||||
return &tenantValidating{handlers: handler}
|
||||
}
|
||||
|
||||
func (w *tenant) GetHandlers() []capsulewebhook.Handler {
|
||||
func (w *tenantValidating) GetHandlers() []capsulewebhook.Handler {
|
||||
return w.handlers
|
||||
}
|
||||
|
||||
func (w *tenant) GetPath() string {
|
||||
return "/tenants"
|
||||
func (w *tenantValidating) GetPath() string {
|
||||
return "/tenants/validating"
|
||||
}
|
||||
|
||||
type tenantMutating struct {
|
||||
handlers []capsulewebhook.Handler
|
||||
}
|
||||
|
||||
func TenantMutating(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
|
||||
return &tenantMutating{handlers: handler}
|
||||
}
|
||||
|
||||
func (w *tenantMutating) GetHandlers() []capsulewebhook.Handler {
|
||||
return w.handlers
|
||||
}
|
||||
|
||||
func (w *tenantMutating) GetPath() string {
|
||||
return "/tenants/mutating"
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
|
||||
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
|
||||
"github.com/projectcapsule/capsule/pkg/webhook/utils"
|
||||
)
|
||||
|
||||
type metaHandler struct{}
|
||||
|
||||
func MetaHandler() capsulewebhook.Handler {
|
||||
return &metaHandler{}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
|
||||
return func(context.Context, admission.Request) *admission.Response {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
|
||||
return func(context.Context, admission.Request) *admission.Response {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
|
||||
return func(_ context.Context, req admission.Request) *admission.Response {
|
||||
tenant := &capsulev1beta2.Tenant{}
|
||||
if err := decoder.Decode(req, tenant); err != nil {
|
||||
return utils.ErroredResponse(err)
|
||||
}
|
||||
|
||||
if tenant.Labels != nil {
|
||||
if tenant.Labels[capsuleapi.TenantNameLabel] != "" {
|
||||
if tenant.Labels[capsuleapi.TenantNameLabel] != tenant.Name {
|
||||
response := admission.Denied(fmt.Sprintf("tenant label '%s' is immutable", capsuleapi.TenantNameLabel))
|
||||
|
||||
return &response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
73
pkg/webhook/tenant/mutation/metadata.go
Normal file
73
pkg/webhook/tenant/mutation/metadata.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package mutation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
|
||||
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
|
||||
"github.com/projectcapsule/capsule/pkg/webhook/utils"
|
||||
)
|
||||
|
||||
type metaHandler struct{}
|
||||
|
||||
func MetaHandler() capsulewebhook.Handler {
|
||||
return &metaHandler{}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnCreate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
|
||||
return func(_ context.Context, req admission.Request) *admission.Response {
|
||||
return h.handle(decoder, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnUpdate(_ client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
|
||||
return func(_ context.Context, req admission.Request) *admission.Response {
|
||||
return h.handle(decoder, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *metaHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
|
||||
return func(context.Context, admission.Request) *admission.Response {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *metaHandler) handle(decoder admission.Decoder, req admission.Request) *admission.Response {
|
||||
tenant := &capsulev1beta2.Tenant{}
|
||||
if err := decoder.Decode(req, tenant); err != nil {
|
||||
return utils.ErroredResponse(err)
|
||||
}
|
||||
|
||||
labels := tenant.GetLabels()
|
||||
if val, ok := labels[capsuleapi.TenantNameLabel]; ok && val == tenant.Name {
|
||||
return nil
|
||||
}
|
||||
|
||||
if labels == nil {
|
||||
labels = make(map[string]string)
|
||||
}
|
||||
|
||||
labels[capsuleapi.TenantNameLabel] = tenant.Name
|
||||
tenant.SetLabels(labels)
|
||||
|
||||
marshaled, err := json.Marshal(tenant)
|
||||
if err != nil {
|
||||
response := admission.Errored(http.StatusInternalServerError, err)
|
||||
|
||||
return &response
|
||||
}
|
||||
|
||||
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
|
||||
|
||||
return &response
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//nolint:dupl
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import "fmt"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//nolint:dupl
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//nolint:dupl
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2020-2025 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//nolint:dupl
|
||||
package tenant
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
Reference in New Issue
Block a user