Files
story/internal/alerts/service.go
T
2026-08-29 21:56:51 +08:00

529 lines
25 KiB
Go

package alerts
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalid = errors.New("alert input is invalid")
ErrAccessDenied = errors.New("alert operations access denied")
ErrNotFound = errors.New("alert resource not found")
ErrStateConflict = errors.New("alert state conflict")
ErrVersionConflict = errors.New("alert version conflict")
)
type RoutingInput struct {
Category string `json:"category"`
Severity string `json:"severity"`
ReceiverGroup string `json:"receiver_group"`
ResponseMinutes int `json:"response_minutes"`
EscalationAfterMinutes int `json:"escalation_after_minutes"`
EscalationReceiverGroup string `json:"escalation_receiver_group"`
RunbookRef string `json:"runbook_ref"`
ChangeSummary string `json:"change_summary"`
}
type RoutingPolicy struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Category string `json:"category"`
Severity string `json:"severity"`
VersionNo int `json:"version_no"`
Status string `json:"status"`
ReceiverGroup string `json:"receiver_group"`
ResponseMinutes int `json:"response_minutes"`
EscalationAfterMinutes int `json:"escalation_after_minutes"`
EscalationReceiverGroup string `json:"escalation_receiver_group"`
RunbookRef string `json:"runbook_ref"`
ChangeSummary string `json:"change_summary"`
CreatedBy string `json:"created_by"`
RetiredAt *time.Time `json:"retired_at,omitempty"`
RowVersion int64 `json:"row_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SilenceInput struct {
RuleCode string `json:"rule_code,omitempty"`
Category string `json:"category,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
ResourcePublicID string `json:"resource_id,omitempty"`
Matcher map[string]any `json:"matcher"`
Reason string `json:"reason"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
}
type Silence struct {
ID string `json:"id"`
RuleCode *string `json:"rule_code,omitempty"`
Category *string `json:"category,omitempty"`
ResourceType *string `json:"resource_type,omitempty"`
ResourcePublicID *string `json:"resource_id,omitempty"`
Matcher map[string]any `json:"matcher"`
Reason string `json:"reason"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
CreatedBy string `json:"created_by"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
RowVersion int64 `json:"row_version"`
CreatedAt time.Time `json:"created_at"`
}
type Action struct {
ID string `json:"id"`
ActionType string `json:"action_type"`
FromStatus *string `json:"from_status,omitempty"`
ToStatus *string `json:"to_status,omitempty"`
ActorType string `json:"actor_type"`
ActorUser *string `json:"actor_user_id,omitempty"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}
type Incident struct {
ID string `json:"id"`
RuleCode string `json:"rule_code"`
Category string `json:"category"`
Severity string `json:"severity"`
Status string `json:"status"`
Fingerprint string `json:"fingerprint"`
Title string `json:"title"`
Summary string `json:"summary"`
ResourceType *string `json:"resource_type,omitempty"`
ResourcePublicID *string `json:"resource_id,omitempty"`
CorrelationRequestID *string `json:"request_id,omitempty"`
ReceiverGroup *string `json:"receiver_group,omitempty"`
RunbookRef *string `json:"runbook_ref,omitempty"`
CurrentOwner *string `json:"current_owner,omitempty"`
FirstDetectedAt time.Time `json:"first_detected_at"`
LastDetectedAt time.Time `json:"last_detected_at"`
OccurrenceCount int64 `json:"occurrence_count"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
MitigatedAt *time.Time `json:"mitigated_at,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
SilencedUntil *time.Time `json:"silenced_until,omitempty"`
EscalatedAt *time.Time `json:"escalated_at,omitempty"`
RowVersion int64 `json:"row_version"`
Actions []Action `json:"actions"`
}
type actor struct{ tenantID, tenantPublicID, userID, userPublicID, role string }
type Service struct {
pool *pgxpool.Pool
audit *audit.Repository
idem *idempotency.Executor
}
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository) (*Service, error) {
if pool == nil || auditRepository == nil {
return nil, errors.New("alert service dependencies are required")
}
return &Service{pool: pool, audit: auditRepository, idem: idempotency.NewExecutor(pool)}, nil
}
func (s *Service) CreateRoutingPolicy(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input RoutingInput) (RoutingPolicy, bool, error) {
normalizeRouting(&input)
if !validRoutingInput(input) {
return RoutingPolicy{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin")
if err != nil {
return RoutingPolicy{}, false, err
}
body, _ := json.Marshal(input)
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/alert-routing-policies", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) {
if _, e := tx.Exec(op, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, a.tenantID+":"+input.Category+":"+input.Severity); e != nil {
return idempotency.Result{}, e
}
var version int
if e := tx.QueryRow(op, `SELECT COALESCE(MAX(version_no),0)+1 FROM alert_routing_policy WHERE tenant_id=$1::uuid AND category=$2 AND severity=$3`, a.tenantID, input.Category, input.Severity).Scan(&version); e != nil {
return idempotency.Result{}, e
}
if _, e := tx.Exec(op, `UPDATE alert_routing_policy SET status='RETIRED',retired_at=now(),row_version=row_version+1,updated_at=now() WHERE tenant_id=$1::uuid AND category=$2 AND severity=$3 AND status='ACTIVE'`, a.tenantID, input.Category, input.Severity); e != nil {
return idempotency.Result{}, e
}
publicID := uuid.NewString()
var internalID string
if e := tx.QueryRow(op, `INSERT INTO alert_routing_policy(public_id,tenant_id,category,severity,version_no,receiver_group,response_minutes,escalation_after_minutes,escalation_receiver_group,runbook_ref,change_summary,created_by) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::uuid) RETURNING id::text`, publicID, a.tenantID, input.Category, input.Severity, version, input.ReceiverGroup, input.ResponseMinutes, input.EscalationAfterMinutes, input.EscalationReceiverGroup, input.RunbookRef, input.ChangeSummary, a.userID).Scan(&internalID); e != nil {
return idempotency.Result{}, e
}
if e := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.routing.activate", ResourceType: "alert_routing_policy", ResourceID: &internalID, RequestID: requestID, Purpose: input.ChangeSummary, Result: "success", Detail: map[string]any{"category": input.Category, "severity": input.Severity, "version_no": version}}); e != nil {
return idempotency.Result{}, e
}
item, e := getRoutingTx(op, tx, a.tenantID, publicID)
if e != nil {
return idempotency.Result{}, e
}
encoded, _ := json.Marshal(item)
return idempotency.Result{StatusCode: 201, Body: encoded, ResourceID: &internalID}, nil
})
if err != nil {
return RoutingPolicy{}, false, err
}
var item RoutingPolicy
if err = json.Unmarshal(result.Body, &item); err != nil {
return RoutingPolicy{}, false, err
}
return item, !result.Replayed, nil
}
func (s *Service) ListRoutingPolicies(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]RoutingPolicy, error) {
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return nil, e
}
rows, e := s.pool.Query(ctx, routingSelect+` WHERE arp.tenant_id=$1::uuid ORDER BY CASE arp.status WHEN 'ACTIVE' THEN 1 ELSE 2 END,arp.category,arp.severity,arp.version_no DESC`, a.tenantID)
if e != nil {
return nil, e
}
defer rows.Close()
items := []RoutingPolicy{}
for rows.Next() {
item, se := scanRouting(rows)
if se != nil {
return nil, se
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) ListIncidents(ctx context.Context, principal identity.Principal, tenantPublicID, status string) ([]Incident, error) {
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return nil, e
}
status = strings.ToUpper(strings.TrimSpace(status))
if status != "" && !validStatus(status) {
return nil, ErrInvalid
}
rows, e := s.pool.Query(ctx, incidentSelect+` WHERE ai.tenant_id=$1::uuid AND (NULLIF($2,'') IS NULL OR ai.status=$2) ORDER BY CASE ai.severity WHEN 'CRITICAL' THEN 1 WHEN 'HIGH' THEN 2 WHEN 'MEDIUM' THEN 3 ELSE 4 END,ai.last_detected_at DESC LIMIT 1000`, a.tenantID, status)
if e != nil {
return nil, e
}
defer rows.Close()
items := []Incident{}
for rows.Next() {
item, se := scanIncident(rows)
if se != nil {
return nil, se
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) GetIncident(ctx context.Context, principal identity.Principal, tenantPublicID, publicID string) (Incident, error) {
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return Incident{}, e
}
if !validUUID(publicID) {
return Incident{}, ErrInvalid
}
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
if e != nil {
return Incident{}, e
}
defer tx.Rollback(context.Background())
item, e := getIncidentTx(ctx, tx, a.tenantID, publicID)
if errors.Is(e, pgx.ErrNoRows) {
return Incident{}, ErrNotFound
}
if e == nil {
e = tx.Commit(ctx)
}
return item, e
}
func (s *Service) CreateSilence(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input SilenceInput) (Silence, bool, error) {
normalizeSilence(&input)
if !validSilenceInput(input, time.Now().UTC()) {
return Silence{}, false, ErrInvalid
}
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return Silence{}, false, e
}
encoded, _ := json.Marshal(input)
result, e := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/alert-silences", Key: key, RequestHash: idempotency.Hash(encoded), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) {
matcher, _ := json.Marshal(input.Matcher)
publicID := uuid.NewString()
var internalID string
e := tx.QueryRow(op, `INSERT INTO alert_silence(public_id,tenant_id,rule_code,category,resource_type,resource_public_id,matcher,reason,starts_at,ends_at,created_by) VALUES($1::uuid,$2::uuid,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),$6::uuid,$7::jsonb,$8,$9,$10,$11::uuid) RETURNING id::text`, publicID, a.tenantID, input.RuleCode, input.Category, input.ResourceType, nullStringUUID(input.ResourcePublicID), matcher, input.Reason, input.StartsAt.UTC(), input.EndsAt.UTC(), a.userID).Scan(&internalID)
if e != nil {
return idempotency.Result{}, e
}
if e = s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.silence.create", ResourceType: "alert_silence", ResourceID: &internalID, RequestID: requestID, Purpose: input.Reason, Result: "success"}); e != nil {
return idempotency.Result{}, e
}
item, e := getSilenceTx(op, tx, a.tenantID, publicID)
if e != nil {
return idempotency.Result{}, e
}
body, _ := json.Marshal(item)
return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &internalID}, nil
})
if e != nil {
return Silence{}, false, e
}
var item Silence
if e = json.Unmarshal(result.Body, &item); e != nil {
return Silence{}, false, e
}
return item, !result.Replayed, nil
}
func (s *Service) ListSilences(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Silence, error) {
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return nil, e
}
rows, e := s.pool.Query(ctx, silenceSelect+` WHERE als.tenant_id=$1::uuid ORDER BY CASE WHEN revoked_at IS NULL AND ends_at>now() THEN 1 ELSE 2 END,ends_at DESC LIMIT 1000`, a.tenantID)
if e != nil {
return nil, e
}
defer rows.Close()
items := []Silence{}
for rows.Next() {
item, se := scanSilence(rows)
if se != nil {
return nil, se
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) RevokeSilence(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID, reason string, expected int64) (Silence, error) {
reason = strings.TrimSpace(reason)
if reason == "" || len([]rune(reason)) > 500 {
return Silence{}, ErrInvalid
}
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return Silence{}, e
}
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{})
if e != nil {
return Silence{}, e
}
defer tx.Rollback(context.Background())
var id string
var version int64
var revoked *time.Time
e = tx.QueryRow(ctx, `SELECT id::text,row_version,revoked_at FROM alert_silence WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, publicID).Scan(&id, &version, &revoked)
if errors.Is(e, pgx.ErrNoRows) {
return Silence{}, ErrNotFound
}
if e != nil {
return Silence{}, e
}
if version != expected {
return Silence{}, ErrVersionConflict
}
if revoked != nil {
return Silence{}, ErrStateConflict
}
_, e = tx.Exec(ctx, `UPDATE alert_silence SET revoked_by=$2::uuid,revoked_at=now(),row_version=row_version+1,updated_at=now() WHERE id=$1::uuid`, id, a.userID)
if e != nil {
return Silence{}, e
}
if e = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.silence.revoke", ResourceType: "alert_silence", ResourceID: &id, RequestID: requestID, Purpose: reason, Result: "success"}); e != nil {
return Silence{}, e
}
item, e := getSilenceTx(ctx, tx, a.tenantID, publicID)
if e == nil {
e = tx.Commit(ctx)
}
return item, e
}
func (s *Service) TransitionIncident(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID, action, note string, expected int64) (Incident, error) {
action = strings.ToUpper(strings.TrimSpace(action))
note = strings.TrimSpace(note)
if note == "" || len([]rune(note)) > 1000 {
return Incident{}, ErrInvalid
}
targets := map[string]string{"ACKNOWLEDGE": "ACKNOWLEDGED", "MITIGATE": "MITIGATED", "RESOLVE": "RESOLVED"}
target, ok := targets[action]
if !ok {
return Incident{}, ErrInvalid
}
a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin")
if e != nil {
return Incident{}, e
}
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{})
if e != nil {
return Incident{}, e
}
defer tx.Rollback(context.Background())
var id, status string
var version int64
e = tx.QueryRow(ctx, `SELECT id::text,status,row_version FROM alert_incident WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, publicID).Scan(&id, &status, &version)
if errors.Is(e, pgx.ErrNoRows) {
return Incident{}, ErrNotFound
}
if e != nil {
return Incident{}, e
}
if version != expected {
return Incident{}, ErrVersionConflict
}
if status == "RESOLVED" || status == target {
return Incident{}, ErrStateConflict
}
now := time.Now().UTC()
_, e = tx.Exec(ctx, `UPDATE alert_incident SET status=$2::varchar,current_owner_id=COALESCE(current_owner_id,$3::uuid),acknowledged_at=CASE WHEN $2::varchar='ACKNOWLEDGED' THEN $4::timestamptz ELSE acknowledged_at END,mitigated_at=CASE WHEN $2::varchar='MITIGATED' THEN $4::timestamptz ELSE mitigated_at END,resolved_at=CASE WHEN $2::varchar='RESOLVED' THEN $4::timestamptz ELSE resolved_at END,row_version=row_version+1,updated_at=$4::timestamptz WHERE id=$1::uuid`, id, target, a.userID, now)
if e != nil {
return Incident{}, e
}
_, e = tx.Exec(ctx, `INSERT INTO alert_action(tenant_id,incident_id,action_type,from_status,to_status,actor_type,actor_user_id,note) VALUES($1::uuid,$2::uuid,$3,$4,$5,'user',$6::uuid,$7)`, a.tenantID, id, target, status, target, a.userID, note)
if e != nil {
return Incident{}, e
}
if e = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.incident." + strings.ToLower(action), ResourceType: "alert_incident", ResourceID: &id, RequestID: requestID, Purpose: note, Result: "success"}); e != nil {
return Incident{}, e
}
item, e := getIncidentTx(ctx, tx, a.tenantID, publicID)
if e == nil {
e = tx.Commit(ctx)
}
return item, e
}
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string, roles ...string) (actor, error) {
if _, e := uuid.Parse(tenantPublicID); e != nil {
return actor{}, ErrInvalid
}
var a actor
e := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.role)
if errors.Is(e, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
if e != nil {
return actor{}, e
}
for _, role := range roles {
if a.role == role {
return a, nil
}
}
return actor{}, ErrAccessDenied
}
func normalizeRouting(i *RoutingInput) {
i.Category = strings.ToUpper(strings.TrimSpace(i.Category))
i.Severity = strings.ToUpper(strings.TrimSpace(i.Severity))
i.ReceiverGroup = strings.TrimSpace(i.ReceiverGroup)
i.EscalationReceiverGroup = strings.TrimSpace(i.EscalationReceiverGroup)
i.RunbookRef = strings.TrimSpace(i.RunbookRef)
i.ChangeSummary = strings.TrimSpace(i.ChangeSummary)
}
func validRoutingInput(i RoutingInput) bool {
return validCategory(i.Category) && validSeverity(i.Severity) && i.ReceiverGroup != "" && len([]rune(i.ReceiverGroup)) <= 120 && i.ResponseMinutes >= 1 && i.ResponseMinutes <= 10080 && i.EscalationAfterMinutes >= i.ResponseMinutes && i.EscalationAfterMinutes <= 10080 && i.EscalationReceiverGroup != "" && len([]rune(i.EscalationReceiverGroup)) <= 120 && i.RunbookRef != "" && len([]rune(i.RunbookRef)) <= 300 && i.ChangeSummary != "" && len([]rune(i.ChangeSummary)) <= 500
}
func normalizeSilence(i *SilenceInput) {
i.RuleCode = strings.ToUpper(strings.TrimSpace(i.RuleCode))
i.Category = strings.ToUpper(strings.TrimSpace(i.Category))
i.ResourceType = strings.TrimSpace(i.ResourceType)
i.ResourcePublicID = strings.TrimSpace(i.ResourcePublicID)
i.Reason = strings.TrimSpace(i.Reason)
}
func validSilenceInput(i SilenceInput, now time.Time) bool {
return !(i.RuleCode == "" && i.Category == "" && i.ResourcePublicID == "") && (i.RuleCode == "" || rulePattern.MatchString(i.RuleCode)) && (i.Category == "" || validCategory(i.Category)) && (i.ResourcePublicID == "" || validUUID(i.ResourcePublicID)) && i.Reason != "" && len([]rune(i.Reason)) <= 500 && !i.StartsAt.IsZero() && i.EndsAt.After(i.StartsAt) && i.EndsAt.Sub(i.StartsAt) <= 30*24*time.Hour && !i.EndsAt.Before(now)
}
func validCategory(v string) bool {
return v == "BUSINESS" || v == "TECHNICAL" || v == "SECURITY" || v == "COST"
}
func validSeverity(v string) bool {
return v == "CRITICAL" || v == "HIGH" || v == "MEDIUM" || v == "LOW"
}
func validStatus(v string) bool {
return v == "OPEN" || v == "ACKNOWLEDGED" || v == "MITIGATED" || v == "RESOLVED" || v == "SILENCED"
}
func validUUID(v string) bool { _, e := uuid.Parse(v); return e == nil }
func nullStringUUID(v string) any {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
var rulePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{2,99}$`)
type scanner interface{ Scan(...any) error }
const routingSelect = `SELECT arp.public_id::text,t.public_id::text,arp.category,arp.severity,arp.version_no,arp.status,arp.receiver_group,arp.response_minutes,arp.escalation_after_minutes,arp.escalation_receiver_group,arp.runbook_ref,arp.change_summary,ua.public_id::text,arp.retired_at,arp.row_version,arp.created_at,arp.updated_at FROM alert_routing_policy arp JOIN tenant t ON t.id=arp.tenant_id JOIN user_account ua ON ua.id=arp.created_by`
func scanRouting(r scanner) (RoutingPolicy, error) {
var i RoutingPolicy
e := r.Scan(&i.ID, &i.TenantID, &i.Category, &i.Severity, &i.VersionNo, &i.Status, &i.ReceiverGroup, &i.ResponseMinutes, &i.EscalationAfterMinutes, &i.EscalationReceiverGroup, &i.RunbookRef, &i.ChangeSummary, &i.CreatedBy, &i.RetiredAt, &i.RowVersion, &i.CreatedAt, &i.UpdatedAt)
return i, e
}
func getRoutingTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (RoutingPolicy, error) {
return scanRouting(tx.QueryRow(ctx, routingSelect+` WHERE arp.tenant_id=$1::uuid AND arp.public_id=$2::uuid`, tenantID, publicID))
}
const incidentSelect = `SELECT ai.public_id::text,ai.rule_code,ai.category,ai.severity,ai.status,ai.fingerprint,ai.title,ai.summary,ai.resource_type,ai.resource_public_id::text,ai.correlation_request_id,arp.receiver_group,arp.runbook_ref,owner.public_id::text,ai.first_detected_at,ai.last_detected_at,ai.occurrence_count,ai.acknowledged_at,ai.mitigated_at,ai.resolved_at,ai.silenced_until,ai.escalated_at,ai.row_version FROM alert_incident ai LEFT JOIN alert_routing_policy arp ON arp.id=ai.routing_policy_id LEFT JOIN user_account owner ON owner.id=ai.current_owner_id`
func scanIncident(r scanner) (Incident, error) {
var i Incident
e := r.Scan(&i.ID, &i.RuleCode, &i.Category, &i.Severity, &i.Status, &i.Fingerprint, &i.Title, &i.Summary, &i.ResourceType, &i.ResourcePublicID, &i.CorrelationRequestID, &i.ReceiverGroup, &i.RunbookRef, &i.CurrentOwner, &i.FirstDetectedAt, &i.LastDetectedAt, &i.OccurrenceCount, &i.AcknowledgedAt, &i.MitigatedAt, &i.ResolvedAt, &i.SilencedUntil, &i.EscalatedAt, &i.RowVersion)
i.Actions = []Action{}
return i, e
}
func getIncidentTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Incident, error) {
i, e := scanIncident(tx.QueryRow(ctx, incidentSelect+` WHERE ai.tenant_id=$1::uuid AND ai.public_id=$2::uuid`, tenantID, publicID))
if e != nil {
return i, e
}
rows, e := tx.Query(ctx, `SELECT aa.public_id::text,aa.action_type,aa.from_status,aa.to_status,aa.actor_type,ua.public_id::text,aa.note,aa.created_at FROM alert_action aa LEFT JOIN user_account ua ON ua.id=aa.actor_user_id WHERE aa.incident_id=(SELECT id FROM alert_incident WHERE tenant_id=$1::uuid AND public_id=$2::uuid) ORDER BY aa.created_at,aa.id`, tenantID, publicID)
if e != nil {
return i, e
}
defer rows.Close()
for rows.Next() {
var a Action
if e = rows.Scan(&a.ID, &a.ActionType, &a.FromStatus, &a.ToStatus, &a.ActorType, &a.ActorUser, &a.Note, &a.CreatedAt); e != nil {
return i, e
}
i.Actions = append(i.Actions, a)
}
return i, rows.Err()
}
const silenceSelect = `SELECT als.public_id::text,als.rule_code,als.category,als.resource_type,als.resource_public_id::text,als.matcher,als.reason,als.starts_at,als.ends_at,ua.public_id::text,als.revoked_at,als.row_version,als.created_at FROM alert_silence als JOIN user_account ua ON ua.id=als.created_by`
func scanSilence(r scanner) (Silence, error) {
var i Silence
var raw []byte
e := r.Scan(&i.ID, &i.RuleCode, &i.Category, &i.ResourceType, &i.ResourcePublicID, &raw, &i.Reason, &i.StartsAt, &i.EndsAt, &i.CreatedBy, &i.RevokedAt, &i.RowVersion, &i.CreatedAt)
if e == nil {
e = json.Unmarshal(raw, &i.Matcher)
}
if i.Matcher == nil {
i.Matcher = map[string]any{}
}
return i, e
}
func getSilenceTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Silence, error) {
return scanSilence(tx.QueryRow(ctx, silenceSelect+` WHERE als.tenant_id=$1::uuid AND als.public_id=$2::uuid`, tenantID, publicID))
}