[registry] Add alphabetical sorting to resource lists

Add sorting helper package with generic functions for sorting
resources by name (cluster-scoped) or namespace/name (namespace-scoped).

Apply sorting to:
- TenantNamespace (by name)
- TenantSecret (refactored to use helper)
- TenantModule (new)
- Application (new)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
This commit is contained in:
Aleksei Sviridkin 2025-12-26 12:48:25 +03:00
parent d7931fdb20
commit 0b16f83eae
No known key found for this signature in database
GPG key ID: 7988329FDF395282
9 changed files with 262 additions and 16 deletions

View file

@ -42,6 +42,7 @@ import (
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
"github.com/cozystack/cozystack/pkg/registry/sorting"
internalapiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
@ -394,6 +395,8 @@ func (r *REST) List(ctx context.Context, options *metainternalversion.ListOption
appList.SetResourceVersion(hrList.GetResourceVersion())
appList.Items = items
sorting.ByNamespacedName[appsv1alpha1.Application, *appsv1alpha1.Application](appList.Items)
klog.V(6).Infof("Successfully listed %d Application resources in namespace %s", len(items), namespace)
return appList, nil
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
package application
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/registry/sorting"
)
func TestApplicationListSortsAlphabetically(t *testing.T) {
items := []appsv1alpha1.Application{
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "zebra"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "bravo"}},
}
sorting.ByNamespacedName[appsv1alpha1.Application, *appsv1alpha1.Application](items)
expected := []struct{ ns, name string }{
{"ns-a", "alpha"},
{"ns-a", "bravo"},
{"ns-b", "alpha"},
{"ns-b", "zebra"},
}
for i, exp := range expected {
if items[i].Namespace != exp.ns || items[i].Name != exp.name {
t.Errorf("item %d: expected %s/%s, got %s/%s", i, exp.ns, exp.name, items[i].Namespace, items[i].Name)
}
}
}

View file

@ -40,6 +40,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
"github.com/cozystack/cozystack/pkg/registry/sorting"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
@ -288,6 +289,8 @@ func (r *REST) List(ctx context.Context, options *metainternalversion.ListOption
moduleList.SetResourceVersion(hrList.GetResourceVersion())
moduleList.Items = items
sorting.ByNamespacedName[corev1alpha1.TenantModule, *corev1alpha1.TenantModule](moduleList.Items)
klog.V(6).Infof("Successfully listed %d TenantModule resources in namespace %s", len(items), namespace)
return moduleList, nil
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
package tenantmodule
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
"github.com/cozystack/cozystack/pkg/registry/sorting"
)
func TestTenantModuleListSortsAlphabetically(t *testing.T) {
items := []corev1alpha1.TenantModule{
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "zebra"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "bravo"}},
}
sorting.ByNamespacedName[corev1alpha1.TenantModule, *corev1alpha1.TenantModule](items)
expected := []struct{ ns, name string }{
{"ns-a", "alpha"},
{"ns-a", "bravo"},
{"ns-b", "alpha"},
{"ns-b", "zebra"},
}
for i, exp := range expected {
if items[i].Namespace != exp.ns || items[i].Name != exp.name {
t.Errorf("item %d: expected %s/%s, got %s/%s", i, exp.ns, exp.name, items[i].Namespace, items[i].Name)
}
}
}

View file

@ -26,6 +26,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
"github.com/cozystack/cozystack/pkg/registry/sorting"
)
const (
@ -206,10 +207,10 @@ func (r *REST) ConvertToTable(_ context.Context, obj runtime.Object, _ runtime.O
for i := range v.Items {
tbl.Rows = append(tbl.Rows, row(&v.Items[i]))
}
tbl.ListMeta.ResourceVersion = v.ListMeta.ResourceVersion
tbl.ResourceVersion = v.ResourceVersion
case *corev1alpha1.TenantNamespace:
tbl.Rows = append(tbl.Rows, row(v))
tbl.ListMeta.ResourceVersion = v.ResourceVersion
tbl.ResourceVersion = v.ResourceVersion
default:
return nil, notAcceptable{r.gvr.GroupResource(), fmt.Sprintf("unexpected %T", obj)}
}
@ -254,6 +255,9 @@ func (r *REST) makeList(src *corev1.NamespaceList, allowed []string) *corev1alph
},
})
}
sorting.ByName[corev1alpha1.TenantNamespace, *corev1alpha1.TenantNamespace](out.Items)
return out
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
package tenantnamespace
import (
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestMakeListSortsAlphabetically(t *testing.T) {
r := &REST{}
// Create namespaces in non-alphabetical order
src := &corev1.NamespaceList{
Items: []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "tenant-zebra"}},
{ObjectMeta: metav1.ObjectMeta{Name: "tenant-alpha"}},
{ObjectMeta: metav1.ObjectMeta{Name: "tenant-mike"}},
{ObjectMeta: metav1.ObjectMeta{Name: "tenant-bravo"}},
},
}
allowed := []string{"tenant-zebra", "tenant-alpha", "tenant-mike", "tenant-bravo"}
result := r.makeList(src, allowed)
expected := []string{"tenant-alpha", "tenant-bravo", "tenant-mike", "tenant-zebra"}
if len(result.Items) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(result.Items))
}
for i, name := range expected {
if result.Items[i].Name != name {
t.Errorf("item %d: expected %q, got %q", i, name, result.Items[i].Name)
}
}
}

View file

@ -9,7 +9,6 @@ import (
"encoding/base64"
"fmt"
"net/http"
"slices"
"time"
corev1 "k8s.io/api/core/v1"
@ -28,6 +27,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
"github.com/cozystack/cozystack/pkg/registry/sorting"
)
// -----------------------------------------------------------------------------
@ -278,17 +278,7 @@ func (r *REST) List(ctx context.Context, opts *metainternal.ListOptions) (runtim
for i := range list.Items {
out.Items = append(out.Items, *secretToTenant(&list.Items[i]))
}
slices.SortFunc(out.Items, func(a, b corev1alpha1.TenantSecret) int {
aKey := fmt.Sprintf("%s/%s", a.Namespace, a.Name)
bKey := fmt.Sprintf("%s/%s", b.Namespace, b.Name)
switch {
case aKey < bKey:
return -1
case aKey > bKey:
return 1
}
return 0
})
sorting.ByNamespacedName[corev1alpha1.TenantSecret, *corev1alpha1.TenantSecret](out.Items)
return out, nil
}
@ -481,10 +471,10 @@ func (r *REST) ConvertToTable(_ context.Context, obj runtime.Object, _ runtime.O
for i := range v.Items {
tbl.Rows = append(tbl.Rows, row(&v.Items[i]))
}
tbl.ListMeta.ResourceVersion = v.ListMeta.ResourceVersion
tbl.ResourceVersion = v.ResourceVersion
case *corev1alpha1.TenantSecret:
tbl.Rows = append(tbl.Rows, row(v))
tbl.ListMeta.ResourceVersion = v.ResourceVersion
tbl.ResourceVersion = v.ResourceVersion
default:
return nil, notAcceptable{r.gvr.GroupResource(), fmt.Sprintf("unexpected %T", obj)}
}

View file

@ -0,0 +1,74 @@
// SPDX-License-Identifier: Apache-2.0
// Package sorting provides generic sorting utilities for registry resources.
package sorting
import (
"slices"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// NameGetter is an interface for objects that have a Name.
type NameGetter interface {
GetName() string
}
// NamespaceGetter is an interface for objects that have both Name and Namespace.
type NamespaceGetter interface {
NameGetter
GetNamespace() string
}
// ByName sorts a slice of objects by their Name in alphabetical order.
// Use this for cluster-scoped resources.
func ByName[T any, PT interface {
*T
NameGetter
}](items []T) {
slices.SortFunc(items, func(a, b T) int {
pa, pb := PT(&a), PT(&b)
switch {
case pa.GetName() < pb.GetName():
return -1
case pa.GetName() > pb.GetName():
return 1
}
return 0
})
}
// ByNamespacedName sorts a slice of objects by Namespace/Name in alphabetical order.
// Use this for namespace-scoped resources.
func ByNamespacedName[T any, PT interface {
*T
NamespaceGetter
}](items []T) {
slices.SortFunc(items, func(a, b T) int {
pa, pb := PT(&a), PT(&b)
aKey := pa.GetNamespace() + "/" + pa.GetName()
bKey := pb.GetNamespace() + "/" + pb.GetName()
switch {
case aKey < bKey:
return -1
case aKey > bKey:
return 1
}
return 0
})
}
// ObjectMetaWrapper wraps metav1.ObjectMeta to implement NamespaceGetter.
type ObjectMetaWrapper struct {
metav1.ObjectMeta
}
// GetName returns the name.
func (o *ObjectMetaWrapper) GetName() string {
return o.Name
}
// GetNamespace returns the namespace.
func (o *ObjectMetaWrapper) GetNamespace() string {
return o.Namespace
}

View file

@ -0,0 +1,60 @@
// SPDX-License-Identifier: Apache-2.0
package sorting
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type testClusterScoped struct {
metav1.ObjectMeta
}
type testNamespaceScoped struct {
metav1.ObjectMeta
}
func TestByName(t *testing.T) {
items := []testClusterScoped{
{ObjectMeta: metav1.ObjectMeta{Name: "zebra"}},
{ObjectMeta: metav1.ObjectMeta{Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Name: "mike"}},
{ObjectMeta: metav1.ObjectMeta{Name: "bravo"}},
}
ByName[testClusterScoped, *testClusterScoped](items)
expected := []string{"alpha", "bravo", "mike", "zebra"}
for i, name := range expected {
if items[i].Name != name {
t.Errorf("item %d: expected %q, got %q", i, name, items[i].Name)
}
}
}
func TestByNamespacedName(t *testing.T) {
items := []testNamespaceScoped{
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "zebra"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-b", Name: "alpha"}},
{ObjectMeta: metav1.ObjectMeta{Namespace: "ns-a", Name: "bravo"}},
}
ByNamespacedName[testNamespaceScoped, *testNamespaceScoped](items)
expected := []struct{ ns, name string }{
{"ns-a", "alpha"},
{"ns-a", "bravo"},
{"ns-b", "alpha"},
{"ns-b", "zebra"},
}
for i, exp := range expected {
if items[i].Namespace != exp.ns || items[i].Name != exp.name {
t.Errorf("item %d: expected %s/%s, got %s/%s", i, exp.ns, exp.name, items[i].Namespace, items[i].Name)
}
}
}