feat: read and write server tags, resolve targets from the database

This commit is contained in:
2026-08-04 13:33:00 +01:00
parent efd29dc259
commit fef0b7c7a1
4 changed files with 143 additions and 0 deletions
+4
View File
@@ -112,6 +112,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureServerIndexes(); err != nil {
log.Printf("warning: failed to ensure server indexes: %v", err)
}
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
+44
View File
@@ -15,6 +15,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -371,3 +372,46 @@ func notifyServerOffline(instanceID string, channelIDs []string, s models.Server
}(ch)
}
}
// ListServersFiltered is ListServers with an optional tag selector. An empty
// selector returns the whole fleet — unlike MatchesTags, where empty means
// "nothing", because here the caller is a list view whose default is
// "everything", not a run about to touch machines.
func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{"instance_id": instanceID}
for k, v := range sel {
filter["tags."+k] = v
}
cur, err := db.Col("servers").Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
servers := []models.Server{}
if err := cur.All(ctx, &servers); err != nil {
return nil, err
}
return servers, nil
}
// EnsureServerIndexes declares the wildcard index over the tag subdocument.
// It is wildcard because the queried key is chosen by the user at request time
// and cannot be named in advance.
//
// Non-fatal, following EnsureSecretIndexes: a missing index degrades tag
// filtering to a collection scan over a small collection, which is slower.
// A fatal error here would refuse to boot the fleet list over it.
func EnsureServerIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("servers").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "tags.$**", Value: 1}},
})
return err
}
+80
View File
@@ -1,9 +1,18 @@
package services
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
@@ -76,3 +85,74 @@ func ParseTagFilters(raw []string) (map[string]string, error) {
}
return out, nil
}
// SetServerTags replaces a server's whole tag map.
//
// Replace rather than patch: a tag set is small enough that sending all of it
// is free, and last-write-wins over a whole map is easier to reason about than
// merge semantics between two people editing the same server.
func SetServerTags(instanceID, serverID string, tags map[string]string) error {
if err := ValidateTags(tags); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID, "instance_id": instanceID},
bson.M{"$set": bson.M{"tags": tags}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return mongo.ErrNoDocuments
}
return nil
}
// KnownTags returns every key in use in this instance with its distinct
// values, for the UI's pickers. This is an aggregation rather than a
// maintained registry: a tag is a property of a server, not an entity, and a
// registry would need reference counting to know when a tag stopped existing.
func KnownTags(instanceID string) (map[string][]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cur, err := db.Col("servers").Find(ctx,
bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}},
options.Find().SetProjection(bson.M{"tags": 1}),
)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
seen := map[string]map[string]bool{}
for cur.Next(ctx) {
var s models.Server
if err := cur.Decode(&s); err != nil {
return nil, err
}
for k, v := range s.Tags {
if seen[k] == nil {
seen[k] = map[string]bool{}
}
seen[k][v] = true
}
}
if err := cur.Err(); err != nil {
return nil, err
}
out := make(map[string][]string, len(seen))
for k, vals := range seen {
list := make([]string, 0, len(vals))
for v := range vals {
list = append(list, v)
}
sort.Strings(list)
out[k] = list
}
return out, nil
}
+15
View File
@@ -51,3 +51,18 @@ func UnionTargets(all []models.Server, ids []string, sel map[string]string) []mo
}
return out
}
// ResolveTargets is the database-backed wrapper around UnionTargets. It is the
// single answer to "which servers does this workflow touch", used by the run
// path and by validation alike, so the two cannot disagree.
func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error) {
all, err := ListServers(instanceID)
if err != nil {
return nil, err
}
matched := UnionTargets(all, ids, sel)
if len(matched) == 0 {
return nil, ErrNoTargets
}
return matched, nil
}