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:
Oliver Bähler
2025-10-06 08:19:26 +02:00
committed by GitHub
parent 9a2effd74e
commit 5ac0f83c5a
55 changed files with 2708 additions and 420 deletions

View File

@@ -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"`
}

View File

@@ -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
View 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)
})
}

View File

@@ -15,6 +15,9 @@ const (
OwnerPromotionLabel = "owner.projectcapsule.dev/promote"
OwnerPromotionLabelTrigger = "true"
CordonedLabel = "projectcapsule.dev/cordoned"
CordonedLabelTrigger = "true"
)
func FreezeLabelTriggers(obj client.Object) bool {

View 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
}

View File

@@ -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
View 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
View 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)
}

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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
}
}

View 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
}

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenant
package validation
import (
"context"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenant
package validation
import (
"context"

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenant
package validation
import (
"context"

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package tenant
package validation
import (
"context"