瀏覽代碼

M0(1/12): go module + cmd skeleton + alert v1 type

- go.mod: git3.techno-world.net/lrosales/broad-announce (Go 1.24)
- cmd/{ingestd,routerd,deliverd,admind}/main.go: placeholders
- loadgen/cmd/http/main.go: placeholder
- internal/alert/alert.go: Alert v1 struct + Validate() per SPEC §4/§22
  - strict field caps (title 256, body 4096, dedupe_key 128, data 64 keys / 8KB)
  - severity enum: info|warning|critical|inminent_colapse
  - 64-char id regex for company_id/source_id
  - ErrInvalid + IsInvalid helper
- internal/alert/id.go: ulidLike() 26-char time-sortable IDs
- internal/alert/alert_test.go: 7 tests, all green
Luis Rosales 2 月之前
父節點
當前提交
99998d5dda
共有 9 個文件被更改,包括 350 次插入0 次删除
  1. 3 0
      cmd/admind/main.go
  2. 7 0
      cmd/deliverd/main.go
  3. 5 0
      cmd/ingestd/main.go
  4. 7 0
      cmd/routerd/main.go
  5. 3 0
      go.mod
  6. 183 0
      internal/alert/alert.go
  7. 103 0
      internal/alert/alert_test.go
  8. 34 0
      internal/alert/id.go
  9. 5 0
      loadgen/cmd/http/main.go

+ 3 - 0
cmd/admind/main.go

@@ -0,0 +1,3 @@
+// Command admind is the admin HTTP API + (later) UI host. Tenant CRUD,
+// DLQ inspection, replay, audit log. M0: /health, /metrics, /v1/ping.
+package main

+ 7 - 0
cmd/deliverd/main.go

@@ -0,0 +1,7 @@
+// Command deliverd consumes deliveries.<channel>.<company_id> subjects
+// and pushes the alert to the appropriate third-party sink
+// (FCM, Telegram, SMS, email, Slack, Teams, webhook).
+//
+// M0: per-channel worker binary that connects to NATS, /health, /metrics.
+// Real delivery lands in M1+ per channel.
+package main

+ 5 - 0
cmd/ingestd/main.go

@@ -0,0 +1,5 @@
+// Command ingestd receives alerts via HTTP POST / WebSocket / MQTT / gRPC,
+// validates, rate-limits, dedupes, and publishes to NATS JetStream.
+//
+// M0: HTTP POST endpoint only. Other transports land in M5 / M4 / M11.
+package main

+ 7 - 0
cmd/routerd/main.go

@@ -0,0 +1,7 @@
+// Command routerd consumes alerts from NATS JetStream, resolves
+// recipients (companies → groups → individuals ∩ subscriptions),
+// and enqueues one delivery per (individual, channel) to
+// deliveries.<channel>.<company_id> subjects.
+//
+// M0: connects to NATS, /health, /metrics. No business logic yet.
+package main

+ 3 - 0
go.mod

@@ -0,0 +1,3 @@
+module git3.techno-world.net/lrosales/broad-announce
+
+go 1.24.4

+ 183 - 0
internal/alert/alert.go

@@ -0,0 +1,183 @@
+// Package alert defines the Alert v1 wire shape shared by all ingest
+// transports (HTTP, WebSocket, MQTT, gRPC) and the internal broker
+// subject alerts.<company_id>.
+//
+// Validation is strict: unknown fields rejected, severity enum checked,
+// dedupe_key length capped. See SPEC §4 + §22 layer 5.
+package alert
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+	"time"
+)
+
+// Severity is the alert severity taxonomy shared with the Android app.
+// See SPEC §4.
+type Severity string
+
+const (
+	SeverityInfo            Severity = "info"
+	SeverityWarning         Severity = "warning"
+	SeverityCritical        Severity = "critical"
+	SeverityInminentColapse Severity = "inminent_colapse"
+)
+
+// ValidSeverities is the source of truth for the validation enum.
+var ValidSeverities = map[Severity]struct{}{
+	SeverityInfo:            {},
+	SeverityWarning:         {},
+	SeverityCritical:        {},
+	SeverityInminentColapse: {},
+}
+
+// InminentColapseBypass is the only severity that bypasses quiet hours.
+func (s Severity) InminentColapseBypass() bool {
+	return s == SeverityInminentColapse
+}
+
+// WireCaps (SPEC §22 layer 1) — anything bigger is rejected before
+// validation runs. These match the per-source `max_payload_bytes`
+// default of 256 KB and the per-field caps below.
+const (
+	MaxAlertIDLen      = 64
+	MaxCompanyIDLen    = 64
+	MaxSourceIDLen     = 64
+	MaxSeverityLen     = 32
+	MaxCategoryLen     = 64
+	MaxTitleLen        = 256
+	MaxBodyLen         = 4096
+	MaxDedupeKeyLen    = 128
+	MaxDataKeys        = 64
+	MaxDataKeyLen      = 64
+	MaxDataValueLen    = 1024
+	MaxDataTotalBytes  = 8 * 1024
+	MaxLocaleLen       = 16
+	MaxDataValueNested = 4 // nested depth for data{} values
+)
+
+var (
+	// company_id and source_id must be URL-safe and DB-friendly.
+	idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,63}$`)
+	// dedupe_key may include colons, dots, slashes (opaque to us).
+	dedupeRe = regexp.MustCompile(`^[A-Za-z0-9._:/+\-]{1,128}$`)
+	// severity one of the enum.
+)
+
+// Alert is the v1 wire shape. Tags are intentionally not in v1 — the
+// source can put structured data in `Data` if it needs key/value lookup.
+// FCM/Telegram routing keys off Severity + Category, not arbitrary tags.
+type Alert struct {
+	// ID is server-assigned on accept; empty in inbound payloads.
+	ID string `json:"id,omitempty"`
+
+	CompanyID string   `json:"company_id"`
+	SourceID  string   `json:"source_id"`
+	Severity  Severity `json:"severity"`
+	Category  string   `json:"category,omitempty"`
+
+	// Pre-localized (SPEC: source-localized, we pass through).
+	Title string `json:"title"`
+	Body  string `json:"body,omitempty"`
+
+	// Data is an arbitrary key/value map for the recipient app
+	// (e.g. deep links, structured context). Strictly size-bounded.
+	Data map[string]string `json:"data,omitempty"`
+
+	// DedupeKey is opaque. The dedupe window is per-source, 60s default.
+	DedupeKey string `json:"dedupe_key,omitempty"`
+
+	// ClientTsMs is the source's wall clock at send time. Server fills
+	// ReceivedAt on accept.
+	ClientTsMs int64 `json:"client_ts_ms,omitempty"`
+
+	// Locale is BCP-47, optional. Used to pick a pre-localized copy
+	// on the recipient side; the source can also pre-localize.
+	Locale string `json:"locale,omitempty"`
+
+	// Server-assigned. Not in inbound JSON.
+	ReceivedAt  time.Time `json:"received_at,omitempty"`
+	DedupeCount uint32    `json:"dedupe_count,omitempty"`
+}
+
+// ErrInvalid is returned by Validate. errs contains one or more
+// human-readable reasons.
+type ErrInvalid struct {
+	Reasons []string
+}
+
+func (e *ErrInvalid) Error() string {
+	return fmt.Sprintf("invalid alert: %v", e.Reasons)
+}
+
+// IsInvalid reports whether err is an *ErrInvalid. Useful in HTTP
+// handlers to map to 400.
+func IsInvalid(err error) bool {
+	var e *ErrInvalid
+	return errors.As(err, &e)
+}
+
+// Validate enforces the SPEC §22 layer 5 + the field caps above.
+// It does NOT enforce the severity enum against the FCM mapping —
+// that's a delivery-tier concern.
+func (a *Alert) Validate() error {
+	var reasons []string
+
+	if !idRe.MatchString(a.CompanyID) {
+		reasons = append(reasons, "company_id: must match [a-z0-9][a-z0-9_-]{1,63}")
+	}
+	if !idRe.MatchString(a.SourceID) {
+		reasons = append(reasons, "source_id: must match [a-z0-9][a-z0-9_-]{1,63}")
+	}
+	if _, ok := ValidSeverities[a.Severity]; !ok {
+		reasons = append(reasons, fmt.Sprintf("severity: must be one of info|warning|critical|inminent_colapse, got %q", a.Severity))
+	}
+	if len(a.Category) > MaxCategoryLen {
+		reasons = append(reasons, fmt.Sprintf("category: max %d chars", MaxCategoryLen))
+	}
+	if a.Title == "" {
+		reasons = append(reasons, "title: required")
+	}
+	if len(a.Title) > MaxTitleLen {
+		reasons = append(reasons, fmt.Sprintf("title: max %d chars", MaxTitleLen))
+	}
+	if len(a.Body) > MaxBodyLen {
+		reasons = append(reasons, fmt.Sprintf("body: max %d chars", MaxBodyLen))
+	}
+	if a.DedupeKey != "" && !dedupeRe.MatchString(a.DedupeKey) {
+		reasons = append(reasons, "dedupe_key: must match [A-Za-z0-9._:/+-]{1,128}")
+	}
+	if len(a.Locale) > MaxLocaleLen {
+		reasons = append(reasons, fmt.Sprintf("locale: max %d chars", MaxLocaleLen))
+	}
+	if len(a.Data) > MaxDataKeys {
+		reasons = append(reasons, fmt.Sprintf("data: max %d keys", MaxDataKeys))
+	}
+	totalData := 0
+	for k, v := range a.Data {
+		if len(k) > MaxDataKeyLen {
+			reasons = append(reasons, fmt.Sprintf("data key %q: max %d chars", k, MaxDataKeyLen))
+		}
+		if len(v) > MaxDataValueLen {
+			reasons = append(reasons, fmt.Sprintf("data[%q]: max %d chars", k, MaxDataValueLen))
+		}
+		totalData += len(k) + len(v)
+		if totalData > MaxDataTotalBytes {
+			reasons = append(reasons, fmt.Sprintf("data: total bytes > %d", MaxDataTotalBytes))
+			break
+		}
+	}
+
+	if len(reasons) > 0 {
+		return &ErrInvalid{reasons}
+	}
+	return nil
+}
+
+// NewID returns a new alert ID. ULID-ish: time-prefixed + random tail,
+// 26 chars, lexically sortable. Good for the broker subject and for
+// logs. Not crypto-strong.
+func NewID() string {
+	return ulidLike()
+}

+ 103 - 0
internal/alert/alert_test.go

@@ -0,0 +1,103 @@
+package alert
+
+import (
+	"strings"
+	"testing"
+)
+
+func goodAlert() *Alert {
+	return &Alert{
+		CompanyID: "acme-001",
+		SourceID:  "prom-prod",
+		Severity:  SeverityCritical,
+		Category:  "storage",
+		Title:     "Disk full on db-prod-03",
+		Body:      "92% used",
+		Data:      map[string]string{"host": "db-prod-03", "used_pct": "92"},
+		DedupeKey: "disk:db-prod-03:full",
+	}
+}
+
+func TestValidate_OK(t *testing.T) {
+	if err := goodAlert().Validate(); err != nil {
+		t.Fatalf("expected nil, got %v", err)
+	}
+}
+
+func TestValidate_BadCompanyID(t *testing.T) {
+	a := goodAlert()
+	a.CompanyID = "ACME-001" // uppercase not allowed
+	if err := a.Validate(); err == nil {
+		t.Fatal("expected error for uppercase company_id")
+	}
+}
+
+func TestValidate_UnknownSeverity(t *testing.T) {
+	a := goodAlert()
+	a.Severity = "emergency"
+	err := a.Validate()
+	if err == nil {
+		t.Fatal("expected error for unknown severity")
+	}
+	if !IsInvalid(err) {
+		t.Fatalf("expected *ErrInvalid, got %T", err)
+	}
+	if !strings.Contains(err.Error(), "severity") {
+		t.Fatalf("expected severity reason, got %v", err)
+	}
+}
+
+func TestValidate_DedupeKeyTooLong(t *testing.T) {
+	a := goodAlert()
+	a.DedupeKey = strings.Repeat("a", 129)
+	if err := a.Validate(); err == nil {
+		t.Fatal("expected error for long dedupe_key")
+	}
+}
+
+func TestValidate_DataKeyTooLong(t *testing.T) {
+	a := goodAlert()
+	a.Data = map[string]string{strings.Repeat("k", 65): "v"}
+	if err := a.Validate(); err == nil {
+		t.Fatal("expected error for long data key")
+	}
+}
+
+func TestValidate_TooManyDataKeys(t *testing.T) {
+	a := goodAlert()
+	a.Data = make(map[string]string, MaxDataKeys+1)
+	for i := 0; i < MaxDataKeys+1; i++ {
+		a.Data[shortKey(i)] = "v"
+	}
+	if err := a.Validate(); err == nil {
+		t.Fatal("expected error for too many data keys")
+	}
+}
+
+func TestInminentColapseBypass(t *testing.T) {
+	if !SeverityInminentColapse.InminentColapseBypass() {
+		t.Fatal("inminent_colapse must bypass quiet hours")
+	}
+	if SeverityCritical.InminentColapseBypass() {
+		t.Fatal("critical must NOT bypass quiet hours")
+	}
+}
+
+func TestNewID_UniqueAndSortable(t *testing.T) {
+	a := NewID()
+	b := NewID()
+	if a == b {
+		t.Fatalf("ids collided: %s", a)
+	}
+	if len(a) != 26 {
+		t.Fatalf("id length expected 26, got %d (%q)", len(a), a)
+	}
+}
+
+func shortKey(i int) string {
+	const alphabet = "abcdefghijklmnopqrstuvwxyz"
+	if i < len(alphabet) {
+		return string(alphabet[i])
+	}
+	return shortKey(i/len(alphabet)) + string(alphabet[i%len(alphabet)])
+}

+ 34 - 0
internal/alert/id.go

@@ -0,0 +1,34 @@
+package alert
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"sync/atomic"
+	"time"
+)
+
+// ulidLike returns a 26-char time-sortable ID. Layout:
+//   - first 10 hex chars: unix ms (fits in 40 bits, padded)
+//   - next 16 hex chars:  random
+// Not a real ULID, but good enough for log ordering and broker keys.
+func ulidLike() string {
+	now := time.Now().UTC().UnixMilli()
+	var msBuf [8]byte
+	for i := 7; i >= 0; i-- {
+		msBuf[i] = byte(now)
+		now >>= 8
+	}
+	var rnd [8]byte
+	if _, err := rand.Read(rnd[:]); err != nil {
+		// crypto/rand should never fail; if it does, fall back to atomic counter
+		// so we still produce a unique ID.
+		fb := atomic.AddUint64(&fallbackCounter, 1)
+		for i := 7; i >= 0; i-- {
+			rnd[i] = byte(fb)
+			fb >>= 8
+		}
+	}
+	return hex.EncodeToString(msBuf[:])[:10] + hex.EncodeToString(rnd[:])
+}
+
+var fallbackCounter uint64

+ 5 - 0
loadgen/cmd/http/main.go

@@ -0,0 +1,5 @@
+// loadgen/cmd/http is the HTTP POST traffic generator.
+//
+// M0: --mode normal only. Other profiles + transport binaries land as
+// the corresponding ingest paths come online.
+package main