From 8f1ea6d5a03d5fc0234652df04107c080ebc2254 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 08:27:04 +0000 Subject: [PATCH] feat: patch models, scoped collections and indexes; pin vantage-shared v0.5.0 --- server/cmd/main.go | 4 + server/go.mod | 3 +- server/go.sum | 6 +- server/internal/models/patch.go | 112 ++++++++++++++++++ server/internal/services/migrate_instance.go | 3 + server/internal/services/patch_indexes.go | 40 +++++++ server/internal/services/patch_scoped_test.go | 20 ++++ 7 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 server/internal/models/patch.go create mode 100644 server/internal/services/patch_indexes.go create mode 100644 server/internal/services/patch_scoped_test.go diff --git a/server/cmd/main.go b/server/cmd/main.go index b43e63c..7375a74 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -179,6 +179,10 @@ func runSchemaSetup() { log.Printf("warning: failed to ensure status page indexes: %v", err) } + if err := services.EnsurePatchIndexes(); err != nil { + log.Printf("warning: patch indexes: %v", err) + } + if err := services.EnsureAuditIndexes(); err != nil { log.Printf("warning: failed to ensure audit indexes: %v", err) } diff --git a/server/go.mod b/server/go.mod index ef98f69..b8a38e0 100644 --- a/server/go.mod +++ b/server/go.mod @@ -38,6 +38,7 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.12.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.8.6 // indirect go.etcd.io/bbolt v1.5.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect @@ -46,7 +47,7 @@ require ( ) require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.3 + gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 github.com/bytedance/sonic v1.15.3 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/server/go.sum b/server/go.sum index cf8021c..6f58fea 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,5 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.3 h1:+ak67Hj1C92hNfTE63nlF1c2wx4L8mML+1aI6VJnukc= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.3.3/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 h1:xwSIEkQKTd4Qk+BYHvoGN+h84Isr2h5qqnitUWF1m2w= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM= github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8= github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs= @@ -152,6 +152,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= diff --git a/server/internal/models/patch.go b/server/internal/models/patch.go new file mode 100644 index 0000000..14e3999 --- /dev/null +++ b/server/internal/models/patch.go @@ -0,0 +1,112 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + PatchScopeAll = "all" + PatchScopeSecurity = "security" + + PatchRebootNever = "never" + PatchRebootIfRequired = "if_required" + + PatchRunRunning = "running" + PatchRunSucceeded = "succeeded" + PatchRunPartial = "partial" + PatchRunFailed = "failed" + PatchRunCancelled = "cancelled" + + PatchSrvQueued = "queued" + PatchSrvWaitingOffline = "waiting_offline" + PatchSrvPatching = "patching" + PatchSrvRebooting = "rebooting" + PatchSrvSucceeded = "succeeded" + PatchSrvFailed = "failed" + PatchSrvUnsupported = "unsupported" + PatchSrvAgentTooOld = "agent_too_old" + PatchSrvMissedOffline = "missed_offline" + PatchSrvWindowClosed = "window_closed" + PatchSrvCancelled = "cancelled" + + PatchSourceSchedule = "schedule" + PatchSourceRunNow = "run_now" + PatchSourceServer = "server" + PatchSourceVulnerabilities = "vulnerabilities" + PatchSourceMCP = "mcp" +) + +// MaintenanceWindow answers "when" and nothing else. Policies reference it by +// ID, so one window can later serve alert muting and status page maintenance +// without a second definition of the same Sunday morning. +type MaintenanceWindow struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + WindowID string `bson:"window_id" json:"window_id"` + Name string `bson:"name" json:"name"` + Cron string `bson:"cron" json:"cron"` // 5-field, window start + TZ string `bson:"tz" json:"tz"` // IANA name + DurationMinutes int `bson:"duration_minutes" json:"duration_minutes"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type PatchPolicy struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + PolicyID string `bson:"policy_id" json:"policy_id"` + Name string `bson:"name" json:"name"` + Enabled bool `bson:"enabled" json:"enabled"` + WindowID string `bson:"window_id" json:"window_id"` + TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"` + TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"` + Scope string `bson:"scope" json:"scope"` + Reboot string `bson:"reboot" json:"reboot"` + MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` // 0 = no cap + NotifyChannelIDs []string `bson:"notify_channel_ids,omitempty" json:"notify_channel_ids,omitempty"` + NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"` + LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"` + LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"` + DisabledReason string `bson:"disabled_reason,omitempty" json:"disabled_reason,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// PatchRun is one firing of a policy, or one manual Apply updates. Scope, +// reboot and concurrency are copied from the policy at fire time so editing +// the policy never rewrites what a past run shows. +type PatchRun struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + RunID string `bson:"run_id" json:"run_id"` + PolicyID string `bson:"policy_id,omitempty" json:"policy_id,omitempty"` + PolicyName string `bson:"policy_name,omitempty" json:"policy_name,omitempty"` + TriggeredBy string `bson:"triggered_by" json:"triggered_by"` + Source string `bson:"source" json:"source"` + Scope string `bson:"scope" json:"scope"` + Reboot string `bson:"reboot" json:"reboot"` + MaxConcurrent int `bson:"max_concurrent" json:"max_concurrent"` + WindowEnd *time.Time `bson:"window_end,omitempty" json:"window_end,omitempty"` + Status string `bson:"status" json:"status"` + CancelledAt *time.Time `bson:"cancelled_at,omitempty" json:"cancelled_at,omitempty"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` + Servers []PatchServerRun `bson:"servers" json:"servers"` +} + +type PatchServerRun struct { + ServerID string `bson:"server_id" json:"server_id"` + Hostname string `bson:"hostname" json:"hostname"` + Status string `bson:"status" json:"status"` + CommandID string `bson:"command_id,omitempty" json:"-"` + PendingBefore int `bson:"pending_before" json:"pending_before"` + PendingAfter *int `bson:"pending_after,omitempty" json:"pending_after,omitempty"` + RebootedAt *time.Time `bson:"rebooted_at,omitempty" json:"rebooted_at,omitempty"` + VerifiedAt *time.Time `bson:"verified_at,omitempty" json:"verified_at,omitempty"` + Output string `bson:"output,omitempty" json:"output,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` + FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` +} diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go index 817bf00..e9d2b94 100644 --- a/server/internal/services/migrate_instance.go +++ b/server/internal/services/migrate_instance.go @@ -48,6 +48,9 @@ var ScopedCollections = []string{ "server_workloads", "status_pages", "status_incidents", + "maintenance_windows", + "patch_policies", + "patch_runs", } // collectionRenames maps the two collections whose names change. Ordered so the diff --git a/server/internal/services/patch_indexes.go b/server/internal/services/patch_indexes.go new file mode 100644 index 0000000..002ff59 --- /dev/null +++ b/server/internal/services/patch_indexes.go @@ -0,0 +1,40 @@ +package services + +import ( + "context" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsurePatchIndexes builds the patching indexes. The command_id index is the +// one that matters: every PatchResult is matched to its server run through it. +func EnsurePatchIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("maintenance_windows").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + if _, err := db.Col("patch_policies").Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "enabled", Value: 1}, {Key: "next_run_at", Value: 1}}}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "window_id", Value: 1}}}, + }); err != nil { + return err + } + _, err := db.Col("patch_runs").Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "policy_id", Value: 1}, {Key: "started_at", Value: -1}}}, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "servers.server_id", Value: 1}, {Key: "started_at", Value: -1}}}, + {Keys: bson.D{{Key: "status", Value: 1}}}, + {Keys: bson.D{{Key: "servers.command_id", Value: 1}}}, + }) + return err +} diff --git a/server/internal/services/patch_scoped_test.go b/server/internal/services/patch_scoped_test.go new file mode 100644 index 0000000..a52ab5b --- /dev/null +++ b/server/internal/services/patch_scoped_test.go @@ -0,0 +1,20 @@ +package services + +import "testing" + +// A tenant-scoped collection missing from ScopedCollections outlives its +// instance when the instance is purged. +func TestPatchCollectionsAreScoped(t *testing.T) { + for _, name := range []string{"maintenance_windows", "patch_policies", "patch_runs"} { + found := false + for _, got := range ScopedCollections { + if got == name { + found = true + break + } + } + if !found { + t.Errorf("ScopedCollections is missing %q", name) + } + } +}