Files
yovision/Bell/cmd/bell-api/main.go
T
QiuSW 8208118904
Harness governance / validate (pull_request) Has been cancelled
feat(bell): add alert acknowledgement vertical slice
2026-08-11 17:01:53 +08:00

336 lines
10 KiB
Go

package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/alert"
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
"yovision/bell/internal/store"
bellweb "yovision/bell/web"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
alertsEnabled bool
alertRulesFile string
alertConsoleEnabled bool
alertConsoleTokenFile string
alertConsoleTenantID int64
alertConsoleSiteID int64
alertConsoleActorRef string
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("Bell stopped", "error", err)
os.Exit(1)
}
}
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
}
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
case "", "false":
case "true":
value.eventIngressEnabled = true
default:
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
}
if value.dsn == "" {
return configuration{}, errors.New("BELL_DB_DSN is required")
}
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
}
host, _, err := net.SplitHostPort(value.address)
if err != nil {
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
}
ip := net.ParseIP(host)
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
}
if (value.tlsCert == "") != (value.tlsKey == "") {
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
}
if value.eventIngressEnabled {
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
}
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
}
}
value.alertsEnabled, err = strictBoolEnv("BELL_ALERTS_ENABLED")
if err != nil {
return configuration{}, err
}
value.alertConsoleEnabled, err = strictBoolEnv("BELL_ALERT_CONSOLE_ENABLED")
if err != nil {
return configuration{}, err
}
if value.alertsEnabled && (value.alertRulesFile == "" || !filepath.IsAbs(value.alertRulesFile)) {
return configuration{}, errors.New("BELL_ALERT_RULES_FILE must be an absolute external path when alerts are enabled")
}
if value.alertConsoleEnabled {
if !value.alertsEnabled || !loopback || os.Getenv("BELL_HTTP_ADDR") == "" {
return configuration{}, errors.New("Bell alert console requires alerts and an explicit loopback bind")
}
if value.alertConsoleTokenFile == "" || !filepath.IsAbs(value.alertConsoleTokenFile) {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_TOKEN_FILE must be an absolute external path")
}
value.alertConsoleTenantID, err = positiveEnv("BELL_ALERT_CONSOLE_TENANT_ID")
if err != nil {
return configuration{}, err
}
value.alertConsoleSiteID, err = positiveEnv("BELL_ALERT_CONSOLE_SITE_ID")
if err != nil {
return configuration{}, err
}
if value.alertConsoleActorRef == "" {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_ACTOR_REF is required")
}
}
return value, nil
}
func run(logger *slog.Logger) error {
cfg, err := loadConfiguration()
if err != nil {
return err
}
pgConfig, err := pgx.ParseConfig(cfg.dsn)
if err != nil {
return errors.New("invalid Bell postgres DSN")
}
if pgConfig.RuntimeParams == nil {
pgConfig.RuntimeParams = make(map[string]string)
}
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
db := stdlib.OpenDB(*pgConfig)
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
defer db.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenPostgres(ctx, db)
if err != nil {
return err
}
if err := repository.AuditRelayReady(ctx); err != nil {
return err
}
keys, err := audit.LoadKeys(cfg.keyFile)
if err != nil {
return err
}
handler, err := audit.NewHandler(repository, keys)
if err != nil {
return err
}
mux := http.NewServeMux()
mux.Handle(audit.RelayPath, handler)
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(ctx); err != nil {
return err
}
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
if err != nil {
return err
}
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
if err != nil {
return err
}
guard, err := event.NewEvidenceGuard(forbiddenNames...)
if err != nil {
return err
}
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
if err != nil {
return err
}
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
if err != nil {
return err
}
mux.Handle(ingress.Path, eventHandler)
}
if cfg.alertsEnabled {
if err := repository.AlertReady(ctx); err != nil {
return err
}
rules, err := alert.LoadRules(cfg.alertRulesFile)
if err != nil {
return err
}
if err := alert.Publish(ctx, repository, rules); err != nil {
return err
}
go alert.RunWorker(ctx, repository, func(workerErr error) {
logger.Error("Bell alert worker retrying", "error", workerErr)
})
}
if cfg.alertConsoleEnabled {
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
if err != nil {
return err
}
console, err := bellweb.NewHandler(repository, bellweb.Config{
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
})
if err != nil {
return err
}
console.Register(mux)
}
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
if err := repository.AuditRelayReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
if cfg.alertsEnabled {
if err := repository.AlertReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
serverErrors := make(chan error, 1)
go func() {
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
if cfg.tlsCert != "" {
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
return
}
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case serverErr := <-serverErrors:
if !errors.Is(serverErr, http.ErrServerClosed) {
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
}
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownContext)
}
func loadForbiddenNames(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 64<<10 {
return nil, errors.New("read Bell evidence forbidden-names file")
}
var values []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
values = append(values, line)
}
if len(values) == 0 || len(values) > 256 {
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
}
return values, nil
}
func writeStatus(writer http.ResponseWriter, status int, value string) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func strictBoolEnv(name string) (bool, error) {
switch os.Getenv(name) {
case "", "false":
return false, nil
case "true":
return true, nil
default:
return false, fmt.Errorf("%s must be true or false", name)
}
}
func positiveEnv(name string) (int64, error) {
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return value, nil
}
func loadConsoleToken(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 4096 {
return "", errors.New("read Bell alert console token file")
}
value := strings.TrimSpace(string(raw))
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
}
return value, nil
}