From efd29dc2591e6f5b813348900ac28b1054bb8132 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 4 Aug 2026 13:31:04 +0100 Subject: [PATCH] feat: parse tag filters and resolve targets as ids union tag selector --- server/internal/services/tags.go | 23 +++++++++++++ server/internal/services/targets.go | 53 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 server/internal/services/targets.go diff --git a/server/internal/services/tags.go b/server/internal/services/tags.go index 67f87b1..7e99865 100644 --- a/server/internal/services/tags.go +++ b/server/internal/services/tags.go @@ -53,3 +53,26 @@ func ValidateTags(tags map[string]string) error { } return nil } + +// ParseTagFilters turns repeated ?tag=key:value query values into a map. +// +// A malformed filter is an error rather than a silently ignored value: a +// filter that matches nothing and a filter that is nonsense look identical in +// a list, and only one of them is the caller's fault. +func ParseTagFilters(raw []string) (map[string]string, error) { + out := make(map[string]string, len(raw)) + for _, r := range raw { + k, v, found := strings.Cut(r, ":") + if !found { + return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r) + } + if strings.Contains(v, ":") { + return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r) + } + out[k] = v + } + if err := ValidateTags(out); err != nil { + return nil, err + } + return out, nil +} diff --git a/server/internal/services/targets.go b/server/internal/services/targets.go new file mode 100644 index 0000000..5c572c2 --- /dev/null +++ b/server/internal/services/targets.go @@ -0,0 +1,53 @@ +package services + +import ( + "errors" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// ErrNoTargets means a workflow named no servers and matched none. Handlers +// map it to 400: a workflow that matches nothing must say so rather than +// report success over zero servers. +var ErrNoTargets = errors.New("workflow has no target servers") + +// MatchesTags reports whether srv carries every pair in sel — AND across keys. +// An empty selector matches nothing. That is deliberate: the alternative, +// "matches everything", turns a cleared field in the workflow designer into a +// fleet-wide run. +func MatchesTags(srv models.Server, sel map[string]string) bool { + if len(sel) == 0 { + return false + } + for k, v := range sel { + if srv.Tags[k] != v { + return false + } + } + return true +} + +// UnionTargets returns the distinct union of the servers named by ids and +// those matching sel, in the order they appear in all. +// +// Order comes from the fleet rather than the arguments so that two workflows +// naming the same servers in a different order still run them in the same +// order, which makes two runs comparable line by line. +// +// Offline servers are NOT filtered out. The dispatcher already answers 503 per +// server, and a patch run that silently omits an unreachable machine is worse +// than one that visibly fails on it. +func UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server { + named := make(map[string]bool, len(ids)) + for _, id := range ids { + named[id] = true + } + + out := make([]models.Server, 0, len(ids)+len(all)) + for _, s := range all { + if named[s.ServerID] || MatchesTags(s, sel) { + out = append(out, s) + } + } + return out +}