feat: parse tag filters and resolve targets as ids union tag selector

This commit is contained in:
2026-08-04 13:31:04 +01:00
parent 13cd41d202
commit efd29dc259
2 changed files with 76 additions and 0 deletions
+23
View File
@@ -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
}
+53
View File
@@ -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
}