diff --git a/api/v1beta2/namespace_options.go b/api/v1beta2/namespace_options.go index 0818890d..5c550be0 100644 --- a/api/v1beta2/namespace_options.go +++ b/api/v1beta2/namespace_options.go @@ -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"` } diff --git a/api/v1beta2/tenant_status.go b/api/v1beta2/tenant_status.go index e438f0eb..d47b48e9 100644 --- a/api/v1beta2/tenant_status.go +++ b/api/v1beta2/tenant_status.go @@ -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 } diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index 7119cc9f..999ae5b5 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -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"` diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 6eb84e92..377e0e51 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -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 +} diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 1c590fa6..6f55e0fc 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -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 | diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 965e4a3d..55dd1391 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -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 diff --git a/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 792257d9..c8e55c32 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -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 }} diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index d0ce8a85..228491cc 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -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 }} diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index fd5c7fd9..3b4869c7 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -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 }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index f165889e..56b772b9 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -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" } } } diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index bfc44812..d7cbdea9 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -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 diff --git a/cmd/main.go b/cmd/main.go index 786541fe..0c21e899 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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")))), diff --git a/controllers/tenant/manager.go b/controllers/tenant/manager.go index 6c0f4286..42c00f80 100644 --- a/controllers/tenant/manager.go +++ b/controllers/tenant/manager.go @@ -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) { + latest := &capsulev1beta2.Tenant{} + if err = r.Get(ctx, types.NamespacedName{Name: tnt.GetName()}, latest); err != nil { + return err + } + + 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 { - tnt.Status.State = capsulev1beta2.TenantStateCordoned + latest.Status.State = capsulev1beta2.TenantStateCordoned + + cordonedCondition.Reason = meta.CordonedReason + cordonedCondition.Message = "Tenant is cordoned" + cordonedCondition.Status = metav1.ConditionTrue } else { - tnt.Status.State = capsulev1beta2.TenantStateActive + latest.Status.State = capsulev1beta2.TenantStateActive } - return r.Client.Status().Update(ctx, tnt) + latest.Status.Conditions.UpdateConditionByType(cordonedCondition) + + return r.Client.Status().Update(ctx, latest) }) } diff --git a/controllers/tenant/metadata.go b/controllers/tenant/metadata.go index 8b63a842..024bc92b 100644 --- a/controllers/tenant/metadata.go +++ b/controllers/tenant/metadata.go @@ -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 + } + + if err := r.Update(ctx, tnt); err != nil { + return err + } - return r.Update(ctx, tnt) + return r.Get(ctx, types.NamespacedName{Name: tnt.GetName()}, tnt) } diff --git a/controllers/tenant/metrics.go b/controllers/tenant/metrics.go index 3364ae08..a4a11411 100644 --- a/controllers/tenant/metrics.go +++ b/controllers/tenant/metrics.go @@ -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 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 } - // Expose cordoned status - r.Metrics.TenantNamespaceCounterGauge.WithLabelValues(tenant.Name).Set(float64(tenant.Status.Size)) - // Expose the namespace counter + 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) + } +} diff --git a/controllers/tenant/namespaces.go b/controllers/tenant/namespaces.go index 76a0bb70..b01cc2ae 100644 --- a/controllers/tenant/namespaces.go +++ b/controllers/tenant/namespaces.go @@ -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 + } + + stat := &capsulev1beta2.TenantStatusNamespaceItem{ + Name: namespace, + UID: ns.GetUID(), + } - 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 + 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,23 +282,6 @@ func buildNamespaceAnnotationsForTenant(tnt *capsulev1beta2.Tenant) map[string]s return annotations } -// 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 { - if !strings.Contains(v, "{{ ") && !strings.Contains(v, " }}") { - continue - } - - t := fasttemplate.New(v, "{{ ", " }}") - tmplString := t.ExecuteString(map[string]interface{}{ - "tenant.name": tnt.Name, - "namespace": ns.Name, - }) - - m[k] = tmplString - } -} - func buildNamespaceLabelsForTenant(tnt *capsulev1beta2.Tenant) map[string]string { labels := make(map[string]string) @@ -144,91 +289,41 @@ func buildNamespaceLabelsForTenant(tnt *capsulev1beta2.Tenant) map[string]string maps.Copy(labels, md.AdditionalMetadata.Labels) } + if tnt.Spec.Cordoned { + labels[meta.CordonedLabel] = "true" + } + 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 +func (r *Manager) collectNamespaces(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) { + list := &corev1.NamespaceList{} - return r.Client.Status().Update(ctx, found, &client.SubResourceUpdateOptions{}) + err = r.List(ctx, list, client.MatchingFieldsSelector{ + Selector: fields.OneTermEqualSelector(".metadata.ownerReferences[*].capsule", tenant.GetName()), }) -} - -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{}) - }) - + if err != nil { 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 - } + tenant.AssignNamespaces(list.Items) - if !ok { - continue - } - - applyTemplateMap(md.Labels, tnt, ns) - applyTemplateMap(md.Annotations, tnt, ns) + return err +} - maps.Copy(labels, md.Labels) - maps.Copy(annotations, md.Annotations) +// 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 { + if !strings.Contains(v, "{{ ") && !strings.Contains(v, " }}") { + continue } - } - - 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) - } + t := fasttemplate.New(v, "{{ ", " }}") + tmplString := t.ExecuteString(map[string]interface{}{ + "tenant.name": tnt.Name, + "namespace": ns.Name, + }) - if ns.Labels == nil { - ns.SetLabels(labels) - } else { - maps.Copy(ns.Labels, labels) + m[k] = tmplString } - - return nil } diff --git a/e2e/container_registry_test.go b/e2e/container_registry_test.go index 7d8b7d82..9444a601 100644 --- a/e2e/container_registry_test.go +++ b/e2e/container_registry_test.go @@ -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,7 @@ 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{ @@ -219,7 +353,7 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() { Containers: []corev1.Container{ { Name: "container", - Image: "docker.io/google-containers/pause-amd64:3.0", + Image: "myregistry.azurecr.io/myapp:latest", }, }, }, @@ -239,8 +373,8 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() { Eventually(func() error { payload := []Patch{{ Op: "replace", - Path: "/spec/containers/0/image", - Value: "myregistry.azurecr.io/google-containers/pause-amd64:3.1", + 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{}) @@ -248,6 +382,89 @@ var _ = Describe("enforcing a Container Registry", Label("tenant"), func() { 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{ + 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: "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 + } + return nil }).Should(Succeed()) }) diff --git a/e2e/imagepullpolicy_multiple_test.go b/e2e/imagepullpolicy_multiple_test.go index 0d0eadfe..1597dda9 100644 --- a/e2e/imagepullpolicy_multiple_test.go +++ b/e2e/imagepullpolicy_multiple_test.go @@ -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()) + }) }) }) diff --git a/e2e/imagepullpolicy_single_test.go b/e2e/imagepullpolicy_single_test.go index 3477343b..b2c0e084 100644 --- a/e2e/imagepullpolicy_single_test.go +++ b/e2e/imagepullpolicy_single_test.go @@ -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()) }) }) }) diff --git a/e2e/namespace_additional_metadata_test.go b/e2e/namespace_additional_metadata_test.go index 7d90998e..282f0d93 100644 --- a/e2e/namespace_additional_metadata_test.go +++ b/e2e/namespace_additional_metadata_test.go @@ -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)) + }) + }) }) }) diff --git a/e2e/namespace_metadata_controller_test.go b/e2e/namespace_metadata_controller_test.go index 610826ae..7a3f0389 100644 --- a/e2e/namespace_metadata_controller_test.go +++ b/e2e/namespace_metadata_controller_test.go @@ -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()) diff --git a/e2e/namespace_metadata_webhook_test.go b/e2e/namespace_metadata_webhook_test.go index 68c2d458..4fa1e377 100644 --- a/e2e/namespace_metadata_webhook_test.go +++ b/e2e/namespace_metadata_webhook_test.go @@ -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", diff --git a/e2e/namespace_status_test.go b/e2e/namespace_status_test.go new file mode 100644 index 00000000..821a4e57 --- /dev/null +++ b/e2e/namespace_status_test.go @@ -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") + }) + }) +}) diff --git a/e2e/scalability_test.go b/e2e/scalability_test.go new file mode 100644 index 00000000..2e5dd204 --- /dev/null +++ b/e2e/scalability_test.go @@ -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) + } + + }) + +}) diff --git a/e2e/tenant_cordoning_test.go b/e2e/tenant_cordoning_test.go index a65cdd5e..2cceb45a 100644 --- a/e2e/tenant_cordoning_test.go +++ b/e2e/tenant_cordoning_test.go @@ -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") }) }) }) diff --git a/pkg/api/additional_metadata.go b/pkg/api/additional_metadata.go index 24561797..4833c7bb 100644 --- a/pkg/api/additional_metadata.go +++ b/pkg/api/additional_metadata.go @@ -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"` } diff --git a/pkg/meta/conditions.go b/pkg/meta/conditions.go index 7034a84d..8e65db40 100644 --- a/pkg/meta/conditions.go +++ b/pkg/meta/conditions.go @@ -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, diff --git a/pkg/meta/conditions_test.go b/pkg/meta/conditions_test.go new file mode 100644 index 00000000..4ec7dccf --- /dev/null +++ b/pkg/meta/conditions_test.go @@ -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) + }) +} diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index 5347cdae..5446a34e 100644 --- a/pkg/meta/labels.go +++ b/pkg/meta/labels.go @@ -15,6 +15,9 @@ const ( OwnerPromotionLabel = "owner.projectcapsule.dev/promote" OwnerPromotionLabelTrigger = "true" + + CordonedLabel = "projectcapsule.dev/cordoned" + CordonedLabelTrigger = "true" ) func FreezeLabelTriggers(obj client.Object) bool { diff --git a/pkg/meta/zz_generated.deepcopy.go b/pkg/meta/zz_generated.deepcopy.go new file mode 100644 index 00000000..cd974dbc --- /dev/null +++ b/pkg/meta/zz_generated.deepcopy.go @@ -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 +} diff --git a/pkg/metrics/tenant_recorder.go b/pkg/metrics/tenant_recorder.go index c4de7aa5..4cf76ecf 100644 --- a/pkg/metrics/tenant_recorder.go +++ b/pkg/metrics/tenant_recorder.go @@ -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.TenantNamespaceRelationshipGauge.DeletePartialMatch(map[string]string{ + r.TenantNamespaceCounterGauge.DeletePartialMatch(map[string]string{ "tenant": tenant, }) - r.TenantResourceUsageGauge.DeletePartialMatch(map[string]string{ + r.TenantCordonedStatusGauge.DeletePartialMatch(map[string]string{ "tenant": tenant, }) - r.TenantResourceLimitGauge.DeletePartialMatch(map[string]string{ + r.TenantNamespaceRelationshipGauge.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, + r.TenantNamespaceConditionGauge.DeletePartialMatch(map[string]string{ + "tenant": tenant, }) } - -func (r *TenantRecorder) DeleteAllMetrics(tenant string) { - r.DeleteTenantResourceMetrics(tenant) - r.DeleteTenantStatusMetrics(tenant) -} diff --git a/pkg/utils/maps.go b/pkg/utils/maps.go new file mode 100644 index 00000000..cd50bb04 --- /dev/null +++ b/pkg/utils/maps.go @@ -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 + } + } +} diff --git a/pkg/utils/maps_test.go b/pkg/utils/maps_test.go new file mode 100644 index 00000000..9bb2ad55 --- /dev/null +++ b/pkg/utils/maps_test.go @@ -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) +} diff --git a/pkg/utils/tenant_labels.go b/pkg/utils/tenant_labels.go index 00053794..1fef285d 100644 --- a/pkg/utils/tenant_labels.go +++ b/pkg/utils/tenant_labels.go @@ -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: diff --git a/pkg/webhook/namespace/mutation/cordoning.go b/pkg/webhook/namespace/mutation/cordoning.go index 2abd452c..81e6780c 100644 --- a/pkg/webhook/namespace/mutation/cordoning.go +++ b/pkg/webhook/namespace/mutation/cordoning.go @@ -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 { diff --git a/pkg/webhook/namespace/mutation/metadata.go b/pkg/webhook/namespace/mutation/metadata.go index 0b72cef8..0dce73e1 100644 --- a/pkg/webhook/namespace/mutation/metadata.go +++ b/pkg/webhook/namespace/mutation/metadata.go @@ -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) diff --git a/pkg/webhook/pod/containerregistry.go b/pkg/webhook/pod/containerregistry.go index f84aed85..e84862ee 100644 --- a/pkg/webhook/pod/containerregistry.go +++ b/pkg/webhook/pod/containerregistry.go @@ -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 + } + + 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 + } + } - // 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.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 } diff --git a/pkg/webhook/pod/containerregistry_registry.go b/pkg/webhook/pod/containerregistry_registry.go index d80b6e97..fe7d87ec 100644 --- a/pkg/webhook/pod/containerregistry_registry.go +++ b/pkg/webhook/pod/containerregistry_registry.go @@ -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[a-zA-Z0-9-._]+(:\d+)?)\/)?(?P.*\/)?(?P[a-zA-Z0-9-._]+:(?P[a-zA-Z0-9-._]+))?`) match := r.FindStringSubmatch(value) diff --git a/pkg/webhook/pod/imagepullpolicy.go b/pkg/webhook/pod/imagepullpolicy.go index 42b7f72e..1eb773d7 100644 --- a/pkg/webhook/pod/imagepullpolicy.go +++ b/pkg/webhook/pod/imagepullpolicy.go @@ -25,54 +25,88 @@ 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) - } + return r.validate(ctx, c, decoder, recorder, req) + } +} - 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 - } +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) + } +} - tnt := tntList.Items[0] +func (r *imagePullPolicy) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} - policy := NewPullPolicy(&tnt) - // if Tenant doesn't enforce the pull policy, exit - if policy == nil { - 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) + } - for _, container := range pod.Spec.Containers { - usedPullPolicy := string(container.ImagePullPolicy) + 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 !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) + if len(tntList.Items) == 0 { + return nil + } - response := admission.Denied(NewImagePullPolicyForbidden(usedPullPolicy, container.Name, policy.AllowedPullPolicies()).Error()) + tnt := tntList.Items[0] - return &response - } + 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 } + } - return nil + for _, container := range pod.Spec.EphemeralContainers { + if response := h.verifyPullPolicy(recorder, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { + return response + } } -} -func (r *imagePullPolicy) OnUpdate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { - return func(context.Context, admission.Request) *admission.Response { - return nil + 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 (r *imagePullPolicy) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { - return func(context.Context, admission.Request) *admission.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 } diff --git a/pkg/webhook/route/tenants.go b/pkg/webhook/route/tenants.go index 0bae2f6c..3fe98525 100644 --- a/pkg/webhook/route/tenants.go +++ b/pkg/webhook/route/tenants.go @@ -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" } diff --git a/pkg/webhook/tenant/metadata.go b/pkg/webhook/tenant/metadata.go deleted file mode 100644 index 7425ec9a..00000000 --- a/pkg/webhook/tenant/metadata.go +++ /dev/null @@ -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 - } -} diff --git a/pkg/webhook/tenant/mutation/metadata.go b/pkg/webhook/tenant/mutation/metadata.go new file mode 100644 index 00000000..f098252d --- /dev/null +++ b/pkg/webhook/tenant/mutation/metadata.go @@ -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 +} diff --git a/pkg/webhook/tenant/containerregistry_regex.go b/pkg/webhook/tenant/validation/containerregistry_regex.go similarity index 99% rename from pkg/webhook/tenant/containerregistry_regex.go rename to pkg/webhook/tenant/validation/containerregistry_regex.go index de47db0e..a89d421f 100644 --- a/pkg/webhook/tenant/containerregistry_regex.go +++ b/pkg/webhook/tenant/validation/containerregistry_regex.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:dupl -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/cordoning.go b/pkg/webhook/tenant/validation/cordoning.go similarity index 99% rename from pkg/webhook/tenant/cordoning.go rename to pkg/webhook/tenant/validation/cordoning.go index 6aa4fe59..2f8fb591 100644 --- a/pkg/webhook/tenant/cordoning.go +++ b/pkg/webhook/tenant/validation/cordoning.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/custom_resource_quota.go b/pkg/webhook/tenant/validation/custom_resource_quota.go similarity index 99% rename from pkg/webhook/tenant/custom_resource_quota.go rename to pkg/webhook/tenant/validation/custom_resource_quota.go index d5b543e4..b0eeb3d4 100644 --- a/pkg/webhook/tenant/custom_resource_quota.go +++ b/pkg/webhook/tenant/validation/custom_resource_quota.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/custom_resource_quota_errors.go b/pkg/webhook/tenant/validation/custom_resource_quota_errors.go similarity index 96% rename from pkg/webhook/tenant/custom_resource_quota_errors.go rename to pkg/webhook/tenant/validation/custom_resource_quota_errors.go index 91fe878a..cb16b65d 100644 --- a/pkg/webhook/tenant/custom_resource_quota_errors.go +++ b/pkg/webhook/tenant/validation/custom_resource_quota_errors.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import "fmt" diff --git a/pkg/webhook/tenant/forbidden_annotations_regex.go b/pkg/webhook/tenant/validation/forbidden_annotations_regex.go similarity index 99% rename from pkg/webhook/tenant/forbidden_annotations_regex.go rename to pkg/webhook/tenant/validation/forbidden_annotations_regex.go index 3c6a41b9..8c2002b7 100644 --- a/pkg/webhook/tenant/forbidden_annotations_regex.go +++ b/pkg/webhook/tenant/validation/forbidden_annotations_regex.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/freezed_emitter.go b/pkg/webhook/tenant/validation/freezed_emitter.go similarity index 98% rename from pkg/webhook/tenant/freezed_emitter.go rename to pkg/webhook/tenant/validation/freezed_emitter.go index aa587a2a..dfe83a60 100644 --- a/pkg/webhook/tenant/freezed_emitter.go +++ b/pkg/webhook/tenant/validation/freezed_emitter.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/hostname_regex.go b/pkg/webhook/tenant/validation/hostname_regex.go similarity index 99% rename from pkg/webhook/tenant/hostname_regex.go rename to pkg/webhook/tenant/validation/hostname_regex.go index 7666de56..1447cfee 100644 --- a/pkg/webhook/tenant/hostname_regex.go +++ b/pkg/webhook/tenant/validation/hostname_regex.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:dupl -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/ingressclass_regex.go b/pkg/webhook/tenant/validation/ingressclass_regex.go similarity index 99% rename from pkg/webhook/tenant/ingressclass_regex.go rename to pkg/webhook/tenant/validation/ingressclass_regex.go index 13767c7c..ee1877f5 100644 --- a/pkg/webhook/tenant/ingressclass_regex.go +++ b/pkg/webhook/tenant/validation/ingressclass_regex.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:dupl -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/name.go b/pkg/webhook/tenant/validation/name.go similarity index 98% rename from pkg/webhook/tenant/name.go rename to pkg/webhook/tenant/validation/name.go index 469ddbef..f7d60aed 100644 --- a/pkg/webhook/tenant/name.go +++ b/pkg/webhook/tenant/validation/name.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/protected.go b/pkg/webhook/tenant/validation/protected.go similarity index 98% rename from pkg/webhook/tenant/protected.go rename to pkg/webhook/tenant/validation/protected.go index 3600c275..0ce5ced5 100644 --- a/pkg/webhook/tenant/protected.go +++ b/pkg/webhook/tenant/validation/protected.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/rolebindings_regex.go b/pkg/webhook/tenant/validation/rolebindings_regex.go similarity index 99% rename from pkg/webhook/tenant/rolebindings_regex.go rename to pkg/webhook/tenant/validation/rolebindings_regex.go index c26ecebd..fef51f50 100644 --- a/pkg/webhook/tenant/rolebindings_regex.go +++ b/pkg/webhook/tenant/validation/rolebindings_regex.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/serviceaccount_format.go b/pkg/webhook/tenant/validation/serviceaccount_format.go similarity index 98% rename from pkg/webhook/tenant/serviceaccount_format.go rename to pkg/webhook/tenant/validation/serviceaccount_format.go index 6bca2147..2074c1be 100644 --- a/pkg/webhook/tenant/serviceaccount_format.go +++ b/pkg/webhook/tenant/validation/serviceaccount_format.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package validation import ( "context" diff --git a/pkg/webhook/tenant/storageclass_regex.go b/pkg/webhook/tenant/validation/storageclass_regex.go similarity index 99% rename from pkg/webhook/tenant/storageclass_regex.go rename to pkg/webhook/tenant/validation/storageclass_regex.go index e08ff96b..4d0683f8 100644 --- a/pkg/webhook/tenant/storageclass_regex.go +++ b/pkg/webhook/tenant/validation/storageclass_regex.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //nolint:dupl -package tenant +package validation import ( "context"