From 39b7d6d4b4a08ace3fd1b50035437c7acd3d5429 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 1 Aug 2026 22:45:18 +0900 Subject: [PATCH] feat: add alert-bridge service for external cronjob -> Discord alerts New standalone service that receives authenticated HTTP POSTs (e.g. from a cronjob elsewhere on the home network) and posts them to a Discord channel via an Incoming Webhook. Fire-and-forget, no dependency on discord-bot's bot session at all - mirrors alexa-bridge's precedent of a small bridge service with its own HTTP listener and auth rather than adding an inbound edge to an existing internal service. Wires alert-bridge into go.work, CI (changes filter, test job, new build-alert-bridge job), and the other five Dockerfiles' COPY lines. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/ci.yaml | 36 ++++++ ai-gateway/Dockerfile | 2 + alert-bridge/.env.example | 7 + alert-bridge/Dockerfile | 37 ++++++ alert-bridge/README.md | 114 +++++++++++++++++ alert-bridge/cmd/bridge/main.go | 93 ++++++++++++++ alert-bridge/go.mod | 33 +++++ alert-bridge/go.sum | 63 +++++++++ .../internal/adapters/primary/http/handler.go | 91 +++++++++++++ .../adapters/primary/http/handler_test.go | 120 ++++++++++++++++++ .../secondary/discordwebhook/client.go | 108 ++++++++++++++++ .../secondary/discordwebhook/client_test.go | 97 ++++++++++++++ alert-bridge/internal/app/alert.go | 27 ++++ alert-bridge/internal/config/config.go | 48 +++++++ alert-bridge/internal/core/domain/alert.go | 18 +++ .../internal/core/ports/driven/notifier.go | 13 ++ alert-bridge/internal/logger/logger.go | 38 ++++++ alert-bridge/internal/telemetry/telemetry.go | 82 ++++++++++++ alexa-bridge/Dockerfile | 2 + discord-bot/Dockerfile | 2 + go.work | 1 + go.work.sum | 32 +++-- ha-gateway/Dockerfile | 2 + tts-gateway/Dockerfile | 2 + 24 files changed, 1056 insertions(+), 12 deletions(-) create mode 100644 alert-bridge/.env.example create mode 100644 alert-bridge/Dockerfile create mode 100644 alert-bridge/README.md create mode 100644 alert-bridge/cmd/bridge/main.go create mode 100644 alert-bridge/go.mod create mode 100644 alert-bridge/go.sum create mode 100644 alert-bridge/internal/adapters/primary/http/handler.go create mode 100644 alert-bridge/internal/adapters/primary/http/handler_test.go create mode 100644 alert-bridge/internal/adapters/secondary/discordwebhook/client.go create mode 100644 alert-bridge/internal/adapters/secondary/discordwebhook/client_test.go create mode 100644 alert-bridge/internal/app/alert.go create mode 100644 alert-bridge/internal/config/config.go create mode 100644 alert-bridge/internal/core/domain/alert.go create mode 100644 alert-bridge/internal/core/ports/driven/notifier.go create mode 100644 alert-bridge/internal/logger/logger.go create mode 100644 alert-bridge/internal/telemetry/telemetry.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 94ac0ec..e23e2cb 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -29,6 +29,7 @@ jobs: ha-gateway: ${{ steps.filter.outputs.ha-gateway }} discord-bot: ${{ steps.filter.outputs.discord-bot }} alexa-bridge: ${{ steps.filter.outputs.alexa-bridge }} + alert-bridge: ${{ steps.filter.outputs.alert-bridge }} tts-gateway: ${{ steps.filter.outputs.tts-gateway }} tts-sidecar: ${{ steps.filter.outputs.tts-sidecar }} tts-model: ${{ steps.filter.outputs.tts-model }} @@ -65,6 +66,11 @@ jobs: - 'go.work' - 'go.work.sum' - 'alexa-bridge/**' + alert-bridge: + - 'gen/**' + - 'go.work' + - 'go.work.sum' + - 'alert-bridge/**' tts-gateway: - 'gen/**' - 'go.work' @@ -96,6 +102,7 @@ jobs: ha-gateway/go.sum discord-bot/go.sum alexa-bridge/go.sum + alert-bridge/go.sum tts-gateway/go.sum gen/go.sum @@ -106,6 +113,7 @@ jobs: cd ../ha-gateway && go vet ./... cd ../discord-bot && go vet ./... cd ../alexa-bridge && go vet ./... + cd ../alert-bridge && go vet ./... cd ../tts-gateway && go vet ./... - name: go test @@ -115,6 +123,7 @@ jobs: cd ../ha-gateway && go test ./... cd ../discord-bot && go test ./... cd ../alexa-bridge && go test ./... + cd ../alert-bridge && go test ./... cd ../tts-gateway && go test ./... build-ai-gateway: @@ -225,6 +234,33 @@ jobs: ${{ env.IMAGE_PREFIX }}/alexa-bridge:${{ github.sha }} ${{ env.IMAGE_PREFIX }}/alexa-bridge:latest + build-alert-bridge: + needs: [test, changes] + if: github.ref == 'refs/heads/main' && needs.changes.outputs.alert-bridge == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - uses: docker/build-push-action@v7 + with: + context: . + file: alert-bridge/Dockerfile + push: true + platforms: linux/amd64 + cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/alert-bridge:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/alert-bridge:buildcache,mode=max + tags: | + ${{ env.IMAGE_PREFIX }}/alert-bridge:${{ github.sha }} + ${{ env.IMAGE_PREFIX }}/alert-bridge:latest + build-tts-gateway: needs: [test, changes] if: github.ref == 'refs/heads/main' && needs.changes.outputs.tts-gateway == 'true' diff --git a/ai-gateway/Dockerfile b/ai-gateway/Dockerfile index a5f90bf..c0772bf 100644 --- a/ai-gateway/Dockerfile +++ b/ai-gateway/Dockerfile @@ -10,6 +10,7 @@ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ WORKDIR /workspace/ai-gateway @@ -21,6 +22,7 @@ COPY ha-gateway/ ./ha-gateway/ COPY discord-bot/ ./discord-bot/ COPY tts-gateway/ ./tts-gateway/ COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ COPY ai-gateway/ ./ai-gateway/ WORKDIR /workspace/ai-gateway diff --git a/alert-bridge/.env.example b/alert-bridge/.env.example new file mode 100644 index 0000000..bc7f914 --- /dev/null +++ b/alert-bridge/.env.example @@ -0,0 +1,7 @@ +HTTP_PORT=8080 +API_KEY=change-me-to-a-random-shared-secret +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-id/your-webhook-token +MENTION_USER_ID= +OTEL_ENDPOINT= +LOG_LEVEL=info +LOG_FORMAT=text diff --git a/alert-bridge/Dockerfile b/alert-bridge/Dockerfile new file mode 100644 index 0000000..bb011a8 --- /dev/null +++ b/alert-bridge/Dockerfile @@ -0,0 +1,37 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /workspace + +COPY go.work go.work.sum ./ + +# Manifests only, copied first, so `go mod download` gets its own cache layer +# invalidated only by dependency changes - not by every source edit below. +COPY gen/go.mod gen/go.sum ./gen/ +COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ +COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ +COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ +COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ +COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ + +WORKDIR /workspace/alert-bridge +RUN go mod download + +WORKDIR /workspace +COPY gen/ ./gen/ +COPY ha-gateway/ ./ha-gateway/ +COPY ai-gateway/ ./ai-gateway/ +COPY discord-bot/ ./discord-bot/ +COPY tts-gateway/ ./tts-gateway/ +COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ + +WORKDIR /workspace/alert-bridge +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=${VERSION}" \ + -o /alert-bridge ./cmd/bridge + +FROM gcr.io/distroless/static:nonroot +COPY --from=builder /alert-bridge /alert-bridge +EXPOSE 8080 +ENTRYPOINT ["/alert-bridge"] diff --git a/alert-bridge/README.md b/alert-bridge/README.md new file mode 100644 index 0000000..e341509 --- /dev/null +++ b/alert-bridge/README.md @@ -0,0 +1,114 @@ +# alert-bridge + +`alert-bridge` turns an authenticated HTTP POST from an external caller (e.g. +a cronjob running elsewhere on the home network) into a message posted to a +Discord channel. It is fire-and-forget: each request becomes one new Discord +message, there is no editing or reacting to prior messages. + +Unlike `ha-gateway`/`ai-gateway`/`discord-bot`/`tts-gateway`, it has no +dependency on any other service in this repo — it posts directly to a Discord +[Incoming Webhook](https://discord.com/developers/docs/resources/webhook), not +through `discord-bot`'s bot session. That keeps the whole flow to one hop and +means `discord-bot` needed zero changes to support this feature. + +## Runtime Flow + +1. The process loads `.env`, configures logging/telemetry, and starts serving + HTTP on `HTTP_PORT`. +2. A caller `POST`s to `/alerts` with a bearer token and a JSON body. +3. The handler checks the bearer token (constant-time compare against + `API_KEY`), validates the body, and defaults `level` to `info` if omitted. +4. The alert is formatted as a Discord embed (color-coded by level) and POSTed + to `DISCORD_WEBHOOK_URL`. `level=error` alerts additionally prefix the + message with a mention of `MENTION_USER_ID`, since embeds alone never + trigger a Discord ping. + +## HTTP API + +- `POST /alerts` — requires `Authorization: Bearer `. + + ```json + {"source": "ba-cronjob", "message": "Finished with 0 errors", "level": "info"} + ``` + + - `source`, `message`: required. + - `level`: optional, one of `info` | `warn` | `error`, defaults to `info`. + - Responses: `202` delivered, `400` bad request, `401` bad/missing token, + `502` the Discord webhook call itself failed. + +- `GET /healthz`: unauthenticated liveness/readiness check, always `200 OK`. + +## Configuration + +Environment variables: + +| Variable | Default | Description | +| --- | --- | --- | +| `HTTP_PORT` | `8080` | HTTP listen port | +| `API_KEY` | required | Shared secret callers must present as `Authorization: Bearer ` | +| `DISCORD_WEBHOOK_URL` | required | Target channel's Discord Incoming Webhook URL | +| `MENTION_USER_ID` | empty | Discord user ID mentioned on `level=error` alerts; leave empty to never mention | +| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | +| `LOG_FORMAT` | `json` | `json` or `text` | + +Example env file: [.env.example](.env.example) + +## Local Run + +```bash +cd alert-bridge +cp .env.example .env +# edit .env: set API_KEY and DISCORD_WEBHOOK_URL +go run ./cmd/bridge +``` + +```bash +curl -X POST http://localhost:8080/alerts \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"source":"ba-cronjob","message":"Started","level":"info"}' +``` + +## Test And Build + +```bash +go test ./... +go build ./... +``` + +Build the container image from the workspace root: + +```bash +docker build -f alert-bridge/Dockerfile -t alert-bridge:dev . +``` + +## Package Map + +```text +cmd/bridge/ # process entrypoint and wiring +internal/adapters/primary/http/ # POST /alerts handler: auth, decode, validate +internal/adapters/secondary/discordwebhook/ # builds the embed payload, posts to the Discord webhook +internal/app/ # thin orchestration between the HTTP handler and the Notifier +internal/core/domain/ # Alert{Source, Message, Level} +internal/core/ports/driven/ # Notifier interface +internal/config/ # environment loading +internal/logger/ # slog setup +internal/telemetry/ # OpenTelemetry setup +``` + +## Deployment + +Kubernetes manifests (Deployment/Service, and an internal-CA `IngressRoute` at +a `*.home.arpa` hostname so LAN callers outside the cluster can reach it) live +in the separate `homelab` repo, not here — not yet added as of this writing. + +## Limitations + +- Single channel/webhook only — no per-source routing to multiple channels. +- No rate limiting of its own; relies on Discord's own webhook rate limits + (5 requests/2s per webhook), which is more than enough for cronjob-scale + notification volume. +- Fire-and-forget only: no message editing, threading, or reaction handling. + That would require routing through `discord-bot`'s live bot session instead + of a plain webhook — not needed for the current use case. diff --git a/alert-bridge/cmd/bridge/main.go b/alert-bridge/cmd/bridge/main.go new file mode 100644 index 0000000..a36a2b0 --- /dev/null +++ b/alert-bridge/cmd/bridge/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + + httpadapter "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/adapters/primary/http" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/adapters/secondary/discordwebhook" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/app" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/config" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/telemetry" +) + +var version = "dev" + +func main() { + _ = godotenv.Load() + + cfg, err := config.Load() + if err != nil { + os.Stderr.WriteString("config error: " + err.Error() + "\n") + os.Exit(1) + } + + log := logger.New(cfg.LogFormat, cfg.LogLevel) + slog.SetDefault(log) + log.Info("starting alert-bridge", + "version", version, + "http_port", cfg.HTTPPort, + "log_level", cfg.LogLevel, + "log_format", cfg.LogFormat, + ) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + ctx = logger.WithLogger(ctx, log) + + shutdown, err := telemetry.Setup(ctx, "alert-bridge", version, cfg) + if err != nil { + log.Error("telemetry setup failed", "err", err) + os.Exit(1) + } + if cfg.OTELEndpoint != "" { + log.Info("telemetry enabled", "endpoint", cfg.OTELEndpoint) + } else { + log.Debug("telemetry disabled") + } + + notifier := discordwebhook.New(cfg.DiscordWebhookURL, cfg.MentionUserID) + alertApp := app.NewAlertApp(notifier) + alertHandler := httpadapter.NewHandler(alertApp, cfg.APIKey) + + mux := http.NewServeMux() + mux.Handle("POST /alerts", alertHandler) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + srv := &http.Server{ + Addr: ":" + cfg.HTTPPort, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Info("alert-bridge listening", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("serve failed", "err", err) + } + }() + + <-ctx.Done() + log.Info("shutdown signal received, draining") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error("http server shutdown failed", "err", err) + } + log.Info("shutdown complete") + + if err := shutdown(context.Background()); err != nil { + log.Error("telemetry shutdown error", "err", err) + } +} diff --git a/alert-bridge/go.mod b/alert-bridge/go.mod new file mode 100644 index 0000000..d3a4cc1 --- /dev/null +++ b/alert-bridge/go.mod @@ -0,0 +1,33 @@ +module gitea.nik4nao.com/nik/home-services/alert-bridge + +go 1.26 + +require ( + github.com/joho/godotenv v1.5.1 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/alert-bridge/go.sum b/alert-bridge/go.sum new file mode 100644 index 0000000..c7e3c9f --- /dev/null +++ b/alert-bridge/go.sum @@ -0,0 +1,63 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= +google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/alert-bridge/internal/adapters/primary/http/handler.go b/alert-bridge/internal/adapters/primary/http/handler.go new file mode 100644 index 0000000..f4a23fc --- /dev/null +++ b/alert-bridge/internal/adapters/primary/http/handler.go @@ -0,0 +1,91 @@ +package http + +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger" +) + +// AlertHandler processes one decoded, validated alert. internal/app.AlertApp +// implements this. +type AlertHandler interface { + Handle(ctx context.Context, alert domain.Alert) error +} + +// alertRequest is the wire shape callers POST to /alerts. +type alertRequest struct { + Source string `json:"source"` + Message string `json:"message"` + Level string `json:"level"` +} + +// Handler is the HTTP entrypoint external callers (e.g. cronjobs) POST alerts to. +type Handler struct { + app AlertHandler + apiKey string +} + +// NewHandler constructs the HTTP handler for the /alerts endpoint. +func NewHandler(app AlertHandler, apiKey string) *Handler { + return &Handler{app: app, apiKey: apiKey} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logger.FromContext(ctx) + + if !h.authorized(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var body alertRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + log.Warn("decode request body failed", "err", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + if body.Source == "" || body.Message == "" { + http.Error(w, "source and message are required", http.StatusBadRequest) + return + } + + level := domain.Level(body.Level) + if level == "" { + level = domain.LevelInfo + } + switch level { + case domain.LevelInfo, domain.LevelWarn, domain.LevelError: + default: + http.Error(w, "level must be one of: info, warn, error", http.StatusBadRequest) + return + } + + alert := domain.Alert{Source: body.Source, Message: body.Message, Level: level} + if err := h.app.Handle(ctx, alert); err != nil { + log.Error("handle alert failed", "err", err, "source", body.Source) + http.Error(w, "failed to deliver alert", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusAccepted) +} + +// authorized checks the Authorization: Bearer header against the +// configured API key using a constant-time comparison so a mistyped key +// doesn't leak how many leading bytes matched via response timing. +func (h *Handler) authorized(r *http.Request) bool { + const prefix = "Bearer " + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, prefix) { + return false + } + token := strings.TrimPrefix(auth, prefix) + return subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 +} diff --git a/alert-bridge/internal/adapters/primary/http/handler_test.go b/alert-bridge/internal/adapters/primary/http/handler_test.go new file mode 100644 index 0000000..a114886 --- /dev/null +++ b/alert-bridge/internal/adapters/primary/http/handler_test.go @@ -0,0 +1,120 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" +) + +type fakeAlertHandler struct { + handleFn func(ctx context.Context, alert domain.Alert) error + got domain.Alert + called bool +} + +func (f *fakeAlertHandler) Handle(ctx context.Context, alert domain.Alert) error { + f.called = true + f.got = alert + if f.handleFn != nil { + return f.handleFn(ctx, alert) + } + return nil +} + +func doRequest(h *Handler, apiKey, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(body)) + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestHandler_Unauthorized(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "wrong-key", `{"source":"x","message":"y"}`) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + if fake.called { + t.Fatal("app should not be called when unauthorized") + } +} + +func TestHandler_MissingAuthHeader(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + req := httptest.NewRequest(http.MethodPost, "/alerts", strings.NewReader(`{"source":"x","message":"y"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestHandler_BadJSON(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "secret", `not json`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestHandler_MissingFields(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "secret", `{"source":"","message":""}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestHandler_InvalidLevel(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"critical"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestHandler_DefaultsLevelToInfo(t *testing.T) { + fake := &fakeAlertHandler{} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "secret", `{"source":"ba-cronjob","message":"Started"}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d", rec.Code) + } + if fake.got.Level != domain.LevelInfo { + t.Fatalf("expected default level info, got %q", fake.got.Level) + } + if fake.got.Source != "ba-cronjob" || fake.got.Message != "Started" { + t.Fatalf("unexpected alert passed through: %+v", fake.got) + } +} + +func TestHandler_NotifierFailure(t *testing.T) { + fake := &fakeAlertHandler{handleFn: func(ctx context.Context, alert domain.Alert) error { + return errors.New("discord unreachable") + }} + h := NewHandler(fake, "secret") + + rec := doRequest(h, "secret", `{"source":"x","message":"y","level":"error"}`) + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", rec.Code) + } +} diff --git a/alert-bridge/internal/adapters/secondary/discordwebhook/client.go b/alert-bridge/internal/adapters/secondary/discordwebhook/client.go new file mode 100644 index 0000000..01a8c66 --- /dev/null +++ b/alert-bridge/internal/adapters/secondary/discordwebhook/client.go @@ -0,0 +1,108 @@ +package discordwebhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" +) + +// Discord embed colors (decimal RGB), matching Alert severity. +const ( + colorInfo = 3447003 // blue + colorWarn = 15844367 // yellow + colorError = 15158332 // red +) + +// Client posts Alerts to a single Discord Incoming Webhook. +type Client struct { + webhookURL string + mentionUserID string + httpClient *http.Client +} + +// New constructs a Discord webhook Notifier. mentionUserID may be empty, in +// which case level=error alerts are posted without a mention. +func New(webhookURL, mentionUserID string) *Client { + return &Client{ + webhookURL: webhookURL, + mentionUserID: mentionUserID, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +type embed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` +} + +// allowedMentions is spelled out explicitly (rather than relying on Discord's +// default) so a mention only ever pings the specific configured user, never +// @everyone/@here/roles even if alert.Message text happens to contain them. +type allowedMentions struct { + Parse []string `json:"parse"` + Users []string `json:"users"` +} + +type webhookPayload struct { + Content string `json:"content,omitempty"` + Embeds []embed `json:"embeds"` + AllowedMentions allowedMentions `json:"allowed_mentions"` +} + +// Notify posts alert to the configured Discord webhook. level=error alerts +// mention mentionUserID (if configured) in the message content — Discord +// embeds alone never trigger a ping, only plain message content can. +func (c *Client) Notify(ctx context.Context, alert domain.Alert) error { + payload := webhookPayload{ + Embeds: []embed{{ + Title: fmt.Sprintf("[%s]", alert.Source), + Description: alert.Message, + Color: colorFor(alert.Level), + }}, + AllowedMentions: allowedMentions{Parse: []string{}, Users: []string{}}, + } + + if alert.Level == domain.LevelError && c.mentionUserID != "" { + payload.Content = fmt.Sprintf("<@%s>", c.mentionUserID) + payload.AllowedMentions.Users = []string{c.mentionUserID} + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal webhook payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send webhook request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return fmt.Errorf("discord webhook returned status %d", resp.StatusCode) + } + return nil +} + +func colorFor(level domain.Level) int { + switch level { + case domain.LevelWarn: + return colorWarn + case domain.LevelError: + return colorError + default: + return colorInfo + } +} diff --git a/alert-bridge/internal/adapters/secondary/discordwebhook/client_test.go b/alert-bridge/internal/adapters/secondary/discordwebhook/client_test.go new file mode 100644 index 0000000..7ec4634 --- /dev/null +++ b/alert-bridge/internal/adapters/secondary/discordwebhook/client_test.go @@ -0,0 +1,97 @@ +package discordwebhook + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" +) + +func TestClient_Notify_InfoNoMention(t *testing.T) { + var got webhookPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := New(server.URL, "182801655711006721") + err := c.Notify(t.Context(), domain.Alert{Source: "ba-cronjob", Message: "Started", Level: domain.LevelInfo}) + if err != nil { + t.Fatalf("Notify failed: %v", err) + } + + if got.Content != "" { + t.Fatalf("expected no mention content for info level, got %q", got.Content) + } + if len(got.Embeds) != 1 || got.Embeds[0].Title != "[ba-cronjob]" || got.Embeds[0].Description != "Started" { + t.Fatalf("unexpected embed: %+v", got.Embeds) + } + if got.Embeds[0].Color != colorInfo { + t.Fatalf("expected info color, got %d", got.Embeds[0].Color) + } +} + +func TestClient_Notify_ErrorMentionsConfiguredUser(t *testing.T) { + var got webhookPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := New(server.URL, "182801655711006721") + err := c.Notify(t.Context(), domain.Alert{Source: "ba-cronjob", Message: "Finished with 3 errors", Level: domain.LevelError}) + if err != nil { + t.Fatalf("Notify failed: %v", err) + } + + if got.Content != "<@182801655711006721>" { + t.Fatalf("expected mention content, got %q", got.Content) + } + if len(got.AllowedMentions.Users) != 1 || got.AllowedMentions.Users[0] != "182801655711006721" { + t.Fatalf("expected allowed_mentions scoped to configured user, got %+v", got.AllowedMentions) + } + if got.Embeds[0].Color != colorError { + t.Fatalf("expected error color, got %d", got.Embeds[0].Color) + } +} + +func TestClient_Notify_NoMentionUserConfigured(t *testing.T) { + var got webhookPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := New(server.URL, "") + err := c.Notify(t.Context(), domain.Alert{Source: "x", Message: "y", Level: domain.LevelError}) + if err != nil { + t.Fatalf("Notify failed: %v", err) + } + if got.Content != "" { + t.Fatalf("expected no mention when no user configured, got %q", got.Content) + } +} + +func TestClient_Notify_NonSuccessStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + c := New(server.URL, "") + err := c.Notify(t.Context(), domain.Alert{Source: "x", Message: "y", Level: domain.LevelInfo}) + if err == nil { + t.Fatal("expected error on non-2xx response, got nil") + } +} diff --git a/alert-bridge/internal/app/alert.go b/alert-bridge/internal/app/alert.go new file mode 100644 index 0000000..532be73 --- /dev/null +++ b/alert-bridge/internal/app/alert.go @@ -0,0 +1,27 @@ +package app + +import ( + "context" + "fmt" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/ports/driven" +) + +// AlertApp orchestrates delivering an incoming alert to its Notifier. +type AlertApp struct { + notifier driven.Notifier +} + +// NewAlertApp constructs the alert application service. +func NewAlertApp(notifier driven.Notifier) *AlertApp { + return &AlertApp{notifier: notifier} +} + +// Handle delivers one alert, wrapping any delivery failure for the caller. +func (a *AlertApp) Handle(ctx context.Context, alert domain.Alert) error { + if err := a.notifier.Notify(ctx, alert); err != nil { + return fmt.Errorf("notify: %w", err) + } + return nil +} diff --git a/alert-bridge/internal/config/config.go b/alert-bridge/internal/config/config.go new file mode 100644 index 0000000..14e03a9 --- /dev/null +++ b/alert-bridge/internal/config/config.go @@ -0,0 +1,48 @@ +package config + +import ( + "errors" + "os" +) + +// Config holds runtime configuration for the alert-bridge process. +type Config struct { + HTTPPort string // HTTP_PORT, default "8080" + APIKey string // API_KEY — required bearer token callers must present in Authorization: Bearer + DiscordWebhookURL string // DISCORD_WEBHOOK_URL — required, the target channel's Discord Incoming Webhook URL + MentionUserID string // MENTION_USER_ID — optional Discord user ID; when set, level=error alerts mention this user + OTELEndpoint string + LogLevel string + LogFormat string +} + +// Load reads alert-bridge configuration from environment variables. +func Load() (*Config, error) { + apiKey := os.Getenv("API_KEY") + if apiKey == "" { + return nil, errors.New("API_KEY is required but not set") + } + + webhookURL := os.Getenv("DISCORD_WEBHOOK_URL") + if webhookURL == "" { + return nil, errors.New("DISCORD_WEBHOOK_URL is required but not set") + } + + return &Config{ + HTTPPort: getenvDefault("HTTP_PORT", "8080"), + APIKey: apiKey, + DiscordWebhookURL: webhookURL, + MentionUserID: os.Getenv("MENTION_USER_ID"), + OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), + LogLevel: getenvDefault("LOG_LEVEL", "info"), + LogFormat: getenvDefault("LOG_FORMAT", "json"), + }, nil +} + +// getenvDefault keeps config loading concise for optional variables with defaults. +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/alert-bridge/internal/core/domain/alert.go b/alert-bridge/internal/core/domain/alert.go new file mode 100644 index 0000000..c338760 --- /dev/null +++ b/alert-bridge/internal/core/domain/alert.go @@ -0,0 +1,18 @@ +package domain + +// Level is the severity of an Alert. It controls the posted Discord embed's +// color and whether the message mentions a user to raise attention. +type Level string + +const ( + LevelInfo Level = "info" + LevelWarn Level = "warn" + LevelError Level = "error" +) + +// Alert is one notification submitted by an external caller (e.g. a cronjob). +type Alert struct { + Source string + Message string + Level Level +} diff --git a/alert-bridge/internal/core/ports/driven/notifier.go b/alert-bridge/internal/core/ports/driven/notifier.go new file mode 100644 index 0000000..a4eb6da --- /dev/null +++ b/alert-bridge/internal/core/ports/driven/notifier.go @@ -0,0 +1,13 @@ +package driven + +import ( + "context" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" +) + +// Notifier delivers an Alert to its destination. internal/adapters/secondary/discordwebhook +// is the only current implementation. +type Notifier interface { + Notify(ctx context.Context, alert domain.Alert) error +} diff --git a/alert-bridge/internal/logger/logger.go b/alert-bridge/internal/logger/logger.go new file mode 100644 index 0000000..498bf86 --- /dev/null +++ b/alert-bridge/internal/logger/logger.go @@ -0,0 +1,38 @@ +package logger + +import ( + "context" + "fmt" + "log/slog" + "os" +) + +type contextKey struct{} + +// New constructs a root logger for the configured format and level. +func New(format, level string) *slog.Logger { + var parsed slog.Level + if err := parsed.UnmarshalText([]byte(level)); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "invalid log level %q, falling back to info\n", level) + parsed = slog.LevelInfo + } + + opts := &slog.HandlerOptions{Level: parsed} + if format == "json" { + return slog.New(slog.NewJSONHandler(os.Stdout, opts)) + } + return slog.New(slog.NewTextHandler(os.Stdout, opts)) +} + +// WithLogger attaches a logger to the provided context. +func WithLogger(ctx context.Context, l *slog.Logger) context.Context { + return context.WithValue(ctx, contextKey{}, l) +} + +// FromContext retrieves a logger from context and falls back to slog.Default(). +func FromContext(ctx context.Context) *slog.Logger { + if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil { + return l + } + return slog.Default() +} diff --git a/alert-bridge/internal/telemetry/telemetry.go b/alert-bridge/internal/telemetry/telemetry.go new file mode 100644 index 0000000..318019c --- /dev/null +++ b/alert-bridge/internal/telemetry/telemetry.go @@ -0,0 +1,82 @@ +package telemetry + +import ( + "context" + "errors" + "time" + + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/config" + "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +// Setup initialises OTel trace and metric providers for one service. +func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) { + if cfg.OTELEndpoint == "" { + otel.SetTracerProvider(tracenoop.NewTracerProvider()) + otel.SetMeterProvider(metricnoop.NewMeterProvider()) + logger.FromContext(ctx).Debug("otel disabled — OTEL_ENDPOINT not set") + return func(context.Context) error { return nil }, nil + } + + res := resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceNameKey.String(serviceName), + semconv.ServiceVersionKey.String(version), + ) + + traceExp, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(cfg.OTELEndpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExp), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + metricExp, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithEndpoint(cfg.OTELEndpoint), + otlpmetricgrpc.WithInsecure(), + ) + if err != nil { + _ = tp.Shutdown(ctx) + return nil, err + } + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp, + sdkmetric.WithInterval(30*time.Second))), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + + return func(ctx context.Context) error { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var shutdownErr error + if err := tp.Shutdown(shutdownCtx); err != nil { + shutdownErr = errors.Join(shutdownErr, err) + } + if err := mp.Shutdown(shutdownCtx); err != nil { + shutdownErr = errors.Join(shutdownErr, err) + } + return shutdownErr + }, nil +} diff --git a/alexa-bridge/Dockerfile b/alexa-bridge/Dockerfile index 0376fe2..19c6ded 100644 --- a/alexa-bridge/Dockerfile +++ b/alexa-bridge/Dockerfile @@ -11,6 +11,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ WORKDIR /workspace/alexa-bridge RUN go mod download @@ -22,6 +23,7 @@ COPY ai-gateway/ ./ai-gateway/ COPY discord-bot/ ./discord-bot/ COPY tts-gateway/ ./tts-gateway/ COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ WORKDIR /workspace/alexa-bridge ARG VERSION=dev diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile index 8727c8b..60c0194 100644 --- a/discord-bot/Dockerfile +++ b/discord-bot/Dockerfile @@ -10,6 +10,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ WORKDIR /workspace/discord-bot @@ -21,6 +22,7 @@ COPY ai-gateway/ ./ai-gateway/ COPY ha-gateway/ ./ha-gateway/ COPY tts-gateway/ ./tts-gateway/ COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ COPY discord-bot/ ./discord-bot/ WORKDIR /workspace/discord-bot diff --git a/go.work b/go.work index 70cc901..2a8e3fb 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ go 1.26 use ( ./ai-gateway + ./alert-bridge ./alexa-bridge ./discord-bot ./gen diff --git a/go.work.sum b/go.work.sum index d2347cc..1338038 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,6 +1,7 @@ cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= @@ -39,6 +40,8 @@ github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -88,12 +91,13 @@ golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -103,6 +107,9 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -110,32 +117,33 @@ golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= -google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/ha-gateway/Dockerfile b/ha-gateway/Dockerfile index 63884e2..8f71d49 100644 --- a/ha-gateway/Dockerfile +++ b/ha-gateway/Dockerfile @@ -10,6 +10,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ WORKDIR /workspace/ha-gateway @@ -21,6 +22,7 @@ COPY ai-gateway/ ./ai-gateway/ COPY discord-bot/ ./discord-bot/ COPY tts-gateway/ ./tts-gateway/ COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ COPY ha-gateway/ ./ha-gateway/ WORKDIR /workspace/ha-gateway diff --git a/tts-gateway/Dockerfile b/tts-gateway/Dockerfile index bea9db3..a0d2d78 100644 --- a/tts-gateway/Dockerfile +++ b/tts-gateway/Dockerfile @@ -16,6 +16,7 @@ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/ +COPY alert-bridge/go.mod alert-bridge/go.sum ./alert-bridge/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ WORKDIR /workspace/tts-gateway @@ -27,6 +28,7 @@ COPY ai-gateway/ ./ai-gateway/ COPY ha-gateway/ ./ha-gateway/ COPY discord-bot/ ./discord-bot/ COPY alexa-bridge/ ./alexa-bridge/ +COPY alert-bridge/ ./alert-bridge/ COPY tts-gateway/ ./tts-gateway/ WORKDIR /workspace/tts-gateway