From 05bf8d17e5667d180a75b701087d71ad9ecfd4da Mon Sep 17 00:00:00 2001 From: Marcus Ramberg Date: Mon, 13 Oct 2025 12:47:03 +0200 Subject: [PATCH] Allow agents to require labels on workflows (#5633) --- .../10-configuration/30-agent.md | 3 +- server/grpc/filter.go | 16 ++++++++ server/grpc/filter_test.go | 41 +++++++++++++++++++ server/queue/queue.go | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/docs/30-administration/10-configuration/30-agent.md b/docs/docs/30-administration/10-configuration/30-agent.md index 2b88600e0a..589c57b830 100644 --- a/docs/docs/30-administration/10-configuration/30-agent.md +++ b/docs/docs/30-administration/10-configuration/30-agent.md @@ -155,7 +155,8 @@ Configures the number of parallel workflows. Configures custom labels for the agent, to let workflows filter by it. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. -By default, agents provide three additional labels `platform=os/arch`, `hostname=my-agent` and `repo=*` which can be overwritten if needed. +If you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched. +By default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels). --- diff --git a/server/grpc/filter.go b/server/grpc/filter.go index b62e72dea6..ba806ba1f2 100644 --- a/server/grpc/filter.go +++ b/server/grpc/filter.go @@ -29,6 +29,10 @@ func createFilterFunc(agentFilter rpc.Filter) queue.FilterFn { // Create a copy of the labels for filtering to avoid modifying the original task labels := maps.Clone(task.Labels) + if requiredLabelsMissing(labels, agentFilter.Labels) { + return false, 0 + } + // ignore internal labels for filtering for k := range labels { if strings.HasPrefix(k, pipelineConsts.InternalLabelPrefix) { @@ -64,3 +68,15 @@ func createFilterFunc(agentFilter rpc.Filter) queue.FilterFn { return true, score } } + +func requiredLabelsMissing(taskLabels, agentLabels map[string]string) bool { + for label, value := range agentLabels { + if len(label) > 0 && label[0] == '!' { + val, ok := taskLabels[label[1:]] + if !ok || val != value { + return true + } + } + } + return false +} diff --git a/server/grpc/filter_test.go b/server/grpc/filter_test.go index 1599a58caf..781f59e3cb 100644 --- a/server/grpc/filter_test.go +++ b/server/grpc/filter_test.go @@ -131,3 +131,44 @@ func TestCreateFilterFunc(t *testing.T) { }) } } + +func TestMissingRequiredLabels(t *testing.T) { + t.Parallel() + + testdata := []struct { + taskLabels map[string]string + requiredLabels map[string]string + want bool + }{ + // Required label present and matches + { + taskLabels: map[string]string{"os": "linux"}, + requiredLabels: map[string]string{"!os": "linux", "platform": "arm64"}, + want: false, + }, + // Required label present but does not match + { + taskLabels: map[string]string{"os": "windows"}, + requiredLabels: map[string]string{"!os": "linux", "platform": "amd64"}, + want: true, + }, + // Required label missing + { + taskLabels: map[string]string{"arch": "amd64"}, + requiredLabels: map[string]string{"!os": "linux"}, + want: true, + }, + // No agent labels + { + taskLabels: map[string]string{"os": "linux"}, + requiredLabels: map[string]string{}, + want: false, + }, + } + + for _, tt := range testdata { + if got := requiredLabelsMissing(tt.taskLabels, tt.requiredLabels); got != tt.want { + t.Errorf("requiredLabelsMissing(%v, %v) = %v, want %v", tt.taskLabels, tt.requiredLabels, got, tt.want) + } + } +} diff --git a/server/queue/queue.go b/server/queue/queue.go index cc24472865..18dcbbe1ea 100644 --- a/server/queue/queue.go +++ b/server/queue/queue.go @@ -67,7 +67,7 @@ func (t *InfoT) String() string { return sb.String() } -// Filter filters tasks in the queue. If the Filter returns false, +// FilterFn filters tasks in the queue. If the Filter returns false, // the Task is skipped and not returned to the subscriber. // The int return value represents the matching score (higher is better). type FilterFn func(*model.Task) (bool, int)