feat: status page authoring API

Adds owner|admin routes under /api/status-pages for authoring status pages
and their incidents/maintenance windows, gated by the status_pages licence
feature. Adds the "status" token scope resource and the ten route-scope
entries, and regenerates the committed OpenAPI document.

Also types ErrPageInvalid as a sentinel for status page/incident validation
failures (previously bare errors), so statusPageError maps them to 400
instead of 500, and createStatusIncident/updateStatusIncident route through
the shared error mapper rather than hand-rolling a 400 for any service error.
This commit is contained in:
2026-08-24 14:43:17 +00:00
parent 6ba54f690c
commit 3abbdc41d6
8 changed files with 1122 additions and 12 deletions
+766
View File
@@ -453,6 +453,21 @@
},
"type": "object"
},
"api.StatusIncidentUpdateRequest": {
"properties": {
"body": {
"type": "string"
},
"status": {
"type": "string"
}
},
"required": [
"body",
"status"
],
"type": "object"
},
"api.StatusResponse": {
"properties": {
"status": {
@@ -1601,6 +1616,170 @@
},
"type": "object"
},
"models.StatusIncident": {
"properties": {
"_id": {
"type": "string"
},
"affected_monitors": {
"items": {
"type": "string"
},
"type": "array",
"uniqueItems": false
},
"created_at": {
"type": "string"
},
"impact": {
"type": "string"
},
"incident_id": {
"type": "string"
},
"instance_id": {
"type": "string"
},
"kind": {
"type": "string"
},
"page_ids": {
"items": {
"type": "string"
},
"type": "array",
"uniqueItems": false
},
"resolved_at": {
"type": "string"
},
"scheduled_end": {
"type": "string"
},
"scheduled_start": {
"type": "string"
},
"started_at": {
"type": "string"
},
"status": {
"type": "string"
},
"title": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"updates": {
"items": {
"$ref": "#/components/schemas/models.StatusIncidentUpdate"
},
"type": "array",
"uniqueItems": false
}
},
"type": "object"
},
"models.StatusIncidentUpdate": {
"properties": {
"at": {
"type": "string"
},
"author": {
"type": "string"
},
"body": {
"type": "string"
},
"status": {
"type": "string"
}
},
"type": "object"
},
"models.StatusPage": {
"properties": {
"_id": {
"type": "string"
},
"banner": {
"$ref": "#/components/schemas/models.StatusPageBanner"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"instance_id": {
"type": "string"
},
"logo_url": {
"type": "string"
},
"page_id": {
"type": "string"
},
"published": {
"type": "boolean"
},
"sections": {
"items": {
"$ref": "#/components/schemas/models.StatusPageSection"
},
"type": "array",
"uniqueItems": false
},
"title": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
},
"models.StatusPageBanner": {
"properties": {
"enabled": {
"type": "boolean"
},
"level": {
"type": "string"
},
"text": {
"type": "string"
}
},
"type": "object"
},
"models.StatusPageEntry": {
"properties": {
"display_name": {
"type": "string"
},
"monitor_id": {
"type": "string"
}
},
"type": "object"
},
"models.StatusPageSection": {
"properties": {
"entries": {
"items": {
"$ref": "#/components/schemas/models.StatusPageEntry"
},
"type": "array",
"uniqueItems": false
},
"name": {
"type": "string"
}
},
"type": "object"
},
"models.StepOverride": {
"properties": {
"script": {
@@ -6871,6 +7050,593 @@
]
}
},
"/status-pages": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/models.StatusPage"
},
"type": "array"
}
}
},
"description": "OK"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "List status pages",
"tags": [
"status-pages"
]
},
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"$ref": "#/components/schemas/models.StatusPage",
"summary": "body",
"description": "Status page"
}
]
}
}
},
"description": "Status page",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusPage"
}
}
},
"description": "Created"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Bad Request"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Conflict"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Create a status page",
"tags": [
"status-pages"
]
}
},
"/status-pages/{pageId}": {
"delete": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Delete a status page",
"tags": [
"status-pages"
]
},
"get": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusPage"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Get a status page",
"tags": [
"status-pages"
]
},
"put": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"$ref": "#/components/schemas/models.StatusPage",
"summary": "body",
"description": "Status page"
}
]
}
}
},
"description": "Status page",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusPage"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Update a status page",
"tags": [
"status-pages"
]
}
},
"/status-pages/{pageId}/incidents": {
"get": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/models.StatusIncident"
},
"type": "array"
}
}
},
"description": "OK"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "List authored incidents for a status page",
"tags": [
"status-pages"
]
},
"post": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"$ref": "#/components/schemas/models.StatusIncident",
"summary": "body",
"description": "Incident"
}
]
}
}
},
"description": "Incident",
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusIncident"
}
}
},
"description": "Created"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Bad Request"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Create an incident or maintenance window",
"tags": [
"status-pages"
]
}
},
"/status-pages/{pageId}/incidents/{incidentId}": {
"delete": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Incident id",
"in": "path",
"name": "incidentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Delete an incident or maintenance window",
"tags": [
"status-pages"
]
},
"put": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Incident id",
"in": "path",
"name": "incidentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"$ref": "#/components/schemas/models.StatusIncident",
"summary": "body",
"description": "Incident"
}
]
}
}
},
"description": "Incident",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusIncident"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Update an incident or maintenance window",
"tags": [
"status-pages"
]
}
},
"/status-pages/{pageId}/incidents/{incidentId}/updates": {
"post": {
"parameters": [
{
"description": "Page id",
"in": "path",
"name": "pageId",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Incident id",
"in": "path",
"name": "incidentId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object"
},
{
"$ref": "#/components/schemas/api.StatusIncidentUpdateRequest",
"summary": "body",
"description": "Update"
}
]
}
}
},
"description": "Update",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/models.StatusIncident"
}
}
},
"description": "OK"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Bad Request"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Post an update to an incident",
"tags": [
"status-pages"
]
}
},
"/steps": {
"get": {
"responses": {
+2
View File
@@ -166,6 +166,8 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
registerStatusPageRoutes(apiGroup)
}
}
+13
View File
@@ -156,6 +156,19 @@ var routeScopes = map[string]string{
"GET /api/openapi.json": "settings:read",
"GET /api/docs": "settings:read",
"GET /api/docs/scalar.js": "settings:read",
// Status pages. Reading is status:read even though the pages themselves
// are public, because these routes read the unpublished ones too.
"GET /api/status-pages": "status:read",
"POST /api/status-pages": "status:write",
"GET /api/status-pages/:pageId": "status:read",
"PUT /api/status-pages/:pageId": "status:write",
"DELETE /api/status-pages/:pageId": "status:write",
"GET /api/status-pages/:pageId/incidents": "status:read",
"POST /api/status-pages/:pageId/incidents": "status:write",
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
}
// RequireScopes enforces routeScopes for token-authenticated requests and does
+312
View File
@@ -0,0 +1,312 @@
package api
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/gin-gonic/gin"
)
func registerStatusPageRoutes(g *gin.RouterGroup) {
// Owner or admin throughout: publishing a page is speaking to the public
// in the instance's name. The feature gate sits alongside the role gate so
// authoring and serving are gated by the same licence feature.
sp := g.Group("/status-pages")
sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages))
sp.GET("", listStatusPages)
sp.POST("", createStatusPage)
sp.GET("/:pageId", getStatusPage)
sp.PUT("/:pageId", updateStatusPage)
sp.DELETE("/:pageId", deleteStatusPage)
sp.GET("/:pageId/incidents", listStatusIncidents)
sp.POST("/:pageId/incidents", createStatusIncident)
sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident)
sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident)
sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate)
}
// statusPageError maps the service errors onto codes once, so ten handlers do
// not each invent their own. services.ErrPageInvalid covers every validation
// failure in the status page and incident services — a missing title or an
// invalid incident status is a 400, not a 500.
func statusPageError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrPageIDTaken):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
// listStatusPages godoc
//
// @Summary List status pages
// @Tags status-pages
// @Produce json
// @Success 200 {array} models.StatusPage
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages [get]
func listStatusPages(c *gin.Context) {
pages, err := services.ListStatusPages(auth.InstanceID(c))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, pages)
}
// createStatusPage godoc
//
// @Summary Create a status page
// @Tags status-pages
// @Accept json
// @Produce json
// @Param body body models.StatusPage true "Status page"
// @Success 201 {object} models.StatusPage
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages [post]
func createStatusPage(c *gin.Context) {
var p models.StatusPage
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
created, err := services.CreateStatusPage(auth.InstanceID(c), &p)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "",
"Status page '"+created.PageID+"' created")
c.JSON(http.StatusCreated, created)
}
// getStatusPage godoc
//
// @Summary Get a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 200 {object} models.StatusPage
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [get]
func getStatusPage(c *gin.Context) {
page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId"))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, page)
}
// updateStatusPage godoc
//
// @Summary Update a status page
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param body body models.StatusPage true "Status page"
// @Success 200 {object} models.StatusPage
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [put]
func updateStatusPage(c *gin.Context) {
var p models.StatusPage
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "",
"Status page '"+updated.PageID+"' updated")
c.JSON(http.StatusOK, updated)
}
// deleteStatusPage godoc
//
// @Summary Delete a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 204 "No Content"
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId} [delete]
func deleteStatusPage(c *gin.Context) {
if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "",
"Status page '"+c.Param("pageId")+"' deleted")
c.Status(http.StatusNoContent)
}
// listStatusIncidents godoc
//
// @Summary List authored incidents for a status page
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Success 200 {array} models.StatusIncident
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents [get]
func listStatusIncidents(c *gin.Context) {
incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId"))
if err != nil {
statusPageError(c, err)
return
}
c.JSON(http.StatusOK, incs)
}
// createStatusIncident godoc
//
// @Summary Create an incident or maintenance window
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param body body models.StatusIncident true "Incident"
// @Success 201 {object} models.StatusIncident
// @Failure 400 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents [post]
func createStatusIncident(c *gin.Context) {
var inc models.StatusIncident
if err := c.ShouldBindJSON(&inc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// The page in the path is always one of the pages the incident names, so
// creating from a page cannot produce an incident that page never shows.
if !contains(inc.PageIDs, c.Param("pageId")) {
inc.PageIDs = append(inc.PageIDs, c.Param("pageId"))
}
created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "",
"Status "+created.Kind+" '"+created.Title+"' created")
c.JSON(http.StatusCreated, created)
}
func contains(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// updateStatusIncident godoc
//
// @Summary Update an incident or maintenance window
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Param body body models.StatusIncident true "Incident"
// @Success 200 {object} models.StatusIncident
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId} [put]
func updateStatusIncident(c *gin.Context) {
var inc models.StatusIncident
if err := c.ShouldBindJSON(&inc); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc)
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "",
"Status "+updated.Kind+" '"+updated.Title+"' updated")
c.JSON(http.StatusOK, updated)
}
// deleteStatusIncident godoc
//
// @Summary Delete an incident or maintenance window
// @Tags status-pages
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Success 204 "No Content"
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId} [delete]
func deleteStatusIncident(c *gin.Context) {
if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "",
"Status incident '"+c.Param("incidentId")+"' deleted")
c.Status(http.StatusNoContent)
}
// appendStatusIncidentUpdate godoc
//
// @Summary Post an update to an incident
// @Tags status-pages
// @Accept json
// @Produce json
// @Param pageId path string true "Page id"
// @Param incidentId path string true "Incident id"
// @Param body body StatusIncidentUpdateRequest true "Update"
// @Success 200 {object} models.StatusIncident
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post]
func appendStatusIncidentUpdate(c *gin.Context) {
var body StatusIncidentUpdateRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, err := services.AppendStatusIncidentUpdate(
auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c))
if err != nil {
statusPageError(c, err)
return
}
services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "",
"Update posted to '"+updated.Title+"' ("+body.Status+")")
c.JSON(http.StatusOK, updated)
}
+9
View File
@@ -237,3 +237,12 @@ type WorkloadLogsResponse struct {
Text string `json:"text"`
Truncated bool `json:"truncated"`
}
// --- status pages ---
// StatusIncidentUpdateRequest is one post to an incident's timeline. The author
// is taken from the session, never from the body.
type StatusIncidentUpdateRequest struct {
Status string `json:"status" binding:"required"`
Body string `json:"body" binding:"required"`
}
+1
View File
@@ -26,6 +26,7 @@ var ScopeResources = []string{
"vulns",
"workloads",
"settings",
"status",
}
const (
+10 -10
View File
@@ -35,34 +35,34 @@ var impacts = map[string]bool{
func validateIncident(inc *models.StatusIncident) error {
if inc.Title == "" {
return errors.New("title is required")
return fmt.Errorf("%w: title is required", ErrPageInvalid)
}
if len(inc.PageIDs) == 0 {
return errors.New("at least one page is required")
return fmt.Errorf("%w: at least one page is required", ErrPageInvalid)
}
switch inc.Kind {
case models.StatusKindIncident:
if !incidentStatuses[inc.Status] {
return fmt.Errorf("invalid incident status %q", inc.Status)
return fmt.Errorf("%w: invalid incident status %q", ErrPageInvalid, inc.Status)
}
case models.StatusKindMaintenance:
if !maintenanceStatuses[inc.Status] {
return fmt.Errorf("invalid maintenance status %q", inc.Status)
return fmt.Errorf("%w: invalid maintenance status %q", ErrPageInvalid, inc.Status)
}
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
return errors.New("maintenance needs a scheduled start and end")
return fmt.Errorf("%w: maintenance needs a scheduled start and end", ErrPageInvalid)
}
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
return errors.New("maintenance must end after it starts")
return fmt.Errorf("%w: maintenance must end after it starts", ErrPageInvalid)
}
default:
return fmt.Errorf("invalid kind %q", inc.Kind)
return fmt.Errorf("%w: invalid kind %q", ErrPageInvalid, inc.Kind)
}
if inc.Impact == "" {
inc.Impact = models.ImpactNone
}
if !impacts[inc.Impact] {
return fmt.Errorf("invalid impact %q", inc.Impact)
return fmt.Errorf("%w: invalid impact %q", ErrPageInvalid, inc.Impact)
}
return nil
}
@@ -214,14 +214,14 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str
return nil, err
}
if body == "" {
return nil, errors.New("update body is required")
return nil, fmt.Errorf("%w: update body is required", ErrPageInvalid)
}
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)
return nil, fmt.Errorf("%w: invalid status %q for a %s", ErrPageInvalid, status, existing.Kind)
}
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
+9 -2
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"errors"
"fmt"
"log"
"regexp"
"time"
@@ -74,6 +75,12 @@ func EnsureStatusPageIndexes() error {
var (
ErrPageNotFound = errors.New("status page not found")
ErrPageIDTaken = errors.New("that page id is already in use")
// ErrPageInvalid is the sentinel for validation failures on a page or
// incident body — anything the caller can fix by sending a different
// request. statusPageError maps it to 400; wrap it rather than returning a
// bare error, or a bad request answers 500.
ErrPageInvalid = errors.New("status page request invalid")
)
// statusRedis is set by main.go at boot. It is nil in any process that has not
@@ -134,7 +141,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
return nil, err
}
if p.Title == "" {
return nil, errors.New("title is required")
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
}
p.ID = bson.ObjectID{}
p.InstanceID = instanceID
@@ -169,7 +176,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
// The page id itself is immutable: it is a URL that has been handed out.
func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) {
if p.Title == "" {
return nil, errors.New("title is required")
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
}
if p.Sections == nil {
p.Sections = []models.StatusPageSection{}