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

@@ -12,6 +12,7 @@ type NamespaceOptions struct {
// Specifies the 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.
Quota *int32 `json:"quota,omitempty"`
// Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional.
// Deprecated: Use additionalMetadataList instead
AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"`
// Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant via a list. Optional.
AdditionalMetadataList []api.AdditionalMetadataSelectorSpec `json:"additionalMetadataList,omitempty"`
@@ -19,4 +20,7 @@ type NamespaceOptions struct {
ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels,omitempty"`
// Define the annotations that a Tenant Owner cannot set for their Namespace resources.
ForbiddenAnnotations api.ForbiddenListSpec `json:"forbiddenAnnotations,omitempty"`
// If enabled only metadata from additionalMetadata is reconciled to the namespaces.
//+kubebuilder:default:=false
ManagedMetadataOnly bool `json:"managedMetadataOnly,omitempty"`
}

View File

@@ -3,6 +3,12 @@
package v1beta2
import (
k8stypes "k8s.io/apimachinery/pkg/types"
"github.com/projectcapsule/capsule/pkg/meta"
)
// +kubebuilder:validation:Enum=Cordoned;Active
type tenantState string
@@ -18,6 +24,68 @@ type TenantStatus struct {
State tenantState `json:"state"`
// How many namespaces are assigned to the Tenant.
Size uint `json:"size"`
// List of namespaces assigned to the Tenant.
// List of namespaces assigned to the Tenant. (Deprecated)
Namespaces []string `json:"namespaces,omitempty"`
// Tracks state for the namespaces associated with this tenant
Spaces []*TenantStatusNamespaceItem `json:"spaces,omitempty"`
// Tenant Condition
Conditions meta.ConditionList `json:"conditions"`
}
type TenantStatusNamespaceItem struct {
// Conditions
Conditions meta.ConditionList `json:"conditions"`
// Namespace Name
Name string `json:"name"`
// Namespace UID
UID k8stypes.UID `json:"uid,omitempty"`
// Managed Metadata
Metadata *TenantStatusNamespaceMetadata `json:"metadata,omitempty"`
}
type TenantStatusNamespaceMetadata struct {
// Managed Labels
Labels map[string]string `json:"labels,omitempty"`
// Managed Annotations
Annotations map[string]string `json:"annotations,omitempty"`
}
func (ms *TenantStatus) GetInstance(stat *TenantStatusNamespaceItem) *TenantStatusNamespaceItem {
for _, source := range ms.Spaces {
if ms.instancequal(source, stat) {
return source
}
}
return nil
}
func (ms *TenantStatus) UpdateInstance(stat *TenantStatusNamespaceItem) {
// Check if the tenant is already present in the status
for i, source := range ms.Spaces {
if ms.instancequal(source, stat) {
ms.Spaces[i] = stat
return
}
}
ms.Spaces = append(ms.Spaces, stat)
}
func (ms *TenantStatus) RemoveInstance(stat *TenantStatusNamespaceItem) {
// Filter out the datasource with given UID
filter := []*TenantStatusNamespaceItem{}
for _, source := range ms.Spaces {
if !ms.instancequal(source, stat) {
filter = append(filter, source)
}
}
ms.Spaces = filter
}
func (ms *TenantStatus) instancequal(a, b *TenantStatusNamespaceItem) bool {
return a.Name == b.Name
}

View File

@@ -32,8 +32,10 @@ type TenantSpec struct {
// Specifies the label to control the placement of pods on a given pool of worker nodes. All namespaces created within the Tenant will have the node selector annotation. This annotation tells the Kubernetes scheduler to place pods on the nodes having the selector label. Optional.
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
// Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional.
// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
NetworkPolicies api.NetworkPolicySpec `json:"networkPolicies,omitempty"`
// Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional.
// Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
LimitRanges api.LimitRangesSpec `json:"limitRanges,omitempty"`
// Specifies a list of ResourceQuota resources assigned to the Tenant. The assigned values are inherited by any namespace created in the Tenant. The Capsule operator aggregates ResourceQuota at Tenant level, so that the hard quota is never crossed for the given Tenant. This permits the Tenant owner to consume resources in the Tenant regardless of the namespace. Optional.
ResourceQuota api.ResourceQuotaSpec `json:"resourceQuotas,omitempty"`
@@ -74,12 +76,13 @@ type TenantSpec struct {
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=tnt
// +kubebuilder:printcolumn:name="State",type="string",JSONPath=".status.state",description="The actual state of the Tenant"
// +kubebuilder:printcolumn:name="State",type="string",JSONPath=".status.conditions[?(@.type==\"Cordoned\")].reason",description="The actual state of the Tenant"
// +kubebuilder:printcolumn:name="Namespace quota",type="integer",JSONPath=".spec.namespaceOptions.quota",description="The max amount of Namespaces can be created"
// +kubebuilder:printcolumn:name="Namespace count",type="integer",JSONPath=".status.size",description="The total amount of Namespaces in use"
// +kubebuilder:printcolumn:name="Node selector",type="string",JSONPath=".spec.nodeSelector",description="Node Selector applied to Pods"
// +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"
// Tenant is the Schema for the tenants API.
type Tenant struct {
metav1.TypeMeta `json:",inline"`

View File

@@ -9,6 +9,7 @@ package v1beta2
import (
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/meta"
corev1 "k8s.io/api/core/v1"
"k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -1215,6 +1216,24 @@ func (in *TenantStatus) DeepCopyInto(out *TenantStatus) {
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Spaces != nil {
in, out := &in.Spaces, &out.Spaces
*out = make([]*TenantStatusNamespaceItem, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(TenantStatusNamespaceItem)
(*in).DeepCopyInto(*out)
}
}
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make(meta.ConditionList, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantStatus.
@@ -1226,3 +1245,59 @@ func (in *TenantStatus) DeepCopy() *TenantStatus {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TenantStatusNamespaceItem) DeepCopyInto(out *TenantStatusNamespaceItem) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make(meta.ConditionList, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Metadata != nil {
in, out := &in.Metadata, &out.Metadata
*out = new(TenantStatusNamespaceMetadata)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantStatusNamespaceItem.
func (in *TenantStatusNamespaceItem) DeepCopy() *TenantStatusNamespaceItem {
if in == nil {
return nil
}
out := new(TenantStatusNamespaceItem)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TenantStatusNamespaceMetadata) DeepCopyInto(out *TenantStatusNamespaceMetadata) {
*out = *in
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantStatusNamespaceMetadata.
func (in *TenantStatusNamespaceMetadata) DeepCopy() *TenantStatusNamespaceMetadata {
if in == nil {
return nil
}
out := new(TenantStatusNamespaceMetadata)
in.DeepCopyInto(out)
return out
}

View File

@@ -254,6 +254,7 @@ The following Values have changed key or Value:
| webhooks.hooks.tenants.matchPolicy | string | `"Exact"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) |
| webhooks.hooks.tenants.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) |
| webhooks.hooks.tenants.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) |
| webhooks.hooks.tenants.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) |
| webhooks.mutatingWebhooksTimeoutSeconds | int | `30` | Timeout in seconds for mutating webhooks |
| webhooks.service.caBundle | string | `""` | CABundle for the webhook service |
| webhooks.service.name | string | `""` | Custom service name for the webhook service |

View File

@@ -1041,7 +1041,7 @@ spec:
status: {}
- additionalPrinterColumns:
- description: The actual state of the Tenant
jsonPath: .status.state
jsonPath: .status.conditions[?(@.type=="Cordoned")].reason
name: State
type: string
- description: The max amount of Namespaces can be created
@@ -1056,6 +1056,14 @@ spec:
jsonPath: .spec.nodeSelector
name: Node selector
type: string
- description: Reconcile Status for the tenant
jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
- description: Reconcile Message for the tenant
jsonPath: .status.conditions[?(@.type=="Ready")].message
name: Status
type: string
- description: Age
jsonPath: .metadata.creationTimestamp
name: Age
@@ -1319,9 +1327,9 @@ spec:
type: string
type: object
limitRanges:
description: Specifies the resource min/max usage restrictions to
the Tenant. The assigned values are inherited by any namespace created
in the Tenant. Optional.
description: |-
Specifies the resource min/max usage restrictions to the Tenant. The assigned values are inherited by any namespace created in the Tenant. Optional.
Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
properties:
items:
items:
@@ -1410,8 +1418,9 @@ spec:
the Tenant owner cannot create further namespaces. Optional.
properties:
additionalMetadata:
description: Specifies additional labels and annotations the Capsule
operator places on any Namespace resource in the Tenant. Optional.
description: |-
Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional.
Deprecated: Use additionalMetadataList instead
properties:
annotations:
additionalProperties:
@@ -1509,6 +1518,11 @@ spec:
deniedRegex:
type: string
type: object
managedMetadataOnly:
default: false
description: If enabled only metadata from additionalMetadata
is reconciled to the namespaces.
type: boolean
quota:
description: Specifies the maximum number of namespaces allowed
for that Tenant. Once the namespace quota assigned to the Tenant
@@ -1519,9 +1533,9 @@ spec:
type: integer
type: object
networkPolicies:
description: Specifies the NetworkPolicies assigned to the Tenant.
The assigned NetworkPolicies are inherited by any namespace created
in the Tenant. Optional.
description: |-
Specifies the NetworkPolicies assigned to the Tenant. The assigned NetworkPolicies are inherited by any namespace created in the Tenant. Optional.
Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/)
properties:
items:
items:
@@ -2423,14 +2437,159 @@ spec:
status:
description: Returns the observed state of the Tenant.
properties:
conditions:
description: Tenant Condition
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
namespaces:
description: List of namespaces assigned to the Tenant.
description: List of namespaces assigned to the Tenant. (Deprecated)
items:
type: string
type: array
size:
description: How many namespaces are assigned to the Tenant.
type: integer
spaces:
description: Tracks state for the namespaces associated with this
tenant
items:
properties:
conditions:
description: Conditions
items:
description: Condition contains details for one aspect of
the current state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False,
Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
metadata:
description: Managed Metadata
properties:
annotations:
additionalProperties:
type: string
description: Managed Annotations
type: object
labels:
additionalProperties:
type: string
description: Managed Labels
type: object
type: object
name:
description: Namespace Name
type: string
uid:
description: Namespace UID
type: string
required:
- conditions
- name
type: object
type: array
state:
default: Active
description: The operational state of the Tenant. Possible values
@@ -2440,6 +2599,7 @@ spec:
- Active
type: string
required:
- conditions
- size
- state
type: object

View File

@@ -24,6 +24,7 @@ spec:
ignoreUserWithGroups:
{{- toYaml .Values.manager.options.ignoreUserWithGroups | nindent 4 }}
protectedNamespaceRegex: {{ .Values.manager.options.protectedNamespaceRegex | quote }}
defaultRegistry: {{ .Values.manager.options.defaultRegistry }}
{{- with .Values.manager.options.nodeMetadata }}
nodeMetadata:
{{- toYaml . | nindent 4 }}

View File

@@ -274,4 +274,44 @@ webhooks:
timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- with .Values.webhooks.hooks.tenants }}
{{- if .enabled }}
- name: tenants.projectcapsule.dev
admissionReviewVersions:
- v1
- v1beta1
clientConfig:
{{- include "capsule.webhooks.service" (dict "path" "/tenants/mutating" "ctx" $) | nindent 4 }}
failurePolicy: {{ .failurePolicy }}
matchPolicy: {{ .matchPolicy }}
reinvocationPolicy: {{ .reinvocationPolicy }}
{{- with .namespaceSelector }}
namespaceSelector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .objectSelector }}
objectSelector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .matchConditions }}
matchConditions:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- apiGroups:
- capsule.clastix.io
apiVersions:
- v1beta2
operations:
- CREATE
- UPDATE
- DELETE
resources:
- tenants
scope: 'Cluster'
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -261,6 +261,7 @@ webhooks:
- UPDATE
resources:
- pods
- pods/ephemeralcontainers
scope: Namespaced
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
@@ -382,7 +383,7 @@ webhooks:
- v1
- v1beta1
clientConfig:
{{- include "capsule.webhooks.service" (dict "path" "/tenants" "ctx" $) | nindent 4 }}
{{- include "capsule.webhooks.service" (dict "path" "/tenants/validating" "ctx" $) | nindent 4 }}
failurePolicy: {{ .failurePolicy }}
matchPolicy: {{ .matchPolicy }}
{{- with .namespaceSelector }}
@@ -408,7 +409,7 @@ webhooks:
- DELETE
resources:
- tenants
scope: '*'
scope: 'Cluster'
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
{{- end }}

View File

@@ -1379,6 +1379,10 @@
"objectSelector": {
"description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)",
"type": "object"
},
"reinvocationPolicy": {
"description": "[ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)",
"type": "string"
}
}
}

View File

@@ -572,6 +572,8 @@ webhooks:
namespaceSelector: {}
# -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)
matchConditions: []
# -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy)
reinvocationPolicy: Never
tenantResourceObjects:
# -- Enable the Hook

View File

@@ -57,7 +57,8 @@ import (
"github.com/projectcapsule/capsule/pkg/webhook/route"
"github.com/projectcapsule/capsule/pkg/webhook/service"
"github.com/projectcapsule/capsule/pkg/webhook/serviceaccounts"
"github.com/projectcapsule/capsule/pkg/webhook/tenant"
tenantmutation "github.com/projectcapsule/capsule/pkg/webhook/tenant/mutation"
tenantvalidation "github.com/projectcapsule/capsule/pkg/webhook/tenant/validation"
tntresource "github.com/projectcapsule/capsule/pkg/webhook/tenantresource"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
@@ -227,19 +228,20 @@ func main() {
// webhooks: the order matters, don't change it and just append
webhooksList := append(
make([]webhook.Webhook, 0),
route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(), pod.PriorityClass(), pod.RuntimeClass()),
route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(cfg), pod.PriorityClass(), pod.RuntimeClass()),
route.Namespace(utils.InCapsuleGroups(cfg, namespacevalidation.PatchHandler(cfg), namespacevalidation.QuotaHandler(), namespacevalidation.FreezeHandler(cfg), namespacevalidation.PrefixHandler(cfg), namespacevalidation.UserMetadataHandler())),
route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()),
route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()),
route.Service(service.Handler()),
route.TenantResourceObjects(utils.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())),
route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())),
route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()),
route.Cordoning(tenant.CordoningHandler(cfg)),
route.TenantMutating(tenantmutation.MetaHandler()),
route.TenantValidating(tenantvalidation.NameHandler(), tenantvalidation.RoleBindingRegexHandler(), tenantvalidation.IngressClassRegexHandler(), tenantvalidation.StorageClassRegexHandler(), tenantvalidation.ContainerRegistryRegexHandler(), tenantvalidation.HostnameRegexHandler(), tenantvalidation.FreezedEmitter(), tenantvalidation.ServiceAccountNameHandler(), tenantvalidation.ForbiddenAnnotationsRegexHandler(), tenantvalidation.ProtectedHandler()),
route.Cordoning(tenantvalidation.CordoningHandler(cfg)),
route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
route.ServiceAccounts(serviceaccounts.Handler(cfg)),
route.NamespacePatch(utils.InCapsuleGroups(cfg, namespacemutation.CordoningLabelHandler(cfg), namespacemutation.OwnerReferenceHandler(cfg), namespacemutation.MetadataHandler(cfg))),
route.CustomResources(tenant.ResourceCounterHandler(manager.GetClient())),
route.CustomResources(tenantvalidation.ResourceCounterHandler(manager.GetClient())),
route.Gateway(gateway.Class(cfg)),
route.Defaults(defaults.Handler(cfg, kubeVersion)),
route.ResourcePoolMutation((resourcepool.PoolMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool")))),

View File

@@ -5,12 +5,15 @@ package tenant
import (
"context"
"fmt"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/record"
"k8s.io/client-go/util/retry"
@@ -20,6 +23,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
"github.com/projectcapsule/capsule/pkg/metrics"
)
@@ -52,7 +56,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
r.Log.Info("Request object not found, could have been deleted after reconcile request")
// If tenant was deleted or cannot be found, clean up metrics
r.Metrics.DeleteAllMetrics(request.Name)
r.Metrics.DeleteAllMetricsForTenant(request.Name)
return reconcile.Result{}, nil
}
@@ -62,17 +66,19 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
return result, err
}
preRecNamespaces := instance.Status.Namespaces
defer func() {
r.syncTenantStatusMetrics(instance)
// Ensuring the Tenant Status
if err = r.updateTenantStatus(ctx, instance); err != nil {
r.Log.Error(err, "Cannot update Tenant status")
if uerr := r.updateTenantStatus(ctx, instance, err); uerr != nil {
err = fmt.Errorf("cannot update tenant status: %w", uerr)
return result, err
}
// Ensuring Metadata
return
}
}()
// Ensuring Metadata.
if err = r.ensureMetadata(ctx, instance); err != nil {
r.Log.Error(err, "Cannot ensure metadata")
err = fmt.Errorf("cannot ensure metadata: %w", err)
return result, err
}
@@ -81,35 +87,25 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
r.Log.Info("Ensuring limit resources count is updated")
if err = r.syncCustomResourceQuotaUsages(ctx, instance); err != nil {
r.Log.Error(err, "Cannot count limited resources")
err = fmt.Errorf("cannot count limited resources: %w", err)
return result, err
}
// Ensuring all namespaces are collected
r.Log.Info("Ensuring all Namespaces are collected")
if err = r.collectNamespaces(ctx, instance); err != nil {
r.Log.Error(err, "Cannot collect Namespace resources")
return result, err
}
// Ensuring Status metrics are exposed
r.Log.Info("Ensuring all status metrics are exposed")
r.syncStatusMetrics(instance, preRecNamespaces)
// Ensuring Namespace metadata
// Reconcile Namespaces
r.Log.Info("Starting processing of Namespaces", "items", len(instance.Status.Namespaces))
if err = r.syncNamespaces(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync Namespace items")
if err = r.reconcileNamespaces(ctx, instance); err != nil {
err = fmt.Errorf("namespace(s) had reconciliation errors")
return result, err
}
// Ensuring NetworkPolicy resources
r.Log.Info("Starting processing of Network Policies")
if err = r.syncNetworkPolicies(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync NetworkPolicy items")
err = fmt.Errorf("cannot sync networkPolicy items: %w", err)
return result, err
}
@@ -117,7 +113,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
r.Log.Info("Starting processing of Limit Ranges", "items", len(instance.Spec.LimitRanges.Items))
if err = r.syncLimitRanges(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync LimitRange items")
err = fmt.Errorf("cannot sync limitrange items: %w", err)
return result, err
}
@@ -125,7 +121,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
r.Log.Info("Starting processing of Resource Quotas", "items", len(instance.Spec.ResourceQuota.Items))
if err = r.syncResourceQuotas(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync ResourceQuota items")
err = fmt.Errorf("cannot sync resourcequota items: %w", err)
return result, err
}
@@ -133,15 +129,7 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
r.Log.Info("Ensuring RoleBindings for Owners and Tenant")
if err = r.syncRoleBindings(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync RoleBindings items")
return result, err
}
// Ensuring Namespace count
r.Log.Info("Ensuring Namespace count")
if err = r.ensureNamespaceCount(ctx, instance); err != nil {
r.Log.Error(err, "Cannot sync Namespace count")
err = fmt.Errorf("cannot sync rolebindings items: %w", err)
return result, err
}
@@ -151,14 +139,40 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
return ctrl.Result{}, err
}
func (r *Manager) updateTenantStatus(ctx context.Context, tnt *capsulev1beta2.Tenant) error {
func (r *Manager) updateTenantStatus(ctx context.Context, tnt *capsulev1beta2.Tenant, reconcileError error) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) {
if tnt.Spec.Cordoned {
tnt.Status.State = capsulev1beta2.TenantStateCordoned
} else {
tnt.Status.State = capsulev1beta2.TenantStateActive
latest := &capsulev1beta2.Tenant{}
if err = r.Get(ctx, types.NamespacedName{Name: tnt.GetName()}, latest); err != nil {
return err
}
return r.Client.Status().Update(ctx, tnt)
latest.Status = tnt.Status
// Set Ready Condition
readyCondition := meta.NewReadyCondition(tnt)
if reconcileError != nil {
readyCondition.Message = reconcileError.Error()
readyCondition.Status = metav1.ConditionFalse
readyCondition.Reason = meta.FailedReason
}
latest.Status.Conditions.UpdateConditionByType(readyCondition)
// Set Cordoned Condition
cordonedCondition := meta.NewCordonedCondition(tnt)
if tnt.Spec.Cordoned {
latest.Status.State = capsulev1beta2.TenantStateCordoned
cordonedCondition.Reason = meta.CordonedReason
cordonedCondition.Message = "Tenant is cordoned"
cordonedCondition.Status = metav1.ConditionTrue
} else {
latest.Status.State = capsulev1beta2.TenantStateActive
}
latest.Status.Conditions.UpdateConditionByType(cordonedCondition)
return r.Client.Status().Update(ctx, latest)
})
}

View File

@@ -6,6 +6,8 @@ package tenant
import (
"context"
"k8s.io/apimachinery/pkg/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
capsuleapi "github.com/projectcapsule/capsule/pkg/api"
)
@@ -17,7 +19,13 @@ func (r *Manager) ensureMetadata(ctx context.Context, tnt *capsulev1beta2.Tenant
tnt.Labels = make(map[string]string)
}
tnt.Labels[capsuleapi.TenantNameLabel] = tnt.Name
if v, ok := tnt.Labels[capsuleapi.TenantNameLabel]; ok && v == tnt.Name {
return err
}
return r.Update(ctx, tnt)
if err := r.Update(ctx, tnt); err != nil {
return err
}
return r.Get(ctx, types.NamespacedName{Name: tnt.GetName()}, tnt)
}

View File

@@ -3,13 +3,15 @@
package tenant
import (
"slices"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
)
// Exposing Status Metrics for tenant.
func (r *Manager) syncStatusMetrics(tenant *capsulev1beta2.Tenant, preRecNamespaces []string) {
func (r *Manager) syncTenantStatusMetrics(tenant *capsulev1beta2.Tenant) {
var cordoned float64 = 0
// Expose namespace-tenant relationship
@@ -17,18 +19,55 @@ func (r *Manager) syncStatusMetrics(tenant *capsulev1beta2.Tenant, preRecNamespa
r.Metrics.TenantNamespaceRelationshipGauge.WithLabelValues(tenant.GetName(), ns).Set(1)
}
// Cleanup deleted namespaces
for _, ns := range preRecNamespaces {
if !slices.Contains(tenant.Status.Namespaces, ns) {
r.Metrics.DeleteNamespaceRelationshipMetrics(ns)
}
}
// Expose cordoned status
r.Metrics.TenantNamespaceCounterGauge.WithLabelValues(tenant.Name).Set(float64(tenant.Status.Size))
if tenant.Spec.Cordoned {
cordoned = 1
}
// Expose cordoned status
r.Metrics.TenantNamespaceCounterGauge.WithLabelValues(tenant.Name).Set(float64(tenant.Status.Size))
// Expose the namespace counter
// Expose Status Metrics
for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} {
var value float64
cond := tenant.Status.Conditions.GetConditionByType(status)
if cond == nil {
r.Metrics.DeleteTenantConditionMetricByType(tenant.Name, status)
continue
}
if cond.Status == metav1.ConditionTrue {
value = 1
}
r.Metrics.TenantConditionGauge.WithLabelValues(tenant.GetName(), status).Set(value)
}
// Expose the namespace counter (Deprecated)
if tenant.Spec.Cordoned {
cordoned = 1
}
r.Metrics.TenantCordonedStatusGauge.WithLabelValues(tenant.Name).Set(cordoned)
}
// Exposing Status Metrics for tenant.
func (r *Manager) syncNamespaceStatusMetrics(tenant *capsulev1beta2.Tenant, namespace *corev1.Namespace) {
for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} {
var value float64
cond := tenant.Status.Conditions.GetConditionByType(status)
if cond == nil {
r.Metrics.DeleteTenantNamespaceConditionMetricByType(namespace.Name, status)
continue
}
if cond.Status == metav1.ConditionTrue {
value = 1
}
r.Metrics.TenantNamespaceConditionGauge.WithLabelValues(tenant.GetName(), namespace.GetName(), status).Set(value)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/valyala/fasttemplate"
"golang.org/x/sync/errgroup"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
@@ -20,51 +21,212 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/meta"
"github.com/projectcapsule/capsule/pkg/utils"
)
// Ensuring all annotations are applied to each Namespace handled by the Tenant.
func (r *Manager) syncNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) {
func (r *Manager) reconcileNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) {
if err = r.collectNamespaces(ctx, tenant); err != nil {
err = fmt.Errorf("cannot collect namespaces: %w", err)
return err
}
gcSet := make(map[string]struct{})
for _, inst := range tenant.Status.Spaces {
gcSet[inst.Name] = struct{}{}
}
group := new(errgroup.Group)
for _, item := range tenant.Status.Namespaces {
namespace := item
delete(gcSet, namespace)
group.Go(func() error {
return r.syncNamespaceMetadata(ctx, namespace, tenant)
return r.reconcileNamespace(ctx, namespace, tenant)
})
}
if err = group.Wait(); err != nil {
r.Log.Error(err, "Cannot sync Namespaces")
err = fmt.Errorf("cannot sync Namespaces: %w", err)
}
for name := range gcSet {
r.Metrics.DeleteAllMetricsForNamespace(name)
tenant.Status.RemoveInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: name,
})
}
tenant.Status.Size = uint(len(tenant.Status.Namespaces))
return err
}
func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, tnt *capsulev1beta2.Tenant) (err error) {
var res controllerutil.OperationResult
func (r *Manager) reconcileNamespace(ctx context.Context, namespace string, tnt *capsulev1beta2.Tenant) (err error) {
ns := &corev1.Namespace{}
if err = r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return err
}
err = retry.RetryOnConflict(retry.DefaultBackoff, func() (conflictErr error) {
ns := &corev1.Namespace{}
if conflictErr = r.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return conflictErr
stat := &capsulev1beta2.TenantStatusNamespaceItem{
Name: namespace,
UID: ns.GetUID(),
}
metaStatus := &capsulev1beta2.TenantStatusNamespaceMetadata{}
// Always update tenant status condition after reconciliation
defer func() {
instance := tnt.Status.GetInstance(stat)
if instance != nil {
stat = instance
}
res, conflictErr = controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
return SyncNamespaceMetadata(tnt, ns)
readCondition := meta.NewReadyCondition(ns)
if err != nil {
readCondition.Status = metav1.ConditionFalse
readCondition.Reason = meta.FailedReason
readCondition.Message = fmt.Sprintf("Failed to reconcile: %v", err)
if instance != nil && instance.Metadata != nil {
stat.Metadata = instance.Metadata
}
} else if metaStatus != nil {
stat.Metadata = metaStatus
}
stat.Conditions.UpdateConditionByType(readCondition)
cordonedCondition := meta.NewCordonedCondition(ns)
if ns.Labels[meta.CordonedLabel] == meta.CordonedLabelTrigger {
cordonedCondition.Reason = meta.CordonedReason
cordonedCondition.Message = "namespace is cordoned"
cordonedCondition.Status = metav1.ConditionTrue
}
stat.Conditions.UpdateConditionByType(cordonedCondition)
tnt.Status.UpdateInstance(stat)
r.syncNamespaceStatusMetrics(tnt, ns)
}()
err = retry.RetryOnConflict(retry.DefaultBackoff, func() (conflictErr error) {
_, conflictErr = controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
metaStatus, err = r.reconcileMetadata(ctx, ns, tnt, stat)
return err
})
return conflictErr
})
r.emitEvent(tnt, namespace, res, "Ensuring Namespace metadata", err)
return err
}
//nolint:nestif
func (r *Manager) reconcileMetadata(
ctx context.Context,
ns *corev1.Namespace,
tnt *capsulev1beta2.Tenant,
stat *capsulev1beta2.TenantStatusNamespaceItem,
) (
managed *capsulev1beta2.TenantStatusNamespaceMetadata,
err error,
) {
capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
originLabels := ns.GetLabels()
if originLabels == nil {
originLabels = make(map[string]string)
}
originAnnotations := ns.GetAnnotations()
if originAnnotations == nil {
originAnnotations = make(map[string]string)
}
managedAnnotations := buildNamespaceAnnotationsForTenant(tnt)
managedLabels := buildNamespaceLabelsForTenant(tnt)
if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
for _, md := range opts.AdditionalMetadataList {
var ok bool
ok, err = utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
if err != nil {
return managed, err
}
if !ok {
continue
}
applyTemplateMap(md.Labels, tnt, ns)
applyTemplateMap(md.Annotations, tnt, ns)
utils.MapMergeNoOverrite(managedLabels, md.Labels)
utils.MapMergeNoOverrite(managedAnnotations, md.Annotations)
}
}
managedMetadataOnly := tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.ManagedMetadataOnly
// Handle User-Defined Metadata, if allowed
if !managedMetadataOnly {
if originLabels != nil {
maps.Copy(originLabels, managedLabels)
}
if originAnnotations != nil {
maps.Copy(originAnnotations, managedAnnotations)
}
// Cleanup old Metadata
instance := tnt.Status.GetInstance(stat)
if instance != nil && instance.Metadata != nil {
for label := range instance.Metadata.Labels {
if _, ok := managedLabels[label]; ok {
continue
}
delete(originLabels, label)
}
for annotation := range instance.Metadata.Annotations {
if _, ok := managedAnnotations[annotation]; ok {
continue
}
delete(originAnnotations, annotation)
}
}
managed = &capsulev1beta2.TenantStatusNamespaceMetadata{
Labels: managedLabels,
Annotations: managedAnnotations,
}
} else {
originLabels = managedLabels
originAnnotations = managedAnnotations
}
originLabels["kubernetes.io/metadata.name"] = ns.GetName()
originLabels[capsuleLabel] = tnt.GetName()
ns.SetLabels(originLabels)
ns.SetAnnotations(originAnnotations)
return managed, err
}
func buildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]string {
annotations := make(map[string]string)
@@ -120,6 +282,35 @@ func buildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]s
return annotations
}
func buildNamespaceLabelsForTenant(tnt *capsulev1beta2.Tenant) map[string]string {
labels := make(map[string]string)
if md := tnt.Spec.NamespaceOptions; md != nil && md.AdditionalMetadata != nil {
maps.Copy(labels, md.AdditionalMetadata.Labels)
}
if tnt.Spec.Cordoned {
labels[meta.CordonedLabel] = "true"
}
return labels
}
func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) {
list := &corev1.NamespaceList{}
err = r.List(ctx, list, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".metadata.ownerReferences[*].capsule", tenant.GetName()),
})
if err != nil {
return err
}
tenant.AssignNamespaces(list.Items)
return err
}
// applyTemplateMap applies templating to all values in the provided map in place.
func applyTemplateMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) {
for k, v := range m {
@@ -136,99 +327,3 @@ func applyTemplateMap(m map[string]string, tnt *capsulev1beta2.Tenant, ns *corev
m[k] = tmplString
}
}
func buildNamespaceLabelsForTenant(tnt *capsulev1beta2.Tenant) map[string]string {
labels := make(map[string]string)
if md := tnt.Spec.NamespaceOptions; md != nil && md.AdditionalMetadata != nil {
maps.Copy(labels, md.AdditionalMetadata.Labels)
}
return labels
}
func (r *Manager) ensureNamespaceCount(ctx context.Context, tenant *capsulev1beta2.Tenant) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
tenant.Status.Size = uint(len(tenant.Status.Namespaces))
found := &capsulev1beta2.Tenant{}
if err := r.Get(ctx, types.NamespacedName{Name: tenant.GetName()}, found); err != nil {
return err
}
found.Status.Size = tenant.Status.Size
return r.Client.Status().Update(ctx, found, &client.SubResourceUpdateOptions{})
})
}
func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) {
list := &corev1.NamespaceList{}
err = r.List(ctx, list, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".metadata.ownerReferences[*].capsule", tenant.GetName()),
})
if err != nil {
return err
}
_, err = controllerutil.CreateOrUpdate(ctx, r.Client, tenant.DeepCopy(), func() error {
tenant.AssignNamespaces(list.Items)
return r.Client.Status().Update(ctx, tenant, &client.SubResourceUpdateOptions{})
})
return err
})
}
// SyncNamespaceMetadata sync namespace metadata according to tenant spec.
func SyncNamespaceMetadata(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) error {
capsuleLabel, _ := utils.GetTypeLabel(&capsulev1beta2.Tenant{})
annotations := buildNamespaceAnnotationsForTenant(tnt)
labels := buildNamespaceLabelsForTenant(tnt)
if opts := tnt.Spec.NamespaceOptions; opts != nil && len(opts.AdditionalMetadataList) > 0 {
for _, md := range opts.AdditionalMetadataList {
ok, err := utils.IsNamespaceSelectedBySelector(ns, md.NamespaceSelector)
if err != nil {
return err
}
if !ok {
continue
}
applyTemplateMap(md.Labels, tnt, ns)
applyTemplateMap(md.Annotations, tnt, ns)
maps.Copy(labels, md.Labels)
maps.Copy(annotations, md.Annotations)
}
}
labels["kubernetes.io/metadata.name"] = ns.GetName()
labels[capsuleLabel] = tnt.GetName()
if tnt.Spec.Cordoned {
ns.Labels[utils.CordonedLabel] = "true"
} else {
delete(ns.Labels, utils.CordonedLabel)
}
if ns.Annotations == nil {
ns.SetAnnotations(annotations)
} else {
maps.Copy(ns.Annotations, annotations)
}
if ns.Labels == nil {
ns.SetLabels(labels)
} else {
maps.Copy(ns.Labels, labels)
}
return nil
}

View File

@@ -10,8 +10,10 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
@@ -23,7 +25,9 @@ type Patch struct {
Value string `json:"value"`
}
var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
var _ = Describe("enforcing a Container Registry", Label("tenant", "images", "registry"), func() {
originConfig := &capsulev1beta2.CapsuleConfiguration{}
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "container-registry",
@@ -43,13 +47,27 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
}
JustBeforeEach(func() {
Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed())
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
// Restore Configuration
Eventually(func() error {
c := &capsulev1beta2.CapsuleConfiguration{}
if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, c); err != nil {
return err
}
// Apply the initial configuration from originConfig to c
c.Spec = originConfig.Spec
return k8sClient.Update(context.Background(), c)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("should add labels to Namespace", func() {
@@ -71,7 +89,6 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
It("should deny running a gcr.io container", func() {
ns := NewNamespace("")
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
@@ -86,14 +103,21 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
},
},
}
cs := ownerClient(tnt.Spec.Owners[0])
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
Expect(err).ShouldNot(Succeed())
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
EventuallyCreation(func() error {
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
return err
}).ShouldNot(Succeed())
})
It("should allow using a registry only match", func() {
ns := NewNamespace("")
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
@@ -110,10 +134,26 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
}
cs := ownerClient(tnt.Spec.Owners[0])
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
EventuallyCreation(func() error {
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
return err
}).Should(Succeed())
By("verifying the image was correctly mutated", func() {
created := &corev1.Pod{}
Expect(k8sClient.Get(context.Background(), types.NamespacedName{
Namespace: ns.Name,
Name: pod.Name,
}, created)).To(Succeed())
Expect(created.Spec.Containers).To(HaveLen(1))
Expect(created.Spec.Containers[0].Image).To(Equal("myregistry.azurecr.io/myapp:latest"))
})
})
It("should deny patching a not matching registry after applying with a matching (Container)", func() {
@@ -144,6 +184,17 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
return err
}).Should(Succeed())
By("verifying the image was correctly mutated", func() {
created := &corev1.Pod{}
Expect(k8sClient.Get(context.Background(), types.NamespacedName{
Namespace: ns.Name,
Name: pod.Name,
}, created)).To(Succeed())
Expect(created.Spec.Containers).To(HaveLen(1))
Expect(created.Spec.Containers[0].Image).To(Equal("myregistry.azurecr.io/myapp:latest"))
})
Eventually(func() error {
payload := []Patch{{
Op: "replace",
@@ -159,6 +210,89 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
}).ShouldNot(Succeed())
})
It("should deny patching a not matching registry after applying with a matching (EphemeralContainer)", func() {
ns := NewNamespace("")
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "container",
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "container",
Image: "docker.io/google-containers/pause-amd64:3.0",
},
},
},
}
cs := ownerClient(tnt.Spec.Owners[0])
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor",
Namespace: ns.GetName(),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""}, // core API group
Resources: []string{"pods/ephemeralcontainers"},
Verbs: []string{"update", "patch"},
},
},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor-binding",
Namespace: ns.GetName(),
},
Subjects: []rbacv1.Subject{
{
Kind: rbacv1.UserKind,
Name: tnt.Spec.Owners[0].Name,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: role.Name,
},
}
// Create role and binding before test logic
Expect(k8sClient.Create(context.TODO(), role)).To(Succeed())
Expect(k8sClient.Create(context.TODO(), rb)).To(Succeed())
EventuallyCreation(func() error {
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
return err
}).Should(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "attacker/google-containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullIfNotPresent,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).ShouldNot(Succeed())
})
It("should deny patching a not matching registry after applying with a matching (initContainer)", func() {
ns := NewNamespace("")
@@ -208,7 +342,50 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
}).ShouldNot(Succeed())
})
It("should allow patching a matching registry after applying with a matching (Container)", func() {
It("should deny patching a not matching registry after applying with a matching (Container)", func() {
ns := NewNamespace("")
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "container",
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "container",
Image: "myregistry.azurecr.io/myapp:latest",
},
},
},
}
cs := ownerClient(tnt.Spec.Owners[0])
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
EventuallyCreation(func() error {
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
return err
}).Should(Succeed())
Eventually(func() error {
payload := []Patch{{
Op: "replace",
Path: "/spec/initContainers/0/image",
Value: "attacker/google-containers/pause-amd64:3.0",
}}
payloadBytes, _ := json.Marshal(payload)
_, err := cs.CoreV1().Pods(ns.GetName()).Patch(context.TODO(), pod.GetName(), types.JSONPatchType, payloadBytes, metav1.PatchOptions{})
if err != nil {
return err
}
return nil
}).ShouldNot(Succeed())
})
It("should allow patching a matching registry after applying with a matching (EphemeralContainer)", func() {
ns := NewNamespace("")
pod := &corev1.Pod{
@@ -230,6 +407,42 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor",
Namespace: ns.GetName(),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""}, // core API group
Resources: []string{"pods/ephemeralcontainers"},
Verbs: []string{"update", "patch"},
},
},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor-binding",
Namespace: ns.GetName(),
},
Subjects: []rbacv1.Subject{
{
Kind: rbacv1.UserKind,
Name: tnt.Spec.Owners[0].Name,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: role.Name,
},
}
// Create role and binding before test logic
Expect(k8sClient.Create(context.TODO(), role)).To(Succeed())
Expect(k8sClient.Create(context.TODO(), rb)).To(Succeed())
EventuallyCreation(func() error {
_, err := cs.CoreV1().Pods(ns.Name).Create(context.Background(), pod, metav1.CreateOptions{})
@@ -237,13 +450,17 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() {
}).Should(Succeed())
Eventually(func() error {
payload := []Patch{{
Op: "replace",
Path: "/spec/containers/0/image",
Value: "myregistry.azurecr.io/google-containers/pause-amd64:3.1",
}}
payloadBytes, _ := json.Marshal(payload)
_, err := cs.CoreV1().Pods(ns.GetName()).Patch(context.TODO(), pod.GetName(), types.JSONPatchType, payloadBytes, metav1.PatchOptions{})
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "myregistry.azurecr.io/google-containers/pause-amd64:3.1",
ImagePullPolicy: corev1.PullIfNotPresent,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}

View File

@@ -9,13 +9,14 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
)
var _ = Describe("enforcing some defined ImagePullPolicy", Label("pod"), func() {
var _ = Describe("enforcing some defined ImagePullPolicy", Label("tenant", "images", "policy"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "image-pull-policies",
@@ -48,6 +49,42 @@ var _ = Describe("enforcing some defined ImagePullPolicy", Label("pod"), func()
cs := ownerClient(tnt.Spec.Owners[0])
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor",
Namespace: ns.GetName(),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""}, // core API group
Resources: []string{"pods/ephemeralcontainers"},
Verbs: []string{"update", "patch"},
},
},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor-binding",
Namespace: ns.GetName(),
},
Subjects: []rbacv1.Subject{
{
Kind: rbacv1.UserKind,
Name: tnt.Spec.Owners[0].Name,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: role.Name,
},
}
// Create role and binding before test logic
Expect(k8sClient.Create(context.TODO(), role)).To(Succeed())
Expect(k8sClient.Create(context.TODO(), rb)).To(Succeed())
By("allowing Always", func() {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
@@ -69,6 +106,25 @@ var _ = Describe("enforcing some defined ImagePullPolicy", Label("pod"), func()
return
}).Should(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullAlways,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).Should(Succeed())
})
By("allowing IfNotPresent", func() {
@@ -92,6 +148,24 @@ var _ = Describe("enforcing some defined ImagePullPolicy", Label("pod"), func()
return
}).Should(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullIfNotPresent,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).Should(Succeed())
})
By("blocking Never", func() {
@@ -115,6 +189,25 @@ var _ = Describe("enforcing some defined ImagePullPolicy", Label("pod"), func()
return
}).ShouldNot(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullNever,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).ShouldNot(Succeed())
})
})
})

View File

@@ -9,13 +9,14 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
)
var _ = Describe("enforcing a defined ImagePullPolicy", Label("pod"), func() {
var _ = Describe("enforcing a defined ImagePullPolicy", Label("tenant", "images", "policy"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "image-pull-policy",
@@ -48,6 +49,42 @@ var _ = Describe("enforcing a defined ImagePullPolicy", Label("pod"), func() {
cs := ownerClient(tnt.Spec.Owners[0])
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor",
Namespace: ns.GetName(),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""}, // core API group
Resources: []string{"pods/ephemeralcontainers"},
Verbs: []string{"update", "patch"},
},
},
}
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "ephemeralcontainers-editor-binding",
Namespace: ns.GetName(),
},
Subjects: []rbacv1.Subject{
{
Kind: rbacv1.UserKind,
Name: tnt.Spec.Owners[0].Name,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: role.Name,
},
}
// Create role and binding before test logic
Expect(k8sClient.Create(context.TODO(), role)).To(Succeed())
Expect(k8sClient.Create(context.TODO(), rb)).To(Succeed())
By("allowing Always", func() {
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
@@ -69,6 +106,24 @@ var _ = Describe("enforcing a defined ImagePullPolicy", Label("pod"), func() {
return
}).Should(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullAlways,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).Should(Succeed())
})
By("blocking IfNotPresent", func() {
@@ -92,6 +147,24 @@ var _ = Describe("enforcing a defined ImagePullPolicy", Label("pod"), func() {
return
}).ShouldNot(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullIfNotPresent,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).ShouldNot(Succeed())
})
By("blocking Never", func() {
@@ -115,6 +188,24 @@ var _ = Describe("enforcing a defined ImagePullPolicy", Label("pod"), func() {
return
}).ShouldNot(Succeed())
Eventually(func() error {
pod.Spec.EphemeralContainers = []corev1.EphemeralContainer{
{
EphemeralContainerCommon: corev1.EphemeralContainerCommon{
Name: "dbg",
Image: "gcr.io/google_containers/pause-amd64:3.0",
ImagePullPolicy: corev1.PullNever,
},
},
}
_, err := cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers(context.Background(), pod.Name, pod, metav1.UpdateOptions{})
if err != nil {
return err
}
return nil
}).ShouldNot(Succeed())
})
})
})

View File

@@ -8,14 +8,17 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/meta"
)
var _ = Describe("creating a Namespace for a Tenant with additional metadata", Label("namespace"), func() {
var _ = Describe("creating a Namespace for a Tenant with additional metadata", Label("namespace", "metadata"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-metadata",
@@ -35,7 +38,16 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
Kind: "User",
},
},
NamespaceOptions: &capsulev1beta2.NamespaceOptions{
},
}
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
It("should contain additional Namespace metadata", func() {
By("prepare tenant", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
AdditionalMetadata: &api.AdditionalMetadataSpec{
Labels: map[string]string{
"k8s.io/custom-label": "foo",
@@ -48,20 +60,16 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
"clastix.io/custom-annotation": "buzz",
},
},
},
},
}
}
JustBeforeEach(func() {
EventuallyCreation(func() error {
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
It("should contain additional Namespace metadata", func() {
ns := NewNamespace("")
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
@@ -105,29 +113,10 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
})
})
var _ = Describe("creating a Namespace for a Tenant with additional metadata list", func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-metadata",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "cap",
Kind: "dummy",
Name: "tenant-metadata",
UID: "tenant-metadata",
},
},
},
Spec: capsulev1beta2.TenantSpec{
Owners: capsulev1beta2.OwnerListSpec{
{
Name: "gatsby",
Kind: "User",
},
},
NamespaceOptions: &capsulev1beta2.NamespaceOptions{
It("should contain additional Namespace metadata", func() {
By("prepare tenant", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
{
Labels: map[string]string{
@@ -184,20 +173,16 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata lis
},
},
},
},
},
}
}
JustBeforeEach(func() {
EventuallyCreation(func() error {
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
It("should contain additional Namespace metadata", func() {
labels := map[string]string{
"matching_namespace_label": "matching_namespace_label_value",
}
@@ -295,6 +280,434 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata lis
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeFalse())
})
})
It("should contain additional Namespace metadata", func() {
By("prepare tenant", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ManagedMetadataOnly: false,
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
{
Labels: map[string]string{
"clastix.io/custom-label": "bar",
},
Annotations: map[string]string{
"clastix.io/custom-annotation": "buzz",
},
},
{
Labels: map[string]string{
"k8s.io/custom-label": "foo",
},
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
},
},
},
}
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
labels := map[string]string{
"matching_namespace_label": "matching_namespace_label_value",
}
ns := NewNamespace("", labels)
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
By("checking additional labels", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for _, mv := range tnt.Spec.NamespaceOptions.AdditionalMetadataList {
for k, v := range mv.Labels {
if k == "capsule.clastix.io/tenant" || k == "kubernetes.io/metadata.name" {
continue // this label is managed and shouldn't be set by the user
}
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Labels); !ok {
return
}
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking managed labels", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
if ok, _ = HaveKeyWithValue("capsule.clastix.io/tenant", tnt.GetName()).Match(ns.Labels); !ok {
return
}
if ok, _ = HaveKeyWithValue("kubernetes.io/metadata.name", ns.GetName()).Match(ns.Labels); !ok {
return
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("checking additional annotations", func() {
Eventually(func() (ok bool) {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
for _, mv := range tnt.Spec.NamespaceOptions.AdditionalMetadataList {
for k, v := range mv.Annotations {
if ok, _ = HaveKeyWithValue(k, v).Match(ns.Annotations); !ok {
return
}
}
}
return
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
})
By("patching labels and annotations on the Namespace", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).To(Succeed())
before := ns.DeepCopy()
ns.Labels["test-label"] = "test-value"
ns.Labels["k8s.io/custom-label"] = "foo-value"
ns.Annotations["test-annotation"] = "test-value"
ns.Annotations["k8s.io/custom-annotation"] = "bizz-value"
Expect(k8sClient.Patch(context.TODO(), ns, client.MergeFrom(before))).To(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
By("Add additional annotations (Tenant Owner)", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
expectedLabels := map[string]string{
"test-label": "test-value",
"clastix.io/custom-label": "bar",
"k8s.io/custom-label": "foo",
"matching_namespace_label": "matching_namespace_label_value",
"capsule.clastix.io/tenant": tnt.GetName(),
"kubernetes.io/metadata.name": ns.GetName(),
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetLabels()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedLabels))
expectedAnnotations := map[string]string{
"test-annotation": "test-value",
"clastix.io/custom-annotation": "buzz",
"k8s.io/custom-annotation": "bizz",
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedAnnotations))
By("verify tenant status", func() {
condition := tnt.Status.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected tenant condition reason to be Succeeded")
})
By("verify namespace status", func() {
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns.GetName(), UID: ns.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded")
expectedMetadata := &capsulev1beta2.TenantStatusNamespaceMetadata{
Labels: map[string]string{
"clastix.io/custom-label": "bar",
"k8s.io/custom-label": "foo",
},
Annotations: map[string]string{
"clastix.io/custom-annotation": "buzz",
"k8s.io/custom-annotation": "bizz",
},
}
Expect(instance.Metadata).To(Equal(expectedMetadata))
})
})
By("change managed additional metadata", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ManagedMetadataOnly: false,
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
{
Labels: map[string]string{
"clastix.io/custom-label": "bar",
},
},
{
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
},
},
},
}
Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
By("verify metadata lifecycle (valid update)", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).To(Succeed())
expectedLabels := map[string]string{
"test-label": "test-value",
"clastix.io/custom-label": "bar",
"matching_namespace_label": "matching_namespace_label_value",
"capsule.clastix.io/tenant": tnt.GetName(),
"kubernetes.io/metadata.name": ns.GetName(),
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetLabels()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedLabels))
expectedAnnotations := map[string]string{
"test-annotation": "test-value",
"k8s.io/custom-annotation": "bizz",
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedAnnotations))
By("verify tenant status", func() {
condition := tnt.Status.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected tenant condition reason to be Succeeded")
})
By("verify namespace status", func() {
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns.GetName(), UID: ns.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded")
expectedMetadata := &capsulev1beta2.TenantStatusNamespaceMetadata{
Labels: map[string]string{
"clastix.io/custom-label": "bar",
},
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
},
}
Expect(instance.Metadata).To(Equal(expectedMetadata))
})
})
By("change managed additional metadata (provoke an error)", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ManagedMetadataOnly: false,
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{
{
Labels: map[string]string{
"clastix.io???custom-label": "bar",
},
},
},
}
Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
By("verify metadata lifecycle (faulty update)", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).To(Succeed())
expectedLabels := map[string]string{
"test-label": "test-value",
"clastix.io/custom-label": "bar",
"matching_namespace_label": "matching_namespace_label_value",
"capsule.clastix.io/tenant": tnt.GetName(),
"kubernetes.io/metadata.name": ns.GetName(),
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetLabels()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedLabels))
expectedAnnotations := map[string]string{
"test-annotation": "test-value",
"k8s.io/custom-annotation": "bizz",
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedAnnotations))
By("verify tenant status", func() {
condition := tnt.Status.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionFalse), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.FailedReason), "Expected tenant condition reason to be Succeeded")
})
By("verify namespace status", func() {
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns.GetName(), UID: ns.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionFalse), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.FailedReason), "Expected namespace condition reason to be Succeeded")
expectedMetadata := &capsulev1beta2.TenantStatusNamespaceMetadata{
Labels: map[string]string{
"clastix.io/custom-label": "bar",
},
Annotations: map[string]string{
"k8s.io/custom-annotation": "bizz",
},
}
Expect(instance.Metadata).To(Equal(expectedMetadata))
})
})
By("change managed additional metadata (empty update)", func() {
tnt.Spec.NamespaceOptions = &capsulev1beta2.NamespaceOptions{
ManagedMetadataOnly: false,
AdditionalMetadataList: []api.AdditionalMetadataSelectorSpec{},
}
Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
})
By("verify metadata lifecycle (empty update)", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).To(Succeed())
expectedLabels := map[string]string{
"test-label": "test-value",
"matching_namespace_label": "matching_namespace_label_value",
"capsule.clastix.io/tenant": tnt.GetName(),
"kubernetes.io/metadata.name": ns.GetName(),
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetLabels()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedLabels))
expectedAnnotations := map[string]string{
"test-annotation": "test-value",
}
Eventually(func() map[string]string {
got := &corev1.Namespace{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, got); err != nil {
return nil
}
ann := got.GetAnnotations()
if ann == nil {
ann = map[string]string{}
}
return ann
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedAnnotations))
By("verify tenant status", func() {
condition := tnt.Status.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected tenant condition reason to be Succeeded")
})
By("verify namespace status", func() {
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns.GetName(), UID: ns.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded")
expectedMetadata := &capsulev1beta2.TenantStatusNamespaceMetadata{}
Expect(instance.Metadata).To(Equal(expectedMetadata))
})
})
})
})

View File

@@ -18,7 +18,7 @@ import (
var _ = Describe("creating a Namespace for a Tenant with additional metadata", Label("namespace"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-metadata",
Name: "tenant-metadata-controller",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "cap",
@@ -68,6 +68,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(ns.Labels).ShouldNot(HaveKeyWithValue("newlabel", "foobazbar"))
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels["newlabel"] = "foobazbar"
Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
@@ -81,6 +82,7 @@ var _ = Describe("creating a Namespace for a Tenant with additional metadata", L
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(ns.Labels).ShouldNot(HaveKeyWithValue("newannotation", "foobazbar"))
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, tnt)).Should(Succeed())
tnt.Spec.NamespaceOptions.AdditionalMetadata.Annotations["newannotation"] = "foobazbar"
Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())

View File

@@ -20,7 +20,7 @@ import (
var _ = Describe("creating a Namespace for a Tenant with additional metadata", Label("namespace"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-metadata",
Name: "tenant-metadata-webhook",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "cap",

View File

@@ -0,0 +1,112 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
)
var _ = Describe("creating namespace with status lifecycle", Label("namespace", "status"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-status",
},
Spec: capsulev1beta2.TenantSpec{
Owners: capsulev1beta2.OwnerListSpec{
{
Name: "gatsby",
Kind: "User",
},
},
},
}
JustBeforeEach(func() {
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
It("verify namespace lifecycle (functionality)", func() {
ns1 := NewNamespace("")
By("creating first namespace", func() {
NamespaceCreation(ns1, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElements(ns1.GetName()))
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
Expect(tnt.Status.Size).To(Equal(uint(1)))
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns1.GetName(), UID: ns1.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns1.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded")
})
ns2 := NewNamespace("")
By("creating second namespace", func() {
NamespaceCreation(ns2, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElements(ns2.GetName()))
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
Expect(tnt.Status.Size).To(Equal(uint(2)))
instance := tnt.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns2.GetName(), UID: ns2.GetUID()})
Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil")
condition := instance.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(instance.Name).To(Equal(ns2.GetName()))
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True")
Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded")
})
By("removing first namespace", func() {
Expect(k8sClient.Delete(context.TODO(), ns1)).Should(Succeed())
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
Expect(t.Status.Size).To(Equal(uint(1)))
instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns1.GetName(), UID: ns1.GetUID()})
Expect(instance).To(BeNil(), "Namespace instance should be nil")
})
By("removing second namespace", func() {
Expect(k8sClient.Delete(context.TODO(), ns2)).Should(Succeed())
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
Expect(t.Status.Size).To(Equal(uint(0)))
instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{Name: ns2.GetName(), UID: ns2.GetUID()})
Expect(instance).To(BeNil(), "Namespace instance should be nil")
})
})
})

136
e2e/scalability_test.go Normal file
View File

@@ -0,0 +1,136 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"fmt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/meta"
)
var _ = Describe("verify scalability", Label("scalability"), func() {
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-scalability",
},
Spec: capsulev1beta2.TenantSpec{
Owners: capsulev1beta2.OwnerListSpec{
{
Name: "gatsby",
Kind: "User",
},
},
},
}
JustBeforeEach(func() {
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
})
JustAfterEach(func() {
Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
})
It("verify lifecycle (scalability)", func() {
const amount = 50
getTenant := func() *capsulev1beta2.Tenant {
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).To(Succeed())
return t
}
waitSize := func(expected uint) {
Eventually(func() uint {
return getTenant().Status.Size
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expected))
}
waitInstancePresent := func(ns *corev1.Namespace) {
Eventually(func() error {
t := getTenant()
inst := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
if inst == nil {
return fmt.Errorf("instance not found for ns=%q uid=%q", ns.GetName(), ns.GetUID())
}
condition := inst.Conditions.GetConditionByType(meta.ReadyCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
if inst == nil {
return fmt.Errorf("instance not found for ns=%q uid=%q", ns.GetName(), ns.GetUID())
}
if inst.Name != ns.GetName() {
return fmt.Errorf("instance.Name=%q, want %q", inst.Name, ns.GetName())
}
cond := inst.Conditions.GetConditionByType(meta.ReadyCondition)
if cond == nil {
return fmt.Errorf("missing %q condition", meta.ReadyCondition)
}
if cond.Type != meta.ReadyCondition {
return fmt.Errorf("cond.Type=%q, want %q", cond.Type, meta.ReadyCondition)
}
if cond.Status != metav1.ConditionTrue {
return fmt.Errorf("cond.Status=%q, want %q", cond.Status, metav1.ConditionTrue)
}
if cond.Reason != meta.SucceededReason {
return fmt.Errorf("cond.Reason=%q, want %q", cond.Reason, meta.SucceededReason)
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
waitInstanceAbsent := func(ns *corev1.Namespace) {
Eventually(func() bool {
t := getTenant()
inst := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{
Name: ns.GetName(),
UID: ns.GetUID(),
})
return inst == nil
}, defaultTimeoutInterval, defaultPollInterval).Should(BeTrue())
}
// --- Scale up: create N namespaces and verify Tenant status each time ---
namespaces := make([]*corev1.Namespace, 0, amount)
for i := 0; i < amount; i++ {
ns := NewNamespace(fmt.Sprintf("scale-%d", i))
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
// Expect size bumped to i+1 and instance present
waitSize(uint(i + 1))
waitInstancePresent(ns)
namespaces = append(namespaces, ns)
}
// --- Scale down: delete N namespaces and verify Tenant status each time ---
for i := 0; i < amount; i++ {
ns := namespaces[i]
Expect(k8sClient.Delete(context.TODO(), ns)).To(Succeed())
// Expect size decremented and instance absent
waitSize(uint(amount - i - 1))
waitInstanceAbsent(ns)
}
})
})

View File

@@ -7,7 +7,7 @@ import (
"context"
"time"
"github.com/projectcapsule/capsule/pkg/utils"
"github.com/projectcapsule/capsule/pkg/meta"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -60,6 +60,19 @@ var _ = Describe("cordoning a Tenant", Label("tenant"), func() {
},
},
}
By("Verifing Tenant Status", func() {
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
condition := t.Status.Conditions.GetConditionByType(meta.CordonedCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionFalse), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.CordonedCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.ActiveReason), "Expected tenant condition reason to be Succeeded")
})
By("creating a Namespace", func() {
NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
@@ -79,10 +92,22 @@ var _ = Describe("cordoning a Tenant", Label("tenant"), func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(ns.Labels).To(HaveKey(utils.CordonedLabel))
Expect(ns.Labels).To(HaveKey(meta.CordonedLabel))
})
By("Verifing Tenant Status", func() {
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
condition := t.Status.Conditions.GetConditionByType(meta.CordonedCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.CordonedCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.CordonedReason), "Expected tenant condition reason to be Succeeded")
})
By("cordoning the Tenant deletion must be blocked", func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.Name}, tnt)).Should(Succeed())
@@ -116,8 +141,20 @@ var _ = Describe("cordoning a Tenant", Label("tenant"), func() {
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
Expect(ns.Labels).ToNot(HaveKey(utils.CordonedLabel))
Expect(ns.Labels).ToNot(HaveKey(meta.CordonedLabel))
})
By("Verifing Tenant Status", func() {
t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed())
condition := t.Status.Conditions.GetConditionByType(meta.CordonedCondition)
Expect(condition).NotTo(BeNil(), "Condition instance should not be nil")
Expect(condition.Status).To(Equal(metav1.ConditionFalse), "Expected tenant condition status to be True")
Expect(condition.Type).To(Equal(meta.CordonedCondition), "Expected tenant condition type to be Ready")
Expect(condition.Reason).To(Equal(meta.ActiveReason), "Expected tenant condition reason to be Succeeded")
})
})
})

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"