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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user