diff --git a/server/cmd/main.go b/server/cmd/main.go index c7a8c5e..7139f61 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -174,6 +174,10 @@ func runSchemaSetup() { log.Printf("warning: failed to ensure workload indexes: %v", err) } + if err := services.EnsureStatusPageIndexes(); err != nil { + log.Printf("status page indexes: %v", err) + } + if err := services.EnsureAuditIndexes(); err != nil { log.Printf("warning: failed to ensure audit indexes: %v", err) } diff --git a/server/internal/models/statuspage.go b/server/internal/models/statuspage.go new file mode 100644 index 0000000..712ae79 --- /dev/null +++ b/server/internal/models/statuspage.go @@ -0,0 +1,110 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// A status page entry kind. Incidents and maintenance share one document +// because they share a timeline, an impact and a set of affected components. +const ( + StatusKindIncident = "incident" + StatusKindMaintenance = "maintenance" +) + +const ( + ImpactNone = "none" + ImpactMinor = "minor" + ImpactMajor = "major" + ImpactCritical = "critical" +) + +// Incident statuses. +const ( + IncidentInvestigating = "investigating" + IncidentIdentified = "identified" + IncidentMonitoring = "monitoring" + IncidentResolved = "resolved" +) + +// Maintenance statuses. +const ( + MaintenanceScheduled = "scheduled" + MaintenanceInProgress = "in_progress" + MaintenanceCompleted = "completed" +) + +// StatusPageEntry names one monitor on one page. +// +// DisplayName overrides the monitor's own name for this page only. A monitor's +// internal name is frequently not a name anybody wants published, and the same +// monitor may need different words on a customer page and a partner page. +type StatusPageEntry struct { + MonitorID string `bson:"monitor_id" json:"monitor_id"` + DisplayName string `bson:"display_name,omitempty" json:"display_name,omitempty"` +} + +// StatusPageSection is page-local and unrelated to Monitor.Group, which labels +// rows on the authenticated monitors list. +type StatusPageSection struct { + Name string `bson:"name" json:"name"` + Entries []StatusPageEntry `bson:"entries" json:"entries"` +} + +// StatusPageBanner is three fields on the page rather than a collection, +// because it is one string with no lifecycle. +type StatusPageBanner struct { + Enabled bool `bson:"enabled" json:"enabled"` + Level string `bson:"level,omitempty" json:"level,omitempty"` + Text string `bson:"text,omitempty" json:"text,omitempty"` +} + +// StatusPage is read whole, always, which is why its structure is embedded +// rather than joined: one page is one read is one cache fill. +type StatusPage struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + PageID string `bson:"page_id" json:"page_id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + LogoURL string `bson:"logo_url,omitempty" json:"logo_url,omitempty"` + Published bool `bson:"published" json:"published"` + Banner StatusPageBanner `bson:"banner" json:"banner"` + Sections []StatusPageSection `bson:"sections" json:"sections"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +type StatusIncidentUpdate struct { + At time.Time `bson:"at" json:"at"` + Status string `bson:"status" json:"status"` + Body string `bson:"body" json:"body"` + Author string `bson:"author" json:"author"` +} + +// StatusIncident is operator-authored. Monitor-detected outages stay in the +// incidents collection and are derived at assembly time; copying them here +// would be a second writer for the same fact. +// +// PageIDs is explicit rather than derived from AffectedMonitors: deriving it +// would mean adding a monitor to a page retroactively republishes old +// incidents to a new audience. +type StatusIncident struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + IncidentID string `bson:"incident_id" json:"incident_id"` + PageIDs []string `bson:"page_ids" json:"page_ids"` + Kind string `bson:"kind" json:"kind"` + Title string `bson:"title" json:"title"` + Impact string `bson:"impact" json:"impact"` + AffectedMonitors []string `bson:"affected_monitors,omitempty" json:"affected_monitors,omitempty"` + Status string `bson:"status" json:"status"` + ScheduledStart *time.Time `bson:"scheduled_start,omitempty" json:"scheduled_start,omitempty"` + ScheduledEnd *time.Time `bson:"scheduled_end,omitempty" json:"scheduled_end,omitempty"` + Updates []StatusIncidentUpdate `bson:"updates" json:"updates"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go index 01752fe..6c018b4 100644 --- a/server/internal/services/migrate_instance.go +++ b/server/internal/services/migrate_instance.go @@ -46,6 +46,8 @@ var ScopedCollections = []string{ "vuln_alert_rules", "api_tokens", "server_workloads", + "status_pages", + "status_incidents", } // collectionRenames maps the two collections whose names change. Ordered so the diff --git a/server/internal/services/statuspages.go b/server/internal/services/statuspages.go new file mode 100644 index 0000000..1a46d4b --- /dev/null +++ b/server/internal/services/statuspages.go @@ -0,0 +1,47 @@ +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" +) + +func spCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 5*time.Second) +} + +// EnsureStatusPageIndexes follows EnsureWorkflowIndexes rather than +// EnsureAuthIndexes: the unique page_id index is a correctness property, but a +// missing secondary index on a small collection degrades to a scan, which is no +// reason to refuse to serve the fleet. main.go warns rather than exiting. +func EnsureStatusPageIndexes() error { + ctx, cancel := spCtx() + defer cancel() + + if _, err := db.Col("status_pages").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "page_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + if _, err := db.Col("status_incidents").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "incident_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + // The public read filters by page and orders by recency, and it is the + // only query on this collection that runs on every visit. + if _, err := db.Col("status_incidents").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "page_ids", Value: 1}, {Key: "started_at", Value: -1}}, + }); err != nil { + return err + } + return nil +} diff --git a/server/internal/services/statuspages_test.go b/server/internal/services/statuspages_test.go new file mode 100644 index 0000000..e72ddd8 --- /dev/null +++ b/server/internal/services/statuspages_test.go @@ -0,0 +1,22 @@ +package services + +import "testing" + +// A new instance-scoped collection that is not in ScopedCollections leaves its +// rows behind when the instance is deleted. This is the cheapest possible +// guard against the omission. +func TestStatusCollectionsAreScoped(t *testing.T) { + want := []string{"status_pages", "status_incidents"} + for _, name := range want { + found := false + for _, got := range ScopedCollections { + if got == name { + found = true + break + } + } + if !found { + t.Errorf("ScopedCollections is missing %q", name) + } + } +} diff --git a/shared/license/license.go b/shared/license/license.go index bffb278..651bd92 100644 --- a/shared/license/license.go +++ b/shared/license/license.go @@ -35,6 +35,11 @@ const ( // findings themselves. The gate is at collection, not display: an ungated // instance stores no inventory, and storage is the expensive half. FeatureVulnScanning = "vuln_scanning" + // FeatureStatusPages gates public status pages at both ends: authoring + // them, and serving them. Serving answers 200 with available:false rather + // than 403, because the page has to render an explanation to a member of + // the public who cannot do anything about it. + FeatureStatusPages = "status_pages" ) // Support levels. Carried for display and enforced by nothing — there is no code