From 61b17692aa703ceb4159b4558b57c85d13810ad6 Mon Sep 17 00:00:00 2001 From: James Kong Date: Fri, 24 Oct 2025 16:10:51 +0800 Subject: [PATCH] feat: add job filter to memory client When calling `ListJobs`, the filter can now be applied to the jobs. This supports all the filter options that are supported by the `ListJobs` method. It also makes a change to the DeleteJob method to perform a soft delete of the job, rather than a hard delete. This is in line with how the Job Distributor API works. --- .changeset/six-books-drum.md | 5 + offchain/jd/memory/job_filter.go | 102 +++ offchain/jd/memory/job_filter_test.go | 743 +++++++++++++++++++++ offchain/jd/memory/memory_client.go | 34 +- offchain/jd/memory/memory_client_test.go | 82 ++- offchain/jd/memory/node_filter.go | 78 +-- offchain/jd/memory/node_filter_test.go | 221 +----- offchain/jd/memory/selector_filter.go | 95 +++ offchain/jd/memory/selector_filter_test.go | 236 +++++++ 9 files changed, 1276 insertions(+), 320 deletions(-) create mode 100644 .changeset/six-books-drum.md create mode 100644 offchain/jd/memory/job_filter.go create mode 100644 offchain/jd/memory/job_filter_test.go create mode 100644 offchain/jd/memory/selector_filter.go create mode 100644 offchain/jd/memory/selector_filter_test.go diff --git a/.changeset/six-books-drum.md b/.changeset/six-books-drum.md new file mode 100644 index 000000000..705825af4 --- /dev/null +++ b/.changeset/six-books-drum.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +JD Memory Client now supports filtering in `ListJobs` diff --git a/offchain/jd/memory/job_filter.go b/offchain/jd/memory/job_filter.go new file mode 100644 index 000000000..829f20076 --- /dev/null +++ b/offchain/jd/memory/job_filter.go @@ -0,0 +1,102 @@ +package memory + +import ( + "slices" + + jobv1 "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/job" + "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/shared/ptypes" +) + +// applyJobFilter applies the filter to the list of jobs and returns the filtered results. +func applyJobFilter( + jobs []*jobv1.Job, filter *jobv1.ListJobsRequest_Filter, +) []*jobv1.Job { + var filtered []*jobv1.Job + + for _, job := range jobs { + if jobMatchesFilter(job, filter) { + filtered = append(filtered, job) + } + } + + return filtered +} + +// jobMatchesFilter checks if a job matches the given filter criteria. +func jobMatchesFilter(job *jobv1.Job, filter *jobv1.ListJobsRequest_Filter) bool { + // Check if job is soft-deleted and should be excluded + if !jobMatchesDeletedFilter(job, filter) { + return false + } + + // Check job IDs + if len(filter.Ids) > 0 { + if !jobMatchesJobIds(job, filter.Ids) { + return false + } + } + + // Check UUIDs + if len(filter.Uuids) > 0 { + if !jobMatchesUuids(job, filter.Uuids) { + return false + } + } + + // Check node IDs + if len(filter.NodeIds) > 0 { + if !jobMatchesNodeIds(job, filter.NodeIds) { + return false + } + } + + // Check selectors + if len(filter.Selectors) > 0 { + for _, selector := range filter.Selectors { + if !jobMatchesSelector(job, selector) { + return false + } + } + } + + return true +} + +// jobMatchesJobIds checks if a job's ID is in the provided list of job IDs. +func jobMatchesJobIds(job *jobv1.Job, jobIds []string) bool { + return slices.Contains(jobIds, job.Id) +} + +// jobMatchesUuids checks if a job's UUID is in the provided list of UUIDs. +func jobMatchesUuids(job *jobv1.Job, uuids []string) bool { + return slices.Contains(uuids, job.Uuid) +} + +// jobMatchesNodeIds checks if a job's node ID is in the provided list of node IDs. +func jobMatchesNodeIds(job *jobv1.Job, nodeIds []string) bool { + return slices.Contains(nodeIds, job.NodeId) +} + +// jobMatchesDeletedFilter checks if a job should be included based on its deleted status. +// By default, soft-deleted jobs (with DeletedAt set) are excluded unless IncludeDeleted is true. +func jobMatchesDeletedFilter(job *jobv1.Job, filter *jobv1.ListJobsRequest_Filter) bool { + // If job is soft-deleted (DeletedAt is not nil) + if job.DeletedAt != nil { + // Only include if IncludeDeleted is explicitly set to true + return filter.IncludeDeleted + } + + // If job is not soft-deleted, always include it + return true +} + +// jobMatchesSelector checks if a job matches a specific selector. +func jobMatchesSelector(job *jobv1.Job, selector *ptypes.Selector) bool { + // Get the job's labels as a map for easier lookup + jobLabels := make(map[string]*string) + for _, label := range job.Labels { + jobLabels[label.Key] = label.Value + } + + return matchesSelector(jobLabels, selector) +} diff --git a/offchain/jd/memory/job_filter_test.go b/offchain/jd/memory/job_filter_test.go new file mode 100644 index 000000000..012793ad3 --- /dev/null +++ b/offchain/jd/memory/job_filter_test.go @@ -0,0 +1,743 @@ +package memory + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + jobv1 "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/job" + "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/shared/ptypes" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/smartcontractkit/chainlink-deployments-framework/internal/pointer" +) + +func TestApplyJobFilter(t *testing.T) { + t.Parallel() + + // Create test jobs + jobs := []*jobv1.Job{ + { + Id: "job-1", + Uuid: "uuid-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + {Key: "type", Value: pointer.To("ocr")}, + }, + }, + { + Id: "job-2", + Uuid: "uuid-2", + NodeId: "node-2", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("test")}, + {Key: "type", Value: pointer.To("flux")}, + }, + }, + { + Id: "job-3", + Uuid: "uuid-3", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + {Key: "type", Value: pointer.To("flux")}, + }, + }, + } + + tests := []struct { + name string + jobs []*jobv1.Job + filter *jobv1.ListJobsRequest_Filter + expected []string // Expected job IDs + }{ + { + name: "no filter - return all jobs", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{}, + expected: []string{"job-1", "job-2", "job-3"}, + }, + { + name: "filter by job IDs", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + }, + expected: []string{"job-1", "job-2"}, + }, + { + name: "filter by node IDs", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{ + NodeIds: []string{"node-1"}, + }, + expected: []string{"job-1", "job-3"}, + }, + { + name: "filter by UUIDs", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{ + Uuids: []string{"uuid-1", "uuid-2"}, + }, + expected: []string{"job-1", "job-2"}, + }, + { + name: "filter by label", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + expected: []string{"job-1", "job-3"}, + }, + { + name: "combined filters", + jobs: jobs, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2", "job-3"}, + NodeIds: []string{"node-1"}, + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + expected: []string{"job-1", "job-3"}, + }, + { + name: "empty jobs list", + jobs: []*jobv1.Job{}, + filter: &jobv1.ListJobsRequest_Filter{ + NodeIds: []string{"node-1"}, + }, + expected: []string{}, + }, + { + name: "filter excludes soft-deleted jobs by default", + jobs: []*jobv1.Job{ + { + Id: "job-1", + Uuid: "uuid-1", + NodeId: "node-1", + }, + { + Id: "job-2", + Uuid: "uuid-2", + NodeId: "node-2", + DeletedAt: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{}, + expected: []string{"job-1"}, + }, + { + name: "filter includes soft-deleted jobs when IncludeDeleted is true", + jobs: []*jobv1.Job{ + { + Id: "job-1", + Uuid: "uuid-1", + NodeId: "node-1", + }, + { + Id: "job-2", + Uuid: "uuid-2", + NodeId: "node-2", + DeletedAt: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: true, + }, + expected: []string{"job-1", "job-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := applyJobFilter(tt.jobs, tt.filter) + + // Extract job IDs for comparison + resultIds := make([]string, len(result)) + for i, job := range result { + resultIds[i] = job.Id + } + + require.ElementsMatch(t, tt.expected, resultIds) + }) + } +} + +func TestJobMatchesIds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + jobIds []string + want bool + }{ + { + name: "job id matches", + job: &jobv1.Job{Id: "job-1"}, + jobIds: []string{"job-1", "job-2"}, + want: true, + }, + { + name: "job id does not match", + job: &jobv1.Job{Id: "job-3"}, + jobIds: []string{"job-1", "job-2"}, + want: false, + }, + { + name: "empty job ids list", + job: &jobv1.Job{Id: "job-1"}, + jobIds: []string{}, + want: false, + }, + { + name: "single job id match", + job: &jobv1.Job{Id: "job-1"}, + jobIds: []string{"job-1"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesJobIds(tt.job, tt.jobIds) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestJobMatchesUuids(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + uuids []string + want bool + }{ + { + name: "uuid matches", + job: &jobv1.Job{Id: "job-1", Uuid: "uuid-1"}, + uuids: []string{"uuid-1", "uuid-2"}, + want: true, + }, + { + name: "uuid does not match", + job: &jobv1.Job{Id: "job-3", Uuid: "uuid-3"}, + uuids: []string{"uuid-1", "uuid-2"}, + want: false, + }, + { + name: "empty uuids list", + job: &jobv1.Job{Id: "job-1", Uuid: "uuid-1"}, + uuids: []string{}, + want: false, + }, + { + name: "single uuid match", + job: &jobv1.Job{Id: "job-1", Uuid: "uuid-1"}, + uuids: []string{"uuid-1"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesUuids(tt.job, tt.uuids) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestJobMatchesDeletedFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + filter *jobv1.ListJobsRequest_Filter + want bool + }{ + { + name: "non-deleted job with no filter", + job: &jobv1.Job{ + Id: "job-1", + }, + filter: &jobv1.ListJobsRequest_Filter{}, + want: true, + }, + { + name: "non-deleted job with IncludeDeleted false", + job: &jobv1.Job{ + Id: "job-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: false, + }, + want: true, + }, + { + name: "non-deleted job with IncludeDeleted true", + job: &jobv1.Job{ + Id: "job-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: true, + }, + want: true, + }, + { + name: "soft-deleted job with no filter", + job: &jobv1.Job{ + Id: "job-1", + DeletedAt: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + filter: &jobv1.ListJobsRequest_Filter{}, + want: false, + }, + { + name: "soft-deleted job with IncludeDeleted false", + job: &jobv1.Job{ + Id: "job-1", + DeletedAt: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: false, + }, + want: false, + }, + { + name: "soft-deleted job with IncludeDeleted true", + job: &jobv1.Job{ + Id: "job-1", + DeletedAt: ×tamppb.Timestamp{Seconds: 1234567890}, + }, + filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: true, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesDeletedFilter(tt.job, tt.filter) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestJobMatchesNodeIds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + nodeIds []string + want bool + }{ + { + name: "node id matches", + job: &jobv1.Job{NodeId: "node-1"}, + nodeIds: []string{"node-1", "node-2"}, + want: true, + }, + { + name: "node id does not match", + job: &jobv1.Job{NodeId: "node-3"}, + nodeIds: []string{"node-1", "node-2"}, + want: false, + }, + { + name: "empty node ids list", + job: &jobv1.Job{NodeId: "node-1"}, + nodeIds: []string{}, + want: false, + }, + { + name: "single node id match", + job: &jobv1.Job{NodeId: "node-1"}, + nodeIds: []string{"node-1"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesNodeIds(tt.job, tt.nodeIds) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestJobMatchesSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + selector *ptypes.Selector + want bool + }{ + { + name: "basic selector matching", + job: &jobv1.Job{ + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + {Key: "type", Value: pointer.To("ocr")}, + }, + }, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: true, + }, + { + name: "job with nil label value", + job: &jobv1.Job{ + Labels: []*ptypes.Label{ + {Key: "environment", Value: nil}, + {Key: "type", Value: pointer.To("ocr")}, + }, + }, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "job with empty labels", + job: &jobv1.Job{ + Labels: []*ptypes.Label{}, + }, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "job with nil labels", + job: &jobv1.Job{ + Labels: nil, + }, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesSelector(tt.job, tt.selector) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestJobMatchesFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + job *jobv1.Job + filter *jobv1.ListJobsRequest_Filter + want bool + }{ + { + name: "no filter - should match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{}, + want: true, + }, + { + name: "job id filter - matching id", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + }, + want: true, + }, + { + name: "job id filter - non-matching id", + job: &jobv1.Job{ + Id: "job-3", + NodeId: "node-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + }, + want: false, + }, + { + name: "node id filter - matching node id", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + NodeIds: []string{"node-1", "node-2"}, + }, + want: true, + }, + { + name: "node id filter - non-matching node id", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-3", + }, + filter: &jobv1.ListJobsRequest_Filter{ + NodeIds: []string{"node-1", "node-2"}, + }, + want: false, + }, + { + name: "uuid filter - matching uuid", + job: &jobv1.Job{ + Id: "job-1", + Uuid: "uuid-1", + NodeId: "node-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + Uuids: []string{"uuid-1", "uuid-2"}, + }, + want: true, + }, + { + name: "uuid filter - non-matching uuid", + job: &jobv1.Job{ + Id: "job-3", + Uuid: "uuid-3", + NodeId: "node-1", + }, + filter: &jobv1.ListJobsRequest_Filter{ + Uuids: []string{"uuid-1", "uuid-2"}, + }, + want: false, + }, + { + name: "selector filter - matching selector", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: true, + }, + { + name: "selector filter - non-matching selector", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("test")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: false, + }, + { + name: "multiple selectors - all match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + {Key: "type", Value: pointer.To("ocr")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + { + Key: "type", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("ocr"), + }, + }, + }, + want: true, + }, + { + name: "multiple selectors - one does not match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + {Key: "type", Value: pointer.To("flux")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + { + Key: "type", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("ocr"), + }, + }, + }, + want: false, + }, + { + name: "combined filters - all match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + NodeIds: []string{"node-1", "node-2"}, + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: true, + }, + { + name: "combined filters - job id does not match", + job: &jobv1.Job{ + Id: "job-3", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + NodeIds: []string{"node-1", "node-2"}, + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: false, + }, + { + name: "combined filters - node id does not match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-3", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + NodeIds: []string{"node-1", "node-2"}, + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: false, + }, + { + name: "combined filters - selector does not match", + job: &jobv1.Job{ + Id: "job-1", + NodeId: "node-1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("test")}, + }, + }, + filter: &jobv1.ListJobsRequest_Filter{ + Ids: []string{"job-1", "job-2"}, + NodeIds: []string{"node-1", "node-2"}, + Selectors: []*ptypes.Selector{ + { + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := jobMatchesFilter(tt.job, tt.filter) + assert.Equal(t, tt.want, result) + }) + } +} diff --git a/offchain/jd/memory/memory_client.go b/offchain/jd/memory/memory_client.go index 1a8ad4d2a..093a35ef6 100644 --- a/offchain/jd/memory/memory_client.go +++ b/offchain/jd/memory/memory_client.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/google/uuid" csav1 "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/csa" @@ -12,6 +13,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/smartcontractkit/chainlink-deployments-framework/offchain" ) @@ -66,6 +68,7 @@ func (m *MemoryJobDistributor) ProposeJob(ctx context.Context, in *jobv1.Propose // Also create a job based on the proposal job := &jobv1.Job{ Id: jobID, + Uuid: uuid.New().String(), NodeId: in.NodeId, Labels: in.Labels, } @@ -109,14 +112,26 @@ func (m *MemoryJobDistributor) GetJob(ctx context.Context, in *jobv1.GetJobReque // ListJobs returns all jobs stored in memory. func (m *MemoryJobDistributor) ListJobs(ctx context.Context, in *jobv1.ListJobsRequest, opts ...grpc.CallOption) (*jobv1.ListJobsResponse, error) { m.mu.RLock() - jobs := make([]*jobv1.Job, 0, len(m.jobs)) + allJobs := make([]*jobv1.Job, 0, len(m.jobs)) for _, job := range m.jobs { - jobs = append(jobs, job) + allJobs = append(allJobs, job) } m.mu.RUnlock() + // Apply filtering - always filter out soft-deleted jobs by default + var filteredJobs []*jobv1.Job + if in.Filter != nil { + filteredJobs = applyJobFilter(allJobs, in.Filter) + } else { + // Create a default filter that excludes soft-deleted jobs + defaultFilter := &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: false, + } + filteredJobs = applyJobFilter(allJobs, defaultFilter) + } + return &jobv1.ListJobsResponse{ - Jobs: jobs, + Jobs: filteredJobs, }, nil } @@ -250,16 +265,13 @@ func (m *MemoryJobDistributor) DeleteJob(ctx context.Context, in *jobv1.DeleteJo } m.mu.Lock() - job := m.jobs[jobID] - delete(m.jobs, jobID) + defer m.mu.Unlock() - // Also remove any associated proposals - for id, proposal := range m.proposals { - if proposal.JobId == jobID { - delete(m.proposals, id) - } + job, ok := m.jobs[jobID] + if !ok { + return nil, status.Errorf(codes.NotFound, "job with id %s not found", jobID) } - m.mu.Unlock() + job.DeletedAt = ×tamppb.Timestamp{Seconds: time.Now().Unix()} return &jobv1.DeleteJobResponse{ Job: job, diff --git a/offchain/jd/memory/memory_client_test.go b/offchain/jd/memory/memory_client_test.go index 66e3d3346..81d5e6ca2 100644 --- a/offchain/jd/memory/memory_client_test.go +++ b/offchain/jd/memory/memory_client_test.go @@ -106,14 +106,19 @@ func TestMemoryJobDistributor_GetJob(t *testing.T) { func TestMemoryJobDistributor_ListJobs(t *testing.T) { t.Parallel() - t.Run("list jobs returns all jobs", func(t *testing.T) { + t.Run("list jobs", func(t *testing.T) { t.Parallel() + client := NewMemoryJobDistributor() ctx := t.Context() + // Create multiple jobs _, err := client.ProposeJob(ctx, &jobv1.ProposeJobRequest{ NodeId: "test-node-1", Spec: "job spec 1", + Labels: []*ptypes.Label{ + {Key: "environment", Value: pointer.To("prod")}, + }, }) require.NoError(t, err) @@ -123,16 +128,71 @@ func TestMemoryJobDistributor_ListJobs(t *testing.T) { }) require.NoError(t, err) - // List all jobs + t.Run("list all jobs", func(t *testing.T) { + t.Parallel() + + listResp, err := client.ListJobs(ctx, &jobv1.ListJobsRequest{}) + require.NoError(t, err) + require.NotNil(t, listResp) + assert.Len(t, listResp.Jobs, 2) + }) + + t.Run("filter jobs by label", func(t *testing.T) { + t.Parallel() + listResp, err := client.ListJobs(ctx, &jobv1.ListJobsRequest{ + Filter: &jobv1.ListJobsRequest_Filter{ + Selectors: []*ptypes.Selector{ + {Key: "environment", Op: ptypes.SelectorOp_EQ, Value: pointer.To("prod")}, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, listResp) + assert.Len(t, listResp.Jobs, 1) + }) + }) + + t.Run("list jobs excludes soft-deleted jobs by default", func(t *testing.T) { + t.Parallel() + + client := NewMemoryJobDistributor() + ctx := t.Context() + + // Create a job + proposeResp, err := client.ProposeJob(ctx, &jobv1.ProposeJobRequest{ + NodeId: "test-node-1", + Spec: "job spec 1", + }) + require.NoError(t, err) + jobID := proposeResp.Proposal.JobId + + // Soft delete the job + _, err = client.DeleteJob(ctx, &jobv1.DeleteJobRequest{ + IdOneof: &jobv1.DeleteJobRequest_Id{Id: jobID}, + }) + require.NoError(t, err) + + // List jobs should exclude the soft-deleted job listResp, err := client.ListJobs(ctx, &jobv1.ListJobsRequest{}) require.NoError(t, err) require.NotNil(t, listResp) + assert.Empty(t, listResp.Jobs) - assert.Len(t, listResp.Jobs, 2) + // But when IncludeDeleted is true, it should include the soft-deleted job + listRespWithDeleted, err := client.ListJobs(ctx, &jobv1.ListJobsRequest{ + Filter: &jobv1.ListJobsRequest_Filter{ + IncludeDeleted: true, + }, + }) + require.NoError(t, err) + require.NotNil(t, listRespWithDeleted) + assert.Len(t, listRespWithDeleted.Jobs, 1) + assert.Equal(t, jobID, listRespWithDeleted.Jobs[0].Id) }) t.Run("list jobs on empty store returns empty list", func(t *testing.T) { t.Parallel() + emptyClient := NewMemoryJobDistributor() ctx := t.Context() listResp, err := emptyClient.ListJobs(ctx, &jobv1.ListJobsRequest{}) @@ -301,7 +361,6 @@ func TestMemoryJobDistributor_DeleteJob(t *testing.T) { }) require.NoError(t, err) jobID := proposeResp.Proposal.JobId - proposalID := proposeResp.Proposal.Id // Delete the job deleteResp, err := client.DeleteJob(ctx, &jobv1.DeleteJobRequest{ @@ -314,24 +373,19 @@ func TestMemoryJobDistributor_DeleteJob(t *testing.T) { getResp, err := client.GetJob(ctx, &jobv1.GetJobRequest{ IdOneof: &jobv1.GetJobRequest_Id{Id: jobID}, }) - require.ErrorContains(t, err, "not found") - assert.Nil(t, getResp) + require.NoError(t, err) - // Verify proposal is also deleted - getProposalResp, err := client.GetProposal(ctx, &jobv1.GetProposalRequest{Id: proposalID}) - require.ErrorContains(t, err, "not found") - assert.Nil(t, getProposalResp) + assert.NotNil(t, getResp.Job.DeletedAt) }) - t.Run("delete non-existent job succeeds", func(t *testing.T) { + t.Run("delete non-existent job does errors", func(t *testing.T) { t.Parallel() client := NewMemoryJobDistributor() ctx := t.Context() - resp, err := client.DeleteJob(ctx, &jobv1.DeleteJobRequest{ + _, err := client.DeleteJob(ctx, &jobv1.DeleteJobRequest{ IdOneof: &jobv1.DeleteJobRequest_Id{Id: "non-existent"}, }) - require.NoError(t, err) - require.NotNil(t, resp) + require.Error(t, err) }) } diff --git a/offchain/jd/memory/node_filter.go b/offchain/jd/memory/node_filter.go index abcd88339..ca7793842 100644 --- a/offchain/jd/memory/node_filter.go +++ b/offchain/jd/memory/node_filter.go @@ -2,7 +2,6 @@ package memory import ( "slices" - "strings" nodev1 "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/node" "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/shared/ptypes" @@ -76,81 +75,10 @@ func nodeMatchesEnabledState(node *nodev1.Node, enabled nodev1.EnableState) bool // nodeMatchesSelector checks if a node matches a specific selector. func nodeMatchesSelector(node *nodev1.Node, selector *ptypes.Selector) bool { // Get the node's labels as a map for easier lookup - nodeLabels := make(map[string]string) + nodeLabels := make(map[string]*string) for _, label := range node.Labels { - if label.Value != nil { - nodeLabels[label.Key] = *label.Value - } + nodeLabels[label.Key] = label.Value } - // Check if the selector key exists in the node's labels - nodeValue, hasKey := nodeLabels[selector.Key] - - switch selector.Op { - case ptypes.SelectorOp_EQ: - // Equality check - if selector.Value == nil { - return false - } - - return hasKey && nodeValue == *selector.Value - - case ptypes.SelectorOp_NOT_EQ: - // Not equal check - if selector.Value == nil { - return false - } - - return hasKey && nodeValue != *selector.Value - - case ptypes.SelectorOp_IN: - // IN operation - check if node value is in the selector values - if selector.Value == nil { - return false - } - if !hasKey { - return false - } - - // Parse comma-separated values - values := strings.Split(*selector.Value, ",") - for _, value := range values { - if strings.TrimSpace(value) == nodeValue { - return true - } - } - - return false - - case ptypes.SelectorOp_NOT_IN: - // NOT IN operation - check if node value is not in the selector values - if selector.Value == nil { - return false - } - if !hasKey { - return true // Key doesn't exist, so it's not in the list - } - - // Parse comma-separated values - values := strings.Split(*selector.Value, ",") - for _, value := range values { - if strings.TrimSpace(value) == nodeValue { - return false // Found in the list, so NOT_IN is false - } - } - - return true // Not found in the list, so NOT_IN is true - - case ptypes.SelectorOp_EXIST: - // Check if the key exists (regardless of value) - return hasKey - - case ptypes.SelectorOp_NOT_EXIST: - // Check if the key does not exist - return !hasKey - - default: - // Unknown operation, default to false - return false - } + return matchesSelector(nodeLabels, selector) } diff --git a/offchain/jd/memory/node_filter_test.go b/offchain/jd/memory/node_filter_test.go index 284d76a6d..36c04988a 100644 --- a/offchain/jd/memory/node_filter_test.go +++ b/offchain/jd/memory/node_filter_test.go @@ -249,37 +249,10 @@ func TestNodeMatchesSelector(t *testing.T) { want bool }{ { - name: "EQ operation - matching value", + name: "basic selector matching", node: &nodev1.Node{ Labels: []*ptypes.Label{ {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_EQ, - Value: pointer.To("prod"), - }, - want: true, - }, - { - name: "EQ operation - non-matching value", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("test")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_EQ, - Value: pointer.To("prod"), - }, - want: false, - }, - { - name: "EQ operation - missing key", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ {Key: "region", Value: pointer.To("us-east-1")}, }, }, @@ -288,200 +261,8 @@ func TestNodeMatchesSelector(t *testing.T) { Op: ptypes.SelectorOp_EQ, Value: pointer.To("prod"), }, - want: false, - }, - { - name: "EQ operation - nil value", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_EQ, - Value: nil, - }, - want: false, - }, - { - name: "NOT_EQ operation - different value", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("test")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_EQ, - Value: pointer.To("prod"), - }, - want: true, - }, - { - name: "NOT_EQ operation - same value", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_EQ, - Value: pointer.To("prod"), - }, - want: false, - }, - { - name: "NOT_EQ operation - missing key", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "region", Value: pointer.To("us-east-1")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_EQ, - Value: pointer.To("prod"), - }, - want: false, - }, - { - name: "IN operation - matching value in comma-separated list", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_IN, - Value: pointer.To("prod,staging,test"), - }, want: true, }, - { - name: "IN operation - non-matching value in comma-separated list", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("dev")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_IN, - Value: pointer.To("prod,staging,test"), - }, - want: false, - }, - { - name: "IN operation - matching value with spaces", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("staging")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_IN, - Value: pointer.To("prod, staging , test"), - }, - want: true, - }, - { - name: "NOT_IN operation - value not in comma-separated list", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("dev")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_IN, - Value: pointer.To("prod,staging,test"), - }, - want: true, - }, - { - name: "NOT_IN operation - value in comma-separated list", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_IN, - Value: pointer.To("prod,staging,test"), - }, - want: false, - }, - { - name: "EXIST operation - key exists", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_EXIST, - }, - want: true, - }, - { - name: "EXIST operation - key does not exist", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "region", Value: pointer.To("us-east-1")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_EXIST, - }, - want: false, - }, - { - name: "NOT_EXIST operation - key does not exist", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "region", Value: pointer.To("us-east-1")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_EXIST, - }, - want: true, - }, - { - name: "NOT_EXIST operation - key exists", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp_NOT_EXIST, - }, - want: false, - }, - { - name: "unknown operation", - node: &nodev1.Node{ - Labels: []*ptypes.Label{ - {Key: "environment", Value: pointer.To("prod")}, - }, - }, - selector: &ptypes.Selector{ - Key: "environment", - Op: ptypes.SelectorOp(999), // Unknown operation - Value: pointer.To("prod"), - }, - want: false, - }, { name: "node with nil label value", node: &nodev1.Node{ diff --git a/offchain/jd/memory/selector_filter.go b/offchain/jd/memory/selector_filter.go new file mode 100644 index 000000000..fe2e6d9f8 --- /dev/null +++ b/offchain/jd/memory/selector_filter.go @@ -0,0 +1,95 @@ +package memory + +import ( + "strings" + + "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/shared/ptypes" +) + +// matchesSelector checks if a set of labels matches a specific selector. +// This is a generic function that can be used for any object with labels. +func matchesSelector(labels map[string]*string, selector *ptypes.Selector) bool { + // Check if the selector key exists in the labels + labelValuePtr, hasKey := labels[selector.Key] + + // If key doesn't exist or label value is nil, we need to handle it appropriately + if !hasKey || labelValuePtr == nil { + // For EXIST/NOT_EXIST operations, we only care about key existence + switch selector.Op { + case ptypes.SelectorOp_EXIST: + return hasKey + case ptypes.SelectorOp_NOT_EXIST: + return !hasKey + case ptypes.SelectorOp_EQ, ptypes.SelectorOp_NOT_EQ, ptypes.SelectorOp_IN, ptypes.SelectorOp_NOT_IN: + // For other operations, if key doesn't exist or value is nil, return false + return false + default: + // Unknown operation, default to false + return false + } + } + + labelValue := *labelValuePtr + + switch selector.Op { + case ptypes.SelectorOp_EQ: + // Equality check + if selector.Value == nil { + return false + } + + return labelValue == *selector.Value + + case ptypes.SelectorOp_NOT_EQ: + // Not equal check + if selector.Value == nil { + return false + } + + return labelValue != *selector.Value + + case ptypes.SelectorOp_IN: + // IN operation - check if label value is in the selector values + if selector.Value == nil { + return false + } + + // Parse comma-separated values + values := strings.Split(*selector.Value, ",") + for _, value := range values { + if strings.TrimSpace(value) == labelValue { + return true + } + } + + return false + + case ptypes.SelectorOp_NOT_IN: + // NOT IN operation - check if label value is not in the selector values + if selector.Value == nil { + return false + } + + // Parse comma-separated values + values := strings.Split(*selector.Value, ",") + for _, value := range values { + if strings.TrimSpace(value) == labelValue { + return false // Found in the list, so NOT_IN is false + } + } + + return true // Not found in the list, so NOT_IN is true + + case ptypes.SelectorOp_EXIST: + // Check if the key exists (regardless of value) + return hasKey + + case ptypes.SelectorOp_NOT_EXIST: + // Check if the key does not exist + return !hasKey + + default: + // Unknown operation, default to false + return false + } +} diff --git a/offchain/jd/memory/selector_filter_test.go b/offchain/jd/memory/selector_filter_test.go new file mode 100644 index 000000000..2703eda58 --- /dev/null +++ b/offchain/jd/memory/selector_filter_test.go @@ -0,0 +1,236 @@ +package memory + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-protos/job-distributor/v1/shared/ptypes" + + "github.com/smartcontractkit/chainlink-deployments-framework/internal/pointer" +) + +func TestMatchesSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels map[string]*string + selector *ptypes.Selector + want bool + }{ + { + name: "EQ operation - matching value", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: true, + }, + { + name: "EQ operation - non-matching value", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("test"), + }, + want: false, + }, + { + name: "EQ operation - missing key", + labels: map[string]*string{"type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "EQ operation - nil value", + labels: map[string]*string{"environment": pointer.To("prod")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: nil, + }, + want: false, + }, + { + name: "EQ operation - nil label value", + labels: map[string]*string{"environment": nil, "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "NOT_EQ operation - different value", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_EQ, + Value: pointer.To("test"), + }, + want: true, + }, + { + name: "NOT_EQ operation - same value", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "NOT_EQ operation - missing key", + labels: map[string]*string{"type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "IN operation - matching value in comma-separated list", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_IN, + Value: pointer.To("prod,test,dev"), + }, + want: true, + }, + { + name: "IN operation - non-matching value in comma-separated list", + labels: map[string]*string{"environment": pointer.To("staging"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_IN, + Value: pointer.To("prod,test,dev"), + }, + want: false, + }, + { + name: "IN operation - matching value with spaces", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_IN, + Value: pointer.To(" prod , test , dev "), + }, + want: true, + }, + { + name: "NOT_IN operation - value not in comma-separated list", + labels: map[string]*string{"environment": pointer.To("staging"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_IN, + Value: pointer.To("prod,test,dev"), + }, + want: true, + }, + { + name: "NOT_IN operation - value in comma-separated list", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_IN, + Value: pointer.To("prod,test,dev"), + }, + want: false, + }, + { + name: "EXIST operation - key exists", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EXIST, + }, + want: true, + }, + { + name: "EXIST operation - key does not exist", + labels: map[string]*string{"type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EXIST, + }, + want: false, + }, + { + name: "EXIST operation - key exists with nil value", + labels: map[string]*string{"environment": nil, "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EXIST, + }, + want: true, + }, + { + name: "NOT_EXIST operation - key does not exist", + labels: map[string]*string{"type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_EXIST, + }, + want: true, + }, + { + name: "NOT_EXIST operation - key exists", + labels: map[string]*string{"environment": pointer.To("prod"), "type": pointer.To("ocr")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_NOT_EXIST, + }, + want: false, + }, + { + name: "unknown operation", + labels: map[string]*string{"environment": pointer.To("prod")}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp(999), // Unknown operation + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "empty labels map", + labels: map[string]*string{}, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + { + name: "nil labels map", + labels: nil, + selector: &ptypes.Selector{ + Key: "environment", + Op: ptypes.SelectorOp_EQ, + Value: pointer.To("prod"), + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := matchesSelector(tt.labels, tt.selector) + assert.Equal(t, tt.want, result) + }) + } +}