From 3a1614066ec3af127f5c46e74b5ca084a1b0d075 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 08:50:36 +0000 Subject: [PATCH] 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. --- server/internal/services/patch_policies.go | 193 ++++++++++++++++++ .../internal/services/patch_validate_test.go | 90 ++++++++ server/internal/services/patch_window.go | 166 +++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 server/internal/services/patch_policies.go create mode 100644 server/internal/services/patch_validate_test.go create mode 100644 server/internal/services/patch_window.go diff --git a/server/internal/services/patch_policies.go b/server/internal/services/patch_policies.go new file mode 100644 index 0000000..241f1d5 --- /dev/null +++ b/server/internal/services/patch_policies.go @@ -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 +} diff --git a/server/internal/services/patch_validate_test.go b/server/internal/services/patch_validate_test.go new file mode 100644 index 0000000..ea05406 --- /dev/null +++ b/server/internal/services/patch_validate_test.go @@ -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) + } +} diff --git a/server/internal/services/patch_window.go b/server/internal/services/patch_window.go new file mode 100644 index 0000000..76d087c --- /dev/null +++ b/server/internal/services/patch_window.go @@ -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 +}