feat: upstream enterprise preview (#1841)

feat: upstream enterprise preview

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
Co-authored-by: CorentinPtrl <pitrel.corentin@gmail.com>
This commit is contained in:
Oliver Bähler
2026-05-28 00:58:58 +02:00
committed by GitHub
co-authored by CorentinPtrl
parent 7a65ab7afc
commit cc4fb45d70
462 changed files with 48456 additions and 10529 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
// TenantSpec defines the desired state of Tenant.
@@ -36,7 +37,7 @@ type TenantSpec struct {
// +optional
ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"`
// Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional.
AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"`
AdditionalRoleBindings []rbac.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"`
// Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional.
ImagePullPolicies []api.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"`
// Specifies the allowed priorityClasses assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed PriorityClasses. Optional.
+2 -1
View File
@@ -9,6 +9,7 @@ package v1beta1
import (
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rbac"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@@ -323,7 +324,7 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
in.ResourceQuota.DeepCopyInto(&out.ResourceQuota)
if in.AdditionalRoleBindings != nil {
in, out := &in.AdditionalRoleBindings, &out.AdditionalRoleBindings
*out = make([]api.AdditionalRoleBindingsSpec, len(*in))
*out = make([]rbac.AdditionalRoleBindingsSpec, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
+2 -7
View File
@@ -4,16 +4,11 @@
package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
// CapsuleConfigurationStatus defines the Capsule configuration status.
type CapsuleConfigurationStatus struct {
// Last time all caches were invalided
LastCacheInvalidation metav1.Time `json:"lastCacheInvalidation,omitempty"`
// Users which are considered Capsule Users and are bound to the Capsule Tenant construct.
Users api.UserListSpec `json:"users,omitempty"`
Users rbac.UserListSpec `json:"users,omitempty"`
}
+67 -22
View File
@@ -4,26 +4,19 @@
package v1beta2
import (
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/runtime/admission"
)
// CapsuleConfigurationSpec defines the Capsule configuration.
type CapsuleConfigurationSpec struct {
// Define entities which are considered part of the Capsule construct
// Users not mentioned here will be ignored by Capsule
Users api.UserListSpec `json:"users,omitempty"`
// Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
//
// Names of the users considered as Capsule users.
UserNames []string `json:"userNames,omitempty"`
// Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
//
// Names of the groups considered as Capsule users.
UserGroups []string `json:"userGroups,omitempty"`
Users rbac.UserListSpec `json:"users,omitempty"`
// Define groups which when found in the request of a user will be ignored by the Capsule
// this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups.
IgnoreUserWithGroups []string `json:"ignoreUserWithGroups,omitempty"`
@@ -54,7 +47,7 @@ type CapsuleConfigurationSpec struct {
// These entities are automatically owners for all existing tenants. Meaning they can add namespaces to any tenant. However they must be specific by using the capsule label
// for interacting with namespaces. Because if that label is not defined, it's assumed that namespace interaction was not targeted towards a tenant and will therefor
// be ignored by capsule.
Administrators api.UserListSpec `json:"administrators,omitempty"`
Administrators rbac.UserListSpec `json:"administrators,omitempty"`
// Configuration for dynamic Validating and Mutating Admission webhooks managed by Capsule.
Admission DynamicAdmission `json:"admission,omitempty"`
// Define Properties for managed ClusterRoles by Capsule
@@ -63,6 +56,18 @@ type CapsuleConfigurationSpec struct {
// Define the period of time upon a cache invalidation is executed for all caches.
// +kubebuilder:default="24h"
CacheInvalidation metav1.Duration `json:"cacheInvalidation"`
// Service Account Client configuration for impersonation properties
// +optional
Impersonation ServiceAccountClient `json:"impersonation,omitzero"`
// Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
//
// Names of the users considered as Capsule users.
UserNames []string `json:"userNames,omitempty"`
// Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
//
// Names of the groups considered as Capsule users.
UserGroups []string `json:"userGroups,omitempty"`
}
type RBACConfiguration struct {
@@ -81,24 +86,31 @@ type RBACConfiguration struct {
}
type DynamicAdmission struct {
// Service Name of the Admission Service
// +kubebuilder:default=capsule-webhook-service
ServiceName string `json:"serviceName,omitempty"`
// Configure dynamic Mutating Admission for Capsule
Mutating DynamicAdmissionConfig `json:"mutating,omitempty"`
Mutating *DynamicMutatingAdmissionConfig `json:"mutating,omitempty"`
// Configure dynamic Validating Admission for Capsule
Validating DynamicAdmissionConfig `json:"validating,omitempty"`
Validating *DynamicValidatingAdmissionConfig `json:"validating,omitempty"`
}
type DynamicAdmissionConfig struct {
// Name the Admission Webhook
Name meta.RFC1123Name `json:"name,omitempty"`
// Labels added to the Admission Webhook
type DynamicValidatingAdmissionConfig struct {
admission.DynamicAdmissionConfig `json:",inline"`
// Define Dynamic Admission Webhooks
// +optional
Labels map[string]string `json:"labels,omitempty"`
// Annotations added to the Admission Webhook
Webhooks []*admission.ValidatingWebhook `json:"webhooks,omitempty"`
}
type DynamicMutatingAdmissionConfig struct {
admission.DynamicAdmissionConfig `json:",inline"`
// Define Dynamic Admission Webhooks
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
// From the upstram struct
Client admissionregistrationv1.WebhookClientConfig `json:"client"`
Webhooks []*admission.MutatingWebhook `json:"webhooks,omitempty"`
}
type NodeMetadata struct {
@@ -115,14 +127,47 @@ type CapsuleResources struct {
// Must be in the same Namespace where the Capsule Deployment is deployed.
// +kubebuilder:default=capsule-tls
TLSSecretName string `json:"TLSSecretName"` //nolint:tagliatelle
// Deprecated: use dynamic admission instead
//
// Name of the MutatingWebhookConfiguration which contains the dynamic admission controller paths and resources.
// +kubebuilder:default=capsule-mutating-webhook-configuration
MutatingWebhookConfigurationName string `json:"mutatingWebhookConfigurationName"`
// Deprecated: use dynamic admission instead
//
// Name of the ValidatingWebhookConfiguration which contains the dynamic admission controller paths and resources.
// +kubebuilder:default=capsule-validating-webhook-configuration
ValidatingWebhookConfigurationName string `json:"validatingWebhookConfigurationName"`
}
// +kubebuilder:object:generate=true
type ServiceAccountClient struct {
// Kubernetes API Endpoint to use for impersonation
Endpoint string `json:"endpoint,omitempty"`
// Namespace where the CA certificate secret is located
CASecretNamespace meta.RFC1123SubdomainName `json:"caSecretNamespace,omitempty"`
// Name of the secret containing the CA certificate
CASecretName meta.RFC1123Name `json:"caSecretName,omitempty"`
// Key in the secret that holds the CA certificate (e.g., "ca.crt")
// +kubebuilder:default=ca.crt
CASecretKey string `json:"caSecretKey,omitempty"`
// If true, TLS certificate verification is skipped (not recommended for production)
// +kubebuilder:default=false
SkipTLSVerify bool `json:"skipTlsVerify,omitempty"`
// Default ServiceAccount for global resources (GlobalTenantResource)
// When defined, users are required to use this ServiceAccount anywhere in the cluster
// unless they explicitly provide their own.
GlobalDefaultServiceAccount meta.RFC1123Name `json:"globalDefaultServiceAccount,omitempty"`
// Default ServiceAccount for global resources (GlobalTenantResource)
// When defined, users are required to use this ServiceAccount anywhere in the cluster
// unless they explicitly provide their own.
// +optional
GlobalDefaultServiceAccountNamespace meta.RFC1123SubdomainName `json:"globalDefaultServiceAccountNamespace,omitempty"`
// Default ServiceAccount for namespaced resources (TenantResource)
// When defined, users are required to use this ServiceAccount within the namespace
// where they deploy the resource, unless they explicitly provide their own.
TenantDefaultServiceAccount meta.RFC1123Name `json:"tenantDefaultServiceAccount,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
func (c *CustomQuotaSpec) CollectJSONPathExpressions() (expressions []string) {
set := map[string]struct{}{}
for _, source := range c.Sources {
if source.Path != "" {
set[source.Path] = struct{}{}
}
for _, sel := range source.Selectors {
for _, fs := range sel.FieldSelectors {
if fs != "" {
set[fs] = struct{}{}
}
}
}
}
for e := range set {
expressions = append(expressions, e)
}
return expressions
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
k8smeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// CustomQuotaStatus defines the observed state of GlobalResourceQuota.
type CustomQuotaStatus struct {
// Usage measurements
// +optional
Usage CustomQuotaStatusUsage `json:"usage,omitempty"`
// Objects regarding this policy
Claims []CustomQuotaClaimItem `json:"claims,omitempty"`
// Targeting GVK
Targets []CustomQuotaStatusTarget `json:"targets"`
// Conditions
Conditions meta.ConditionList `json:"conditions"`
}
func (s *CustomQuotaStatus) HasClaimUID(uid types.UID) bool {
for i := range s.Claims {
if s.Claims[i].UID == uid {
return true
}
}
return false
}
type CustomQuotaClaimItem struct {
metav1.GroupVersionKind `json:",inline"`
meta.NamespacedObjectWithUIDReference `json:",inline"`
// Resource Quantity for given item
Usage resource.Quantity `json:"usage"`
}
type CustomQuotaStatusTarget struct {
metav1.GroupVersionKind `json:",inline"`
CustomQuotaSpecSourceConfig `json:",inline"`
// Path on GVK where usage is evaluated
Scope k8smeta.RESTScopeName `json:"scope,omitempty"`
}
// CustomQuotaStatus defines the observed state of GlobalResourceQuota.
type CustomQuotaStatusUsage struct {
// Used is the current observed total usage of the resource.
// +optional
Used resource.Quantity `json:"used"`
// Used is the current observed total available of the resource (limit - used).
// +optional
Available resource.Quantity `json:"available"`
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/runtime/gvk"
"github.com/projectcapsule/capsule/pkg/runtime/quota"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
)
// CustomQuotaSpec.
type CustomQuotaSpec struct {
// Select items governed by this quota
ScopeSelectors []metav1.LabelSelector `json:"scopeSelectors,omitempty"`
// Resource Quantity as limit
Limit resource.Quantity `json:"limit"`
// Target resource
Sources []CustomQuotaSpecSource `json:"sources,omitzero"`
// Additional Options for the CustomQuotaSpecification
// +kubebuilder:default:={emitMetricPerClaimUsage:false}
Options *CustomQuotaOptionsSpec `json:"options,omitzero"`
}
// CustomQuotaOptionsSpec.
type CustomQuotaOptionsSpec struct {
// Additionally expose usage metrics for each claim contributing to the quota.
// This is disabled by default to avoid high cardinality in the metrics, but can be enabled for more granular monitoring and alerting.
// +kubebuilder:default:=false
EmitPerClaimMetrics bool `json:"emitMetricPerClaimUsage,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="self.op == 'count' ? !has(self.path) || size(self.path) == 0 : has(self.path) && size(self.path) > 0",message="path must be empty when op is 'count'; otherwise path must be set and non-empty"
type CustomQuotaSpecSource struct {
gvk.VersionKind `json:",inline"`
CustomQuotaSpecSourceConfig `json:",inline"`
}
type CustomQuotaSpecSourceConfig struct {
// Path on GVK where usage is evaluated.
// Must be empty when op is "count".
// Required and non-empty for all other operations.
// +optional
Path string `json:"path,omitempty"`
// Operation used to evaluate usage.
// +kubebuilder:default:=add
Operation quota.Operation `json:"op,omitempty"`
// Provide more granular selectors for these sources
// The ScopeSelector and NamespaceSelector are always applied
// Allowing these selectors to make further selecting on the resulting subset.
Selectors []selectors.SelectorWithFields `json:"selectors,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Namespaced,shortName=cq
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Limit",type="string",JSONPath=".spec.limit",description="The total limit available"
// +kubebuilder:printcolumn:name="Used",type="string",JSONPath=".status.usage.used",description="The total used amount"
// +kubebuilder:printcolumn:name="Available",type="string",JSONPath=".status.usage.available",description="The total amount available"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message"
type CustomQuota struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec CustomQuotaSpec `json:"spec"`
// +optional
Status CustomQuotaStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// CustomQuotaList contains a list of CustomQuota.
type CustomQuotaList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitzero"`
Items []CustomQuota `json:"items"`
}
func init() {
SchemeBuilder.Register(&CustomQuota{}, &CustomQuotaList{})
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import "slices"
// CustomQuotaStatus defines the observed state of GlobalResourceQuota.
type GlobalCustomQuotaStatus struct {
CustomQuotaStatus `json:",inline"`
// Observed Namespaces
Namespaces []string `json:"namespaces,omitempty"`
}
func (g *GlobalCustomQuotaStatus) NamespacePresent(ns string) bool {
return slices.Contains(g.Namespaces, ns)
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
)
// ClusterCustomQuotaSpec.
type GlobalCustomQuotaSpec struct {
CustomQuotaSpec `json:",inline"`
// Select specifc namespaces where this Quota selects items.
NamespaceSelectors []selectors.NamespaceSelector `json:"namespaceSelectors,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Cluster,shortName=gcq
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Limit",type="string",JSONPath=".spec.limit",description="The total limit available"
// +kubebuilder:printcolumn:name="Used",type="string",JSONPath=".status.usage.used",description="The total used amount"
// +kubebuilder:printcolumn:name="Available",type="string",JSONPath=".status.usage.available",description="The total amount available"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message"
type GlobalCustomQuota struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec GlobalCustomQuotaSpec `json:"spec"`
// +optional
Status GlobalCustomQuotaStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// ClusterCustomQuotaList contains a list of ClusterCustomQuota.
type GlobalCustomQuotaList struct {
metav1.TypeMeta `json:",inline"`
// +optional
metav1.ListMeta `json:"metadata,omitzero"`
Items []GlobalCustomQuota `json:"items"`
}
func init() {
SchemeBuilder.Register(&GlobalCustomQuota{}, &GlobalCustomQuotaList{})
}
-33
View File
@@ -1,33 +0,0 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
)
// +kubebuilder:object:generate=true
type NamespaceRule struct {
// Enforce these properties via Rules
NamespaceRuleBody `json:",inline"`
// Select namespaces which are going to usese
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
}
// +kubebuilder:object:generate=true
type NamespaceRuleBody struct {
// Enforcement Rules applied
//+optional
Enforce NamespaceRuleEnforceBody `json:"enforce,omitzero"`
}
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceBody struct {
// Define registries which are allowed to be used within this tenant
// The rules are aggregated, since you can use Regular Expressions the match registry endpoints
Registries []api.OCIRegistry `json:"registries,omitempty"`
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// QuantityLedgerReservation represents one active inflight reservation.
// ID should be stable for retries of the same admission request.
// In practice, admission.Request.UID is a good default.
type QuantityLedgerReservation struct {
// Unique reservation identifier.
// +kubebuilder:validation:MinLength=1
ID string `json:"id"`
// Amount reserved for this request.
Usage resource.Quantity `json:"usage"`
// Object that this reservation is intended to create/update.
ObjectRef QuantityLedgerObjectRef `json:"objectRef"`
// Time the reservation was first created.
CreatedAt metav1.Time `json:"createdAt"`
// Time the reservation was last refreshed or updated.
UpdatedAt metav1.Time `json:"updatedAt"`
// Time after which the reservation may be considered stale.
// +optional
ExpiresAt *metav1.Time `json:"expiresAt,omitempty"`
}
// QuantityLedgerPendingDelete tracks objects that are expected to disappear from claims
// soon, but may still temporarily appear during rebuild due to propagation delay.
type QuantityLedgerPendingDelete struct {
ObjectRef QuantityLedgerObjectRef `json:"objectRef"`
CreatedAt metav1.Time `json:"createdAt"`
}
// QuantityLedgerStatus contains the mutable coordination state used by admission
// and quota controllers.
type QuantityLedgerStatus struct {
// Reserved is the aggregate sum of all active reservations.
// Controllers/webhooks should treat this as derived data from Reservations.
// +optional
Reserved resource.Quantity `json:"reserved,omitempty"`
// Active inflight reservations for this quota.
// +optional
Reservations []QuantityLedgerReservation `json:"reservations,omitempty"`
// Pending delete hints carried over from admission delete handling.
// +optional
PendingDeletes []QuantityLedgerPendingDelete `json:"pendingDeletes,omitempty"`
// Conditions for the resource claim
// +optional
Conditions meta.ConditionList `json:"conditions,omitzero"`
// Allocated is the admission-owned total that has been accepted by the webhook.
// It must be updated only through optimistic concurrency on QuantityLedger.
Allocated resource.Quantity `json:"allocated,omitempty"`
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
// QuotaLedgerTargetRef identifies the quota object that owns this ledger.
// Namespace is optional for cluster-scoped targets such as GlobalCustomQuota.
type QuantityLedgerTargetRef struct {
// APIGroup of the target quota resource, for example "capsule.clastix.io".
// +optional
APIGroup string `json:"apiGroup,omitempty"`
// Kind of the target quota resource, for example "CustomQuota" or "GlobalCustomQuota".
// +kubebuilder:validation:MinLength=1
Kind string `json:"kind"`
// Namespace of the target quota resource.
// Must be empty for cluster-scoped targets.
// +optional
Namespace string `json:"namespace,omitempty"`
// Name of the target quota resource.
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// UID of the target quota resource.
// Optional, but useful for stale reference detection.
// +optional
UID types.UID `json:"uid,omitempty"`
}
// QuotaLedgerObjectRef identifies the object for which a reservation exists.
// UID may be empty for CREATE admission before the object is persisted.
type QuantityLedgerObjectRef struct {
// APIGroup of the tracked object.
// +optional
APIGroup string `json:"apiGroup,omitempty"`
// APIVersion of the tracked object, for example "v1".
// +kubebuilder:validation:MinLength=1
APIVersion string `json:"apiVersion"`
// Kind of the tracked object, for example "Pod".
// +kubebuilder:validation:MinLength=1
Kind string `json:"kind"`
// Namespace of the tracked object.
// +optional
Namespace string `json:"namespace,omitempty"`
// Name of the tracked object.
// +optional
Name string `json:"name,omitempty"`
// UID of the tracked object.
// +optional
UID types.UID `json:"uid,omitempty"`
}
// QuotaLedgerSpec contains the immutable target reference.
type QuantityLedgerSpec struct {
// TargetRef points to the quota object that this ledger belongs to.
TargetRef QuantityLedgerTargetRef `json:"targetRef"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=quantityledgers,scope=Namespaced,shortName=ql
// +kubebuilder:printcolumn:name="TargetKind",type=string,JSONPath=`.spec.targetRef.kind`
// +kubebuilder:printcolumn:name="TargetNamespace",type=string,JSONPath=`.spec.targetRef.namespace`
// +kubebuilder:printcolumn:name="TargetName",type=string,JSONPath=`.spec.targetRef.name`
// +kubebuilder:printcolumn:name="Reserved",type=string,JSONPath=`.status.reserved`
// +kubebuilder:printcolumn:name="Reservations",type=integer,JSONPath=`.status.reservations.size()`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type QuantityLedger struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec QuantityLedgerSpec `json:"spec,omitempty"`
Status QuantityLedgerStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type QuantityLedgerList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []QuantityLedger `json:"items"`
}
func init() {
SchemeBuilder.Register(&QuantityLedger{}, &QuantityLedgerList{})
}
+1 -2
View File
@@ -5,7 +5,6 @@ package v1beta2
import (
"errors"
"fmt"
"sort"
corev1 "k8s.io/api/core/v1"
@@ -15,7 +14,7 @@ import (
)
func (r *ResourcePool) GetQuotaName() string {
return fmt.Sprintf("capsule-pool-%s", r.GetName())
return meta.NameForManagedPoolResourceQuota(r.GetName())
}
func (r *ResourcePool) AssignNamespaces(namespaces []corev1.Namespace) {
+47 -46
View File
@@ -1,18 +1,19 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
package v1beta2_test
import (
"testing"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/stretchr/testify/assert"
)
func TestGetClaimFromStatus(t *testing.T) {
@@ -20,7 +21,7 @@ func TestGetClaimFromStatus(t *testing.T) {
testUID := types.UID("test-uid")
otherUID := types.UID("wrong-uid")
claim := &ResourcePoolClaim{
claim := &v1beta2.ResourcePoolClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "claim-a",
Namespace: ns,
@@ -28,11 +29,11 @@ func TestGetClaimFromStatus(t *testing.T) {
},
}
pool := &ResourcePool{
Status: ResourcePoolStatus{
Claims: ResourcePoolNamespaceClaimsStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
ns: {
&ResourcePoolClaimsItem{
&v1beta2.ResourcePoolClaimsItem{
NamespacedRFC1123ObjectReferenceWithNamespaceWithUID: meta.NamespacedRFC1123ObjectReferenceWithNamespaceWithUID{
UID: testUID,
},
@@ -76,21 +77,21 @@ func makeResourceList(cpu, memory string) corev1.ResourceList {
}
}
func makeClaim(name, ns string, uid types.UID, res corev1.ResourceList) *ResourcePoolClaim {
return &ResourcePoolClaim{
func makeClaim(name, ns string, uid types.UID, res corev1.ResourceList) *v1beta2.ResourcePoolClaim {
return &v1beta2.ResourcePoolClaim{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
UID: uid,
},
Spec: ResourcePoolClaimSpec{
Spec: v1beta2.ResourcePoolClaimSpec{
ResourceClaims: res,
},
}
}
func TestAssignNamespaces(t *testing.T) {
pool := &ResourcePool{}
pool := &v1beta2.ResourcePool{}
namespaces := []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "active-ns"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}},
@@ -104,12 +105,12 @@ func TestAssignNamespaces(t *testing.T) {
}
func TestAssignClaims(t *testing.T) {
pool := &ResourcePool{
Status: ResourcePoolStatus{
Claims: ResourcePoolNamespaceClaimsStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
"ns": {
&ResourcePoolClaimsItem{},
&ResourcePoolClaimsItem{},
&v1beta2.ResourcePoolClaimsItem{},
&v1beta2.ResourcePoolClaimsItem{},
},
},
},
@@ -120,7 +121,7 @@ func TestAssignClaims(t *testing.T) {
}
func TestAddRemoveClaimToStatus(t *testing.T) {
pool := &ResourcePool{}
pool := &v1beta2.ResourcePool{}
claim := makeClaim("claim-1", "ns", "uid-1", makeResourceList("1", "1Gi"))
pool.AddClaimToStatus(claim)
@@ -135,16 +136,16 @@ func TestAddRemoveClaimToStatus(t *testing.T) {
}
func TestCalculateResources(t *testing.T) {
pool := &ResourcePool{
Status: ResourcePoolStatus{
Allocation: ResourcePoolQuotaStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Allocation: v1beta2.ResourcePoolQuotaStatus{
Hard: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("2"),
},
},
Claims: ResourcePoolNamespaceClaimsStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
"ns": {
&ResourcePoolClaimsItem{
&v1beta2.ResourcePoolClaimsItem{
Claims: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("1"),
},
@@ -164,9 +165,9 @@ func TestCalculateResources(t *testing.T) {
}
func TestCanClaimFromPool(t *testing.T) {
pool := &ResourcePool{
Status: ResourcePoolStatus{
Allocation: ResourcePoolQuotaStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Allocation: v1beta2.ResourcePoolQuotaStatus{
Hard: corev1.ResourceList{
corev1.ResourceLimitsMemory: resource.MustParse("1Gi"),
},
@@ -189,16 +190,16 @@ func TestCanClaimFromPool(t *testing.T) {
}
func TestGetResourceQuotaHardResources(t *testing.T) {
pool := &ResourcePool{
Spec: ResourcePoolSpec{
pool := &v1beta2.ResourcePool{
Spec: v1beta2.ResourcePoolSpec{
Defaults: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("1"),
},
},
Status: ResourcePoolStatus{
Claims: ResourcePoolNamespaceClaimsStatus{
Status: v1beta2.ResourcePoolStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
"ns": {
&ResourcePoolClaimsItem{
&v1beta2.ResourcePoolClaimsItem{
Claims: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("1"),
},
@@ -214,11 +215,11 @@ func TestGetResourceQuotaHardResources(t *testing.T) {
}
func TestGetNamespaceClaims(t *testing.T) {
pool := &ResourcePool{
Status: ResourcePoolStatus{
Claims: ResourcePoolNamespaceClaimsStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
"ns": {
&ResourcePoolClaimsItem{
&v1beta2.ResourcePoolClaimsItem{
NamespacedRFC1123ObjectReferenceWithNamespaceWithUID: meta.NamespacedRFC1123ObjectReferenceWithNamespaceWithUID{UID: "uid1"},
Claims: corev1.ResourceList{
corev1.ResourceLimitsCPU: resource.MustParse("1"),
@@ -236,11 +237,11 @@ func TestGetNamespaceClaims(t *testing.T) {
}
func TestGetClaimedByNamespaceClaims(t *testing.T) {
pool := &ResourcePool{
Status: ResourcePoolStatus{
Claims: ResourcePoolNamespaceClaimsStatus{
pool := &v1beta2.ResourcePool{
Status: v1beta2.ResourcePoolStatus{
Claims: v1beta2.ResourcePoolNamespaceClaimsStatus{
"ns1": {
&ResourcePoolClaimsItem{
&v1beta2.ResourcePoolClaimsItem{
Claims: makeResourceList("1", "1Gi"),
},
},
@@ -258,8 +259,8 @@ func TestGetClaimedByNamespaceClaims(t *testing.T) {
func TestIsBoundToResourcePool_2(t *testing.T) {
t.Run("bound to resource pool (Assigned=True)", func(t *testing.T) {
claim := &ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim := &v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{},
},
}
@@ -268,8 +269,8 @@ func TestIsBoundToResourcePool_2(t *testing.T) {
})
t.Run("not bound - wrong condition type", func(t *testing.T) {
claim := &ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim := &v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{},
},
@@ -284,8 +285,8 @@ func TestIsBoundToResourcePool_2(t *testing.T) {
})
t.Run("not bound - condition not true", func(t *testing.T) {
claim := &ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim := &v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{},
},
@@ -300,8 +301,8 @@ func TestIsBoundToResourcePool_2(t *testing.T) {
})
t.Run("not bound - condition not true", func(t *testing.T) {
claim := &ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim := &v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{},
},
+33 -35
View File
@@ -1,7 +1,7 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
package v1beta2_test
import (
"testing"
@@ -9,36 +9,34 @@ import (
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
func TestIsBoundToResourcePool(t *testing.T) {
tests := []struct {
name string
claim ResourcePoolClaim
claim v1beta2.ResourcePoolClaim
expected bool
}{
{
name: "bound to resource pool (Assigned=True)",
claim: ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{
Type: meta.BoundCondition,
Status: metav1.ConditionTrue,
Reason: meta.SucceededReason,
Message: "reconciled",
LastTransitionTime: metav1.Now(),
},
claim: v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Condition: metav1.Condition{
Type: meta.BoundCondition,
Status: metav1.ConditionTrue,
Reason: meta.SucceededReason,
Message: "reconciled",
LastTransitionTime: metav1.Now(),
},
},
},
expected: true,
},
{
name: "not bound - wrong condition type",
claim: ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim: v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{
Type: meta.AssignedCondition,
@@ -54,8 +52,8 @@ func TestIsBoundToResourcePool(t *testing.T) {
},
{
name: "not bound - status not true",
claim: ResourcePoolClaim{
Status: ResourcePoolClaimStatus{
claim: v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{
Conditions: meta.ConditionList{
meta.Condition{
Type: meta.AssignedCondition,
@@ -71,8 +69,8 @@ func TestIsBoundToResourcePool(t *testing.T) {
},
{
name: "not bound - empty condition",
claim: ResourcePoolClaim{
Status: ResourcePoolClaimStatus{},
claim: v1beta2.ResourcePoolClaim{
Status: v1beta2.ResourcePoolClaimStatus{},
},
expected: false,
},
@@ -89,16 +87,16 @@ func TestIsBoundToResourcePool(t *testing.T) {
func TestGetPool(t *testing.T) {
tests := []struct {
name string
claim ResourcePoolClaim
claim v1beta2.ResourcePoolClaim
expected string
}{
{
name: "returns status pool name when set",
claim: ResourcePoolClaim{
Spec: ResourcePoolClaimSpec{
claim: v1beta2.ResourcePoolClaim{
Spec: v1beta2.ResourcePoolClaimSpec{
Pool: "spec-pool",
},
Status: ResourcePoolClaimStatus{
Status: v1beta2.ResourcePoolClaimStatus{
Pool: meta.LocalRFC1123ObjectReferenceWithUID{
Name: meta.RFC1123Name("status-pool"),
},
@@ -108,11 +106,11 @@ func TestGetPool(t *testing.T) {
},
{
name: "falls back to spec pool when status pool name is empty",
claim: ResourcePoolClaim{
Spec: ResourcePoolClaimSpec{
claim: v1beta2.ResourcePoolClaim{
Spec: v1beta2.ResourcePoolClaimSpec{
Pool: "spec-pool",
},
Status: ResourcePoolClaimStatus{
Status: v1beta2.ResourcePoolClaimStatus{
Pool: meta.LocalRFC1123ObjectReferenceWithUID{
Name: meta.RFC1123Name(""),
},
@@ -122,11 +120,11 @@ func TestGetPool(t *testing.T) {
},
{
name: "falls back to spec pool when status pool struct is zero-value",
claim: ResourcePoolClaim{
Spec: ResourcePoolClaimSpec{
claim: v1beta2.ResourcePoolClaim{
Spec: v1beta2.ResourcePoolClaimSpec{
Pool: "spec-pool",
},
Status: ResourcePoolClaimStatus{
Status: v1beta2.ResourcePoolClaimStatus{
Pool: meta.LocalRFC1123ObjectReferenceWithUID{},
},
},
@@ -134,11 +132,11 @@ func TestGetPool(t *testing.T) {
},
{
name: "returns empty when both status and spec are empty",
claim: ResourcePoolClaim{
Spec: ResourcePoolClaimSpec{
claim: v1beta2.ResourcePoolClaim{
Spec: v1beta2.ResourcePoolClaimSpec{
Pool: "",
},
Status: ResourcePoolClaimStatus{
Status: v1beta2.ResourcePoolClaimStatus{
Pool: meta.LocalRFC1123ObjectReferenceWithUID{
Name: meta.RFC1123Name(""),
},
@@ -148,11 +146,11 @@ func TestGetPool(t *testing.T) {
},
{
name: "status wins even if spec differs",
claim: ResourcePoolClaim{
Spec: ResourcePoolClaimSpec{
claim: v1beta2.ResourcePoolClaim{
Spec: v1beta2.ResourcePoolClaimSpec{
Pool: "spec-pool",
},
Status: ResourcePoolClaimStatus{
Status: v1beta2.ResourcePoolClaimStatus{
Pool: meta.LocalRFC1123ObjectReferenceWithUID{
Name: meta.RFC1123Name("status-pool"),
},
+16 -8
View File
@@ -5,8 +5,21 @@ package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// RuleStatus contains the accumulated rules applying to namespace it's deployed in.
// +kubebuilder:object:generate=true
type RuleStatusSpec struct {
// Managed Enforcement properties per Namespace (aggregated from rules)
//+optional
Rule api.NamespaceRuleBodyNamespace `json:"rule,omitzero"`
// Conditions
Conditions meta.ConditionList `json:"conditions"`
}
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
@@ -17,6 +30,9 @@ type RuleStatus struct {
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// +optional
Spec []*api.NamespaceRuleBodyNamespace `json:"spec,omitzero"`
// +optional
Status RuleStatusSpec `json:"status,omitzero"`
}
@@ -34,11 +50,3 @@ type RuleStatusList struct {
func init() {
SchemeBuilder.Register(&RuleStatus{}, &RuleStatusList{})
}
// RuleStatus contains the accumulated rules applying to namespace it's deployed in.
// +kubebuilder:object:generate=true
type RuleStatusSpec struct {
// Managed Enforcement properties per Namespace (aggregated from rules)
//+optional
Rule NamespaceRuleBody `json:"rule,omitzero"`
}
+12 -10
View File
@@ -13,6 +13,7 @@ import (
capsulev1beta1 "github.com/projectcapsule/capsule/api/v1beta1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
func (in *Tenant) ConvertFrom(raw conversion.Hub) error {
@@ -27,28 +28,28 @@ func (in *Tenant) ConvertFrom(raw conversion.Hub) error {
}
in.ObjectMeta = src.ObjectMeta
in.Spec.Owners = make(api.OwnerListSpec, 0, len(src.Spec.Owners))
in.Spec.Owners = make(rbac.OwnerListSpec, 0, len(src.Spec.Owners))
for index, owner := range src.Spec.Owners {
proxySettings := make([]api.ProxySettings, 0, len(owner.ProxyOperations))
proxySettings := make([]rbac.ProxySettings, 0, len(owner.ProxyOperations))
for _, proxyOp := range owner.ProxyOperations {
ops := make([]api.ProxyOperation, 0, len(proxyOp.Operations))
ops := make([]rbac.ProxyOperation, 0, len(proxyOp.Operations))
for _, op := range proxyOp.Operations {
ops = append(ops, api.ProxyOperation(op))
ops = append(ops, rbac.ProxyOperation(op))
}
proxySettings = append(proxySettings, api.ProxySettings{
Kind: api.ProxyServiceKind(proxyOp.Kind),
proxySettings = append(proxySettings, rbac.ProxySettings{
Kind: rbac.ProxyServiceKind(proxyOp.Kind),
Operations: ops,
})
}
in.Spec.Owners = append(in.Spec.Owners, api.OwnerSpec{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Kind: api.OwnerKind(owner.Kind),
in.Spec.Owners = append(in.Spec.Owners, rbac.OwnerSpec{
CoreOwnerSpec: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Kind: rbac.OwnerKind(owner.Kind),
Name: owner.Name,
},
ClusterRoles: owner.GetRoles(*src, index),
@@ -281,6 +282,7 @@ func (in *Tenant) ConvertTo(raw conversion.Hub) error {
dst.Status.Size = in.Status.Size
dst.Status.Namespaces = in.Status.Namespaces
//nolint:exhaustive
switch in.Status.State {
case TenantStateActive:
dst.Status.State = capsulev1beta1.TenantStateActive
+123 -67
View File
@@ -4,17 +4,20 @@
package v1beta2
import (
"slices"
"context"
"sort"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
func (in *Tenant) GetRoleBindings() []api.AdditionalRoleBindingsSpec {
roleBindings := make([]api.AdditionalRoleBindingsSpec, 0, len(in.Spec.AdditionalRoleBindings))
func (in *Tenant) GetRoleBindings() []rbac.AdditionalRoleBindingsSpec {
roleBindings := make([]rbac.AdditionalRoleBindingsSpec, 0, len(in.Spec.AdditionalRoleBindings))
for _, owner := range in.Status.Owners {
roleBindings = append(roleBindings, owner.ToAdditionalRolebindings()...)
@@ -25,6 +28,16 @@ func (in *Tenant) GetRoleBindings() []api.AdditionalRoleBindingsSpec {
return roleBindings
}
func (in *Tenant) GetPromotionRoleBindings() []rbac.AdditionalRoleBindingsWithNamespaceSpec {
roleBindings := make([]rbac.AdditionalRoleBindingsWithNamespaceSpec, 0, len(in.Status.Promotions))
for _, promotion := range in.Status.Promotions {
roleBindings = append(roleBindings, promotion.ToAdditionalRolebindings()...)
}
return roleBindings
}
func (in *Tenant) IsFull() bool {
// we don't have limits on assigned Namespaces
if in.Spec.NamespaceOptions == nil || in.Spec.NamespaceOptions.Quota == nil {
@@ -35,12 +48,10 @@ func (in *Tenant) IsFull() bool {
}
func (in *Tenant) AssignNamespaces(namespaces []corev1.Namespace) {
var l []string
l := make([]string, 0, len(namespaces))
for _, ns := range namespaces {
if ns.Status.Phase == corev1.NamespaceActive {
l = append(l, ns.GetName())
}
l = append(l, ns.GetName())
}
sort.Strings(l)
@@ -49,14 +60,44 @@ func (in *Tenant) AssignNamespaces(namespaces []corev1.Namespace) {
in.Status.Size = uint(len(l))
}
func (in *Tenant) GetOwnerProxySettings(name string, kind api.OwnerKind) []api.ProxySettings {
func (in *Tenant) GetNamespaces() (res []string) {
return in.Status.Namespaces
}
// Fetch all namespaces defined in the status.
func (in *Tenant) GetNamespaceObjects(ctx context.Context, c client.Reader) (namespaces []corev1.Namespace, err error) {
nsList := &corev1.NamespaceList{}
if len(in.Status.Namespaces) == 0 {
return nsList.Items, nil
}
req, err := labels.NewRequirement(
corev1.LabelMetadataName,
selection.In,
in.Status.Namespaces,
)
if err != nil {
return nil, err
}
selector := labels.NewSelector().Add(*req)
if err := c.List(ctx, nsList, client.MatchingLabelsSelector{Selector: selector}); err != nil {
return nil, err
}
return nsList.Items, nil
}
func (in *Tenant) GetOwnerProxySettings(name string, kind rbac.OwnerKind) []rbac.ProxySettings {
return in.Spec.Owners.FindOwner(name, kind).ProxyOperations
}
// GetClusterRolePermissions returns a map where the clusterRole is the key
// and the value is a list of permission subjects (kind and name) that reference that role.
// These mappings are gathered from the owners and additionalRolebindings spec.
func (in *Tenant) GetSubjectsByClusterRoles(ignoreOwnerKind []api.OwnerKind) (rolePerms map[string][]rbacv1.Subject) {
func (in *Tenant) GetSubjectsByClusterRoles(ignoreOwnerKind []rbac.OwnerKind) (rolePerms map[string][]rbacv1.Subject) {
rolePerms = make(map[string][]rbacv1.Subject)
// Helper to add permissions for a given clusterRole
@@ -80,7 +121,7 @@ func (in *Tenant) GetSubjectsByClusterRoles(ignoreOwnerKind []api.OwnerKind) (ro
}
// Process owners
for _, owner := range in.Spec.Owners {
for _, owner := range in.Status.Owners {
if !isIgnoredKind(owner.Kind.String()) {
for _, clusterRole := range owner.ClusterRoles {
perm := rbacv1.Subject{
@@ -108,72 +149,87 @@ func (in *Tenant) GetSubjectsByClusterRoles(ignoreOwnerKind []api.OwnerKind) (ro
return rolePerms
}
// Get the permissions for a tenant ordered by groups and users.
func (in *Tenant) GetClusterRolesBySubject(ignoreOwnerKind []api.OwnerKind) (maps map[string]map[string]api.TenantSubjectRoles) {
maps = make(map[string]map[string]api.TenantSubjectRoles)
func (in *Tenant) GetClusterRolesBySubject(ignoreOwnerKind []rbac.OwnerKind) []rbac.SubjectRoles {
ignore := make(map[string]struct{}, len(ignoreOwnerKind))
for _, k := range ignoreOwnerKind {
ignore[k.String()] = struct{}{}
}
// Initialize a nested map for kind ("User", "Group") and name
initNestedMap := func(kind string) {
if _, exists := maps[kind]; !exists {
maps[kind] = make(map[string]api.TenantSubjectRoles)
roleSet := map[string]map[string]map[string]struct{}{}
ensure := func(kind, name string) map[string]struct{} {
km, ok := roleSet[kind]
if !ok {
km = map[string]map[string]struct{}{}
roleSet[kind] = km
}
ns, ok := km[name]
if !ok {
ns = map[string]struct{}{}
km[name] = ns
}
return ns
}
for _, owner := range in.Status.Owners {
kind := owner.Kind.String()
if _, skip := ignore[kind]; skip {
continue
}
s := ensure(kind, owner.Name)
for _, r := range owner.ClusterRoles {
s[r] = struct{}{}
}
}
// Helper to check if a kind is in the ignoreOwnerKind list
isIgnoredKind := func(kind string) bool {
for _, ignored := range ignoreOwnerKind {
if kind == ignored.String() {
return true
for _, rb := range in.Spec.AdditionalRoleBindings {
for _, subj := range rb.Subjects {
if _, skip := ignore[subj.Kind]; skip {
continue
}
}
return false
s := ensure(subj.Kind, subj.Name)
s[rb.ClusterRoleName] = struct{}{}
}
}
// Process owners
for _, owner := range in.Spec.Owners {
if !isIgnoredKind(owner.Kind.String()) {
initNestedMap(owner.Kind.String())
// Flatten deterministically: sort kinds, names, roles
kinds := make([]string, 0, len(roleSet))
for k := range roleSet {
kinds = append(kinds, k)
}
if perm, exists := maps[owner.Kind.String()][owner.Name]; exists {
// If the permission entry already exists, append cluster roles
perm.ClusterRoles = append(perm.ClusterRoles, owner.ClusterRoles...)
maps[owner.Kind.String()][owner.Name] = perm
} else {
// Create a new permission entry
maps[owner.Kind.String()][owner.Name] = api.TenantSubjectRoles{
ClusterRoles: owner.ClusterRoles,
}
sort.Strings(kinds)
totalSubjects := 0
for _, byName := range roleSet {
totalSubjects += len(byName)
}
out := make([]rbac.SubjectRoles, 0, totalSubjects)
for _, kind := range kinds {
names := make([]string, 0, len(roleSet[kind]))
for n := range roleSet[kind] {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
roles := make([]string, 0, len(roleSet[kind][name]))
for r := range roleSet[kind][name] {
roles = append(roles, r)
}
sort.Strings(roles)
out = append(out, rbac.SubjectRoles{Kind: kind, Name: name, Roles: roles})
}
}
// Process additional role bindings
for _, role := range in.Spec.AdditionalRoleBindings {
for _, subject := range role.Subjects {
if !isIgnoredKind(subject.Kind) {
initNestedMap(subject.Kind)
if perm, exists := maps[subject.Kind][subject.Name]; exists {
// If the permission entry already exists, append cluster roles
perm.ClusterRoles = append(perm.ClusterRoles, role.ClusterRoleName)
maps[subject.Kind][subject.Name] = perm
} else {
// Create a new permission entry
maps[subject.Kind][subject.Name] = api.TenantSubjectRoles{
ClusterRoles: []string{role.ClusterRoleName},
}
}
}
}
}
// Remove duplicates from cluster roles in both maps
for kind, nameMap := range maps {
for name, perm := range nameMap {
perm.ClusterRoles = slices.Compact(perm.ClusterRoles)
maps[kind][name] = perm
}
}
return maps
return out
}
+153 -119
View File
@@ -1,83 +1,78 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
package v1beta2_test
import (
"reflect"
"testing"
"github.com/projectcapsule/capsule/pkg/api"
rbacv1 "k8s.io/api/rbac/v1"
"github.com/projectcapsule/capsule/api/v1beta2"
capsulerbac "github.com/projectcapsule/capsule/pkg/api/rbac"
)
var tenant = &Tenant{
Spec: TenantSpec{
Owners: []api.OwnerSpec{
{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Kind: "User",
func testTenant() *v1beta2.Tenant {
return &v1beta2.Tenant{
Spec: v1beta2.TenantSpec{
AdditionalRoleBindings: []capsulerbac.AdditionalRoleBindingsSpec{
{
ClusterRoleName: "developer",
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "user2"},
{Kind: "Group", Name: "group1"},
},
},
{
ClusterRoleName: "cluster-admin",
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "user3"},
{Kind: "Group", Name: "group1"},
},
},
{
ClusterRoleName: "deployer",
Subjects: []rbacv1.Subject{
{Kind: "ServiceAccount", Name: "system:serviceaccount:argocd:argo-operator"},
},
},
},
},
Status: v1beta2.TenantStatus{
Owners: capsulerbac.OwnerStatusListSpec{
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.UserOwner,
Name: "user1",
},
ClusterRoles: []string{"cluster-admin", "read-only"},
},
},
{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Kind: "Group",
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.GroupOwner,
Name: "group1",
},
ClusterRoles: []string{"edit"},
},
},
{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Kind: api.ServiceAccountOwner,
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.ServiceAccountOwner,
Name: "service",
},
ClusterRoles: []string{"read-only"},
},
},
},
AdditionalRoleBindings: []api.AdditionalRoleBindingsSpec{
{
ClusterRoleName: "developer",
Subjects: []rbacv1.Subject{
{Kind: "User", Name: "user2"},
{Kind: "Group", Name: "group1"},
},
},
{
ClusterRoleName: "cluster-admin",
Subjects: []rbacv1.Subject{
{
Kind: "User",
Name: "user3",
},
{
Kind: "Group",
Name: "group1",
},
},
},
{
ClusterRoleName: "deployer",
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: "system:serviceaccount:argocd:argo-operator",
},
},
},
},
},
}
}
// TestGetClusterRolePermissions tests the GetClusterRolePermissions function
func TestGetSubjectsByClusterRoles(t *testing.T) {
t.Parallel()
tenant := testTenant()
expected := map[string][]rbacv1.Subject{
"cluster-admin": {
{Kind: "User", Name: "user1"},
@@ -100,15 +95,11 @@ func TestGetSubjectsByClusterRoles(t *testing.T) {
},
}
// Call the function to test
permissions := tenant.GetSubjectsByClusterRoles(nil)
if !reflect.DeepEqual(permissions, expected) {
t.Errorf("Expected %v, but got %v", expected, permissions)
got := tenant.GetSubjectsByClusterRoles(nil)
if !reflect.DeepEqual(got, expected) {
t.Fatalf("expected %#v\n got %#v", expected, got)
}
// Ignore SubjectTypes (Ignores ServiceAccounts)
ignored := tenant.GetSubjectsByClusterRoles([]api.OwnerKind{"ServiceAccount"})
expectedIgnored := map[string][]rbacv1.Subject{
"cluster-admin": {
{Kind: "User", Name: "user1"},
@@ -127,78 +118,121 @@ func TestGetSubjectsByClusterRoles(t *testing.T) {
},
}
if !reflect.DeepEqual(ignored, expectedIgnored) {
t.Errorf("Expected %v, but got %v", expectedIgnored, ignored)
gotIgnored := tenant.GetSubjectsByClusterRoles([]capsulerbac.OwnerKind{capsulerbac.ServiceAccountOwner})
if !reflect.DeepEqual(gotIgnored, expectedIgnored) {
t.Fatalf("expected %#v\n got %#v", expectedIgnored, gotIgnored)
}
}
func TestGetClusterRolesBySubject(t *testing.T) {
func TestGetClusterRolesBySubjectSorted(t *testing.T) {
t.Parallel()
expected := map[string]map[string]api.TenantSubjectRoles{
"User": {
"user1": {
ClusterRoles: []string{"cluster-admin", "read-only"},
},
"user2": {
ClusterRoles: []string{"developer"},
},
"user3": {
ClusterRoles: []string{"cluster-admin"},
tenant := &v1beta2.Tenant{
Spec: v1beta2.TenantSpec{
AdditionalRoleBindings: []capsulerbac.AdditionalRoleBindingsSpec{
{
ClusterRoleName: "deployer",
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: "system:serviceaccount:argocd:argo-operator",
},
},
},
{
ClusterRoleName: "developer",
Subjects: []rbacv1.Subject{
{
Kind: "Group",
Name: "group1",
},
},
},
},
},
"Group": {
"group1": {
ClusterRoles: []string{"edit", "developer", "cluster-admin"},
},
},
"ServiceAccount": {
"service": {
ClusterRoles: []string{"read-only"},
},
"system:serviceaccount:argocd:argo-operator": {
ClusterRoles: []string{"deployer"},
Status: v1beta2.TenantStatus{
Owners: capsulerbac.OwnerStatusListSpec{
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.UserOwner,
Name: "user1",
},
ClusterRoles: []string{"cluster-admin", "read-only"},
},
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.UserOwner,
Name: "user2",
},
ClusterRoles: []string{"developer"},
},
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.UserOwner,
Name: "user3",
},
ClusterRoles: []string{"cluster-admin"},
},
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.GroupOwner,
Name: "group1",
},
ClusterRoles: []string{"edit", "developer", "cluster-admin"},
},
{
UserSpec: capsulerbac.UserSpec{
Kind: capsulerbac.ServiceAccountOwner,
Name: "service",
},
ClusterRoles: []string{"read-only"},
},
},
},
}
permissions := tenant.GetClusterRolesBySubject(nil)
if !reflect.DeepEqual(permissions, expected) {
t.Errorf("Expected %v, but got %v", expected, permissions)
expected := []capsulerbac.SubjectRoles{
{Kind: "Group", Name: "group1", Roles: []string{"cluster-admin", "developer", "edit"}},
{Kind: "ServiceAccount", Name: "service", Roles: []string{"read-only"}},
{Kind: "ServiceAccount", Name: "system:serviceaccount:argocd:argo-operator", Roles: []string{"deployer"}},
{Kind: "User", Name: "user1", Roles: []string{"cluster-admin", "read-only"}},
{Kind: "User", Name: "user2", Roles: []string{"developer"}},
{Kind: "User", Name: "user3", Roles: []string{"cluster-admin"}},
}
delete(expected, "ServiceAccount")
ignored := tenant.GetClusterRolesBySubject([]api.OwnerKind{"ServiceAccount"})
t.Run("includes all kinds and is deterministic, deduped and sorted", func(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(ignored, expected) {
t.Errorf("Expected %v, but got %v", expected, ignored)
}
}
// Helper function to run tests
func TestMain(t *testing.M) {
t.Run()
}
// permissionsEqual checks the equality of two TenantPermission structs.
func permissionsEqual(a, b api.TenantSubjectRoles) bool {
if a.Kind != b.Kind {
return false
}
if len(a.ClusterRoles) != len(b.ClusterRoles) {
return false
}
// Create a map to count occurrences of cluster roles
counts := make(map[string]int)
for _, role := range a.ClusterRoles {
counts[role]++
}
for _, role := range b.ClusterRoles {
counts[role]--
if counts[role] < 0 {
return false // More occurrences in b than in a
got := tenant.GetClusterRolesBySubject(nil)
if !reflect.DeepEqual(got, expected) {
t.Fatalf("expected %#v\n got %#v", expected, got)
}
}
return true
})
t.Run("ignores ServiceAccount kind", func(t *testing.T) {
t.Parallel()
got := tenant.GetClusterRolesBySubject([]capsulerbac.OwnerKind{capsulerbac.ServiceAccountOwner})
expectedIgnored := []capsulerbac.SubjectRoles{
{Kind: "Group", Name: "group1", Roles: []string{"cluster-admin", "developer", "edit"}},
{Kind: "User", Name: "user1", Roles: []string{"cluster-admin", "read-only"}},
{Kind: "User", Name: "user2", Roles: []string{"developer"}},
{Kind: "User", Name: "user3", Roles: []string{"cluster-admin"}},
}
if !reflect.DeepEqual(got, expectedIgnored) {
t.Fatalf("expected %#v\n got %#v", expectedIgnored, got)
}
})
t.Run("empty tenant yields empty slice", func(t *testing.T) {
t.Parallel()
empty := &v1beta2.Tenant{}
got := empty.GetClusterRolesBySubject(nil)
if len(got) != 0 {
t.Fatalf("expected empty, got %#v", got)
}
})
}
+19 -5
View File
@@ -8,14 +8,16 @@ import (
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
// +kubebuilder:validation:Enum=Cordoned;Active
// +kubebuilder:validation:Enum=Cordoned;Active;Terminating
type tenantState string
const (
TenantStateActive tenantState = "Active"
TenantStateCordoned tenantState = "Cordoned"
TenantStateActive tenantState = "Active"
TenantStateCordoned tenantState = "Cordoned"
TenantStateTerminating tenantState = "Terminating"
)
// Returns the observed state of the Tenant.
@@ -24,9 +26,11 @@ type TenantStatus struct {
TenantAvailableStatus `json:",inline"`
// Collected owners for this tenant
Owners api.OwnerStatusListSpec `json:"owners,omitempty"`
Owners rbac.OwnerStatusListSpec `json:"owners,omitempty"`
// Promoted ServiceAccounts across the Tenant
Promotions rbac.PromotionStatusListSpec `json:"promotions,omitempty"`
// +kubebuilder:default=Active
// The operational state of the Tenant. Possible values are "Active", "Cordoned".
// The operational state of the Tenant. Possible values are "Active", "Cordoned" or "Terminating".
State tenantState `json:"state"`
// How many namespaces are assigned to the Tenant.
Size uint `json:"size"`
@@ -52,6 +56,16 @@ type TenantStatusNamespaceItem struct {
Enforce TenantStatusNamespaceEnforcement `json:"enforce,omitzero"`
}
// RuleStatus contains the accumulated rules applying to namespace it's deployed in.
// +kubebuilder:object:generate=true
type TenantStatusRuleStatusItem struct {
// Promotions originating from this namespace
Promotions rbac.OwnerStatusListSpec `json:"promotions,omitempty"`
// Target Namespaces for this rule
TargetNamespaces []meta.RFC1123SubdomainName `json:"namespaces,omitempty"`
}
type TenantStatusNamespaceEnforcement struct {
// Registries which are allowed within this namespace
Registries []api.OCIRegistry `json:"registry,omitempty"`
+15 -8
View File
@@ -6,16 +6,23 @@ package v1beta2
import (
"context"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
)
// TenantSpec defines the desired state of Tenant.
type TenantSpec struct {
// Specify additional data relating to the tenant.
// Mainly useable in templating and more accessible than labels/annotations.
// +optional
Data apiextensionsv1.JSON `json:"data"`
// Specify Permissions for the Tenant.
// +optional
Permissions Permissions `json:"permissions,omitzero"`
@@ -25,11 +32,11 @@ type TenantSpec struct {
//
// Read More: https://projectcapsule.dev/docs/tenants/rules/
//+optional
Rules []*NamespaceRule `json:"rules,omitzero"`
Rules []*api.NamespaceRuleBodyTenant `json:"rules,omitzero"`
// Specifies the owners of the Tenant.
// Optional
Owners api.OwnerListSpec `json:"owners,omitempty"`
Owners rbac.OwnerListSpec `json:"owners,omitempty"`
// Specifies options for the Namespaces, such as additional metadata or maximum number of namespaces allowed for that Tenant. Once the namespace quota assigned to the Tenant has been reached, the Tenant owner cannot create further namespaces. Optional.
NamespaceOptions *NamespaceOptions `json:"namespaceOptions,omitempty"`
// Specifies options for the Service, such as additional metadata or block of certain type of Services. Optional.
@@ -50,7 +57,7 @@ type TenantSpec struct {
// +optional
ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitzero"`
// Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional.
AdditionalRoleBindings []api.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"`
AdditionalRoleBindings []rbac.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"`
// Specifies the allowed RuntimeClasses assigned to the Tenant.
// Capsule assures that all Pods resources created in the Tenant can use only one of the allowed RuntimeClasses.
// Optional.
@@ -108,11 +115,15 @@ type Permissions struct {
// The elements are OR operations and independent. You can see the resulting Tenant Owners
// in the Status.Owners specification of the Tenant.
MatchOwners []*metav1.LabelSelector `json:"matchOwners,omitempty"`
// ClusterRoles granted to the promoted ServiceAccounts across the Tenant
//+kubebuilder:default:=true
AllowOwnerPromotion bool `json:"allowOwnerPromotion,omitempty"`
}
func (p *Permissions) ListMatchingOwners(
ctx context.Context,
c client.Client,
c client.Reader,
tnt string,
opts ...client.ListOption,
) ([]*TenantOwner, error) {
@@ -150,10 +161,6 @@ type Tenant struct {
Status TenantStatus `json:"status,omitzero"`
}
func (in *Tenant) GetNamespaces() (res []string) {
return in.Status.Namespaces
}
// +kubebuilder:object:root=true
// TenantList contains a list of Tenant.
+3 -3
View File
@@ -6,13 +6,13 @@ package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
// TenantOwnerSpec defines the desired state of TenantOwner.
type TenantOwnerSpec struct {
// Subject
api.CoreOwnerSpec `json:",inline"`
rbac.CoreOwnerSpec `json:",inline"`
// Adds the given subject as capsule user. When enabled this subject does not have to be
// mentioned in the CapsuleConfiguration as Capsule User. In almost all scenarios Tenant Owners
@@ -26,7 +26,7 @@ type TenantOwnerStatus struct{}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:resource:scope=Cluster,shortName=to
// TenantOwner is the Schema for the tenantowners API.
type TenantOwner struct {
+22 -18
View File
@@ -5,13 +5,25 @@ package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// GlobalTenantResourceSpec defines the desired state of GlobalTenantResource.
type GlobalTenantResourceSpec struct {
TenantResourceSpec `json:",inline"`
TenantResourceCommonSpec `json:",inline"`
// Local ServiceAccount which will perform all the actions defined in the TenantResource
// You must provide permissions accordingly to that ServiceAccount
//+optional
ServiceAccount *meta.NamespacedRFC1123ObjectReferenceWithNamespace `json:"serviceAccount,omitzero"`
// Resource Scope, Can either be
// - Tenant: Create Resources for each tenant in selected Tenants
// - Namespace: Create Resources for each namespace in selected Tenants
// +kubebuilder:default:=Namespace
// +optional
Scope api.ResourceScope `json:"scope"`
// Defines the Tenant selector used target the tenants on which resources must be propagated.
// +optional
TenantSelector metav1.LabelSelector `json:"tenantSelector,omitzero"`
@@ -19,27 +31,19 @@ type GlobalTenantResourceSpec struct {
// GlobalTenantResourceStatus defines the observed state of GlobalTenantResource.
type GlobalTenantResourceStatus struct {
TenantResourceCommonStatus `json:",inline"`
// List of Tenants addressed by the GlobalTenantResource.
SelectedTenants []string `json:"selectedTenants"`
// List of the replicated resources for the given TenantResource.
ProcessedItems ProcessedItems `json:"processedItems,omitzero"`
}
type ProcessedItems []ObjectReferenceStatus
func (p *ProcessedItems) AsSet() sets.Set[string] {
set := sets.New[string]()
for _, i := range *p {
set.Insert(i.String())
}
return set
SelectedTenants []string `json:"selectedTenants,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster
// +kubebuilder:resource:scope=Cluster,shortName=gtr
// +kubebuilder:printcolumn:name="Items",type="integer",JSONPath=".status.size",description="The total amount of items being replicated"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
// GlobalTenantResource allows to propagate resource replications to a specific subset of Tenant resources.
type GlobalTenantResource struct {
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package v1beta2
import (
"sort"
)
func (in *GlobalTenantResource) AssignTenants(tnts []Tenant) {
l := make([]string, 0, len(tnts))
for _, tnt := range tnts {
l = append(l, tnt.GetName())
}
sort.Strings(l)
in.Status.SelectedTenants = l
}
+11 -32
View File
@@ -5,52 +5,31 @@ package v1beta2
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
)
// TenantResourceSpec defines the desired state of TenantResource.
type TenantResourceSpec struct {
// Define the period of time upon a second reconciliation must be invoked.
// Keep in mind that any change to the manifests will trigger a new reconciliation.
// +kubebuilder:default="60s"
ResyncPeriod metav1.Duration `json:"resyncPeriod"`
// When the replicated resource manifest is deleted, all the objects replicated so far will be automatically deleted.
// Disable this to keep replicated resources although the deletion of the replication manifest.
// +kubebuilder:default=true
PruningOnDelete *bool `json:"pruningOnDelete,omitempty"`
// Defines the rules to select targeting Namespace, along with the objects that must be replicated.
Resources []ResourceSpec `json:"resources"`
}
TenantResourceCommonSpec `json:",inline"`
type ResourceSpec struct {
// Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated.
// In case of nil value, all the Tenant Namespaces are targeted.
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
// List of the resources already existing in other Namespaces that must be replicated.
NamespacedItems []ObjectReference `json:"namespacedItems,omitempty"`
// List of raw resources that must be replicated.
RawItems []RawExtension `json:"rawItems,omitempty"`
// Besides the Capsule metadata required by TenantResource controller, defines additional metadata that must be
// added to the replicated resources.
AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"`
}
// +kubebuilder:validation:XEmbeddedResource
// +kubebuilder:validation:XPreserveUnknownFields
type RawExtension struct {
runtime.RawExtension `json:",inline"`
// Local ServiceAccount which will perform all the actions defined in the TenantResource
// You must provide permissions accordingly to that ServiceAccount
//+optional
ServiceAccount *meta.LocalRFC1123ObjectReference `json:"serviceAccount,omitzero"`
}
// TenantResourceStatus defines the observed state of TenantResource.
type TenantResourceStatus struct {
// List of the replicated resources for the given TenantResource.
ProcessedItems ProcessedItems `json:"processedItems"`
TenantResourceCommonStatus `json:",inline"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Items",type="integer",JSONPath=".status.size",description="The total amount of items being replicated"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
// TenantResource allows a Tenant Owner, if enabled with proper RBAC, to propagate resources in its Namespace.
// The object must be deployed in a Tenant Namespace, and cannot reference object living in non-Tenant namespaces.
+81 -54
View File
@@ -4,71 +4,98 @@
package v1beta2
import (
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
tpl "github.com/projectcapsule/capsule/pkg/template"
)
type ObjectReferenceAbstract struct {
// Kind of the referent.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
Kind string `json:"kind"`
// Namespace of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
Namespace string `json:"namespace"`
// API version of the referent.
APIVersion string `json:"apiVersion,omitempty"`
type TenantResourceCommonStatus struct {
// Condition of the GlobalTenantResource.
Conditions meta.ConditionList `json:"conditions,omitempty"`
// List of the replicated resources for the given TenantResource.
//+optional
ProcessedItems meta.ProcessedItems `json:"processedItems,omitzero"`
// How many items are being replicated by the TenantResource.
Size uint `json:"size"`
// Serviceaccount used for impersonation
//+optional
ServiceAccount *meta.NamespacedRFC1123ObjectReferenceWithNamespace `json:"serviceAccount,omitzero"`
}
type ObjectReferenceStatus struct {
ObjectReferenceAbstract `json:",inline"`
// Name of the referent.
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
Name string `json:"name"`
func (s *TenantResourceCommonStatus) UpdateStats() {
s.Size = uint(len(s.ProcessedItems))
}
type ObjectReference struct {
ObjectReferenceAbstract `json:",inline"`
// Label selector used to select the given resources in the given Namespace.
Selector metav1.LabelSelector `json:"selector"`
type TenantResourceCommonSpec struct {
// Provide additional settings
// +kubebuilder:default={}
Settings TenantResourceCommonSpecSettings `json:"settings,omitzero"`
// DependsOn may contain a meta.NamespacedObjectReference slice
// with references to TenantResource resources that must be ready before this
// TenantResource can be reconciled.
// +optional
DependsOn []meta.LocalRFC1123ObjectReference `json:"dependsOn,omitempty"`
// Define the period of time upon a second reconciliation must be invoked.
// Keep in mind that any change to the manifests will trigger a new reconciliation.
// +kubebuilder:default="60s"
ResyncPeriod metav1.Duration `json:"resyncPeriod"`
// When the replicated resource manifest is deleted, all the objects replicated so far will be automatically deleted.
// Disable this to keep replicated resources although the deletion of the replication manifest.
// +kubebuilder:default=true
PruningOnDelete *bool `json:"pruningOnDelete,omitempty"`
// When cordoning a replication it will no longer execute any applies or deletions (paused).
// This is useful for maintenances
// +kubebuilder:default=false
Cordoned *bool `json:"cordoned,omitempty"`
// Defines the rules to select targeting Namespace, along with the objects that must be replicated.
Resources []ResourceSpec `json:"resources"`
}
func (in *ObjectReferenceStatus) String() string {
return fmt.Sprintf("Kind=%s,APIVersion=%s,Namespace=%s,Name=%s", in.Kind, in.APIVersion, in.Namespace, in.Name)
type TenantResourceCommonSpecSettings struct {
// Enabling this allows TenanResources to interact with objects which were not created by a TenantResource. In this case on prune no deletion of the entire object is made.
// +kubebuilder:default=false
Adopt *bool `json:"adopt,omitempty"`
// Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field.
// You may create collisions with this.
// +kubebuilder:default=false
Force *bool `json:"force,omitempty"`
}
func (in *ObjectReferenceStatus) ParseFromString(value string) error {
rawParts := strings.Split(value, ",")
type ResourceSpec struct {
// Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated.
// In case of nil value, all the Tenant Namespaces are targeted.
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
// List of the resources already existing in other Namespaces that must be replicated.
NamespacedItems []tpl.ResourceReference `json:"namespacedItems,omitempty"`
// List of raw resources that must be replicated.
RawItems []RawExtension `json:"rawItems,omitempty"`
// Besides the Capsule metadata required by TenantResource controller, defines additional metadata that must be
// added to the replicated resources.
AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"`
// Templates for advanced use cases
Generators []TemplateItemSpec `json:"generators,omitempty"`
// Provide additional template context, which can be used throughout all
// the declared items for the replication
// +optional
Context *tpl.TemplateContext `json:"context,omitempty"`
}
if len(rawParts) != 4 {
return fmt.Errorf("unexpected raw parts")
}
// +kubebuilder:validation:XPreserveUnknownFields
type RawExtension struct {
runtime.RawExtension `json:",inline"`
}
for _, i := range rawParts {
parts := strings.Split(i, "=")
if len(parts) != 2 {
return fmt.Errorf("unrecognized separator")
}
k, v := parts[0], parts[1]
switch k {
case "Kind":
in.Kind = v
case "APIVersion":
in.APIVersion = v
case "Namespace":
in.Namespace = v
case "Name":
in.Name = v
default:
return fmt.Errorf("unrecognized marker: %s", k)
}
}
return nil
type TemplateItemSpec struct {
// Template contains any amount of yaml which is applied to Kubernetes.
// This can be a single resource or multiple resources
Template string `json:"template,omitempty"`
// Missing Key Option for templating
// +kubebuilder:default=zero
MissingKey tpl.MissingKeyOption `json:"missingKey,omitempty"`
}
File diff suppressed because it is too large Load Diff