Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ EINO_PATTERNS.md
runbook-
tests
testdata
runbook-ingest
runbook-ingest
_drafts
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ func main() {
rootCtx, rootCancel := context.WithCancel(context.Background())
defer rootCancel()

// Start the recurring daily incident-report scheduler. It is independent
// of agent.enable (the report covers webhook incidents too) and is inert
// unless an operator turns the daily digest on in the report settings, so a
// default install starts a single idle ticker and sends nothing.
services.StartReportScheduler(rootCtx, store)

if cfg.Agent.Enable {
// Try to attach to the existing Redis client; if on-call wasn't
// enabled but agent is, open one now (best effort — fall back to
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/VersusControl/versus-incident

go 1.26.0

toolchain go1.26.4
toolchain go1.26.5

require (
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.78.2
Expand Down
40 changes: 36 additions & 4 deletions pkg/controllers/incidents_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ func NewIncidentAdminController() *IncidentAdminController {

// Register attaches the admin endpoints under /api/admin/incidents.
//
// GET /api/admin/incidents list (newest first; ?limit=NN)
// GET /api/admin/incidents/search full-text search (?q=&limit=NN)
// GET /api/admin/incidents/:id single record
// POST /api/admin/incidents/:id/resolve mark resolved (idempotent)
// GET /api/admin/incidents list (newest first; ?limit=NN)
// GET /api/admin/incidents/search full-text search (?q=&limit=NN)
// GET /api/admin/incidents/intake-settings read intake settings
// PUT /api/admin/incidents/intake-settings update intake settings
// GET /api/admin/incidents/:id single record
// POST /api/admin/incidents/:id/resolve mark resolved (idempotent)
func (i *IncidentAdminController) Register(router fiber.Router) {
// Capabilities probe — lets the UI enable/disable search depending on
// whether the active storage backend implements storage.Searcher.
Expand All @@ -45,6 +47,10 @@ func (i *IncidentAdminController) Register(router fiber.Router) {
// /search MUST be registered before /:id so the literal path is not
// swallowed by the :id parameter route.
g.Get("/search", i.search)
// /intake-settings likewise MUST precede /:id so the literal settings
// path is not captured as an incident id.
g.Get("/intake-settings", i.getIntakeSettings)
g.Put("/intake-settings", i.putIntakeSettings)
g.Get("/:id", i.get)
g.Post("/:id/resolve", i.resolve)
g.Post("/:id/analyze", i.analyze)
Expand Down Expand Up @@ -335,6 +341,32 @@ func (i *IncidentAdminController) resolve(c *fiber.Ctx) error {
})
}

// getIntakeSettings returns the current runtime intake settings (or the
// built-in defaults — auto-resolve ON — when none are stored). Same
// X-Gateway-Secret guard as the other admin settings routes (the group's
// authMiddleware).
func (i *IncidentAdminController) getIntakeSettings(c *fiber.Ctx) error {
return c.JSON(services.LoadIntakeSettings(services.Storage()))
}

// putIntakeSettings persists updated runtime intake settings. The whole
// settings object is replaced (idempotent). 503 when no storage backend is
// configured, mirroring the report settings PUT.
func (i *IncidentAdminController) putIntakeSettings(c *fiber.Ctx) error {
var s services.IntakeSettings
if err := c.BodyParser(&s); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid settings body"})
}
if err := services.SaveIntakeSettings(services.Storage(), s); err != nil {
if errors.Is(err, services.ErrIntakeNoStorage) {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "storage not configured"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Return the effective settings after the write.
return c.JSON(services.LoadIntakeSettings(services.Storage()))
}

// ---------------------------------------------------------------------------
// Analyze
// ---------------------------------------------------------------------------
Expand Down
101 changes: 101 additions & 0 deletions pkg/controllers/incidents_admin_intake_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package controllers

import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"

"github.com/VersusControl/versus-incident/pkg/config"
"github.com/VersusControl/versus-incident/pkg/services"
"github.com/VersusControl/versus-incident/pkg/storage"

"github.com/gofiber/fiber/v2"
)

const intakeSecret = "test-gateway-secret"

// TestIntakeSettings_GetPutRoundTrip drives the intake settings endpoints: GET
// returns the default (auto-resolve ON) on a fresh store, PUT persists a
// disable, and a subsequent GET reflects it.
func TestIntakeSettings_GetPutRoundTrip(t *testing.T) {
loadGatewayConfig(t, intakeSecret)
config.GetConfig().GatewaySecret = intakeSecret
st := storage.NewMemory()
services.SetStorage(st)
t.Cleanup(func() { services.SetStorage(nil) })

app := fiber.New()
api := app.Group("/api")
NewIncidentAdminController().Register(api)

// GET → default ON.
getReq := httptest.NewRequest("GET", "/api/admin/incidents/intake-settings", nil)
getReq.Header.Set("X-Gateway-Secret", intakeSecret)
getResp, err := app.Test(getReq, -1)
if err != nil {
t.Fatalf("GET: %v", err)
}
var got services.IntakeSettings
_ = json.NewDecoder(getResp.Body).Decode(&got)
getResp.Body.Close()
if !got.AutoResolveWebhook {
t.Fatalf("default = %+v, want auto_resolve_webhook true", got)
}

// PUT → disable auto-resolve.
putReq := httptest.NewRequest("PUT", "/api/admin/incidents/intake-settings", strings.NewReader(`{"auto_resolve_webhook":false}`))
putReq.Header.Set("X-Gateway-Secret", intakeSecret)
putReq.Header.Set("Content-Type", "application/json")
putResp, err := app.Test(putReq, -1)
if err != nil {
t.Fatalf("PUT: %v", err)
}
if putResp.StatusCode != fiber.StatusOK {
t.Fatalf("PUT status = %d, want 200", putResp.StatusCode)
}
var afterPut services.IntakeSettings
_ = json.NewDecoder(putResp.Body).Decode(&afterPut)
putResp.Body.Close()
if afterPut.AutoResolveWebhook {
t.Fatalf("PUT response = %+v, want auto_resolve_webhook false", afterPut)
}

// GET again → reflects the disable.
getReq2 := httptest.NewRequest("GET", "/api/admin/incidents/intake-settings", nil)
getReq2.Header.Set("X-Gateway-Secret", intakeSecret)
getResp2, err := app.Test(getReq2, -1)
if err != nil {
t.Fatalf("GET2: %v", err)
}
var got2 services.IntakeSettings
_ = json.NewDecoder(getResp2.Body).Decode(&got2)
getResp2.Body.Close()
if got2.AutoResolveWebhook {
t.Fatalf("after PUT, GET = %+v, want false", got2)
}
}

// TestIntakeSettings_RequiresGatewaySecret proves the intake settings routes
// are gated by the same X-Gateway-Secret guard as the rest of the admin
// surface: a request without the header is rejected.
func TestIntakeSettings_RequiresGatewaySecret(t *testing.T) {
loadGatewayConfig(t, intakeSecret)
config.GetConfig().GatewaySecret = intakeSecret
services.SetStorage(storage.NewMemory())
t.Cleanup(func() { services.SetStorage(nil) })

app := fiber.New()
api := app.Group("/api")
NewIncidentAdminController().Register(api)

req := httptest.NewRequest("GET", "/api/admin/incidents/intake-settings", nil)
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("status = %d, want 401 without the gateway secret", resp.StatusCode)
}
}
2 changes: 2 additions & 0 deletions pkg/controllers/incidents_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func TestResolveRouteRegistered(t *testing.T) {
}{
{"GET", "/api/admin/incidents/"},
{"GET", "/api/admin/incidents/search"},
{"GET", "/api/admin/incidents/intake-settings"},
{"PUT", "/api/admin/incidents/intake-settings"},
{"GET", "/api/admin/incidents/:id"},
{"POST", "/api/admin/incidents/:id/resolve"},
{"POST", "/api/admin/incidents/:id/analyze"},
Expand Down
60 changes: 60 additions & 0 deletions pkg/controllers/incidents_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controllers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
Expand Down Expand Up @@ -291,3 +292,62 @@ func TestReportSettings_GetPutRoundTrip(t *testing.T) {
t.Fatalf("updated = %+v", updated)
}
}

// TestReportSettings_ScheduleValidation drives the scheduler-field validation
// on PUT /settings: a well-formed send_time + timezone is accepted and
// round-trips, while a malformed send_time or an unloadable timezone is
// rejected with 400 and never persisted.
func TestReportSettings_ScheduleValidation(t *testing.T) {
loadGatewayConfig(t, reportSecret)
config.GetConfig().GatewaySecret = reportSecret
st := storage.NewMemory()
services.SetStorage(st)
t.Cleanup(func() { services.SetStorage(nil) })

app := fiber.New()
api := app.Group("/api")
NewReportsAdminController().Register(api)

put := func(body string) *http.Response {
t.Helper()
req := httptest.NewRequest("PUT", "/api/admin/reports/settings", strings.NewReader(body))
req.Header.Set("X-Gateway-Secret", reportSecret)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("PUT: %v", err)
}
return resp
}

// Valid scheduler fields → 200 and round-trip.
ok := put(`{"enable":true,"default_window":"24h","schedule_enabled":true,"send_time":"07:30","timezone":"Asia/Ho_Chi_Minh"}`)
if ok.StatusCode != fiber.StatusOK {
t.Fatalf("valid PUT status = %d, want 200", ok.StatusCode)
}
var got services.ReportSettings
_ = json.NewDecoder(ok.Body).Decode(&got)
ok.Body.Close()
if !got.ScheduleEnabled || got.SendTime != "07:30" || got.Timezone != "Asia/Ho_Chi_Minh" {
t.Fatalf("valid schedule not persisted: %+v", got)
}

// Bad send_time → 400, and the stored value is unchanged.
badTime := put(`{"enable":true,"schedule_enabled":true,"send_time":"25:00","timezone":"UTC"}`)
if badTime.StatusCode != fiber.StatusBadRequest {
t.Fatalf("bad send_time status = %d, want 400", badTime.StatusCode)
}
badTime.Body.Close()

// Bad timezone → 400.
badTZ := put(`{"enable":true,"schedule_enabled":true,"send_time":"09:00","timezone":"Mars/Phobos"}`)
if badTZ.StatusCode != fiber.StatusBadRequest {
t.Fatalf("bad timezone status = %d, want 400", badTZ.StatusCode)
}
badTZ.Body.Close()

// The rejected writes never landed: settings still carry the valid values.
if cur := services.LoadReportSettings(st); cur.SendTime != "07:30" || cur.Timezone != "Asia/Ho_Chi_Minh" {
t.Fatalf("rejected PUT mutated the store: %+v", cur)
}
}
14 changes: 13 additions & 1 deletion pkg/controllers/reports_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,22 @@ func (rc *ReportsAdminController) putSettings(c *fiber.Ctx) error {
if err := c.BodyParser(&s); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid settings body"})
}
// Copy the string off the pooled request buffer before it outlives the
// Copy the strings off the pooled request buffer before they outlive the
// request.
s.DefaultChannel = strings.Clone(strings.TrimSpace(s.DefaultChannel))
s.DefaultWindow = strings.Clone(strings.TrimSpace(s.DefaultWindow))
s.SendTime = strings.Clone(strings.TrimSpace(s.SendTime))
s.Timezone = strings.Clone(strings.TrimSpace(s.Timezone))
// Validate the scheduler fields at the HTTP boundary. Empty is allowed —
// the store sanitizes it back to the built-in default (09:00 / UTC) — but
// a non-empty value must be well-formed: send_time must be "HH:MM" 24h and
// timezone must be "UTC" or a loadable IANA name.
if s.SendTime != "" && !services.ValidSendTime(s.SendTime) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid send_time (want HH:MM, 24-hour)"})
}
if s.Timezone != "" && !services.ValidTimezone(s.Timezone) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid timezone (want UTC or an IANA name like Asia/Ho_Chi_Minh)"})
}
if err := services.SaveReportSettings(services.Storage(), s); err != nil {
if errors.Is(err, services.ErrReportNoStorage) {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "storage not configured"})
Expand Down
8 changes: 7 additions & 1 deletion pkg/core/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ type NotableIncident struct {
// so a renderer draws it verbatim and must never reach back into raw incident
// content. No AckURL, token, or config value is ever placed here.
type ReportModel struct {
// Window identity. All timestamps are UTC for determinism.
// Window identity. Timestamps are expressed in TZLabel's location (UTC by
// default), so a renderer/caption can print them verbatim.
Window string // "today" | "24h" | "7d"
WindowStart time.Time // inclusive
WindowEnd time.Time // exclusive (== GeneratedAt for today/24h)
GeneratedAt time.Time
// TZLabel is the IANA name of the timezone WindowStart/WindowEnd/
// GeneratedAt are expressed in (e.g. "UTC" or "Asia/Ho_Chi_Minh"). The
// renderer and channel caption print it beside the times so a reader knows
// the wall-clock zone. Empty is treated as "UTC".
TZLabel string

// Headline stats.
Total int // incidents in the window
Expand Down
22 changes: 18 additions & 4 deletions pkg/report/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func (r *Renderer) Render(ctx context.Context, m core.ReportModel) (*core.Report
y := padding + 30
drawString(img, fc.title, colText, padding, y, "Incident report")
y += 30
sub := fmt.Sprintf("%s → %s UTC", fmtTime(m.WindowStart), fmtTime(m.WindowEnd))
sub := fmt.Sprintf("%s → %s %s", fmtTime(m.WindowStart), fmtTime(m.WindowEnd), tzLabelOf(m))
drawString(img, fc.body, colTextMuted, padding, y, sub)
y += 26
divider(img, padding, y, contentRight)
Expand Down Expand Up @@ -241,7 +241,7 @@ func (r *Renderer) Render(ctx context.Context, m core.ReportModel) (*core.Report
if footer == "" {
footer = "Versus Incident"
}
left := "generated " + fmtTime(m.GeneratedAt) + " UTC"
left := "generated " + fmtTime(m.GeneratedAt) + " " + tzLabelOf(m)
drawString(img, fc.footer, colTextFaint, padding, footerY, left)
fw := textWidth(fc.footer, footer)
drawString(img, fc.footer, colTextMuted, contentRight-fw, footerY, footer)
Expand All @@ -251,7 +251,7 @@ func (r *Renderer) Render(ctx context.Context, m core.ReportModel) (*core.Report
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("report: encode png: %w", err)
}
filename := "incidents-" + m.Window + "-" + m.WindowEnd.UTC().Format("20060102") + ".png"
filename := "incidents-" + m.Window + "-" + m.WindowEnd.Format("20060102") + ".png"
return &core.ReportImage{
Data: buf.Bytes(),
MIME: "image/png",
Expand Down Expand Up @@ -494,8 +494,22 @@ func truncateToWidth(face font.Face, s string, maxW int) string {
return "…"
}

// fmtTime renders a timestamp in ITS OWN location. The model's times are
// already expressed in the report's timezone (UTC by default), so the renderer
// must NOT force UTC here — doing so would undo the timezone conversion. For a
// UTC model this is byte-for-byte identical to the previous behaviour.
func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04")
return t.Format("2006-01-02 15:04")
}

// tzLabelOf returns the timezone label drawn beside the card's timestamps,
// defaulting to "UTC" for a model built without one so the card never shows a
// dangling, unlabelled time.
func tzLabelOf(m core.ReportModel) string {
if strings.TrimSpace(m.TZLabel) == "" {
return "UTC"
}
return m.TZLabel
}

func sanitizeFilename(s string) string {
Expand Down
20 changes: 20 additions & 0 deletions pkg/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,26 @@ func ownership() func(name string) bool {
return ownerSlot
}

// Owns reports whether THIS instance should run the unit of work named `name`
// under the installed ownership predicate. With no predicate installed
// (community / single-instance) it returns true — own-everything — so a
// single-instance deployment always owns every named job.
//
// It is the query companion to SetOwnership, for a consumer that runs a
// wall-clock action OUTSIDE the Scheduler.Run loop (e.g. a scheduled report
// send driven by its own ticker) but still wants the SAME single-owner gate
// under HA: gate the action behind Owns(name) and exactly one replica fires it,
// keyed by the enterprise cluster.Identity predicate. Tier-neutral: OSS
// installs no predicate, so Owns is a constant true and community behaviour is
// unchanged.
func Owns(name string) bool {
pred := ownership()
if pred == nil {
return true
}
return pred(name)
}

// Scheduler runs a fixed set of jobs until its context is canceled.
// Construct it with New (explicit jobs) or NewFromRegistry (the
// process-wide registry snapshot).
Expand Down
Loading