diff --git a/server/cmd/main.go b/server/cmd/main.go index f29193d..b561e95 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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) } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 9d28b82..291e17f 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -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 +} diff --git a/server/internal/services/tags.go b/server/internal/services/tags.go index 7e99865..578e838 100644 --- a/server/internal/services/tags.go +++ b/server/internal/services/tags.go @@ -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 +} diff --git a/server/internal/services/targets.go b/server/internal/services/targets.go index 5c572c2..741311e 100644 --- a/server/internal/services/targets.go +++ b/server/internal/services/targets.go @@ -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 +}