Skip to content

Commit d79bf8d

Browse files
committed
feat: add !=, == support for namespace field selector
Signed-off-by: Miltiadis Alexis <[email protected]>
1 parent 6a7caf0 commit d79bf8d

File tree

8 files changed

+173
-11
lines changed

8 files changed

+173
-11
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Description: Add `!=` and `==` operators for namespace field selector
2+
Authors: [Miltiadis Alexis](https://github.com/miltalex)
3+
Component: General
4+
Issues: 13468
5+
6+
You can now use the `!=` and `==` operators when filtering workflows by namespace field.
7+
This provides more flexible query capabilities, allowing you to easily exclude specific namespaces or match exact namespace values in your workflow queries.
8+
For example, you can filter with `namespace!=kube-system` to exclude system namespaces or `namespace==production` to target only production environments.

persist/sqldb/selector.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ import (
1010
)
1111

1212
func BuildArchivedWorkflowSelector(selector db.Selector, tableName, labelTableName string, t sqldb.DBType, options utils.ListOptions, count bool) (db.Selector, error) {
13+
if options.NamespaceFilter == "NotEquals" {
14+
selector = selector.And(namespaceNotEqual(options.Namespace))
15+
} else {
16+
selector = selector.And(namespaceEqual(options.Namespace))
17+
}
18+
1319
selector = selector.
14-
And(namespaceEqual(options.Namespace)).
1520
And(namePrefixClause(options.NamePrefix)).
1621
And(startedAtFromClause(options.MinStartedAt)).
1722
And(startedAtToClause(options.MaxStartedAt)).
@@ -59,7 +64,11 @@ func BuildArchivedWorkflowSelector(selector db.Selector, tableName, labelTableNa
5964
func BuildWorkflowSelector(in string, inArgs []any, tableName, labelTableName string, t sqldb.DBType, options utils.ListOptions, count bool) (out string, outArgs []any, err error) {
6065
var clauses []*db.RawExpr
6166
if options.Namespace != "" {
62-
clauses = append(clauses, db.Raw("namespace = ?", options.Namespace))
67+
if options.NamespaceFilter == "NotEquals" {
68+
clauses = append(clauses, db.Raw("namespace != ?", options.Namespace))
69+
} else {
70+
clauses = append(clauses, db.Raw("namespace = ?", options.Namespace))
71+
}
6372
}
6473
if options.Name != "" {
6574
nameFilter := options.NameFilter

persist/sqldb/workflow_archive.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,15 @@ func (r *workflowArchive) CountWorkflows(ctx context.Context, options sutils.Lis
288288
selector := r.session.SQL().
289289
Select(db.Raw("count(*) as total")).
290290
From(archiveTableName).
291-
Where(r.clusterManagedNamespaceAndInstanceID()).
292-
And(namespaceEqual(options.Namespace)).
291+
Where(r.clusterManagedNamespaceAndInstanceID())
292+
293+
if options.NamespaceFilter == "NotEquals" {
294+
selector = selector.And(namespaceNotEqual(options.Namespace))
295+
} else {
296+
selector = selector.And(namespaceEqual(options.Namespace))
297+
}
298+
299+
selector = selector.
293300
And(namePrefixClause(options.NamePrefix)).
294301
And(startedAtFromClause(options.MinStartedAt)).
295302
And(startedAtToClause(options.MaxStartedAt)).
@@ -343,8 +350,15 @@ func (r *workflowArchive) countWorkflowsOptimized(options sutils.ListOptions) (i
343350
sampleSelector := r.session.SQL().
344351
Select(db.Raw("count(*) as total")).
345352
From(archiveTableName).
346-
Where(r.clusterManagedNamespaceAndInstanceID()).
347-
And(namespaceEqual(options.Namespace)).
353+
Where(r.clusterManagedNamespaceAndInstanceID())
354+
355+
if options.NamespaceFilter == "NotEquals" {
356+
sampleSelector = sampleSelector.And(namespaceNotEqual(options.Namespace))
357+
} else {
358+
sampleSelector = sampleSelector.And(namespaceEqual(options.Namespace))
359+
}
360+
361+
sampleSelector = sampleSelector.
348362
And(namePrefixClause(options.NamePrefix)).
349363
And(startedAtFromClause(options.MinStartedAt)).
350364
And(startedAtToClause(options.MaxStartedAt)).
@@ -400,8 +414,15 @@ func (r *workflowArchive) HasMoreWorkflows(ctx context.Context, options sutils.L
400414
selector := r.session.SQL().
401415
Select("uid").
402416
From(archiveTableName).
403-
Where(r.clusterManagedNamespaceAndInstanceID()).
404-
And(namespaceEqual(options.Namespace)).
417+
Where(r.clusterManagedNamespaceAndInstanceID())
418+
419+
if options.NamespaceFilter == "NotEquals" {
420+
selector = selector.And(namespaceNotEqual(options.Namespace))
421+
} else {
422+
selector = selector.And(namespaceEqual(options.Namespace))
423+
}
424+
425+
selector = selector.
405426
And(namePrefixClause(options.NamePrefix)).
406427
And(startedAtFromClause(options.MinStartedAt)).
407428
And(startedAtToClause(options.MaxStartedAt)).
@@ -489,6 +510,13 @@ func namespaceEqual(namespace string) db.Cond {
489510
return db.Cond{}
490511
}
491512

513+
func namespaceNotEqual(namespace string) db.Cond {
514+
if namespace != "" {
515+
return db.Cond{"namespace !=": namespace}
516+
}
517+
return db.Cond{}
518+
}
519+
492520
func nameEqual(name string) db.Cond {
493521
if name != "" {
494522
return db.Cond{"name": name}

server/utils/list_options.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
type ListOptions struct {
1616
Namespace, Name string
1717
NamePrefix, NameFilter string
18+
NamespaceFilter string
1819
MinStartedAt, MaxStartedAt time.Time
1920
CreatedAfter, FinishedBefore time.Time
2021
LabelRequirements labels.Requirements
@@ -73,6 +74,7 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
7374
// namespace is now specified as its own query parameter
7475
// note that for backward compatibility, the field selector 'metadata.namespace' is also supported for now
7576
namespace := ns // optional
77+
namespaceFilter := ""
7678
name := ""
7779
minStartedAt := time.Time{}
7880
maxStartedAt := time.Time{}
@@ -96,7 +98,21 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
9698
if len(selector) == 0 {
9799
continue
98100
}
99-
if after, ok := strings.CutPrefix(selector, "metadata.namespace="); ok {
101+
if after, ok := strings.CutPrefix(selector, "metadata.namespace!="); ok {
102+
namespace = after
103+
namespaceFilter = "NotEquals"
104+
} else if after, ok := strings.CutPrefix(selector, "metadata.namespace=="); ok {
105+
fieldSelectedNamespace := after
106+
switch namespace {
107+
case "":
108+
namespace = fieldSelectedNamespace
109+
case fieldSelectedNamespace:
110+
break
111+
default:
112+
return ListOptions{}, status.Errorf(codes.InvalidArgument,
113+
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", namespace, fieldSelectedNamespace)
114+
}
115+
} else if after, ok := strings.CutPrefix(selector, "metadata.namespace="); ok {
100116
// for backward compatibility, the field selector 'metadata.namespace' is supported for now despite the addition
101117
// of the new 'namespace' query parameter, which is what the UI uses
102118
fieldSelectedNamespace := after
@@ -147,6 +163,7 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
147163
Name: name,
148164
NamePrefix: namePrefix,
149165
NameFilter: nameFilter,
166+
NamespaceFilter: namespaceFilter,
150167
CreatedAfter: createdAfterTime,
151168
FinishedBefore: finishedBeforeTime,
152169
MinStartedAt: minStartedAt,

server/utils/list_options_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,67 @@ func TestBuildListOptions(t *testing.T) {
130130
NameFilter: "",
131131
},
132132
},
133+
{
134+
name: "Field selector with metadata.namespace!=",
135+
options: metav1.ListOptions{
136+
FieldSelector: "metadata.namespace!=excluded",
137+
},
138+
expected: ListOptions{
139+
Namespace: "excluded",
140+
NamespaceFilter: "NotEquals",
141+
},
142+
},
143+
{
144+
name: "Field selector with metadata.namespace==",
145+
options: metav1.ListOptions{
146+
FieldSelector: "metadata.namespace==included",
147+
},
148+
expected: ListOptions{
149+
Namespace: "included",
150+
},
151+
},
152+
{
153+
name: "Field selector with metadata.namespace!= and metadata.namespace==",
154+
options: metav1.ListOptions{
155+
FieldSelector: "metadata.namespace!=excluded,metadata.namespace==included",
156+
},
157+
// Logic: != sets ns=excluded, filter=NotEquals.
158+
// Then == sets ns=included.
159+
// Conflict check for ==: ns=excluded (from prev) vs included. Conflict!
160+
expectedError: status.Errorf(codes.InvalidArgument,
161+
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", "excluded", "included"),
162+
},
163+
{
164+
name: "Field selector with metadata.namespace== and metadata.namespace!=",
165+
options: metav1.ListOptions{
166+
FieldSelector: "metadata.namespace==included,metadata.namespace!=excluded",
167+
},
168+
// Logic: == sets ns=included.
169+
// Then != sets ns=excluded, filter=NotEquals.
170+
expected: ListOptions{
171+
Namespace: "excluded",
172+
NamespaceFilter: "NotEquals",
173+
},
174+
},
175+
{
176+
name: "Conflict metadata.namespace== and ns param",
177+
options: metav1.ListOptions{
178+
FieldSelector: "metadata.namespace==included",
179+
},
180+
ns: "other",
181+
expectedError: status.Errorf(codes.InvalidArgument,
182+
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", "other", "included"),
183+
},
184+
{
185+
name: "Valid metadata.namespace== and ns param",
186+
options: metav1.ListOptions{
187+
FieldSelector: "metadata.namespace==included",
188+
},
189+
ns: "included",
190+
expected: ListOptions{
191+
Namespace: "included",
192+
},
193+
},
133194
{
134195
name: "Invalid field selector",
135196
options: metav1.ListOptions{

server/workflow/workflow_server.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,17 @@ func (s *workflowServer) ListWorkflows(ctx context.Context, req *workflowpkg.Wor
188188
}
189189

190190
// verify if we have permission to list Workflows
191-
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, options.Namespace, "")
191+
targetNamespace := options.Namespace
192+
if options.NamespaceFilter == "NotEquals" {
193+
targetNamespace = ""
194+
}
195+
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, targetNamespace, "")
192196
if err != nil {
193197
return nil, sutils.ToStatusError(err, codes.Internal)
194198
}
199+
195200
if !allowed {
196-
return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("Permission denied, you are not allowed to list workflows in namespace \"%s\". Maybe you want to specify a namespace with query parameter `.namespace=%s`?", options.Namespace, options.Namespace))
201+
return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("Permission denied, you are not allowed to list workflows in namespace \"%s\". Maybe you want to specify a namespace with query parameter `.namespace=%s`?", targetNamespace, targetNamespace))
197202
}
198203

199204
var wfs wfv1.Workflows

server/workflowarchive/archived_workflow_server_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ func Test_archivedWorkflowServer(t *testing.T) {
6868
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "my-name", NamePrefix: "my-", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0, ShowRemainingItemCount: true}).Return(v1alpha1.Workflows{{}}, nil)
6969
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "excluded-name", NameFilter: "NotEquals", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0}).Return(v1alpha1.Workflows{{}}, nil)
7070
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "exact-name", NameFilter: "", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0}).Return(v1alpha1.Workflows{{}}, nil)
71+
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "excluded-ns", NamespaceFilter: "NotEquals", Name: "", NamePrefix: "", MinStartedAt: time.Time{}, MaxStartedAt: time.Time{}, Limit: 2, Offset: 0}).Return(v1alpha1.Workflows{{}, {}}, nil)
72+
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "user-ns", Name: "", NamePrefix: "", MinStartedAt: time.Time{}, MaxStartedAt: time.Time{}, Limit: 1, Offset: 0}).Return(v1alpha1.Workflows{{}}, nil)
7173
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "user-ns", Name: "", NamePrefix: "", MinStartedAt: time.Time{}, MaxStartedAt: time.Time{}, Limit: 2, Offset: 0}).Return(v1alpha1.Workflows{{}, {}}, nil)
7274
repo.On("CountWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "my-name", NamePrefix: "my-", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0}).Return(int64(5), nil)
7375
repo.On("CountWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "my-name", NamePrefix: "my-", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0, ShowRemainingItemCount: true}).Return(int64(5), nil)
@@ -198,6 +200,18 @@ func Test_archivedWorkflowServer(t *testing.T) {
198200
_, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{Namespace: "user-ns", ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace=other-ns"}})
199201
assert.Equal(t, err, status.Error(codes.InvalidArgument, "'namespace' query param (\"user-ns\") and fieldselector 'metadata.namespace' (\"other-ns\") are both specified and contradict each other"))
200202

203+
// namespace NotEquals
204+
resp, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace!=excluded-ns"}})
205+
require.NoError(t, err)
206+
assert.Len(t, resp.Items, 1)
207+
assert.Equal(t, "1", resp.Continue)
208+
209+
// namespace DoubleEquals
210+
resp, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace==user-ns"}})
211+
require.NoError(t, err)
212+
assert.Len(t, resp.Items, 1)
213+
assert.Equal(t, "1", resp.Continue)
214+
201215
})
202216
t.Run("GetArchivedWorkflow", func(t *testing.T) {
203217
allowed = false

test/e2e/argo_server_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,19 @@ func (s *ArgoServerSuite) TestPermission() {
554554
IsEqual(1)
555555
})
556556

557+
// Test list workflows with good token and NotEquals namespace
558+
s.Run("ListWFsGoodTokenNotEqualsNamespace", func() {
559+
s.e().GET("/api/v1/workflows/"+nsName).
560+
WithQuery("listOptions.fieldSelector", "metadata.namespace!="+nsName+"-excluded").
561+
Expect().
562+
Status(200).
563+
JSON().
564+
Path("$.items").
565+
Array().
566+
Length().
567+
IsEqual(1)
568+
})
569+
557570
s.Given().
558571
When().
559572
WaitForWorkflow(fixtures.ToBeArchived)
@@ -587,6 +600,13 @@ func (s *ArgoServerSuite) TestPermission() {
587600
Status(403)
588601
})
589602

603+
s.Run("ListWFsGoodTokenNotEqualsNamespaceBadToken", func() {
604+
s.e().GET("/api/v1/workflows/"+nsName).
605+
WithQuery("listOptions.fieldSelector", "metadata.namespace!="+nsName+"-excluded").
606+
Expect().
607+
Status(403)
608+
})
609+
590610
// Test list workflows with bad token
591611
s.Run("ListWFsBadToken", func() {
592612
s.e().GET("/api/v1/workflows/" + nsName).

0 commit comments

Comments
 (0)