Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/v1beta2/tenant_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ type TenantStatusNamespaceMetadata struct {
}

type TenantAvailableStatus struct {
// Available Nodes within Tenant
// +optional
Nodes []string `json:"nodes,omitempty"`
// Available Class Types within Tenant
// +optional
Classes TenantAvailableClassesStatus `json:"classes,omitzero"`
Expand Down
5 changes: 5 additions & 0 deletions api/v1beta2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions charts/capsule/crds/capsule.clastix.io_tenants.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2869,6 +2869,11 @@ spec:
items:
type: string
type: array
nodes:
description: Available Nodes within Tenant
items:
type: string
type: array
owners:
description: Collected owners for this tenant
items:
Expand Down
203 changes: 203 additions & 0 deletions e2e/tenant_node_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0

package e2e

import (
"context"
"sort"

. "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/api"
)

var _ = Describe("when Tenant handles Node status", Label("tenant", "nodes", "status"), func() {
const (
e2eNodeLabelKey = "capsule.clastix.io/e2e-node-status"
e2eNodeLabelValue = "true"
)

tntNoRestrictions := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-node-status-no-restrictions",
},
Spec: capsulev1beta2.TenantSpec{
Owners: api.OwnerListSpec{
{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Name: "e2e-node-status-no-restrictions",
Kind: "User",
},
},
},
},
},
}

tntWithSelector := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-node-status-with-selector",
},
Spec: capsulev1beta2.TenantSpec{
Owners: api.OwnerListSpec{
{
CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{
Name: "e2e-node-status-with-selector",
Kind: "User",
},
},
},
},
NodeSelector: map[string]string{
e2eNodeLabelKey: e2eNodeLabelValue,
},
},
}

var (
allNodeNames []string
primaryNode string
secondaryNode string
)

setNodeLabel := func(nodeName string, value *string) error {
node := &corev1.Node{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: nodeName}, node); err != nil {
return err
}

labels := node.GetLabels()
if labels == nil {
labels = map[string]string{}
}

if value == nil {
delete(labels, e2eNodeLabelKey)
} else {
labels[e2eNodeLabelKey] = *value
}

node.SetLabels(labels)

return k8sClient.Update(context.TODO(), node)
}

JustBeforeEach(func() {
nodeList := &corev1.NodeList{}
Expect(k8sClient.List(context.TODO(), nodeList)).To(Succeed())
Expect(nodeList.Items).ToNot(BeEmpty())

allNodeNames = allNodeNames[:0]
for i := range nodeList.Items {
allNodeNames = append(allNodeNames, nodeList.Items[i].GetName())
}
sort.Strings(allNodeNames)

primaryNode = nodeList.Items[0].GetName()
secondaryNode = ""
if len(nodeList.Items) > 1 {
secondaryNode = nodeList.Items[1].GetName()
}

for i := range nodeList.Items {
name := nodeList.Items[i].GetName()
EventuallyCreation(func() error {
if name == primaryNode {
return setNodeLabel(name, ptrTo(e2eNodeLabelValue))
}

return setNodeLabel(name, nil)
}).Should(Succeed())
}

for _, tnt := range []*capsulev1beta2.Tenant{tntNoRestrictions, tntWithSelector} {
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.TODO(), tnt)
}).Should(Succeed())
}
})

JustAfterEach(func() {
for _, tnt := range []*capsulev1beta2.Tenant{tntNoRestrictions, tntWithSelector} {
EventuallyCreation(func() error {
return ignoreNotFound(k8sClient.Delete(context.TODO(), tnt))
}).Should(Succeed())
}

nodeList := &corev1.NodeList{}
Expect(k8sClient.List(context.TODO(), nodeList)).To(Succeed())

for i := range nodeList.Items {
name := nodeList.Items[i].GetName()
EventuallyCreation(func() error {
return setNodeLabel(name, nil)
}).Should(Succeed())
}
})

It("should reconcile status nodes on create and metadata update events", func() {
By("verifying initial status nodes")
Eventually(func() ([]string, error) {
t := &capsulev1beta2.Tenant{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tntNoRestrictions.GetName()}, t); err != nil {
return nil, err
}

return t.Status.Nodes, nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(allNodeNames))

Eventually(func() ([]string, error) {
t := &capsulev1beta2.Tenant{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tntWithSelector.GetName()}, t); err != nil {
return nil, err
}

return t.Status.Nodes, nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal([]string{primaryNode}))

By("updating node labels to trigger metadata-based status reconciliation")
EventuallyCreation(func() error {
return setNodeLabel(primaryNode, nil)
}).Should(Succeed())

expectedSelectorNodes := []string{}
if secondaryNode != "" {
EventuallyCreation(func() error {
return setNodeLabel(secondaryNode, ptrTo(e2eNodeLabelValue))
}).Should(Succeed())
expectedSelectorNodes = append(expectedSelectorNodes, secondaryNode)
}
sort.Strings(expectedSelectorNodes)

Eventually(func() ([]string, error) {
t := &capsulev1beta2.Tenant{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tntWithSelector.GetName()}, t); err != nil {
return nil, err
}

return t.Status.Nodes, nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(expectedSelectorNodes))

Eventually(func() ([]string, error) {
t := &capsulev1beta2.Tenant{}
if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tntNoRestrictions.GetName()}, t); err != nil {
return nil, err
}

return t.Status.Nodes, nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(allNodeNames))
})
})

func ptrTo(s string) *string {
return &s
}
4 changes: 4 additions & 0 deletions internal/controllers/tenant/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller
),
builder.WithPredicates(predicates.UpdatedLabelsPredicate{}),
).
Watches(
&corev1.Node{},
r.statusOnlyHandlerNodes(),
).
Watches(
&capsulev1beta2.TenantOwner{},
handler.TypedFuncs[client.Object, ctrl.Request]{
Expand Down
38 changes: 38 additions & 0 deletions internal/controllers/tenant/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"fmt"
"sort"

corev1 "k8s.io/api/core/v1"
nodev1 "k8s.io/api/node/v1"
resources "k8s.io/api/resource/v1"
schedulingv1 "k8s.io/api/scheduling/v1"
Expand Down Expand Up @@ -112,9 +113,46 @@

log.V(5).Info("collected available runtimeclasses", "size", len(tnt.Status.Classes.RuntimeClasses))

if err = r.collectAvailableNodes(ctx, tnt); err != nil {
return err
}

log.V(5).Info("collected available nodes", "size", len(tnt.Status.Nodes))

return nil
}

func (r *Manager) collectAvailableNodes(ctx context.Context, tnt *capsulev1beta2.Tenant) (err error) {
nodeList := &corev1.NodeList{}
if err = r.List(ctx, nodeList); err != nil {
return err
}

nodes := make([]string, 0, len(nodeList.Items))
for i := range nodeList.Items {

Check failure on line 132 in internal/controllers/tenant/status.go

View workflow job for this annotation

GitHub Actions / lint

ranges should only be cuddled with assignments used in the iteration (wsl)
n := &nodeList.Items[i]
if !tenantNodeSelectorMatch(tnt, n) {
continue
}

nodes = append(nodes, n.GetName())
}

sort.Strings(nodes)

tnt.Status.Nodes = nodes

return nil
}

func tenantNodeSelectorMatch(tnt *capsulev1beta2.Tenant, obj client.Object) bool {
if len(tnt.Spec.NodeSelector) == 0 {
return true
}

return labels.SelectorFromSet(tnt.Spec.NodeSelector).Matches(labels.Set(obj.GetLabels()))
}

func (r *Manager) collectAvailableDeviceClasses(ctx context.Context, tnt *capsulev1beta2.Tenant) (err error) {
if tnt.Status.Classes.DeviceClasses, err = listObjectNamesBySelector2(
ctx,
Expand Down
Loading
Loading