feat: status page schema, licence feature and indexes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user