feat: maintenance window and patch policy services

Named patch_window.go (not patch_windows.go) since the _windows.go
suffix is Go's implicit GOOS build constraint and would silently
exclude the file on non-Windows builds.
This commit is contained in:
2026-09-15 08:50:36 +00:00
parent c0e26d0493
commit 3a1614066e
3 changed files with 449 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"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"
)
var (
ErrPolicyInvalid = errors.New("invalid patch policy")
ErrPolicyNotFound = errors.New("patch policy not found")
)
// ValidatePolicy checks a policy without touching the database. The window's
// existence and the token's tag scope are checked by Create and Update.
func ValidatePolicy(p models.PatchPolicy) error {
if n := strings.TrimSpace(p.Name); n == "" || len(n) > 100 {
return fmt.Errorf("%w: name must be 1 to 100 characters", ErrPolicyInvalid)
}
if p.WindowID == "" {
return fmt.Errorf("%w: a maintenance window is required", ErrPolicyInvalid)
}
if p.Scope != models.PatchScopeAll && p.Scope != models.PatchScopeSecurity {
return fmt.Errorf("%w: scope must be %q or %q", ErrPolicyInvalid, models.PatchScopeAll, models.PatchScopeSecurity)
}
if p.Reboot != models.PatchRebootNever && p.Reboot != models.PatchRebootIfRequired {
return fmt.Errorf("%w: reboot must be %q or %q", ErrPolicyInvalid, models.PatchRebootNever, models.PatchRebootIfRequired)
}
if p.MaxConcurrent < 0 || p.MaxConcurrent > 1000 {
return fmt.Errorf("%w: max concurrent must be between 0 and 1000", ErrPolicyInvalid)
}
// Same rule as workflows: an empty selector matches nothing, and saying so
// at save time beats a policy that silently patches nobody every Sunday.
if len(p.TargetServerIDs) == 0 && len(p.TargetTags) == 0 {
return ErrNoTargets
}
return ValidateTags(p.TargetTags)
}
func nextRunFor(p models.PatchPolicy, w models.MaintenanceWindow, now time.Time) *time.Time {
if !p.Enabled {
return nil
}
start, err := patchsched.NextStart(w.Cron, w.TZ, now)
if err != nil {
return nil
}
return &start
}
func ListPolicies(instanceID string) ([]models.PatchPolicy, error) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col("patch_policies").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1}))
if err != nil {
return nil, err
}
out := []models.PatchPolicy{}
return out, cur.All(ctx, &out)
}
func GetPolicy(instanceID, policyID string) (*models.PatchPolicy, error) {
ctx, cancel := patchCtx()
defer cancel()
var p models.PatchPolicy
err := db.Col("patch_policies").FindOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrPolicyNotFound
}
return &p, err
}
// preparePolicy runs every check a save needs and returns the policy's window.
func preparePolicy(instanceID string, p *models.PatchPolicy, tokenScope map[string]string) (*models.MaintenanceWindow, error) {
p.Name = strings.TrimSpace(p.Name)
if p.TargetServerIDs == nil {
p.TargetServerIDs = []string{}
}
if err := ValidatePolicy(*p); err != nil {
return nil, err
}
w, err := GetWindow(instanceID, p.WindowID)
if errors.Is(err, ErrWindowNotFound) {
return nil, fmt.Errorf("%w: that maintenance window does not exist", ErrPolicyInvalid)
}
if err != nil {
return nil, err
}
// validateTargetServers returns a plain "target server X not found"; wrap
// it so the handler can answer 400 without matching on text.
if err := validateTargetServers(instanceID, p.TargetServerIDs, tokenScope); err != nil {
return nil, fmt.Errorf("%w: %v", ErrPolicyInvalid, err)
}
if err := validateWorkflowTargetScope(instanceID, p.TargetServerIDs, p.TargetTags, tokenScope); err != nil {
return nil, err
}
return w, nil
}
func CreatePolicy(instanceID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) {
w, err := preparePolicy(instanceID, &p, tokenScope)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
now := time.Now()
p.InstanceID, p.PolicyID = instanceID, uuid.New().String()
p.CreatedAt, p.UpdatedAt = now, now
p.NextRunAt = nextRunFor(p, *w, now)
p.LastRunAt, p.LastSkipped, p.DisabledReason = nil, nil, ""
if _, err := db.Col("patch_policies").InsertOne(ctx, p); err != nil {
return nil, err
}
return &p, nil
}
func UpdatePolicy(instanceID, policyID string, p models.PatchPolicy, tokenScope map[string]string) (*models.PatchPolicy, error) {
// Missing policy answers 404 before any validation error.
if _, err := GetPolicy(instanceID, policyID); err != nil {
return nil, err
}
w, err := preparePolicy(instanceID, &p, tokenScope)
if err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
now := time.Now()
set := bson.M{
"name": p.Name, "enabled": p.Enabled, "window_id": p.WindowID,
"target_server_ids": p.TargetServerIDs, "target_tags": p.TargetTags,
"scope": p.Scope, "reboot": p.Reboot, "max_concurrent": p.MaxConcurrent,
"notify_channel_ids": p.NotifyChannelIDs, "updated_at": now,
}
update := bson.M{"$set": set}
if next := nextRunFor(p, *w, now); next != nil {
set["next_run_at"] = *next
// Re-enabling is the operator's answer to whatever disabled it.
update["$unset"] = bson.M{"disabled_reason": ""}
} else {
update["$unset"] = bson.M{"next_run_at": ""}
}
if _, err := db.Col("patch_policies").UpdateOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID}, update); err != nil {
return nil, err
}
return GetPolicy(instanceID, policyID)
}
func DeletePolicy(instanceID, policyID string) error {
ctx, cancel := patchCtx()
defer cancel()
res, err := db.Col("patch_policies").DeleteOne(ctx, bson.M{"instance_id": instanceID, "policy_id": policyID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrPolicyNotFound
}
return nil
}
// recomputePolicySchedules moves every enabled policy on this window to the
// window's next start.
func recomputePolicySchedules(ctx context.Context, w models.MaintenanceWindow) error {
start, err := patchsched.NextStart(w.Cron, w.TZ, time.Now())
if err != nil {
return err
}
_, err = db.Col("patch_policies").UpdateMany(ctx,
bson.M{"instance_id": w.InstanceID, "window_id": w.WindowID, "enabled": true},
bson.M{"$set": bson.M{"next_run_at": start}})
return err
}
// CountPolicyTargets resolves the selector as a run would, at this moment.
func CountPolicyTargets(p models.PatchPolicy) (int, error) {
servers, err := ResolveTargets(p.InstanceID, p.TargetServerIDs, p.TargetTags)
if errors.Is(err, ErrNoTargets) {
return 0, nil
}
return len(servers), err
}
@@ -0,0 +1,90 @@
package services
import (
"errors"
"strings"
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func goodWindow() models.MaintenanceWindow {
return models.MaintenanceWindow{Name: "Sunday", Cron: "0 2 * * 0", TZ: "Europe/London", DurationMinutes: 120}
}
func TestValidateWindow(t *testing.T) {
if err := ValidateWindow(goodWindow()); err != nil {
t.Fatalf("good window: %v", err)
}
bad := map[string]func(*models.MaintenanceWindow){
"empty name": func(w *models.MaintenanceWindow) { w.Name = " " },
"long name": func(w *models.MaintenanceWindow) { w.Name = strings.Repeat("a", 101) },
"too short": func(w *models.MaintenanceWindow) { w.DurationMinutes = 14 },
"too long": func(w *models.MaintenanceWindow) { w.DurationMinutes = 721 },
"bad cron": func(w *models.MaintenanceWindow) { w.Cron = "every sunday" },
"six-field cron": func(w *models.MaintenanceWindow) { w.Cron = "0 0 2 * * 0" },
"bad tz": func(w *models.MaintenanceWindow) { w.TZ = "Mars/Olympus" },
"no tz": func(w *models.MaintenanceWindow) { w.TZ = "" },
}
for name, mut := range bad {
w := goodWindow()
mut(&w)
if err := ValidateWindow(w); !errors.Is(err, ErrWindowInvalid) {
t.Errorf("%s: err = %v, want ErrWindowInvalid", name, err)
}
}
}
func TestPreviewWindowSpansDoNotOverlap(t *testing.T) {
from := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
spans, err := PreviewWindow("0 * * * *", "UTC", 90, from, 3)
if err != nil {
t.Fatal(err)
}
if len(spans) != 3 {
t.Fatalf("got %d spans", len(spans))
}
for i := 1; i < len(spans); i++ {
if spans[i].Start.Before(spans[i-1].End) {
t.Fatalf("span %d starts %s before previous ends %s", i, spans[i].Start, spans[i-1].End)
}
}
if !spans[0].End.Equal(spans[0].Start.Add(90 * time.Minute)) {
t.Fatal("end must be start + duration")
}
}
func goodPolicy() models.PatchPolicy {
return models.PatchPolicy{
Name: "Sunday prod", WindowID: "w1", TargetTags: map[string]string{"env": "prod"},
Scope: models.PatchScopeSecurity, Reboot: models.PatchRebootNever,
}
}
func TestValidatePolicy(t *testing.T) {
if err := ValidatePolicy(goodPolicy()); err != nil {
t.Fatalf("good policy: %v", err)
}
bad := map[string]func(*models.PatchPolicy){
"empty name": func(p *models.PatchPolicy) { p.Name = "" },
"no window": func(p *models.PatchPolicy) { p.WindowID = "" },
"bad scope": func(p *models.PatchPolicy) { p.Scope = "everything" },
"bad reboot": func(p *models.PatchPolicy) { p.Reboot = "always" },
"negative cap": func(p *models.PatchPolicy) { p.MaxConcurrent = -1 },
"huge cap": func(p *models.PatchPolicy) { p.MaxConcurrent = 1001 },
"uppercase tag": func(p *models.PatchPolicy) { p.TargetTags = map[string]string{"Env": "prod"} },
}
for name, mut := range bad {
p := goodPolicy()
mut(&p)
if err := ValidatePolicy(p); err == nil {
t.Errorf("%s: want an error", name)
}
}
p := goodPolicy()
p.TargetTags = nil
if err := ValidatePolicy(p); !errors.Is(err, ErrNoTargets) {
t.Errorf("empty selector: err = %v, want ErrNoTargets", err)
}
}
+166
View File
@@ -0,0 +1,166 @@
package services
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/patchsched"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
"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"
)
var (
ErrWindowInvalid = errors.New("invalid maintenance window")
ErrWindowNotFound = errors.New("maintenance window not found")
ErrWindowInUse = errors.New("maintenance window is used by a patch policy")
)
const (
minWindowMinutes = 15
maxWindowMinutes = 720
)
func patchCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
func ValidateWindow(w models.MaintenanceWindow) error {
if n := strings.TrimSpace(w.Name); n == "" || len(n) > 100 {
return fmt.Errorf("%w: name must be 1 to 100 characters", ErrWindowInvalid)
}
if w.DurationMinutes < minWindowMinutes || w.DurationMinutes > maxWindowMinutes {
return fmt.Errorf("%w: duration must be between %d and %d minutes", ErrWindowInvalid, minWindowMinutes, maxWindowMinutes)
}
if _, err := workflowsched.ParseSchedule(w.Cron, w.TZ); err != nil {
return fmt.Errorf("%w: %v", ErrWindowInvalid, err)
}
return nil
}
type WindowSpan struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
// PreviewWindow returns the next n windows, computed exactly as the scheduler
// computes them, so the editor cannot disagree with what will fire.
func PreviewWindow(cron, tz string, durationMinutes int, from time.Time, n int) ([]WindowSpan, error) {
if err := ValidateWindow(models.MaintenanceWindow{Name: "preview", Cron: cron, TZ: tz, DurationMinutes: durationMinutes}); err != nil {
return nil, err
}
out := make([]WindowSpan, 0, n)
for len(out) < n {
start, err := patchsched.NextStart(cron, tz, from)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrWindowInvalid, err)
}
end := patchsched.WindowEnd(start, durationMinutes)
out = append(out, WindowSpan{Start: start, End: end})
from = patchsched.Later(start, end)
}
return out, nil
}
func ListWindows(instanceID string) ([]models.MaintenanceWindow, error) {
ctx, cancel := patchCtx()
defer cancel()
cur, err := db.Col("maintenance_windows").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"name": 1}))
if err != nil {
return nil, err
}
out := []models.MaintenanceWindow{}
return out, cur.All(ctx, &out)
}
func GetWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) {
w, err := LookupWindow(instanceID, windowID)
if err == nil && w == nil {
return nil, ErrWindowNotFound
}
return w, err
}
// LookupWindow is GetWindow for the scheduler, which must tell "gone" (nil,
// nil: disable the policy) from a database error (retry next tick).
func LookupWindow(instanceID, windowID string) (*models.MaintenanceWindow, error) {
ctx, cancel := patchCtx()
defer cancel()
var w models.MaintenanceWindow
err := db.Col("maintenance_windows").FindOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID}).Decode(&w)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &w, nil
}
func CreateWindow(instanceID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) {
w.Name = strings.TrimSpace(w.Name)
if err := ValidateWindow(w); err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
w.InstanceID, w.WindowID = instanceID, uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
if _, err := db.Col("maintenance_windows").InsertOne(ctx, w); err != nil {
return nil, err
}
return &w, nil
}
// UpdateWindow saves the window and moves next_run_at on every enabled policy
// that uses it, so an edited Sunday becomes the next Sunday everywhere at once.
func UpdateWindow(instanceID, windowID string, w models.MaintenanceWindow) (*models.MaintenanceWindow, error) {
w.Name = strings.TrimSpace(w.Name)
if err := ValidateWindow(w); err != nil {
return nil, err
}
ctx, cancel := patchCtx()
defer cancel()
res, err := db.Col("maintenance_windows").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "window_id": windowID},
bson.M{"$set": bson.M{"name": w.Name, "cron": w.Cron, "tz": w.TZ, "duration_minutes": w.DurationMinutes, "updated_at": time.Now()}})
if err != nil {
return nil, err
}
if res.MatchedCount == 0 {
return nil, ErrWindowNotFound
}
saved, err := GetWindow(instanceID, windowID)
if err != nil {
return nil, err
}
return saved, recomputePolicySchedules(ctx, *saved)
}
func DeleteWindow(instanceID, windowID string) error {
ctx, cancel := patchCtx()
defer cancel()
n, err := db.Col("patch_policies").CountDocuments(ctx, bson.M{"instance_id": instanceID, "window_id": windowID})
if err != nil {
return err
}
if n > 0 {
return ErrWindowInUse
}
res, err := db.Col("maintenance_windows").DeleteOne(ctx, bson.M{"instance_id": instanceID, "window_id": windowID})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrWindowNotFound
}
return nil
}