diff --git a/api/v1beta2/capsuleconfiguration_status.go b/api/v1beta2/capsuleconfiguration_status.go index 716b98d96..d10d0689b 100644 --- a/api/v1beta2/capsuleconfiguration_status.go +++ b/api/v1beta2/capsuleconfiguration_status.go @@ -4,14 +4,27 @@ package v1beta2 import ( + "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" ) // CapsuleConfigurationStatus defines the Capsule configuration status. type CapsuleConfigurationStatus struct { + // Users which are considered Capsule Users and are bound to the Capsule Tenant construct. + Users rbac.UserListSpec `json:"users,omitempty"` + // Conditions holds the reconciliation conditions for this CapsuleConfiguration. + // Includes a Ready condition indicating whether the configuration was + // successfully validated and applied. + // +optional + Conditions meta.ConditionList `json:"conditions,omitempty"` + // TenantCount is the total number of Tenant objects currently present in the cluster. + // +optional + TenantCount *int64 `json:"tenantCount,omitempty"` + // ManagedNamespaceCount is the total number of namespaces currently under Capsule + // management, aggregated from status.size across all Tenants. + // +optional + ManagedNamespaceCount *int64 `json:"managedNamespaceCount,omitempty"` // ObservedGeneration is the most recent generation the controller has observed. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` - // Users which are considered Capsule Users and are bound to the Capsule Tenant construct. - Users rbac.UserListSpec `json:"users,omitempty"` } diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 3e592f823..34e97e33f 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -172,6 +172,10 @@ type ServiceAccountClient struct { // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster // +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Tenants",type="integer",JSONPath=".status.tenantCount",description="Total number of Tenants" +// +kubebuilder:printcolumn:name="Namespaces",type="integer",JSONPath=".status.managedNamespaceCount",description="Total managed namespaces" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile status" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // CapsuleConfiguration is the Schema for the Capsule configuration API. type CapsuleConfiguration struct { diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 6653aa02a..bc0ac9ca9 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -161,6 +161,23 @@ func (in *CapsuleConfigurationStatus) DeepCopyInto(out *CapsuleConfigurationStat *out = make(rbac.UserListSpec, len(*in)) copy(*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.TenantCount != nil { + in, out := &in.TenantCount, &out.TenantCount + *out = new(int64) + **out = **in + } + if in.ManagedNamespaceCount != nil { + in, out := &in.ManagedNamespaceCount, &out.ManagedNamespaceCount + *out = new(int64) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfigurationStatus. diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 82234d9a4..3d4584db9 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -14,7 +14,23 @@ spec: singular: capsuleconfiguration scope: Cluster versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Total number of Tenants + jsonPath: .status.tenantCount + name: Tenants + type: integer + - description: Total managed namespaces + jsonPath: .status.managedNamespaceCount + name: Namespaces + type: integer + - description: Reconcile status + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: CapsuleConfiguration is the Schema for the Capsule configuration @@ -1250,11 +1266,82 @@ spec: description: CapsuleConfigurationStatus defines the Capsule configuration status. properties: + conditions: + description: |- + Conditions holds the reconciliation conditions for this CapsuleConfiguration. + Includes a Ready condition indicating whether the configuration was + successfully validated and applied. + 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 + managedNamespaceCount: + description: |- + ManagedNamespaceCount is the total number of namespaces currently under Capsule + management, aggregated from status.size across all Tenants. + format: int64 + type: integer observedGeneration: description: ObservedGeneration is the most recent generation the controller has observed. format: int64 type: integer + tenantCount: + description: TenantCount is the total number of Tenant objects currently + present in the cluster. + format: int64 + type: integer users: description: Users which are considered Capsule Users and are bound to the Capsule Tenant construct. diff --git a/e2e/config_status_counters_test.go b/e2e/config_status_counters_test.go new file mode 100644 index 000000000..85fa5a664 --- /dev/null +++ b/e2e/config_status_counters_test.go @@ -0,0 +1,186 @@ +// Copyright 2020-2026 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" +) + +// configStatusCounters returns the current TenantCount and ManagedNamespaceCount +// from the default CapsuleConfiguration, treating a nil pointer as zero. +func configStatusCounters(g Gomega) (tenantCount, namespaceCount int64) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + g.Expect(k8sClient.Get(context.TODO(), client.ObjectKey{Name: defaultConfigurationName}, cfg)).To(Succeed()) + + if cfg.Status.TenantCount != nil { + tenantCount = *cfg.Status.TenantCount + } + + if cfg.Status.ManagedNamespaceCount != nil { + namespaceCount = *cfg.Status.ManagedNamespaceCount + } + + return tenantCount, namespaceCount +} + +var _ = Describe("CapsuleConfiguration status counters", Ordered, Label("config", "status", "counters"), func() { + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-cfg-counters-tnt", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: "e2e-cfg-counters-owner", + Kind: "User", + }, + }, + }, + }, + }, + } + + ns := NewNamespace("e2e-cfg-counters-ns") + + JustAfterEach(func() { + // Safety-net cleanup; EventuallyDeletion is idempotent for already-deleted objects. + EventuallyDeletion(ns) + EventuallyDeletion(tnt) + }) + + It("reflects Tenant create/delete and Namespace create/delete in status counters", func() { + var baseTenantCount, baseNSCount int64 + + // Wait for the controller to have populated the counters at least once. + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + g.Expect(k8sClient.Get(context.TODO(), client.ObjectKey{Name: defaultConfigurationName}, cfg)).To(Succeed()) + g.Expect(cfg.Status.TenantCount).NotTo(BeNil(), "TenantCount must be initialised by the controller before the test") + + baseTenantCount = *cfg.Status.TenantCount + + if cfg.Status.ManagedNamespaceCount != nil { + baseNSCount = *cfg.Status.ManagedNamespaceCount + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("creating a Tenant and asserting TenantCount increments by one") + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + + Eventually(func(g Gomega) { + tc, _ := configStatusCounters(g) + g.Expect(tc).To(Equal(baseTenantCount+1), "TenantCount should be baseTenantCount+1 after Tenant creation") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("creating a Namespace under the Tenant and asserting ManagedNamespaceCount increments by one") + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + TenantNamespaceReady(tnt, ns, 1) + + Eventually(func(g Gomega) { + _, nc := configStatusCounters(g) + g.Expect(nc).To(Equal(baseNSCount+1), "ManagedNamespaceCount should be baseNSCount+1 after Namespace creation") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("deleting the Namespace and asserting ManagedNamespaceCount returns to the baseline") + + EventuallyDeletion(ns) + + Eventually(func(g Gomega) { + _, nc := configStatusCounters(g) + g.Expect(nc).To(Equal(baseNSCount), "ManagedNamespaceCount should return to baseline after Namespace deletion") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("deleting the Tenant and asserting TenantCount returns to the baseline") + + EventuallyDeletion(tnt) + + Eventually(func(g Gomega) { + tc, _ := configStatusCounters(g) + g.Expect(tc).To(Equal(baseTenantCount), "TenantCount should return to baseline after Tenant deletion") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("does not count Namespaces that belong to no Tenant", func() { + var baseNSCount int64 + + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + g.Expect(k8sClient.Get(context.TODO(), client.ObjectKey{Name: defaultConfigurationName}, cfg)).To(Succeed()) + + if cfg.Status.ManagedNamespaceCount != nil { + baseNSCount = *cfg.Status.ManagedNamespaceCount + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + unmanaged := NewNamespace("e2e-cfg-counters-unmanaged") + + DeferCleanup(func() { + EventuallyDeletion(unmanaged) + }) + + Expect(k8sClient.Create(context.TODO(), unmanaged)).To(Succeed()) + + // Wait long enough for a potential (spurious) reconcile to settle. + Consistently(func(g Gomega) { + _, nc := configStatusCounters(g) + g.Expect(nc).To(Equal(baseNSCount), "ManagedNamespaceCount must not change for unmanaged Namespaces") + }, "10s", defaultPollInterval).Should(Succeed()) + + EventuallyDeletion(unmanaged) + }) + + It("sets status.size on the Tenant reflecting the namespace count", func() { + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + + ns1 := NewNamespace("e2e-cfg-counters-size-ns1") + ns2 := NewNamespace("e2e-cfg-counters-size-ns2") + + owner := tnt.Spec.Owners[0].UserSpec + + DeferCleanup(func() { + for _, n := range []*corev1.Namespace{ns1, ns2} { + EventuallyDeletion(n) + } + }) + + NamespaceCreation(ns1, owner, defaultTimeoutInterval).Should(Succeed()) + TenantNamespaceReady(tnt, ns1, 1) + + NamespaceCreation(ns2, owner, defaultTimeoutInterval).Should(Succeed()) + TenantNamespaceReady(tnt, ns2, 2) + + Eventually(func(g Gomega) { + _, nc := configStatusCounters(g) + g.Expect(nc).To(BeNumerically(">=", int64(2)), "ManagedNamespaceCount should include both namespaces") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) +}) diff --git a/internal/controllers/cfg/status/manager.go b/internal/controllers/cfg/status/manager.go index f556a76b2..ca012ebbd 100644 --- a/internal/controllers/cfg/status/manager.go +++ b/internal/controllers/cfg/status/manager.go @@ -10,6 +10,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" 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/util/retry" @@ -23,6 +24,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/controllers/utils" + capmeta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/predicates" @@ -64,6 +66,21 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller predicates.TenantStatusOwnersChangedPredicate{}, ), ). + Watches( + &capsulev1beta2.Tenant{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: ctrlConfig.ConfigurationName, + }, + }, + } + }), + builder.WithPredicates( + predicates.TenantCountOrSizeChangedPredicate{}, + ), + ). Watches( &capsulev1beta2.TenantOwner{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { @@ -132,7 +149,7 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res } defer func() { - if uerr := r.updateConfigStatus(ctx, instance); uerr != nil { + if uerr := r.updateConfigStatus(ctx, instance, err); uerr != nil { err = fmt.Errorf("cannot update config status: %w", uerr) return @@ -150,6 +167,10 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res log.V(5).Info("gathering capsule users", "users", len(instance.Status.Users)) + if err := r.gatherTenantCounts(ctx, instance); err != nil { + return reconcile.Result{}, err + } + return reconcile.Result{}, err } @@ -183,9 +204,32 @@ func (r *Manager) gatherCapsuleUsers( return nil } +func (r *Manager) gatherTenantCounts( + ctx context.Context, + instance *capsulev1beta2.CapsuleConfiguration, +) error { + tenantList := &capsulev1beta2.TenantList{} + if err := r.List(ctx, tenantList); err != nil { + return fmt.Errorf("listing Tenants: %w", err) + } + + var namespaceCount uint64 + for i := range tenantList.Items { + namespaceCount += uint64(tenantList.Items[i].Status.Size) + } + + tc := int64(len(tenantList.Items)) + mnc := int64(namespaceCount) + instance.Status.TenantCount = &tc + instance.Status.ManagedNamespaceCount = &mnc + + return nil +} + func (r *Manager) updateConfigStatus( ctx context.Context, instance *capsulev1beta2.CapsuleConfiguration, + reconcileErr error, ) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { latest := &capsulev1beta2.CapsuleConfiguration{} @@ -196,6 +240,17 @@ func (r *Manager) updateConfigStatus( latest.Status = instance.Status latest.Status.ObservedGeneration = instance.GetGeneration() + readyCondition := capmeta.NewReadyCondition(latest) + readyCondition.ObservedGeneration = instance.GetGeneration() + + if reconcileErr != nil { + readyCondition.Message = reconcileErr.Error() + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = capmeta.FailedReason + } + + latest.Status.Conditions.UpdateConditionByType(readyCondition) + return r.Client.Status().Update(ctx, latest) }) } diff --git a/pkg/runtime/predicates/tenant_change.go b/pkg/runtime/predicates/tenant_change.go index 13e38b51b..ed2287d03 100644 --- a/pkg/runtime/predicates/tenant_change.go +++ b/pkg/runtime/predicates/tenant_change.go @@ -16,6 +16,27 @@ func (TenantStatusOwnersChangedPredicate) Create(event.CreateEvent) bool { ret func (TenantStatusOwnersChangedPredicate) Delete(event.DeleteEvent) bool { return false } func (TenantStatusOwnersChangedPredicate) Generic(event.GenericEvent) bool { return false } +// TenantCountOrSizeChangedPredicate fires on Tenant create/delete (tenantCount changes) and on +// updates where status.size changed (namespaceCount changes). All other update events are filtered +// out. Kept separate from TenantStatusOwnersChangedPredicate which intentionally ignores +// create/delete because those do not affect user aggregation. +type TenantCountOrSizeChangedPredicate struct{} + +func (TenantCountOrSizeChangedPredicate) Create(event.CreateEvent) bool { return true } +func (TenantCountOrSizeChangedPredicate) Delete(event.DeleteEvent) bool { return true } +func (TenantCountOrSizeChangedPredicate) Generic(event.GenericEvent) bool { return false } + +func (TenantCountOrSizeChangedPredicate) Update(e event.UpdateEvent) bool { + oldObj, ok1 := e.ObjectOld.(*capsulev1beta2.Tenant) + newObj, ok2 := e.ObjectNew.(*capsulev1beta2.Tenant) + + if !ok1 || !ok2 { + return false + } + + return oldObj.Status.Size != newObj.Status.Size +} + func (TenantStatusOwnersChangedPredicate) Update(e event.UpdateEvent) bool { oldObj, ok1 := e.ObjectOld.(*capsulev1beta2.Tenant) newObj, ok2 := e.ObjectNew.(*capsulev1beta2.Tenant)