Skip to content
Open
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
8 changes: 8 additions & 0 deletions .features/pending/namespace-field-selectors-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Description: Add `!=` and `==` operators for namespace field selector
Authors: [Miltiadis Alexis](https://github.com/miltalex)
Component: General
Issues: 13468

You can now use the `!=` and `==` operators when filtering workflows by namespace field.
This provides more flexible query capabilities, allowing you to easily exclude specific namespaces or match exact namespace values in your workflow queries.
For example, you can filter with `namespace!=kube-system` to exclude system namespaces or `namespace==production` to target only production environments.
13 changes: 11 additions & 2 deletions persist/sqldb/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import (
)

func BuildArchivedWorkflowSelector(selector db.Selector, tableName, labelTableName string, t sqldb.DBType, options utils.ListOptions, count bool) (db.Selector, error) {
if options.NamespaceFilter == "NotEquals" {
selector = selector.And(namespaceNotEqual(options.Namespace))
} else {
selector = selector.And(namespaceEqual(options.Namespace))
}

selector = selector.
And(namespaceEqual(options.Namespace)).
And(namePrefixClause(options.NamePrefix)).
And(startedAtFromClause(options.MinStartedAt)).
And(startedAtToClause(options.MaxStartedAt)).
Expand Down Expand Up @@ -59,7 +64,11 @@ func BuildArchivedWorkflowSelector(selector db.Selector, tableName, labelTableNa
func BuildWorkflowSelector(in string, inArgs []any, tableName, labelTableName string, t sqldb.DBType, options utils.ListOptions, count bool) (out string, outArgs []any, err error) {
var clauses []*db.RawExpr
if options.Namespace != "" {
clauses = append(clauses, db.Raw("namespace = ?", options.Namespace))
if options.NamespaceFilter == "NotEquals" {
clauses = append(clauses, db.Raw("namespace != ?", options.Namespace))
} else {
clauses = append(clauses, db.Raw("namespace = ?", options.Namespace))
}
}
if options.Name != "" {
nameFilter := options.NameFilter
Expand Down
40 changes: 34 additions & 6 deletions persist/sqldb/workflow_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,15 @@ func (r *workflowArchive) CountWorkflows(ctx context.Context, options sutils.Lis
selector := r.session.SQL().
Select(db.Raw("count(*) as total")).
From(archiveTableName).
Where(r.clusterManagedNamespaceAndInstanceID()).
And(namespaceEqual(options.Namespace)).
Where(r.clusterManagedNamespaceAndInstanceID())

if options.NamespaceFilter == "NotEquals" {
selector = selector.And(namespaceNotEqual(options.Namespace))
} else {
selector = selector.And(namespaceEqual(options.Namespace))
}

selector = selector.
And(namePrefixClause(options.NamePrefix)).
And(startedAtFromClause(options.MinStartedAt)).
And(startedAtToClause(options.MaxStartedAt)).
Expand Down Expand Up @@ -343,8 +350,15 @@ func (r *workflowArchive) countWorkflowsOptimized(options sutils.ListOptions) (i
sampleSelector := r.session.SQL().
Select(db.Raw("count(*) as total")).
From(archiveTableName).
Where(r.clusterManagedNamespaceAndInstanceID()).
And(namespaceEqual(options.Namespace)).
Where(r.clusterManagedNamespaceAndInstanceID())

if options.NamespaceFilter == "NotEquals" {
sampleSelector = sampleSelector.And(namespaceNotEqual(options.Namespace))
} else {
sampleSelector = sampleSelector.And(namespaceEqual(options.Namespace))
}

sampleSelector = sampleSelector.
And(namePrefixClause(options.NamePrefix)).
And(startedAtFromClause(options.MinStartedAt)).
And(startedAtToClause(options.MaxStartedAt)).
Expand Down Expand Up @@ -400,8 +414,15 @@ func (r *workflowArchive) HasMoreWorkflows(ctx context.Context, options sutils.L
selector := r.session.SQL().
Select("uid").
From(archiveTableName).
Where(r.clusterManagedNamespaceAndInstanceID()).
And(namespaceEqual(options.Namespace)).
Where(r.clusterManagedNamespaceAndInstanceID())

if options.NamespaceFilter == "NotEquals" {
selector = selector.And(namespaceNotEqual(options.Namespace))
} else {
selector = selector.And(namespaceEqual(options.Namespace))
}

selector = selector.
And(namePrefixClause(options.NamePrefix)).
And(startedAtFromClause(options.MinStartedAt)).
And(startedAtToClause(options.MaxStartedAt)).
Expand Down Expand Up @@ -489,6 +510,13 @@ func namespaceEqual(namespace string) db.Cond {
return db.Cond{}
}

func namespaceNotEqual(namespace string) db.Cond {
if namespace != "" {
return db.Cond{"namespace !=": namespace}
}
return db.Cond{}
}

func nameEqual(name string) db.Cond {
if name != "" {
return db.Cond{"name": name}
Expand Down
19 changes: 18 additions & 1 deletion server/utils/list_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
type ListOptions struct {
Namespace, Name string
NamePrefix, NameFilter string
NamespaceFilter string
MinStartedAt, MaxStartedAt time.Time
CreatedAfter, FinishedBefore time.Time
LabelRequirements labels.Requirements
Expand Down Expand Up @@ -73,6 +74,7 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
// namespace is now specified as its own query parameter
// note that for backward compatibility, the field selector 'metadata.namespace' is also supported for now
namespace := ns // optional
namespaceFilter := ""
name := ""
minStartedAt := time.Time{}
maxStartedAt := time.Time{}
Expand All @@ -96,7 +98,21 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
if len(selector) == 0 {
continue
}
if after, ok := strings.CutPrefix(selector, "metadata.namespace="); ok {
if after, ok := strings.CutPrefix(selector, "metadata.namespace!="); ok {
namespace = after
namespaceFilter = "NotEquals"
} else if after, ok := strings.CutPrefix(selector, "metadata.namespace=="); ok {
fieldSelectedNamespace := after
switch namespace {
case "":
namespace = fieldSelectedNamespace
case fieldSelectedNamespace:
break
default:
return ListOptions{}, status.Errorf(codes.InvalidArgument,
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", namespace, fieldSelectedNamespace)
}
} else if after, ok := strings.CutPrefix(selector, "metadata.namespace="); ok {
// for backward compatibility, the field selector 'metadata.namespace' is supported for now despite the addition
// of the new 'namespace' query parameter, which is what the UI uses
fieldSelectedNamespace := after
Expand Down Expand Up @@ -147,6 +163,7 @@ func BuildListOptions(options metav1.ListOptions, ns, namePrefix, nameFilter, cr
Name: name,
NamePrefix: namePrefix,
NameFilter: nameFilter,
NamespaceFilter: namespaceFilter,
CreatedAfter: createdAfterTime,
FinishedBefore: finishedBeforeTime,
MinStartedAt: minStartedAt,
Expand Down
61 changes: 61 additions & 0 deletions server/utils/list_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,67 @@ func TestBuildListOptions(t *testing.T) {
NameFilter: "",
},
},
{
name: "Field selector with metadata.namespace!=",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace!=excluded",
},
expected: ListOptions{
Namespace: "excluded",
NamespaceFilter: "NotEquals",
},
},
{
name: "Field selector with metadata.namespace==",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace==included",
},
expected: ListOptions{
Namespace: "included",
},
},
{
name: "Field selector with metadata.namespace!= and metadata.namespace==",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace!=excluded,metadata.namespace==included",
},
// Logic: != sets ns=excluded, filter=NotEquals.
// Then == sets ns=included.
// Conflict check for ==: ns=excluded (from prev) vs included. Conflict!
expectedError: status.Errorf(codes.InvalidArgument,
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", "excluded", "included"),
},
{
name: "Field selector with metadata.namespace== and metadata.namespace!=",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace==included,metadata.namespace!=excluded",
},
// Logic: == sets ns=included.
// Then != sets ns=excluded, filter=NotEquals.
expected: ListOptions{
Namespace: "excluded",
NamespaceFilter: "NotEquals",
},
},
{
name: "Conflict metadata.namespace== and ns param",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace==included",
},
ns: "other",
expectedError: status.Errorf(codes.InvalidArgument,
"'namespace' query param (%q) and fieldselector 'metadata.namespace' (%q) are both specified and contradict each other", "other", "included"),
},
{
name: "Valid metadata.namespace== and ns param",
options: metav1.ListOptions{
FieldSelector: "metadata.namespace==included",
},
ns: "included",
expected: ListOptions{
Namespace: "included",
},
},
{
name: "Invalid field selector",
options: metav1.ListOptions{
Expand Down
9 changes: 7 additions & 2 deletions server/workflow/workflow_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,17 @@ func (s *workflowServer) ListWorkflows(ctx context.Context, req *workflowpkg.Wor
}

// verify if we have permission to list Workflows
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, options.Namespace, "")
targetNamespace := options.Namespace
if options.NamespaceFilter == "NotEquals" {
targetNamespace = ""
}
allowed, err := auth.CanI(ctx, "list", workflow.WorkflowPlural, targetNamespace, "")
if err != nil {
return nil, sutils.ToStatusError(err, codes.Internal)
}

if !allowed {
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))
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))
}

var wfs wfv1.Workflows
Expand Down
14 changes: 14 additions & 0 deletions server/workflowarchive/archived_workflow_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ func Test_archivedWorkflowServer(t *testing.T) {
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)
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)
repo.On("ListWorkflows", mock.Anything, sutils.ListOptions{Namespace: "", Name: "exact-name", NameFilter: "", MinStartedAt: minStartAt, MaxStartedAt: maxStartAt, Limit: 2, Offset: 0}).Return(v1alpha1.Workflows{{}}, nil)
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)
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)
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)
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)
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)
Expand Down Expand Up @@ -198,6 +200,18 @@ func Test_archivedWorkflowServer(t *testing.T) {
_, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{Namespace: "user-ns", ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace=other-ns"}})
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"))

// namespace NotEquals
resp, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace!=excluded-ns"}})
require.NoError(t, err)
assert.Len(t, resp.Items, 1)
assert.Equal(t, "1", resp.Continue)

// namespace DoubleEquals
resp, err = w.ListArchivedWorkflows(ctx, &workflowarchivepkg.ListArchivedWorkflowsRequest{ListOptions: &metav1.ListOptions{Limit: 1, FieldSelector: "metadata.namespace==user-ns"}})
require.NoError(t, err)
assert.Len(t, resp.Items, 1)
assert.Equal(t, "1", resp.Continue)

})
t.Run("GetArchivedWorkflow", func(t *testing.T) {
allowed = false
Expand Down
104 changes: 104 additions & 0 deletions test/e2e/argo_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,57 @@ func (s *ArgoServerSuite) TestPermission() {
token := s.bearerToken
defer func() { s.bearerToken = token }()

clusterSaName := "argotestcluster"
s.createServiceAccount(clusterSaName)

// Load ClusterRole from testdata
var clusterRoleName string
s.Run("LoadClusterRoleYaml", func() {
obj, err := fixtures.LoadObject("@testdata/argo-server-test-clusterrole.yaml")
s.Require().NoError(err)
clusterRole, _ := obj.(*rbacv1.ClusterRole)
clusterRoleName = clusterRole.Name
_, err = s.KubeClient.RbacV1().ClusterRoles().Create(ctx, clusterRole, metav1.CreateOptions{})
s.Require().NoError(err)
})
defer func() {
_ = s.KubeClient.RbacV1().ClusterRoles().Delete(ctx, clusterRoleName, metav1.DeleteOptions{})
}()

// Create ClusterRoleBinding
clusterRoleBindingName := "argotest-clusterrole-binding"
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: clusterRoleBindingName},
Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: clusterSaName, Namespace: nsName}},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: clusterRoleName,
},
}
s.Run("CreateClusterRoleBinding", func() {
_, err := s.KubeClient.RbacV1().ClusterRoleBindings().Create(ctx, clusterRoleBinding, metav1.CreateOptions{})
s.Require().NoError(err)
})
defer func() {
_ = s.KubeClient.RbacV1().ClusterRoleBindings().Delete(ctx, clusterRoleBindingName, metav1.DeleteOptions{})
}()

// Sleep 2 seconds to wait for serviceaccount token created.
// The secret creation slowness is seen in k3d.
time.Sleep(2 * time.Second)

// Get token of cluster serviceaccount
var clusterSaToken string
s.Run("GetClusterSAToken", func() {
sAccount, err := s.KubeClient.CoreV1().ServiceAccounts(nsName).Get(ctx, clusterSaName, metav1.GetOptions{})
s.Require().NoError(err)
secretName := secrets.TokenNameForServiceAccount(sAccount)
secret, err := s.KubeClient.CoreV1().Secrets(nsName).Get(ctx, secretName, metav1.GetOptions{})
s.Require().NoError(err)
clusterSaToken = string(secret.Data["token"])
})

// Test creating workflow with good token
var uid string
s.bearerToken = goodToken
Expand Down Expand Up @@ -554,6 +605,52 @@ func (s *ArgoServerSuite) TestPermission() {
IsEqual(1)
})

// Test list workflows with good token and NotEquals namespace
s.Run("ListWFsGoodTokenNotEqualsNamespace", func() {
token := s.bearerToken
defer func() { s.bearerToken = token }()
s.bearerToken = clusterSaToken
s.e().GET("/api/v1/workflows/").
WithQuery("listOptions.fieldSelector", "metadata.namespace!="+nsName).
Expect().
Status(200).
JSON().
Path("$.items").
IsNull()
})

// Test list workflows with good token and NotEquals excluded namespace
s.Run("ListWFsGoodTokenNotEqualsNamespaceExcluded", func() {
token := s.bearerToken
defer func() { s.bearerToken = token }()
s.bearerToken = clusterSaToken
s.e().GET("/api/v1/workflows/").
WithQuery("listOptions.fieldSelector", "metadata.namespace!="+nsName+"-excluded").
Expect().
Status(200).
JSON().
Path("$.items").
Array().
Length().
IsEqual(1)
})

// Test list workflows with good token and NotEquals namespace
s.Run("ListWFsGoodTokenDoubleEqualsNamespace", func() {
token := s.bearerToken
defer func() { s.bearerToken = token }()
s.bearerToken = clusterSaToken
s.e().GET("/api/v1/workflows/").
WithQuery("listOptions.fieldSelector", "metadata.namespace=="+nsName).
Expect().
Status(200).
JSON().
Path("$.items").
Array().
Length().
IsEqual(1)
})

s.Given().
When().
WaitForWorkflow(fixtures.ToBeArchived)
Expand Down Expand Up @@ -587,6 +684,13 @@ func (s *ArgoServerSuite) TestPermission() {
Status(403)
})

s.Run("ListWFsBadTokenNotEqualsNamespace", func() {
s.e().GET("/api/v1/workflows/").
WithQuery("listOptions.fieldSelector", "metadata.namespace!="+nsName+"-excluded").
Expect().
Status(403)
})

// Test list workflows with bad token
s.Run("ListWFsBadToken", func() {
s.e().GET("/api/v1/workflows/" + nsName).
Expand Down
Loading
Loading