From b1cdf15cc83194eb900b253246186e4453d0ad87 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Thu, 16 Jul 2026 09:14:51 -0300 Subject: [PATCH] adding test containers --- .gitattributes | 9 + .gitea/workflows/haven-notify.yaml | 72 ++++- haven-notify/.dockerignore | 6 + haven-notify/.gitignore | 1 + haven-notify/Dockerfile | 26 +- haven-notify/README.md | 142 ++++++--- haven-notify/discord.go | 281 ++++++++++++++++++ haven-notify/discord_test.go | 243 ++++++++++++++++ haven-notify/e2e_test.go | 322 +++++++++++++++++++++ haven-notify/go.mod | 61 ++++ haven-notify/go.sum | 145 ++++++++++ haven-notify/main.go | 215 ++------------ haven-notify/server.go | 392 +++++++++++++++++++++++++ haven-notify/server_test.go | 450 +++++++++++++++++++++++++++++ haven-notify/template/backup.tmpl | 25 +- haven-notify/template/error.tmpl | 24 +- haven-notify/template/update.tmpl | 18 +- haven-notify/templates.go | 135 +++++++++ haven-notify/templates_test.go | 121 ++++++++ 19 files changed, 2390 insertions(+), 298 deletions(-) create mode 100644 haven-notify/.dockerignore create mode 100644 haven-notify/.gitignore create mode 100644 haven-notify/discord.go create mode 100644 haven-notify/discord_test.go create mode 100644 haven-notify/e2e_test.go create mode 100644 haven-notify/go.mod create mode 100644 haven-notify/go.sum create mode 100644 haven-notify/server.go create mode 100644 haven-notify/server_test.go create mode 100644 haven-notify/templates.go create mode 100644 haven-notify/templates_test.go diff --git a/.gitattributes b/.gitattributes index dfdb8b7..a58e7f0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,10 @@ +.gitattributes text eol=lf *.sh text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf +Dockerfile text eol=lf +*.tmpl text eol=lf diff --git a/.gitea/workflows/haven-notify.yaml b/.gitea/workflows/haven-notify.yaml index 70a5c94..6908b89 100644 --- a/.gitea/workflows/haven-notify.yaml +++ b/.gitea/workflows/haven-notify.yaml @@ -6,16 +6,74 @@ on: - main paths: - "haven-notify/**" - - ".gitea/workflows/**" + - ".gitea/workflows/haven-notify.yaml" + pull_request: + branches: + - main + paths: + - "haven-notify/**" + - ".gitea/workflows/haven-notify.yaml" workflow_dispatch: {} env: IMAGE: git.ivanch.me/ivanch/haven-notify jobs: + test: + name: Test Haven Notify + runs-on: runner-full-amd64 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: haven-notify/go.mod + cache-dependency-path: haven-notify/go.sum + + - name: Verify formatting + working-directory: haven-notify + run: | + unformatted="$(gofmt -l *.go)" + if [ -n "$unformatted" ]; then + echo "The following Go files need formatting:" + echo "$unformatted" + exit 1 + fi + + - name: Verify Docker access + run: docker info + + - name: Run static analysis + working-directory: haven-notify + run: go vet ./... + + - name: Run race tests and collect coverage + working-directory: haven-notify + run: go test -race -shuffle=on -count=1 -covermode=atomic -coverprofile=coverage.out ./... + + - name: Enforce coverage threshold + working-directory: haven-notify + run: | + coverage="$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%", "", $3); print $3}')" + echo "Statement coverage: ${coverage}%" + awk -v coverage="$coverage" 'BEGIN { exit !(coverage + 0 >= 90) }' + + - name: Run fuzz smoke test + working-directory: haven-notify + run: go test -run=^$ -fuzz=FuzzErrorNotificationHandler -fuzztime=10s . + + - name: Run container end-to-end tests + working-directory: haven-notify + run: go test -tags=e2e -run '^TestContainerE2E$' -count=1 -timeout=5m -v . + build: name: Build Haven Notify Image runs-on: runner-slim + needs: test + if: ${{ gitea.event_name != 'pull_request' }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -28,6 +86,18 @@ jobs: ssh_key: ${{ secrets.SSH_KEY_DOCKERBUILD }} build_context: haven-notify + build-pr: + name: Build Haven Notify Image (PR) + runs-on: runner-full-amd64 + needs: test + if: ${{ gitea.event_name == 'pull_request' }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build --pull --tag haven-notify:pr-${{ gitea.sha }} haven-notify + deploy: name: Deploy Haven Notify (internal) runs-on: runner-slim diff --git a/haven-notify/.dockerignore b/haven-notify/.dockerignore new file mode 100644 index 0000000..6311f69 --- /dev/null +++ b/haven-notify/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +assets/ +coverage.out +README.md +*_test.go diff --git a/haven-notify/.gitignore b/haven-notify/.gitignore new file mode 100644 index 0000000..2d83068 --- /dev/null +++ b/haven-notify/.gitignore @@ -0,0 +1 @@ +coverage.out diff --git a/haven-notify/Dockerfile b/haven-notify/Dockerfile index e217806..ddd82be 100644 --- a/haven-notify/Dockerfile +++ b/haven-notify/Dockerfile @@ -1,20 +1,22 @@ -# Start from the official Golang image for building -FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder +ARG BUILDPLATFORM +FROM --platform=$BUILDPLATFORM golang:1.25.10-alpine3.23@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder ARG TARGETARCH ARG TARGETOS -WORKDIR /app -COPY . . -# Build statically for Linux -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o haven-notify main.go - -# Use Alpine for running, with CA certificates for TLS -FROM alpine:latest -WORKDIR /app -RUN apk --no-cache add ca-certificates +WORKDIR /src +COPY go.mod go.sum ./ +COPY *.go ./ COPY template/ template/ -COPY --from=builder /app/haven-notify . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/haven-notify . + +FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 +WORKDIR /app +RUN apk --no-cache add ca-certificates \ + && addgroup -S haven-notify \ + && adduser -S -D -H -G haven-notify haven-notify +COPY --from=builder --chown=haven-notify:haven-notify /out/haven-notify /app/haven-notify EXPOSE 8080 ENV WEBHOOK_URL="" +USER haven-notify ENTRYPOINT ["/app/haven-notify"] diff --git a/haven-notify/README.md b/haven-notify/README.md index 65a126b..7142686 100644 --- a/haven-notify/README.md +++ b/haven-notify/README.md @@ -3,21 +3,28 @@ ## Overview -Haven Notify is an internal service designed to send notifications to a specified Discord channel. -It's built in Go and can be deployed as a container or managed service. +Haven Notify is an internal Go service that validates notification requests, renders Discord-compatible payloads, and delivers them to a configured Discord webhook. + +It runs as a standalone binary or container. Templates are embedded in the binary and validated at startup. ## Prerequisites -- Go 1.18 or newer -- Docker -- A Discord Webhook URL -## API Specification +- Go 1.25.10 +- A Docker API compatible with Testcontainers for the end-to-end suite +- A Discord webhook URL for normal service operation + +`WEBHOOK_URL` is required and must be an absolute HTTP or HTTPS URL. The process exits before becoming ready when the configuration or embedded templates are invalid. + +## API + +All notification endpoints require `POST` and `Content-Type: application/json`. Request bodies are limited to 64 KiB, documented fields are required, and unknown fields are accepted for forward compatibility. ### Send Notification + - **Endpoint**: `/notify` -- **Method**: `POST` - **Request Body**: + ```json { "title": "Notification Title", @@ -26,71 +33,128 @@ It's built in Go and can be deployed as a container or managed service. ``` ### Send Backup Notification + - **Endpoint**: `/template/notify/backup` -- **Method**: `POST` - **Request Body**: + ```json { - "title": "Notification Title", - "asset": "Notification Asset Name", - "backupSizeInMB": 500, + "title": "Nightly backup", + "asset": "Photos", + "backupSizeInMB": 1536, "extra": [ { - "name": "Additional Info", - "value": "Some extra information" + "name": "Host", + "value": "nebula" } ] } ``` ### Send Update Notification + - **Endpoint**: `/template/notify/update` -- **Method**: `POST` - **Request Body**: + ```json { - "host": "Notification Title", - "asset": "Notification Message", - "time": 500 // in seconds + "host": "nexus", + "asset": "k3s", + "time": 42 } ``` +`time` is expressed in seconds. + ### Send Error Notification + - **Endpoint**: `/template/notify/error` -- **Method**: `POST` - **Request Body**: + ```json { - "caller": "Who triggered the error", + "caller": "backup-job", "message": "Error while moving file", "critical": true, "extra": [ { - "name": "Additional Info", - "value": "Some extra information" + "name": "Path", + "value": "/mnt/backups" } ] } ``` -## Setup & Usage +### Health Probes + +- `GET` or `HEAD /ready` returns `200 Ready` after configuration and templates initialize. +- `GET` or `HEAD /live` returns `200 Alive` while the process can serve HTTP. + +### Response Statuses + +| Status | Meaning | +|---|---| +| `200` | Notification delivered | +| `400` | Invalid or missing request data | +| `404` | Unknown route or template | +| `405` | Unsupported method | +| `415` | Content type is not JSON | +| `502` | Discord rejected the request or delivery retries failed | +| `504` | Discord delivery exceeded its timeout | + +Haven Notify suppresses Discord mention parsing and safely truncates content to Discord's message and embed limits. Transport errors, rate limits, and server errors receive at most two retries within a ten-second total delivery budget. + +## Run + +### Go + +```sh +WEBHOOK_URL=https://discord.com/api/webhooks/... go run . +``` ### Docker -1. Build the Docker image: - ```sh - docker build -t haven-notify . - ``` -2. Run the container: - ```sh - docker run -e WEBHOOK_URL=your_webhook_url haven-notify - ``` -### Kubernetes -The Deployment/Service/Ingress manifest lives in the **`haven`** homelab repo at `infra/haven-notify.yaml` (this repo only holds the Go source + Dockerfile + CI). -1. Edit the manifest to set your environment variables. -2. Create a generic secret named `discord-webhook` with `HAVEN_WEBHOOK_URL=your_webhook_url`: - - `kubectl create secret generic discord-webhook --from-literal=HAVEN_WEBHOOK_URL= -n ` -3. Apply deployment (from the `haven` repo): - ```sh - kubectl apply -f infra/haven-notify.yaml - ``` +```sh +docker build -t haven-notify . +docker run --rm -p 8080:8080 \ + -e WEBHOOK_URL=https://discord.com/api/webhooks/... \ + haven-notify +``` + +The runtime container uses a non-root `haven-notify` user and contains only the binary, Alpine runtime files, and CA certificates. + +## Tests + +Run unit and HTTP contract tests: + +```sh +go test ./... +``` + +Run static analysis, the race detector, and coverage (the race detector requires CGO support): + +```sh +go vet ./... +go test -race -shuffle=on -count=1 -covermode=atomic -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` + +Run the seeded fuzz target: + +```sh +go test -run=^$ -fuzz=FuzzErrorNotificationHandler -fuzztime=10s . +``` + +Run the black-box container suite: + +```sh +go test -tags=e2e -run '^TestContainerE2E$' -count=1 -timeout=5m -v . +``` + +The end-to-end suite builds the production Dockerfile, starts Haven Notify and a disposable MockServer on an isolated network, and verifies the complete HTTP-to-Discord payload flow. It never calls a real Discord webhook. Configure `DOCKER_HOST` or the Testcontainers host settings when the Docker daemon is not available through the platform's default socket. + +## Deployment + +The Kubernetes Deployment, Service, and Ingress manifest lives in the `haven` homelab repository at `infra/haven-notify.yaml`. This repository holds the service source, Dockerfile, tests, and Gitea Actions pipeline. + +The production manifest maps the `HAVEN_WEBHOOK_URL` key from the `discord-webhook` secret into the container's `WEBHOOK_URL` environment variable. diff --git a/haven-notify/discord.go b/haven-notify/discord.go new file mode 100644 index 0000000..4c50c55 --- /dev/null +++ b/haven-notify/discord.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + deliveryTimeout = 10 * time.Second + attemptTimeout = 5 * time.Second + maximumAttempts = 3 + maximumRetryDelay = 2 * time.Second + truncationMarker = "… [truncated]" +) + +var ( + errWebhookDelivery = errors.New("webhook delivery failed") + errWebhookTimeout = errors.New("webhook delivery timed out") +) + +type sleepFunc func(context.Context, time.Duration) error + +type discordClient struct { + webhookURL string + httpClient *http.Client + sleep sleepFunc +} + +type discordPayload struct { + Content string `json:"content,omitempty"` + Embeds []discordEmbed `json:"embeds,omitempty"` + AllowedMentions discordAllowedMentions `json:"allowed_mentions"` +} + +type discordAllowedMentions struct { + Parse []string `json:"parse"` +} + +type discordEmbed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Color int `json:"color,omitempty"` + Fields []discordEmbedField `json:"fields,omitempty"` + Footer *discordEmbedFooter `json:"footer,omitempty"` +} + +type discordEmbedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +type discordEmbedFooter struct { + Text string `json:"text"` +} + +func newDiscordClient(webhookURL string, client *http.Client, sleep sleepFunc) (*discordClient, error) { + parsed, err := url.Parse(webhookURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return nil, errors.New("WEBHOOK_URL must be an absolute HTTP(S) URL") + } + if client == nil { + client = &http.Client{Timeout: attemptTimeout} + } + if sleep == nil { + sleep = sleepContext + } + return &discordClient{webhookURL: webhookURL, httpClient: client, sleep: sleep}, nil +} + +func (c *discordClient) send(ctx context.Context, payload discordPayload) error { + normalizeDiscordPayload(&payload) + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("%w: marshal payload", errWebhookDelivery) + } + + deliveryCtx, cancel := context.WithTimeout(ctx, deliveryTimeout) + defer cancel() + + var lastErr error + for attempt := 0; attempt < maximumAttempts; attempt++ { + attemptCtx, attemptCancel := context.WithTimeout(deliveryCtx, attemptTimeout) + request, requestErr := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.webhookURL, bytes.NewReader(body)) + if requestErr != nil { + attemptCancel() + return fmt.Errorf("%w: create request", errWebhookDelivery) + } + request.Header.Set("Content-Type", "application/json") + + response, requestErr := c.httpClient.Do(request) + if requestErr == nil { + delay := retryDelay(response, attempt) + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) + _ = response.Body.Close() + attemptCancel() + + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("%w: HTTP %d", errWebhookDelivery, response.StatusCode) + if !retryableStatus(response.StatusCode) || attempt == maximumAttempts-1 { + return lastErr + } + if err := c.sleep(deliveryCtx, delay); err != nil { + return errWebhookTimeout + } + continue + } + + attemptCancel() + lastErr = requestErr + if deliveryCtx.Err() != nil { + return errWebhookTimeout + } + if attempt == maximumAttempts-1 { + if isTimeout(requestErr) { + return fmt.Errorf("%w: %v", errWebhookTimeout, requestErr) + } + return fmt.Errorf("%w: %v", errWebhookDelivery, requestErr) + } + if err := c.sleep(deliveryCtx, fallbackRetryDelay(attempt)); err != nil { + return errWebhookTimeout + } + } + + return fmt.Errorf("%w: %v", errWebhookDelivery, lastErr) +} + +func retryableStatus(status int) bool { + return status == http.StatusTooManyRequests || status >= 500 +} + +func retryDelay(response *http.Response, attempt int) time.Duration { + if response.StatusCode == http.StatusTooManyRequests { + for _, header := range []string{"Retry-After", "X-RateLimit-Reset-After"} { + if delay, ok := parseRetryDelay(response.Header.Get(header)); ok { + return min(delay, maximumRetryDelay) + } + } + } + return fallbackRetryDelay(attempt) +} + +func parseRetryDelay(value string) (time.Duration, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + if seconds, err := strconv.ParseFloat(value, 64); err == nil && seconds >= 0 { + return time.Duration(seconds * float64(time.Second)), true + } + if timestamp, err := http.ParseTime(value); err == nil { + return max(time.Until(timestamp), 0), true + } + return 0, false +} + +func fallbackRetryDelay(attempt int) time.Duration { + return 250 * time.Millisecond * time.Duration(1< 10 { + payload.Embeds = payload.Embeds[:10] + } + + for embedIndex := range payload.Embeds { + embed := &payload.Embeds[embedIndex] + embed.Title = truncateRunes(embed.Title, 256) + embed.Description = truncateRunes(embed.Description, 4096) + if len(embed.Fields) > 25 { + embed.Fields = embed.Fields[:25] + } + for fieldIndex := range embed.Fields { + embed.Fields[fieldIndex].Name = truncateRunes(embed.Fields[fieldIndex].Name, 256) + embed.Fields[fieldIndex].Value = truncateRunes(embed.Fields[fieldIndex].Value, 1024) + } + if embed.Footer != nil { + embed.Footer.Text = truncateRunes(embed.Footer.Text, 2048) + } + } + + for discordEmbedCharacters(payload.Embeds) > 6000 { + removed := false + for embedIndex := len(payload.Embeds) - 1; embedIndex >= 0; embedIndex-- { + embed := &payload.Embeds[embedIndex] + if len(embed.Fields) > 0 { + embed.Fields = embed.Fields[:len(embed.Fields)-1] + removed = true + break + } + } + if !removed { + break + } + } + + remainingOverflow := discordEmbedCharacters(payload.Embeds) - 6000 + for embedIndex := len(payload.Embeds) - 1; remainingOverflow > 0 && embedIndex >= 0; embedIndex-- { + embed := &payload.Embeds[embedIndex] + if embed.Footer != nil && embed.Footer.Text != "" { + newLimit := max(utf8.RuneCountInString(embed.Footer.Text)-remainingOverflow, 0) + embed.Footer.Text = truncateRunes(embed.Footer.Text, newLimit) + } + remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000 + if remainingOverflow > 0 && embed.Description != "" { + newLimit := max(utf8.RuneCountInString(embed.Description)-remainingOverflow, 0) + embed.Description = truncateRunes(embed.Description, newLimit) + } + remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000 + if remainingOverflow > 0 && embed.Title != "" { + newLimit := max(utf8.RuneCountInString(embed.Title)-remainingOverflow, 0) + embed.Title = truncateRunes(embed.Title, newLimit) + } + remainingOverflow = discordEmbedCharacters(payload.Embeds) - 6000 + } +} + +func discordEmbedCharacters(embeds []discordEmbed) int { + total := 0 + for _, embed := range embeds { + total += utf8.RuneCountInString(embed.Title) + total += utf8.RuneCountInString(embed.Description) + if embed.Footer != nil { + total += utf8.RuneCountInString(embed.Footer.Text) + } + for _, field := range embed.Fields { + total += utf8.RuneCountInString(field.Name) + total += utf8.RuneCountInString(field.Value) + } + } + return total +} + +func truncateRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + if limit <= 0 { + return "" + } + marker := []rune(truncationMarker) + if limit <= len(marker) { + return string(marker[:limit]) + } + return string(runes[:limit-len(marker)]) + truncationMarker +} diff --git a/haven-notify/discord_test.go b/haven-notify/discord_test.go new file mode 100644 index 0000000..22be943 --- /dev/null +++ b/haven-notify/discord_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" +) + +func TestDiscordDeliveryRetries(t *testing.T) { + tests := []struct { + name string + statuses []int + headers []http.Header + transportErr error + wantCalls int + wantSleeps []time.Duration + wantError error + }{ + {name: "success", statuses: []int{204}, wantCalls: 1}, + {name: "all 2xx accepted", statuses: []int{299}, wantCalls: 1}, + {name: "permanent 4xx", statuses: []int{400}, wantCalls: 1, wantError: errWebhookDelivery}, + {name: "5xx recovers", statuses: []int{500, 204}, wantCalls: 2, wantSleeps: []time.Duration{250 * time.Millisecond}}, + {name: "5xx exhausts", statuses: []int{500, 502, 503}, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery}, + {name: "429 honors capped retry after", statuses: []int{429, 204}, headers: []http.Header{{"Retry-After": []string{"10"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{2 * time.Second}}, + {name: "429 uses reset after", statuses: []int{429, 204}, headers: []http.Header{{"X-Ratelimit-Reset-After": []string{"0.125"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{125 * time.Millisecond}}, + {name: "transport errors exhaust", transportErr: errors.New("connection reset"), wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery}, + {name: "timeouts exhaust", transportErr: context.DeadlineExceeded, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookTimeout}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var mu sync.Mutex + calls := 0 + var sleeps []time.Duration + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if request.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q", request.Header.Get("Content-Type")) + } + if test.transportErr != nil { + return nil, test.transportErr + } + index := min(calls-1, len(test.statuses)-1) + var headers http.Header + if index < len(test.headers) { + headers = test.headers[index] + } + return response(test.statuses[index], headers), nil + }) + client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error { + sleeps = append(sleeps, delay) + return nil + }) + if err != nil { + t.Fatal(err) + } + err = client.send(context.Background(), discordPayload{Content: "hello"}) + if !errors.Is(err, test.wantError) { + t.Fatalf("send() error = %v, want %v", err, test.wantError) + } + if calls != test.wantCalls { + t.Fatalf("calls = %d, want %d", calls, test.wantCalls) + } + if len(sleeps) != len(test.wantSleeps) { + t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps) + } + for index := range sleeps { + if sleeps[index] != test.wantSleeps[index] { + t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps) + } + } + }) + } +} + +func TestDiscordDeliveryPayloadAndCanceledSleep(t *testing.T) { + var sent discordPayload + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + if err := json.NewDecoder(request.Body).Decode(&sent); err != nil { + t.Fatal(err) + } + return response(204, nil), nil + }) + client, err := newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: transport}, nil) + if err != nil { + t.Fatal(err) + } + if err := client.send(context.Background(), discordPayload{Content: strings.Repeat("界", 2100)}); err != nil { + t.Fatal(err) + } + if utf8.RuneCountInString(sent.Content) != 2000 || !strings.HasSuffix(sent.Content, truncationMarker) { + t.Fatalf("content length/suffix = %d/%q", utf8.RuneCountInString(sent.Content), []rune(sent.Content)[1980:]) + } + if sent.AllowedMentions.Parse == nil || len(sent.AllowedMentions.Parse) != 0 { + t.Fatalf("allowed mentions = %#v", sent.AllowedMentions) + } + + client, err = newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return response(500, nil), nil + })}, func(context.Context, time.Duration) error { return context.DeadlineExceeded }) + if err != nil { + t.Fatal(err) + } + if err := client.send(context.Background(), discordPayload{Content: "x"}); !errors.Is(err, errWebhookTimeout) { + t.Fatalf("send() error = %v, want timeout", err) + } +} + +func TestDiscordDeliveryRecoversFromTransportError(t *testing.T) { + var calls int + var sleeps []time.Duration + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return nil, errors.New("temporary connection failure") + } + return response(http.StatusNoContent, nil), nil + }) + client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error { + sleeps = append(sleeps, delay) + return nil + }) + if err != nil { + t.Fatal(err) + } + if err := client.send(context.Background(), discordPayload{Content: "hello"}); err != nil { + t.Fatal(err) + } + if calls != 2 || len(sleeps) != 1 || sleeps[0] != 250*time.Millisecond { + t.Fatalf("calls/sleeps = %d/%v", calls, sleeps) + } +} + +func TestDiscordDeliveryHonorsCallerCancellation(t *testing.T) { + var calls, sleeps int + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + calls++ + return nil, request.Context().Err() + }) + client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(context.Context, time.Duration) error { + sleeps++ + return nil + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.send(ctx, discordPayload{Content: "hello"}); !errors.Is(err, errWebhookTimeout) { + t.Fatalf("send() error = %v, want timeout", err) + } + if calls > 1 || sleeps != 0 { + t.Fatalf("canceled delivery made %d calls and %d sleeps", calls, sleeps) + } +} + +func TestRetryDelayParsing(t *testing.T) { + if got, ok := parseRetryDelay("1.5"); !ok || got != 1500*time.Millisecond { + t.Fatalf("parseRetryDelay() = %v, %v", got, ok) + } + if _, ok := parseRetryDelay("garbage"); ok { + t.Fatal("invalid retry delay accepted") + } + httpDate := time.Now().Add(time.Second).UTC().Format(http.TimeFormat) + if got, ok := parseRetryDelay(httpDate); !ok || got < 0 || got > time.Second { + t.Fatalf("HTTP-date retry delay = %v, %v", got, ok) + } + if got := fallbackRetryDelay(1); got != 500*time.Millisecond { + t.Fatalf("fallbackRetryDelay(1) = %v", got) + } + if retryableStatus(499) || !retryableStatus(500) || !retryableStatus(429) { + t.Fatal("retryableStatus returned unexpected result") + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if err := sleepContext(canceled, time.Hour); !errors.Is(err, context.Canceled) { + t.Fatalf("sleepContext() error = %v", err) + } +} + +func TestNormalizeDiscordPayloadLimits(t *testing.T) { + fields := make([]discordEmbedField, 30) + for index := range fields { + fields[index] = discordEmbedField{ + Name: strings.Repeat("n", 300), + Value: strings.Repeat("界", 1200), + } + } + payload := discordPayload{ + Content: strings.Repeat("c", 2100), + Embeds: []discordEmbed{{ + Title: strings.Repeat("t", 300), + Description: strings.Repeat("d", 5000), + Fields: fields, + Footer: &discordEmbedFooter{Text: strings.Repeat("f", 2200)}, + }}, + } + normalizeDiscordPayload(&payload) + if utf8.RuneCountInString(payload.Content) != 2000 || utf8.RuneCountInString(payload.Embeds[0].Title) > 256 || utf8.RuneCountInString(payload.Embeds[0].Description) > 4096 { + t.Fatal("individual limits were not applied") + } + if len(payload.Embeds[0].Fields) > 25 || discordEmbedCharacters(payload.Embeds) > 6000 { + t.Fatalf("embed limits = fields %d, chars %d", len(payload.Embeds[0].Fields), discordEmbedCharacters(payload.Embeds)) + } + for _, field := range payload.Embeds[0].Fields { + if utf8.RuneCountInString(field.Name) > 256 || utf8.RuneCountInString(field.Value) > 1024 { + t.Fatal("field limit was not applied") + } + } + + if got := truncateRunes("abcdef", 4); got != "… [t" { + t.Fatalf("small truncation = %q", got) + } + if got := truncateRunes("abc", 3); got != "abc" { + t.Fatalf("unneeded truncation = %q", got) + } + if got := truncateRunes("abc", 0); got != "" { + t.Fatalf("zero truncation = %q", got) + } +} + +func TestDiscordClientRejectsInvalidURLs(t *testing.T) { + for _, value := range []string{"", "relative/path", "ftp://example.com/hook", "https:///hook"} { + if _, err := newDiscordClient(value, nil, nil); err == nil { + t.Fatalf("newDiscordClient(%q) succeeded", value) + } + } +} + +func TestIsTimeout(t *testing.T) { + if !isTimeout(context.DeadlineExceeded) || isTimeout(errors.New("ordinary")) { + t.Fatal("isTimeout returned unexpected result") + } +} diff --git a/haven-notify/e2e_test.go b/haven-notify/e2e_test.go new file mode 100644 index 0000000..58b67a0 --- /dev/null +++ b/haven-notify/e2e_test.go @@ -0,0 +1,322 @@ +//go:build e2e + +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/log" + "github.com/testcontainers/testcontainers-go/modules/mockserver" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" +) + +const mockServerImage = "mockserver/mockserver:5.15.0@sha256:0f9ef78c94894ac3e70135d156193b25e23872575d58e2228344964273b4af6b" + +type mockRecordedRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Headers map[string][]string `json:"headers"` + Body struct { + RawBytes string `json:"rawBytes"` + } `json:"body"` +} + +func TestContainerE2E(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + logger := log.TestLogger(t) + + testNetwork, err := network.New(ctx) + if err != nil { + t.Fatal(err) + } + testcontainers.CleanupNetwork(t, testNetwork) + + mock, err := mockserver.Run(ctx, mockServerImage, + network.WithNetwork([]string{"discord-mock"}, testNetwork), + testcontainers.WithLogger(logger), + ) + testcontainers.CleanupContainer(t, mock) + if err != nil { + t.Fatalf("start MockServer: %v", err) + } + mockURL, err := mock.URL(ctx) + if err != nil { + t.Fatal(err) + } + + buildPlatform := "linux/amd64" + targetOS := "linux" + targetArch := "amd64" + app, err := testcontainers.Run(ctx, "", + testcontainers.WithDockerfile(testcontainers.FromDockerfile{ + Context: ".", + Dockerfile: "Dockerfile", + Repo: "haven-notify-e2e", + Tag: "test", + KeepImage: false, + BuildArgs: map[string]*string{ + "BUILDPLATFORM": &buildPlatform, + "TARGETOS": &targetOS, + "TARGETARCH": &targetArch, + }, + }), + network.WithNetwork([]string{"haven-notify"}, testNetwork), + testcontainers.WithEnv(map[string]string{"WEBHOOK_URL": "http://discord-mock:1080/discord"}), + testcontainers.WithExposedPorts("8080/tcp"), + testcontainers.WithWaitStrategy(wait.ForHTTP("/ready").WithPort("8080/tcp").WithStartupTimeout(45*time.Second)), + testcontainers.WithLogger(logger), + ) + testcontainers.CleanupContainer(t, app) + if err != nil { + t.Fatalf("start Haven Notify: %v", err) + } + appURL, err := app.PortEndpoint(ctx, "8080/tcp", "http") + if err != nil { + t.Fatal(err) + } + inspect, err := app.Inspect(ctx) + if err != nil { + t.Fatal(err) + } + if inspect.Config.User != "haven-notify" { + t.Fatalf("container user = %q", inspect.Config.User) + } + + t.Run("health probes", func(t *testing.T) { + for path, body := range map[string]string{"/ready": "Ready", "/live": "Alive"} { + response, err := http.Get(appURL + path) + if err != nil { + t.Fatal(err) + } + contents, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != 200 || string(contents) != body { + t.Fatalf("%s = (%d, %q)", path, response.StatusCode, contents) + } + } + }) + + if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil { + t.Fatal(err) + } + cases := []struct { + path string + body string + want discordPayload + }{ + { + path: "/notify", + body: `{"title":"Título \"quoted\"","message":"line 1\nC:\\backups @everyone"}`, + want: discordPayload{ + Content: "**Título \"quoted\"**\nline 1\nC:\\backups @everyone", + AllowedMentions: discordAllowedMentions{Parse: []string{}}, + }, + }, + { + path: "/template/notify/backup", + body: `{"title":"Nightly","asset":"photos","backupSizeInMB":1536,"extra":[{"name":"Host","value":"nebula"}]}`, + want: discordPayload{ + Embeds: []discordEmbed{{ + Title: "📦 Backup - Nightly", + Description: "**photos** has been backed up successfully! ✅🫡\n", + Color: 3066993, + Fields: []discordEmbedField{ + {Name: "💾 Backup Size", Value: "1.50 GiB", Inline: true}, + {Name: "Host", Value: "nebula", Inline: true}, + }, + Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, + }}, + AllowedMentions: discordAllowedMentions{Parse: []string{}}, + }, + }, + { + path: "/template/notify/update", + body: `{"host":"nexus","asset":"k3s","time":0}`, + want: discordPayload{ + Embeds: []discordEmbed{{ + Title: "🔄 Update - k3s", + Description: "**nexus** has successfully updated **k3s**! ✅", + Color: 3447003, + Fields: []discordEmbedField{ + {Name: "⏱️ Time Taken", Value: "0 seconds", Inline: true}, + }, + Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, + }}, + AllowedMentions: discordAllowedMentions{Parse: []string{}}, + }, + }, + { + path: "/template/notify/error", + body: `{"caller":"backup","message":"line 1\n\"quoted\" ","critical":true}`, + want: discordPayload{ + Embeds: []discordEmbed{{ + Title: "❌ Error", + Description: "**backup** encountered an error!", + Color: 15158332, + Fields: []discordEmbedField{ + {Name: "📄 Message", Value: "line 1\n\"quoted\" ", Inline: false}, + }, + Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, + }}, + AllowedMentions: discordAllowedMentions{Parse: []string{}}, + }, + }, + } + for _, test := range cases { + response, err := postJSON(appURL+test.path, test.body) + if err != nil { + t.Fatalf("POST %s: %v", test.path, err) + } + contents, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != 200 || string(contents) != "Notification sent" { + t.Fatalf("POST %s = (%d, %q)", test.path, response.StatusCode, contents) + } + } + recorded, err := retrieveMockRequests(mockURL) + if err != nil { + t.Fatal(err) + } + if len(recorded) != len(cases) { + t.Fatalf("recorded requests = %d, want %d", len(recorded), len(cases)) + } + for index, request := range recorded { + if request.Method != http.MethodPost || request.Path != "/discord" || len(request.Headers["Content-Type"]) == 0 || request.Headers["Content-Type"][0] != "application/json" { + t.Fatalf("recorded request %d = %+v", index, request) + } + body, err := base64.StdEncoding.DecodeString(request.Body.RawBytes) + if err != nil || !json.Valid(body) || bytes.Contains(body, []byte(""")) { + t.Fatalf("recorded body %d = %s, %v", index, body, err) + } + want, err := json.Marshal(cases[index].want) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(body, want) { + t.Fatalf("recorded body %d = %s, want %s", index, body, want) + } + } + + t.Run("invalid request does not call webhook", func(t *testing.T) { + if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil { + t.Fatal(err) + } + response, err := postJSON(appURL+"/notify", `{"title":"missing message"}`) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != 400 { + t.Fatalf("status = %d", response.StatusCode) + } + recorded, err := retrieveMockRequests(mockURL) + if err != nil || len(recorded) != 0 { + t.Fatalf("recorded = %d, error = %v", len(recorded), err) + } + }) + + t.Run("transient failure retries", func(t *testing.T) { + if err := configureMock(mockURL, []mockExpectation{{Status: 500, Times: 1, Priority: 10}, {Status: 204}}); err != nil { + t.Fatal(err) + } + response, err := postJSON(appURL+"/notify", `{"title":"retry","message":"once"}`) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != 200 { + t.Fatalf("status = %d", response.StatusCode) + } + recorded, err := retrieveMockRequests(mockURL) + if err != nil || len(recorded) != 2 { + t.Fatalf("recorded = %d, error = %v", len(recorded), err) + } + }) + + bad, err := testcontainers.Run(ctx, "haven-notify-e2e:test", + testcontainers.WithWaitStrategy(wait.ForExit().WithExitTimeout(15*time.Second)), + testcontainers.WithLogger(logger), + ) + testcontainers.CleanupContainer(t, bad) + if err != nil { + t.Fatalf("start unconfigured container: %v", err) + } + state, err := bad.State(ctx) + if err != nil { + t.Fatal(err) + } + if state.Running || state.ExitCode == 0 { + t.Fatalf("unconfigured container state = %+v", state) + } +} + +type mockExpectation struct { + Status int + Times int + Priority int +} + +func configureMock(baseURL string, expectations []mockExpectation) error { + request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/reset", nil) + response, err := http.DefaultClient.Do(request) + if err != nil { + return err + } + _ = response.Body.Close() + for _, expectation := range expectations { + payload := map[string]any{ + "httpRequest": map[string]any{"method": "POST", "path": "/discord"}, + "httpResponse": map[string]any{"statusCode": expectation.Status}, + "priority": expectation.Priority, + } + if expectation.Times > 0 { + payload["times"] = map[string]any{"remainingTimes": expectation.Times, "unlimited": false} + } + body, _ := json.Marshal(payload) + request, _ = http.NewRequest(http.MethodPut, baseURL+"/mockserver/expectation", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response, err = http.DefaultClient.Do(request) + if err != nil { + return err + } + _ = response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("MockServer expectation returned %d", response.StatusCode) + } + } + return nil +} + +func retrieveMockRequests(baseURL string) ([]mockRecordedRequest, error) { + request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/retrieve?type=REQUESTS&format=JSON", strings.NewReader(`{"path":"/discord"}`)) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + var recorded []mockRecordedRequest + err = json.NewDecoder(response.Body).Decode(&recorded) + return recorded, err +} + +func postJSON(url, body string) (*http.Response, error) { + request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + return http.DefaultClient.Do(request) +} diff --git a/haven-notify/go.mod b/haven-notify/go.mod new file mode 100644 index 0000000..d25f42f --- /dev/null +++ b/haven-notify/go.mod @@ -0,0 +1,61 @@ +module git.ivanch.me/ivanch/server-scripts/haven-notify + +go 1.25.10 + +require ( + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/mockserver v0.42.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/haven-notify/go.sum b/haven-notify/go.sum new file mode 100644 index 0000000..4f2fcd0 --- /dev/null +++ b/haven-notify/go.sum @@ -0,0 +1,145 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BraspagDevelopers/mock-server-client v0.2.2 h1:Zro0OonNeaDwkkQGIxeJQfYweNKZ+m+8QIlDZAFRc/4= +github.com/BraspagDevelopers/mock-server-client v0.2.2/go.mod h1:LHulrZSfbCNeS/CoycaWdhE495FnyeI3iXm6+4Zjz5c= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-resty/resty/v2 v2.3.0 h1:JOOeAvjSlapTT92p8xiS19Zxev1neGikoHsXJeOq8So= +github.com/go-resty/resty/v2 v2.3.0/go.mod h1:UpN9CgLZNsv4e9XG50UU8xdI0F43UQ4HmxLBDwaroHU= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/mockserver v0.42.0 h1:UfORuhjP6+wd28O21m8cx4CLe+RbWlo31SI9ZgrqXn4= +github.com/testcontainers/testcontainers-go/modules/mockserver v0.42.0/go.mod h1:uwubSg20I0nSLjtpVl4m2Ixin3yeXks4zkHwaCzbRvo= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/haven-notify/main.go b/haven-notify/main.go index d7d3cdd..24475f3 100644 --- a/haven-notify/main.go +++ b/haven-notify/main.go @@ -1,214 +1,37 @@ package main import ( - "bytes" - "encoding/json" + "context" + "errors" "fmt" - "html/template" - "io/ioutil" "log" "net/http" "os" - "strings" - "time" + "os/signal" + "syscall" ) -// Notification payload -type Notification struct { - Title string `json:"title"` - Message string `json:"message"` -} +const listenAddress = ":8080" func main() { - http.HandleFunc("/notify", notifyHandler) - http.HandleFunc("/ready", readinessHandler) - http.HandleFunc("/live", livenessHandler) - http.HandleFunc("/template/notify/", templateNotifyHandler) - log.Println("Starting server on :8080...") - log.Fatal(http.ListenAndServe(":8080", nil)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + logger := log.Default() + if err := run(ctx, os.Getenv("WEBHOOK_URL"), listenAddress, logger); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatalf("Server stopped unexpectedly: %v", err) + } } -func notifyHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("Incoming %s request from %s to %s", r.Method, r.RemoteAddr, r.URL.Path) - if r.Method != http.MethodPost { - log.Printf("Method not allowed: %s", r.Method) - w.WriteHeader(http.StatusMethodNotAllowed) - w.Write([]byte("Method not allowed")) - return - } - - var notif Notification - if err := json.NewDecoder(r.Body).Decode(¬if); err != nil { - log.Printf("Invalid payload: %v", err) - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Invalid payload")) - return - } - log.Printf("Received notification payload: Title='%s', Message='%s'", notif.Title, notif.Message) - - // Call Discord notification function - if err := sendDiscordNotification(notif.Title, notif.Message); err != nil { - log.Printf("Failed to send Discord notification: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to send Discord notification")) - return - } - - log.Printf("Notification sent successfully for Title='%s'", notif.Title) - w.WriteHeader(http.StatusOK) - w.Write([]byte("Notification sent")) -} - -// Readiness handler -func readinessHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Ready")) -} - -// Liveness handler -func livenessHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Alive")) -} - -func sendDiscordNotification(title, message string) error { - webhookURL := os.Getenv("WEBHOOK_URL") - if webhookURL == "" { - log.Printf("WEBHOOK_URL environment variable not set") - return fmt.Errorf("WEBHOOK_URL environment variable not set") - } - - // Discord webhook payload - type discordPayload struct { - Content string `json:"content"` - } - - content := "**" + title + "**\n" + message - payload := discordPayload{Content: content} - - jsonData, err := json.Marshal(payload) +func run(ctx context.Context, webhookURL, address string, logger *log.Logger) error { + app, err := newApplication(applicationConfig{WebhookURL: webhookURL, Logger: logger}) if err != nil { - log.Printf("Failed to marshal Discord payload: %v", err) - return err + return fmt.Errorf("configuration failed: %w", err) } - - log.Printf("Sending Discord notification: Title='%s', Message='%s'", title, message) - resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData)) + listener, err := netListen("tcp", address) if err != nil { - log.Printf("Error posting to Discord webhook: %v", err) - return err + return fmt.Errorf("listen on %s: %w", address, err) } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("Discord webhook returned status: %s", resp.Status) - return fmt.Errorf("Discord webhook returned status: %s", resp.Status) - } - - log.Printf("Discord notification sent successfully: Title='%s'", title) - return nil -} - -func templateNotifyHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("Incoming %s request from %s to %s", r.Method, r.RemoteAddr, r.URL.Path) - if r.Method != http.MethodPost { - log.Printf("Method not allowed: %s", r.Method) - w.WriteHeader(http.StatusMethodNotAllowed) - w.Write([]byte("Method not allowed")) - return - } - - templateName := r.URL.Path[len("/template/notify/"):] // Extract template name - if templateName == "" { - log.Printf("Template name not provided") - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Template name not provided")) - return - } - - templatePath := "template/" + templateName + ".tmpl" - templateData, err := ioutil.ReadFile(templatePath) - if err != nil { - log.Printf("Failed to read template: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to read template")) - return - } - - tmpl, err := template.New(templateName).Funcs(template.FuncMap{ - "formatSize": func(size float64) string { - if size > 1024 { - return fmt.Sprintf("%.2f GiB", size/1024) - } - return fmt.Sprintf("%.2f MiB", size) - }, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "title": strings.Title, - "now": func() string { - return fmt.Sprintf("%d", time.Now().Unix()) - }, - "formatTime": func(timestamp string) string { - if timestamp == "" { - return time.Now().Format("2006-01-02T15:04:05Z") - } - return timestamp - }, - }).Parse(string(templateData)) - if err != nil { - log.Printf("Failed to parse template: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to parse template")) - return - } - - var rawPayload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&rawPayload); err != nil { - log.Printf("Invalid payload: %v", err) - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Invalid payload")) - return - } - - // Normalize keys to lowercase for case-insensitive parsing - payload := make(map[string]interface{}) - for key, value := range rawPayload { - payload[strings.ToLower(key)] = value - } - - var filledTemplate bytes.Buffer - if err := tmpl.Execute(&filledTemplate, payload); err != nil { - log.Printf("Failed to execute template: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to execute template")) - return - } - - webhookURL := os.Getenv("WEBHOOK_URL") - if webhookURL == "" { - log.Printf("WEBHOOK_URL environment variable not set") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("WEBHOOK_URL environment variable not set")) - return - } - - resp, err := http.Post(webhookURL, "application/json", &filledTemplate) - if err != nil { - log.Printf("Error posting to Discord webhook: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to send notification")) - return - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("Discord webhook returned status: %s", resp.Status) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Failed to send notification")) - return - } - - log.Printf("Notification sent successfully using template '%s'", templateName) - w.WriteHeader(http.StatusOK) - w.Write([]byte("Notification sent")) + logger.Printf("Starting server on %s", address) + return serve(ctx, newHTTPServer(app.handler()), listener) } diff --git a/haven-notify/server.go b/haven-notify/server.go new file mode 100644 index 0000000..d354e3f --- /dev/null +++ b/haven-notify/server.go @@ -0,0 +1,392 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "mime" + "net" + "net/http" + "strings" + "time" +) + +var netListen = net.Listen + +const ( + maxRequestBody = 64 << 10 + shutdownTimeout = 10 * time.Second +) + +var ( + errInvalidPayload = errors.New("invalid payload") + errUnsupportedMediaType = errors.New("unsupported media type") +) + +type applicationConfig struct { + WebhookURL string + HTTPClient *http.Client + Sleep sleepFunc + Logger *log.Logger +} + +type application struct { + discord *discordClient + renderer *templateRenderer + logger *log.Logger +} + +type notificationRequest struct { + Title *string `json:"title"` + Message *string `json:"message"` +} + +type extraRequest struct { + Name *string `json:"name"` + Value *string `json:"value"` +} + +type extraRequests []extraRequest + +type backupRequest struct { + Title *string `json:"title"` + Asset *string `json:"asset"` + BackupSizeInMB *float64 `json:"backupSizeInMB"` + Extra extraRequests `json:"extra"` +} + +type updateRequest struct { + Host *string `json:"host"` + Asset *string `json:"asset"` + Time *float64 `json:"time"` +} + +type errorRequest struct { + Caller *string `json:"caller"` + Message *string `json:"message"` + Critical *bool `json:"critical"` + Extra extraRequests `json:"extra"` +} + +func newApplication(cfg applicationConfig) (*application, error) { + logger := cfg.Logger + if logger == nil { + logger = log.New(io.Discard, "", 0) + } + + renderer, err := newTemplateRenderer() + if err != nil { + return nil, fmt.Errorf("load templates: %w", err) + } + + discord, err := newDiscordClient(cfg.WebhookURL, cfg.HTTPClient, cfg.Sleep) + if err != nil { + return nil, err + } + + return &application{discord: discord, renderer: renderer, logger: logger}, nil +} + +func (a *application) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/notify", a.requireMethod(http.MethodPost, a.notifyHandler)) + mux.HandleFunc("/template/notify/backup", a.requireMethod(http.MethodPost, a.backupHandler)) + mux.HandleFunc("/template/notify/update", a.requireMethod(http.MethodPost, a.updateHandler)) + mux.HandleFunc("/template/notify/error", a.requireMethod(http.MethodPost, a.errorHandler)) + mux.HandleFunc("/ready", a.requireProbeMethod(a.readinessHandler)) + mux.HandleFunc("/live", a.requireProbeMethod(a.livenessHandler)) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + writeText(w, http.StatusNotFound, "Not found") + }) + return mux +} + +func newHTTPServer(handler http.Handler) *http.Server { + return &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func serve(ctx context.Context, server *http.Server, listener net.Listener) error { + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(listener) + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + return <-errCh + } +} + +func (a *application) requireMethod(method string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + w.Header().Set("Allow", method) + writeText(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + next(w, r) + } +} + +func (a *application) requireProbeMethod(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + writeText(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + next(w, r) + } +} + +func (a *application) notifyHandler(w http.ResponseWriter, r *http.Request) { + var request notificationRequest + if err := decodeRequest(w, r, &request, "title", "message"); err != nil { + a.writeDecodeError(w, r, err) + return + } + if !requiredString(request.Title) || !requiredString(request.Message) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + payload := discordPayload{ + Content: "**" + *request.Title + "**\n" + *request.Message, + } + if !a.send(w, r, payload) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) backupHandler(w http.ResponseWriter, r *http.Request) { + var request backupRequest + if err := decodeRequest(w, r, &request, "title", "asset", "backupsizeinmb", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + if !requiredString(request.Title) || !requiredString(request.Asset) || request.BackupSizeInMB == nil || *request.BackupSizeInMB < 0 || !validExtra(request.Extra) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + payload, err := a.renderer.renderBackup(request) + if err != nil { + a.logger.Printf("Template rendering failed for %s", r.URL.Path) + writeText(w, http.StatusInternalServerError, "Failed to render notification") + return + } + if !a.send(w, r, payload) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) updateHandler(w http.ResponseWriter, r *http.Request) { + var request updateRequest + if err := decodeRequest(w, r, &request, "host", "asset", "time"); err != nil { + a.writeDecodeError(w, r, err) + return + } + if !requiredString(request.Host) || !requiredString(request.Asset) || request.Time == nil || *request.Time < 0 { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + payload, err := a.renderer.renderUpdate(request) + if err != nil { + a.logger.Printf("Template rendering failed for %s", r.URL.Path) + writeText(w, http.StatusInternalServerError, "Failed to render notification") + return + } + if !a.send(w, r, payload) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) errorHandler(w http.ResponseWriter, r *http.Request) { + var request errorRequest + if err := decodeRequest(w, r, &request, "caller", "message", "critical", "extra"); err != nil { + a.writeDecodeError(w, r, err) + return + } + if !requiredString(request.Caller) || !requiredString(request.Message) || request.Critical == nil || !validExtra(request.Extra) { + a.writeDecodeError(w, r, errInvalidPayload) + return + } + + payload, err := a.renderer.renderError(request) + if err != nil { + a.logger.Printf("Template rendering failed for %s", r.URL.Path) + writeText(w, http.StatusInternalServerError, "Failed to render notification") + return + } + if !a.send(w, r, payload) { + return + } + writeText(w, http.StatusOK, "Notification sent") +} + +func (a *application) readinessHandler(w http.ResponseWriter, _ *http.Request) { + writeText(w, http.StatusOK, "Ready") +} + +func (a *application) livenessHandler(w http.ResponseWriter, _ *http.Request) { + writeText(w, http.StatusOK, "Alive") +} + +func (a *application) send(w http.ResponseWriter, r *http.Request, payload discordPayload) bool { + if err := a.discord.send(r.Context(), payload); err != nil { + if errors.Is(err, errWebhookTimeout) { + a.logger.Printf("Discord delivery timed out for %s", r.URL.Path) + writeText(w, http.StatusGatewayTimeout, "Webhook timed out") + return false + } + a.logger.Printf("Discord delivery failed for %s", r.URL.Path) + writeText(w, http.StatusBadGateway, "Failed to send notification") + return false + } + return true +} + +func (a *application) writeDecodeError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, errUnsupportedMediaType) { + a.logger.Printf("Rejected unsupported media type for %s", r.URL.Path) + writeText(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + a.logger.Printf("Rejected invalid payload for %s", r.URL.Path) + writeText(w, http.StatusBadRequest, "Invalid payload") +} + +func decodeRequest(w http.ResponseWriter, r *http.Request, destination any, knownKeys ...string) error { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + return errUnsupportedMediaType + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) + data, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read request: %w", errInvalidPayload) + } + if len(bytes.TrimSpace(data)) == 0 { + return errInvalidPayload + } + if err := rejectDuplicateKnownKeys(data, knownKeys); err != nil { + return errInvalidPayload + } + if err := json.Unmarshal(data, destination); err != nil { + return errInvalidPayload + } + return nil +} + +func rejectDuplicateKnownKeys(data []byte, knownKeys []string) error { + known := make(map[string]struct{}, len(knownKeys)) + for _, key := range knownKeys { + known[strings.ToLower(key)] = struct{}{} + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil { + return err + } + delim, ok := token.(json.Delim) + if !ok || delim != '{' { + return errInvalidPayload + } + + seen := make(map[string]struct{}, len(knownKeys)) + for decoder.More() { + token, err = decoder.Token() + if err != nil { + return err + } + key, ok := token.(string) + if !ok { + return errInvalidPayload + } + canonical := strings.ToLower(key) + if _, isKnown := known[canonical]; isKnown { + if _, duplicate := seen[canonical]; duplicate { + return errInvalidPayload + } + seen[canonical] = struct{}{} + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return err + } + } + if _, err := decoder.Token(); err != nil { + return err + } + if decoder.Decode(&struct{}{}) != io.EOF { + return errInvalidPayload + } + return nil +} + +func (e *extraRequest) UnmarshalJSON(data []byte) error { + if err := rejectDuplicateKnownKeys(data, []string{"name", "value"}); err != nil { + return err + } + type rawExtra extraRequest + var decoded rawExtra + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *e = extraRequest(decoded) + return nil +} + +func (e *extraRequests) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return errInvalidPayload + } + var decoded []extraRequest + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *e = decoded + return nil +} + +func requiredString(value *string) bool { + return value != nil && strings.TrimSpace(*value) != "" +} + +func validExtra(extra extraRequests) bool { + for _, field := range extra { + if !requiredString(field.Name) || !requiredString(field.Value) { + return false + } + } + return true +} + +func writeText(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) +} diff --git a/haven-notify/server_test.go b/haven-notify/server_test.go new file mode 100644 index 0000000..809da82 --- /dev/null +++ b/haven-notify/server_test.go @@ -0,0 +1,450 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "text/template" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +type webhookRecorder struct { + mu sync.Mutex + requests [][]byte + status int + err error +} + +func (r *webhookRecorder) RoundTrip(request *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(request.Body) + r.mu.Lock() + r.requests = append(r.requests, body) + status, err := r.status, r.err + r.mu.Unlock() + if err != nil { + return nil, err + } + if status == 0 { + status = http.StatusNoContent + } + return response(status, nil), nil +} + +func (r *webhookRecorder) bodies() [][]byte { + r.mu.Lock() + defer r.mu.Unlock() + return append([][]byte(nil), r.requests...) +} + +func testApplication(t testing.TB, transport http.RoundTripper, logger *log.Logger) *application { + t.Helper() + if transport == nil { + transport = &webhookRecorder{} + } + app, err := newApplication(applicationConfig{ + WebhookURL: "http://discord.invalid/webhook", + HTTPClient: &http.Client{Transport: transport}, + Sleep: func(context.Context, time.Duration) error { return nil }, + Logger: logger, + }) + if err != nil { + t.Fatalf("newApplication() error = %v", err) + } + return app +} + +func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, strings.NewReader(body)) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder +} + +func TestProbeAndRoutingContracts(t *testing.T) { + handler := testApplication(t, nil, nil).handler() + tests := []struct { + name string + method string + path string + wantStatus int + wantBody string + wantAllow string + }{ + {name: "ready get", method: http.MethodGet, path: "/ready", wantStatus: 200, wantBody: "Ready"}, + {name: "ready head", method: http.MethodHead, path: "/ready", wantStatus: 200, wantBody: "Ready"}, + {name: "live get", method: http.MethodGet, path: "/live", wantStatus: 200, wantBody: "Alive"}, + {name: "probe wrong method", method: http.MethodPost, path: "/live", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "GET, HEAD"}, + {name: "notify wrong method", method: http.MethodGet, path: "/notify", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, + {name: "backup wrong method", method: http.MethodGet, path: "/template/notify/backup", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, + {name: "update wrong method", method: http.MethodPut, path: "/template/notify/update", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, + {name: "error wrong method", method: http.MethodDelete, path: "/template/notify/error", wantStatus: 405, wantBody: "Method not allowed", wantAllow: "POST"}, + {name: "unknown template", method: http.MethodPost, path: "/template/notify/missing", wantStatus: 404, wantBody: "Not found"}, + {name: "trailing slash is unknown", method: http.MethodPost, path: "/notify/", wantStatus: 404, wantBody: "Not found"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := performRequest(handler, test.method, test.path, "", "") + if got.Code != test.wantStatus || got.Body.String() != test.wantBody { + t.Fatalf("response = (%d, %q), want (%d, %q)", got.Code, got.Body.String(), test.wantStatus, test.wantBody) + } + if got.Header().Get("Allow") != test.wantAllow { + t.Fatalf("Allow = %q, want %q", got.Header().Get("Allow"), test.wantAllow) + } + if got.Header().Get("Content-Type") != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got.Header().Get("Content-Type")) + } + }) + } +} + +func TestRequestValidationContracts(t *testing.T) { + tests := []struct { + name string + path string + contentType string + body string + wantStatus int + }{ + {name: "missing content type", path: "/notify", body: `{}`, wantStatus: 415}, + {name: "wrong content type", path: "/notify", contentType: "text/plain", body: `{}`, wantStatus: 415}, + {name: "media type parameters", path: "/notify", contentType: "application/json; charset=utf-8", body: `{"title":"t","message":"m"}`, wantStatus: 200}, + {name: "empty", path: "/notify", contentType: "application/json", body: "", wantStatus: 400}, + {name: "array", path: "/notify", contentType: "application/json", body: `[]`, wantStatus: 400}, + {name: "malformed", path: "/notify", contentType: "application/json", body: `{`, wantStatus: 400}, + {name: "trailing document", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"m"}{}`, wantStatus: 400}, + {name: "missing required", path: "/notify", contentType: "application/json", body: `{"title":"t"}`, wantStatus: 400}, + {name: "missing title", path: "/notify", contentType: "application/json", body: `{"message":"m"}`, wantStatus: 400}, + {name: "blank required", path: "/notify", contentType: "application/json", body: `{"title":" ","message":"m"}`, wantStatus: 400}, + {name: "blank message", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"\t"}`, wantStatus: 400}, + {name: "wrong type", path: "/notify", contentType: "application/json", body: `{"title":1,"message":"m"}`, wantStatus: 400}, + {name: "wrong message type", path: "/notify", contentType: "application/json", body: `{"title":"t","message":false}`, wantStatus: 400}, + {name: "known duplicate", path: "/notify", contentType: "application/json", body: `{"title":"one","TITLE":"two","message":"m"}`, wantStatus: 400}, + {name: "unknown accepted", path: "/notify", contentType: "application/json", body: `{"title":"t","message":"m","future":true}`, wantStatus: 200}, + {name: "case insensitive", path: "/notify", contentType: "application/json", body: `{"TITLE":"t","MESSAGE":"m"}`, wantStatus: 200}, + {name: "backup negative size", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":-1}`, wantStatus: 400}, + {name: "backup missing size", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a"}`, wantStatus: 400}, + {name: "backup wrong size type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":"large"}`, wantStatus: 400}, + {name: "backup missing title", path: "/template/notify/backup", contentType: "application/json", body: `{"asset":"a","backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup blank title", path: "/template/notify/backup", contentType: "application/json", body: `{"title":" ","asset":"a","backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup wrong title type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":1,"asset":"a","backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup missing asset", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup blank asset", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":" ","backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup wrong asset type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":1,"backupSizeInMB":1}`, wantStatus: 400}, + {name: "backup wrong extra type", path: "/template/notify/backup", contentType: "application/json", body: `{"title":"t","asset":"a","backupSizeInMB":1,"extra":{}}`, wantStatus: 400}, + {name: "update negative time", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a","time":-1}`, wantStatus: 400}, + {name: "update missing host", path: "/template/notify/update", contentType: "application/json", body: `{"asset":"a","time":1}`, wantStatus: 400}, + {name: "update blank host", path: "/template/notify/update", contentType: "application/json", body: `{"host":" ","asset":"a","time":1}`, wantStatus: 400}, + {name: "update wrong host type", path: "/template/notify/update", contentType: "application/json", body: `{"host":1,"asset":"a","time":1}`, wantStatus: 400}, + {name: "update missing asset", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","time":1}`, wantStatus: 400}, + {name: "update blank asset", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":" ","time":1}`, wantStatus: 400}, + {name: "update wrong asset type", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":false,"time":1}`, wantStatus: 400}, + {name: "update missing time", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a"}`, wantStatus: 400}, + {name: "update wrong time type", path: "/template/notify/update", contentType: "application/json", body: `{"host":"h","asset":"a","time":"soon"}`, wantStatus: 400}, + {name: "error missing critical", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m"}`, wantStatus: 400}, + {name: "error wrong critical type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":"yes"}`, wantStatus: 400}, + {name: "error missing caller", path: "/template/notify/error", contentType: "application/json", body: `{"message":"m","critical":false}`, wantStatus: 400}, + {name: "error blank caller", path: "/template/notify/error", contentType: "application/json", body: `{"caller":" ","message":"m","critical":false}`, wantStatus: 400}, + {name: "error wrong caller type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":1,"message":"m","critical":false}`, wantStatus: 400}, + {name: "error missing message", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","critical":false}`, wantStatus: 400}, + {name: "error blank message", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":" ","critical":false}`, wantStatus: 400}, + {name: "error wrong message type", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":[],"critical":false}`, wantStatus: 400}, + {name: "invalid extra", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"n"}]}`, wantStatus: 400}, + {name: "null extra", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":null}`, wantStatus: 400}, + {name: "duplicate nested known key", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"one","NAME":"two","value":"v"}]}`, wantStatus: 400}, + {name: "case insensitive nested fields", path: "/template/notify/error", contentType: "application/json", body: `{"CALLER":"c","MESSAGE":"m","CRITICAL":false,"EXTRA":[{"NAME":"n","VALUE":"v"}]}`, wantStatus: 200}, + {name: "unknown nested field accepted", path: "/template/notify/error", contentType: "application/json", body: `{"caller":"c","message":"m","critical":false,"extra":[{"name":"n","value":"v","future":true}]}`, wantStatus: 200}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := &webhookRecorder{} + got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, test.contentType, test.body) + if got.Code != test.wantStatus { + t.Fatalf("status = %d, body = %q, want %d", got.Code, got.Body.String(), test.wantStatus) + } + wantBody := map[int]string{ + http.StatusOK: "Notification sent", + http.StatusBadRequest: "Invalid payload", + http.StatusUnsupportedMediaType: "Content-Type must be application/json", + }[test.wantStatus] + if got.Body.String() != wantBody { + t.Fatalf("body = %q, want %q", got.Body.String(), wantBody) + } + if got.Header().Get("Content-Type") != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type = %q", got.Header().Get("Content-Type")) + } + wantCalls := 0 + if test.wantStatus == http.StatusOK { + wantCalls = 1 + } + if len(recorder.bodies()) != wantCalls { + t.Fatalf("webhook calls = %d, want %d", len(recorder.bodies()), wantCalls) + } + }) + } +} + +func TestRequestBodyLimit(t *testing.T) { + prefix, suffix := `{"title":"t","message":"`, `"}` + for _, test := range []struct { + name string + bodyBytes int + want int + wantCalls int + }{ + {name: "exactly at limit", bodyBytes: maxRequestBody, want: http.StatusOK, wantCalls: 1}, + {name: "one byte over limit", bodyBytes: maxRequestBody + 1, want: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := &webhookRecorder{} + body := prefix + strings.Repeat("x", test.bodyBytes-len(prefix)-len(suffix)) + suffix + if len(body) != test.bodyBytes { + t.Fatalf("body length = %d, want %d", len(body), test.bodyBytes) + } + got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, "/notify", "application/json", body) + if got.Code != test.want { + t.Fatalf("status = %d, want %d", got.Code, test.want) + } + if len(recorder.bodies()) != test.wantCalls { + t.Fatalf("webhook calls = %d, want %d", len(recorder.bodies()), test.wantCalls) + } + }) + } +} + +func TestNotificationPayloads(t *testing.T) { + tests := []struct { + name string + path string + body string + check func(*testing.T, discordPayload) + }{ + { + name: "plain preserves unicode and quotes", + path: "/notify", + body: `{"title":"Héllo \"world\"","message":"line 1\nline 2 @everyone"}`, + check: func(t *testing.T, payload discordPayload) { + if payload.Content != "**Héllo \"world\"**\nline 1\nline 2 @everyone" { + t.Fatalf("content = %q", payload.Content) + } + }, + }, + { + name: "backup formats gibibytes and extras", + path: "/template/notify/backup", + body: `{"title":"Nightly","asset":"photos","backupSizeInMB":1536,"extra":[{"name":"Host","value":"nebula"}]}`, + check: func(t *testing.T, payload discordPayload) { + embed := payload.Embeds[0] + if embed.Title != "📦 Backup - Nightly" || embed.Fields[0].Value != "1.50 GiB" || embed.Fields[1].Value != "nebula" { + t.Fatalf("unexpected backup embed: %+v", embed) + } + }, + }, + { + name: "update accepts zero time", + path: "/template/notify/update", + body: `{"host":"nexus","asset":"k3s","time":0}`, + check: func(t *testing.T, payload discordPayload) { + if payload.Embeds[0].Fields[0].Value != "0 seconds" { + t.Fatalf("time = %q", payload.Embeds[0].Fields[0].Value) + } + }, + }, + { + name: "error does not HTML escape", + path: "/template/notify/error", + body: `{"caller":"backup","message":"line 1\n\"quoted\" ","critical":true}`, + check: func(t *testing.T, payload discordPayload) { + embed := payload.Embeds[0] + if embed.Color != 15158332 || embed.Fields[0].Value != "line 1\n\"quoted\" " { + t.Fatalf("unexpected error embed: %+v", embed) + } + }, + }, + { + name: "noncritical color", + path: "/template/notify/error", + body: `{"caller":"backup","message":"failure","critical":false}`, + check: func(t *testing.T, payload discordPayload) { + if payload.Embeds[0].Color != 15844367 { + t.Fatalf("color = %d", payload.Embeds[0].Color) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := &webhookRecorder{} + got := performRequest(testApplication(t, recorder, nil).handler(), http.MethodPost, test.path, "application/json", test.body) + if got.Code != http.StatusOK || got.Body.String() != "Notification sent" { + t.Fatalf("response = (%d, %q)", got.Code, got.Body.String()) + } + bodies := recorder.bodies() + if len(bodies) != 1 || !json.Valid(bodies[0]) { + t.Fatalf("webhook bodies = %q", bodies) + } + var payload discordPayload + if err := json.Unmarshal(bodies[0], &payload); err != nil { + t.Fatal(err) + } + if payload.AllowedMentions.Parse == nil || len(payload.AllowedMentions.Parse) != 0 { + t.Fatalf("allowed mentions = %#v", payload.AllowedMentions) + } + test.check(t, payload) + }) + } +} + +func TestHandlerMapsWebhookErrorsAndDoesNotLogSecrets(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantBody string + }{ + {name: "delivery", err: errors.New("network down"), wantStatus: 502, wantBody: "Failed to send notification"}, + {name: "timeout", err: context.DeadlineExceeded, wantStatus: 504, wantBody: "Webhook timed out"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var logs bytes.Buffer + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, test.err }) + app := testApplication(t, transport, log.New(&logs, "", 0)) + got := performRequest(app.handler(), http.MethodPost, "/notify", "application/json", `{"title":"TOP-SECRET","message":"private-message"}`) + if got.Code != test.wantStatus || got.Body.String() != test.wantBody { + t.Fatalf("response = (%d, %q)", got.Code, got.Body.String()) + } + for _, secret := range []string{"TOP-SECRET", "private-message", "discord.invalid"} { + if strings.Contains(logs.String(), secret) { + t.Fatalf("logs exposed %q: %s", secret, logs.String()) + } + } + }) + } +} + +func TestConfigurationAndServerLifecycle(t *testing.T) { + for _, webhookURL := range []string{"", "not a URL", "ftp://example.com/hook", "https:///missing-host"} { + if _, err := newApplication(applicationConfig{WebhookURL: webhookURL}); err == nil { + t.Fatalf("newApplication(%q) succeeded", webhookURL) + } + } + + app := testApplication(t, nil, nil) + server := newHTTPServer(app.handler()) + if server.ReadHeaderTimeout != 5*time.Second || server.ReadTimeout != 10*time.Second || server.WriteTimeout != 15*time.Second || server.IdleTimeout != 60*time.Second { + t.Fatalf("unexpected server timeouts: %+v", server) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- serve(ctx, server, listener) }() + cancel() + select { + case err := <-done: + if !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("serve() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("server did not shut down") + } +} + +func TestRunLifecycleAndListenError(t *testing.T) { + if err := run(context.Background(), "", "127.0.0.1:0", log.New(io.Discard, "", 0)); err == nil { + t.Fatal("run() accepted missing configuration") + } + + originalListen := netListen + netListen = func(string, string) (net.Listener, error) { return nil, errors.New("listen failed") } + if err := run(context.Background(), "http://discord.invalid/hook", "bad", log.New(io.Discard, "", 0)); err == nil || !strings.Contains(err.Error(), "listen failed") { + t.Fatalf("run() error = %v", err) + } + netListen = originalListen + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := run(ctx, "http://discord.invalid/hook", "127.0.0.1:0", log.New(io.Discard, "", 0)); !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("run() error = %v", err) + } +} + +func TestTemplateHandlerRenderFailures(t *testing.T) { + app := testApplication(t, nil, nil) + app.renderer = &templateRenderer{templates: map[string]*template.Template{}} + tests := []struct { + path string + body string + }{ + {path: "/template/notify/backup", body: `{"title":"t","asset":"a","backupSizeInMB":1}`}, + {path: "/template/notify/update", body: `{"host":"h","asset":"a","time":1}`}, + {path: "/template/notify/error", body: `{"caller":"c","message":"m","critical":false}`}, + } + for _, test := range tests { + got := performRequest(app.handler(), http.MethodPost, test.path, "application/json", test.body) + if got.Code != http.StatusInternalServerError || got.Body.String() != "Failed to render notification" { + t.Fatalf("%s response = (%d, %q)", test.path, got.Code, got.Body.String()) + } + } +} + +func FuzzErrorNotificationHandler(f *testing.F) { + seeds := []string{ + `{"caller":"seed","message":"ok","critical":false}`, + `{"caller":"quotes","message":"line\n\"quoted\" ","critical":true}`, + `{}`, + `not-json`, + } + for _, seed := range seeds { + f.Add([]byte(seed)) + } + + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil || !json.Valid(body) { + return nil, errors.New("invalid outgoing JSON") + } + return response(http.StatusNoContent, nil), nil + }) + handler := testApplication(f, transport, nil).handler() + f.Fuzz(func(t *testing.T, body []byte) { + request := httptest.NewRequest(http.MethodPost, "/template/notify/error", bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code < 200 || recorder.Code > 599 { + t.Fatalf("invalid status %d", recorder.Code) + } + }) +} + +func response(status int, headers http.Header) *http.Response { + if headers == nil { + headers = make(http.Header) + } + return &http.Response{StatusCode: status, Header: headers, Body: io.NopCloser(strings.NewReader(""))} +} diff --git a/haven-notify/template/backup.tmpl b/haven-notify/template/backup.tmpl index af76949..3a2cfe5 100644 --- a/haven-notify/template/backup.tmpl +++ b/haven-notify/template/backup.tmpl @@ -1,35 +1,22 @@ -{{/* -Docker Backup Notification Template -Variables expected: -- .title: The backup title/name -- .asset: The asset being backed up -- .backupsizeinmb: The backup size in MB (will be formatted automatically) -- .extra: Optional array of additional fields with .name and .value - -Template Functions Available: -- formatSize: Formats size in MB/GB automatically -*/}} { "embeds": [ { - "title": "📦 Backup - {{.title}}", - "description": "**{{.asset}}** has been backup-ed successfully! ✅🫡\n", + "title": {{json (printf "📦 Backup - %s" .Title)}}, + "description": {{json (printf "**%s** has been backed up successfully! ✅🫡\n" .Asset)}}, "color": 3066993, "fields": [ { "name": "💾 Backup Size", - "value": "{{if .backupsizeinmb}}{{formatSize .backupsizeinmb}}{{else}}Unknown{{end}}", + "value": {{json (formatSize .BackupSizeInMB)}}, "inline": true } - {{- if .extra}} - {{- range $index, $field := .extra}}, + {{- range .Extra}}, { - "name": "{{$field.name}}", - "value": "{{$field.value}}", + "name": {{json .Name}}, + "value": {{json .Value}}, "inline": true } {{- end}} - {{- end}} ], "footer": { "text": "✨ Haven Notify ✨" diff --git a/haven-notify/template/error.tmpl b/haven-notify/template/error.tmpl index f1aa1e8..0f5b182 100644 --- a/haven-notify/template/error.tmpl +++ b/haven-notify/template/error.tmpl @@ -1,36 +1,26 @@ -{{/* -Error Notification Template -Variables expected: -- .caller: The caller of the error -- .message: The error message -- .critical: Boolean indicating if the error is critical -- .extra: Optional array of additional fields with .name and .value -*/}} { "embeds": [ { "title": "❌ Error", - "description": "**{{.caller}}** encountered an error!", - "color": {{if .critical}}15158332{{else}}15844367{{end}}, + "description": {{json (printf "**%s** encountered an error!" .Caller)}}, + "color": {{if .Critical}}15158332{{else}}15844367{{end}}, "fields": [ { "name": "📄 Message", - "value": "{{.message}}", + "value": {{json .Message}}, "inline": false } - {{- if .extra}} - {{- range $index, $field := .extra}}, + {{- range .Extra}}, { - "name": "{{$field.name}}", - "value": "{{$field.value}}", + "name": {{json .Name}}, + "value": {{json .Value}}, "inline": true } {{- end}} - {{- end}} ], "footer": { "text": "✨ Haven Notify ✨" } } ] -} \ No newline at end of file +} diff --git a/haven-notify/template/update.tmpl b/haven-notify/template/update.tmpl index 2ea72c3..4429ad0 100644 --- a/haven-notify/template/update.tmpl +++ b/haven-notify/template/update.tmpl @@ -1,23 +1,13 @@ -{{/* -Update Notification Template -Variables expected: -- .host: The host where the update occurred -- .asset: The asset being updated (Docker or k8s) -- .time: The time in seconds that the script took to run - -Template Functions Available: -- formatTime: Formats time in seconds to a human-readable format -*/}} { "embeds": [ { - "title": "🔄 Update - {{.asset}}", - "description": "**{{.host}}** has successfully updated **{{.asset}}**! ✅", + "title": {{json (printf "🔄 Update - %s" .Asset)}}, + "description": {{json (printf "**%s** has successfully updated **%s**! ✅" .Host .Asset)}}, "color": 3447003, "fields": [ { "name": "⏱️ Time Taken", - "value": "{{if .time}}{{.time}}{{else}}Unknown{{end}} seconds", + "value": {{json (formatSeconds .Time)}}, "inline": true } ], @@ -26,4 +16,4 @@ Template Functions Available: } } ] -} \ No newline at end of file +} diff --git a/haven-notify/templates.go b/haven-notify/templates.go new file mode 100644 index 0000000..14dcd35 --- /dev/null +++ b/haven-notify/templates.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io/fs" + "strconv" + "text/template" +) + +//go:embed template/*.tmpl +var templatesFS embed.FS + +type templateRenderer struct { + templates map[string]*template.Template +} + +type templateExtra struct { + Name string + Value string +} + +type backupTemplateData struct { + Title string + Asset string + BackupSizeInMB float64 + Extra []templateExtra +} + +type updateTemplateData struct { + Host string + Asset string + Time float64 +} + +type errorTemplateData struct { + Caller string + Message string + Critical bool + Extra []templateExtra +} + +func newTemplateRenderer() (*templateRenderer, error) { + return newTemplateRendererFromFS(templatesFS) +} + +func newTemplateRendererFromFS(templateFS fs.FS) (*templateRenderer, error) { + functions := template.FuncMap{ + "formatSize": formatSize, + "formatSeconds": formatSeconds, + "json": jsonLiteral, + } + templates := make(map[string]*template.Template, 3) + for _, name := range []string{"backup", "update", "error"} { + contents, err := fs.ReadFile(templateFS, "template/"+name+".tmpl") + if err != nil { + return nil, err + } + parsed, err := template.New(name).Funcs(functions).Parse(string(contents)) + if err != nil { + return nil, err + } + templates[name] = parsed + } + return &templateRenderer{templates: templates}, nil +} + +func (r *templateRenderer) renderBackup(request backupRequest) (discordPayload, error) { + return r.render("backup", backupTemplateData{ + Title: *request.Title, + Asset: *request.Asset, + BackupSizeInMB: *request.BackupSizeInMB, + Extra: templateExtras(request.Extra), + }) +} + +func (r *templateRenderer) renderUpdate(request updateRequest) (discordPayload, error) { + return r.render("update", updateTemplateData{ + Host: *request.Host, + Asset: *request.Asset, + Time: *request.Time, + }) +} + +func (r *templateRenderer) renderError(request errorRequest) (discordPayload, error) { + return r.render("error", errorTemplateData{ + Caller: *request.Caller, + Message: *request.Message, + Critical: *request.Critical, + Extra: templateExtras(request.Extra), + }) +} + +func (r *templateRenderer) render(name string, data any) (discordPayload, error) { + parsed, ok := r.templates[name] + if !ok { + return discordPayload{}, fmt.Errorf("unknown template %q", name) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, data); err != nil { + return discordPayload{}, err + } + var payload discordPayload + if err := json.Unmarshal(rendered.Bytes(), &payload); err != nil { + return discordPayload{}, fmt.Errorf("template produced invalid JSON: %w", err) + } + normalizeDiscordPayload(&payload) + return payload, nil +} + +func templateExtras(extra extraRequests) []templateExtra { + result := make([]templateExtra, 0, len(extra)) + for _, field := range extra { + result = append(result, templateExtra{Name: *field.Name, Value: *field.Value}) + } + return result +} + +func jsonLiteral(value any) (string, error) { + encoded, err := json.Marshal(value) + return string(encoded), err +} + +func formatSize(size float64) string { + if size >= 1024 { + return fmt.Sprintf("%.2f GiB", size/1024) + } + return fmt.Sprintf("%.2f MiB", size) +} + +func formatSeconds(seconds float64) string { + return strconv.FormatFloat(seconds, 'f', -1, 64) + " seconds" +} diff --git a/haven-notify/templates_test.go b/haven-notify/templates_test.go new file mode 100644 index 0000000..886b1b2 --- /dev/null +++ b/haven-notify/templates_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "testing/fstest" + "text/template" +) + +func stringPointer(value string) *string { return &value } +func floatPointer(value float64) *float64 { return &value } +func boolPointer(value bool) *bool { return &value } + +func TestFormatHelpers(t *testing.T) { + tests := []struct { + value float64 + want string + }{ + {0, "0.00 MiB"}, + {1023, "1023.00 MiB"}, + {1024, "1.00 GiB"}, + {1536, "1.50 GiB"}, + } + for _, test := range tests { + if got := formatSize(test.value); got != test.want { + t.Errorf("formatSize(%v) = %q, want %q", test.value, got, test.want) + } + } + if got := formatSeconds(42.5); got != "42.5 seconds" { + t.Fatalf("formatSeconds() = %q", got) + } + literal, err := jsonLiteral("line\n\"quoted\"") + if err != nil || literal != "\"line\\n\\\"quoted\\\"\"" { + t.Fatalf("jsonLiteral() = %q, %v", literal, err) + } +} + +func TestTemplateRenderer(t *testing.T) { + renderer, err := newTemplateRenderer() + if err != nil { + t.Fatal(err) + } + + backup, err := renderer.renderBackup(backupRequest{ + Title: stringPointer("Nightly"), Asset: stringPointer("photos"), BackupSizeInMB: floatPointer(1024), + Extra: []extraRequest{{Name: stringPointer("Host"), Value: stringPointer("nebula")}}, + }) + if err != nil || backup.Embeds[0].Fields[0].Value != "1.00 GiB" || len(backup.Embeds[0].Fields) != 2 { + t.Fatalf("backup render = %+v, %v", backup, err) + } + + update, err := renderer.renderUpdate(updateRequest{Host: stringPointer("nexus"), Asset: stringPointer("k3s"), Time: floatPointer(0)}) + if err != nil || update.Embeds[0].Fields[0].Value != "0 seconds" { + t.Fatalf("update render = %+v, %v", update, err) + } + + errorPayload, err := renderer.renderError(errorRequest{ + Caller: stringPointer("job"), Message: stringPointer("\"quoted\" "), Critical: boolPointer(false), + }) + if err != nil || errorPayload.Embeds[0].Fields[0].Value != "\"quoted\" " || errorPayload.Embeds[0].Color != 15844367 { + t.Fatalf("error render = %+v, %v", errorPayload, err) + } + + if _, err := renderer.render("missing", struct{}{}); err == nil { + t.Fatal("unknown template rendered") + } + + encoded, err := json.Marshal(errorPayload) + if err != nil || !json.Valid(encoded) || strings.Contains(string(encoded), """) { + t.Fatalf("rendered JSON = %s, %v", encoded, err) + } +} + +func TestTemplateExtraFieldCap(t *testing.T) { + renderer, err := newTemplateRenderer() + if err != nil { + t.Fatal(err) + } + extra := make([]extraRequest, 40) + for index := range extra { + extra[index] = extraRequest{Name: stringPointer(fmt.Sprintf("field-%02d", index)), Value: stringPointer("value")} + } + payload, err := renderer.renderError(errorRequest{ + Caller: stringPointer("job"), Message: stringPointer("failure"), Critical: boolPointer(true), Extra: extra, + }) + if err != nil { + t.Fatal(err) + } + if len(payload.Embeds[0].Fields) != 25 { + t.Fatalf("fields = %d, want 25", len(payload.Embeds[0].Fields)) + } + if payload.Embeds[0].Fields[24].Name != "field-23" { + t.Fatalf("last retained field = %q", payload.Embeds[0].Fields[24].Name) + } +} + +func TestTemplateRendererFailures(t *testing.T) { + if _, err := newTemplateRendererFromFS(fstest.MapFS{}); err == nil { + t.Fatal("missing templates were accepted") + } + invalidFS := fstest.MapFS{ + "template/backup.tmpl": {Data: []byte("{{")}, + "template/update.tmpl": {Data: []byte("{}")}, + "template/error.tmpl": {Data: []byte("{}")}, + } + if _, err := newTemplateRendererFromFS(invalidFS); err == nil { + t.Fatal("invalid template was accepted") + } + + renderer := &templateRenderer{templates: map[string]*template.Template{}} + renderer.templates["invalid-json"] = template.Must(template.New("invalid-json").Parse("not-json")) + if _, err := renderer.render("invalid-json", nil); err == nil { + t.Fatal("invalid JSON template rendered") + } + renderer.templates["execute-error"] = template.Must(template.New("execute-error").Parse("{{index . 0}}")) + if _, err := renderer.render("execute-error", nil); err == nil { + t.Fatal("template execution error was ignored") + } +}