feat: read and write server tags, resolve targets from the database
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user