feat: authored status incidents and maintenance windows
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"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 ErrIncidentNotFound = errors.New("status incident not found")
|
||||
|
||||
var incidentStatuses = map[string]bool{
|
||||
models.IncidentInvestigating: true,
|
||||
models.IncidentIdentified: true,
|
||||
models.IncidentMonitoring: true,
|
||||
models.IncidentResolved: true,
|
||||
}
|
||||
|
||||
var maintenanceStatuses = map[string]bool{
|
||||
models.MaintenanceScheduled: true,
|
||||
models.MaintenanceInProgress: true,
|
||||
models.MaintenanceCompleted: true,
|
||||
}
|
||||
|
||||
var impacts = map[string]bool{
|
||||
models.ImpactNone: true, models.ImpactMinor: true,
|
||||
models.ImpactMajor: true, models.ImpactCritical: true,
|
||||
}
|
||||
|
||||
func validateIncident(inc *models.StatusIncident) error {
|
||||
if inc.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if len(inc.PageIDs) == 0 {
|
||||
return errors.New("at least one page is required")
|
||||
}
|
||||
switch inc.Kind {
|
||||
case models.StatusKindIncident:
|
||||
if !incidentStatuses[inc.Status] {
|
||||
return fmt.Errorf("invalid incident status %q", inc.Status)
|
||||
}
|
||||
case models.StatusKindMaintenance:
|
||||
if !maintenanceStatuses[inc.Status] {
|
||||
return fmt.Errorf("invalid maintenance status %q", inc.Status)
|
||||
}
|
||||
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
|
||||
return errors.New("maintenance needs a scheduled start and end")
|
||||
}
|
||||
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
|
||||
return errors.New("maintenance must end after it starts")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid kind %q", inc.Kind)
|
||||
}
|
||||
if inc.Impact == "" {
|
||||
inc.Impact = models.ImpactNone
|
||||
}
|
||||
if !impacts[inc.Impact] {
|
||||
return fmt.Errorf("invalid impact %q", inc.Impact)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
|
||||
if err := validateIncident(inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inc.ID = bson.ObjectID{}
|
||||
inc.InstanceID = instanceID
|
||||
inc.IncidentID = uuid.NewString()
|
||||
now := time.Now()
|
||||
inc.CreatedAt = now
|
||||
inc.UpdatedAt = now
|
||||
if inc.StartedAt.IsZero() {
|
||||
if inc.Kind == models.StatusKindMaintenance && inc.ScheduledStart != nil {
|
||||
inc.StartedAt = *inc.ScheduledStart
|
||||
} else {
|
||||
inc.StartedAt = now
|
||||
}
|
||||
}
|
||||
if inc.Updates == nil {
|
||||
inc.Updates = []models.StatusIncidentUpdate{}
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").InsertOne(ctx, inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalidatePages(instanceID, inc.PageIDs)
|
||||
return inc, nil
|
||||
}
|
||||
|
||||
func invalidatePages(instanceID string, pageIDs []string) {
|
||||
for _, p := range pageIDs {
|
||||
InvalidateStatusCache(instanceID, p)
|
||||
}
|
||||
}
|
||||
|
||||
func getIncident(instanceID, incidentID string) (*models.StatusIncident, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
var inc models.StatusIncident
|
||||
err := db.Col("status_incidents").
|
||||
FindOne(ctx, bson.M{"instance_id": instanceID, "incident_id": incidentID}).
|
||||
Decode(&inc)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrIncidentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inc, nil
|
||||
}
|
||||
|
||||
func ListStatusIncidents(instanceID, pageID string) ([]models.StatusIncident, error) {
|
||||
filter := bson.M{"instance_id": instanceID}
|
||||
if pageID != "" {
|
||||
filter["page_ids"] = pageID
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("status_incidents").Find(ctx, filter,
|
||||
options.Find().SetSort(bson.M{"started_at": -1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.StatusIncident{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListStatusIncidentsForPage is the public read's query: one page, bounded by
|
||||
// the history window, so a five-year-old instance does not assemble five years
|
||||
// of incidents on every cache miss.
|
||||
func ListStatusIncidentsForPage(instanceID, pageID string, since time.Time) ([]models.StatusIncident, error) {
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("status_incidents").Find(ctx, bson.M{
|
||||
"instance_id": instanceID,
|
||||
"page_ids": pageID,
|
||||
"$or": []bson.M{
|
||||
{"started_at": bson.M{"$gte": since}},
|
||||
{"resolved_at": nil},
|
||||
{"status": models.MaintenanceScheduled},
|
||||
},
|
||||
}, options.Find().SetSort(bson.M{"started_at": -1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.StatusIncident{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inc.Kind = existing.Kind // kind is fixed at creation
|
||||
if err := validateIncident(inc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set := bson.M{
|
||||
"page_ids": inc.PageIDs,
|
||||
"title": inc.Title,
|
||||
"impact": inc.Impact,
|
||||
"affected_monitors": inc.AffectedMonitors,
|
||||
"status": inc.Status,
|
||||
"scheduled_start": inc.ScheduledStart,
|
||||
"scheduled_end": inc.ScheduledEnd,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if inc.Status == models.IncidentResolved || inc.Status == models.MaintenanceCompleted {
|
||||
if existing.ResolvedAt == nil {
|
||||
now := time.Now()
|
||||
set["resolved_at"] = now
|
||||
}
|
||||
} else {
|
||||
// Reopening clears it, so a mistakenly resolved incident does not keep
|
||||
// a resolution time it no longer has.
|
||||
set["resolved_at"] = nil
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID},
|
||||
bson.M{"$set": set}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Both old and new page sets, or a page the incident was just removed from
|
||||
// keeps showing it for up to 30 seconds.
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
invalidatePages(instanceID, inc.PageIDs)
|
||||
return getIncident(instanceID, incidentID)
|
||||
}
|
||||
|
||||
func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author string) (*models.StatusIncident, error) {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == "" {
|
||||
return nil, errors.New("update body is required")
|
||||
}
|
||||
valid := incidentStatuses
|
||||
if existing.Kind == models.StatusKindMaintenance {
|
||||
valid = maintenanceStatuses
|
||||
}
|
||||
if !valid[status] {
|
||||
return nil, fmt.Errorf("invalid status %q for a %s", status, existing.Kind)
|
||||
}
|
||||
|
||||
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
|
||||
set := bson.M{"status": status, "updated_at": upd.At}
|
||||
if status == models.IncidentResolved || status == models.MaintenanceCompleted {
|
||||
set["resolved_at"] = upd.At
|
||||
}
|
||||
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID},
|
||||
bson.M{"$push": bson.M{"updates": upd}, "$set": set}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
return getIncident(instanceID, incidentID)
|
||||
}
|
||||
|
||||
func DeleteStatusIncident(instanceID, incidentID string) error {
|
||||
existing, err := getIncident(instanceID, incidentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := spCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("status_incidents").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "incident_id": incidentID}); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidatePages(instanceID, existing.PageIDs)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user