adding test containers #1
9
.gitattributes
vendored
9
.gitattributes
vendored
@@ -1 +1,10 @@
|
|||||||
|
.gitattributes text eol=lf
|
||||||
*.sh 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
|
||||||
|
|||||||
@@ -6,16 +6,74 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "haven-notify/**"
|
- "haven-notify/**"
|
||||||
- ".gitea/workflows/**"
|
- ".gitea/workflows/haven-notify.yaml"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "haven-notify/**"
|
||||||
|
- ".gitea/workflows/haven-notify.yaml"
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE: git.ivanch.me/ivanch/haven-notify
|
IMAGE: git.ivanch.me/ivanch/haven-notify
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
name: Build Haven Notify Image
|
name: Build Haven Notify Image
|
||||||
runs-on: runner-slim
|
runs-on: runner-slim
|
||||||
|
needs: test
|
||||||
|
if: ${{ (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') && gitea.ref == 'refs/heads/main' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,10 +86,23 @@ jobs:
|
|||||||
ssh_key: ${{ secrets.SSH_KEY_DOCKERBUILD }}
|
ssh_key: ${{ secrets.SSH_KEY_DOCKERBUILD }}
|
||||||
build_context: haven-notify
|
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:
|
deploy:
|
||||||
name: Deploy Haven Notify (internal)
|
name: Deploy Haven Notify (internal)
|
||||||
runs-on: runner-slim
|
runs-on: runner-slim
|
||||||
needs: build
|
needs: build
|
||||||
|
if: ${{ (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') && gitea.ref == 'refs/heads/main' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Rollout Restart Haven Notify
|
- name: Rollout Restart Haven Notify
|
||||||
uses: https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main
|
||||||
|
|||||||
6
haven-notify/.dockerignore
Normal file
6
haven-notify/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
assets/
|
||||||
|
coverage.out
|
||||||
|
README.md
|
||||||
|
*_test.go
|
||||||
1
haven-notify/.gitignore
vendored
Normal file
1
haven-notify/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
coverage.out
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
# Start from the official Golang image for building
|
ARG BUILDPLATFORM
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
|
FROM --platform=$BUILDPLATFORM golang:1.25.10-alpine3.23@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /src
|
||||||
COPY . .
|
COPY go.mod go.sum ./
|
||||||
# Build statically for Linux
|
COPY *.go ./
|
||||||
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
|
|
||||||
COPY template/ template/
|
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
|
EXPOSE 8080
|
||||||
ENV WEBHOOK_URL=""
|
ENV WEBHOOK_URL=""
|
||||||
|
USER haven-notify
|
||||||
ENTRYPOINT ["/app/haven-notify"]
|
ENTRYPOINT ["/app/haven-notify"]
|
||||||
|
|||||||
@@ -3,21 +3,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Overview
|
## 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
|
## 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
|
### Send Notification
|
||||||
|
|
||||||
- **Endpoint**: `/notify`
|
- **Endpoint**: `/notify`
|
||||||
- **Method**: `POST`
|
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Notification Title",
|
"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
|
### Send Backup Notification
|
||||||
|
|
||||||
- **Endpoint**: `/template/notify/backup`
|
- **Endpoint**: `/template/notify/backup`
|
||||||
- **Method**: `POST`
|
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Notification Title",
|
"title": "Nightly backup",
|
||||||
"asset": "Notification Asset Name",
|
"asset": "Photos",
|
||||||
"backupSizeInMB": 500,
|
"backupSizeInMB": 1536,
|
||||||
"extra": [
|
"extra": [
|
||||||
{
|
{
|
||||||
"name": "Additional Info",
|
"name": "Host",
|
||||||
"value": "Some extra information"
|
"value": "nebula"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Send Update Notification
|
### Send Update Notification
|
||||||
|
|
||||||
- **Endpoint**: `/template/notify/update`
|
- **Endpoint**: `/template/notify/update`
|
||||||
- **Method**: `POST`
|
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"host": "Notification Title",
|
"host": "nexus",
|
||||||
"asset": "Notification Message",
|
"asset": "k3s",
|
||||||
"time": 500 // in seconds
|
"time": 42
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`time` is expressed in seconds.
|
||||||
|
|
||||||
### Send Error Notification
|
### Send Error Notification
|
||||||
|
|
||||||
- **Endpoint**: `/template/notify/error`
|
- **Endpoint**: `/template/notify/error`
|
||||||
- **Method**: `POST`
|
|
||||||
- **Request Body**:
|
- **Request Body**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"caller": "Who triggered the error",
|
"caller": "backup-job",
|
||||||
"message": "Error while moving file",
|
"message": "Error while moving file",
|
||||||
"critical": true,
|
"critical": true,
|
||||||
"extra": [
|
"extra": [
|
||||||
{
|
{
|
||||||
"name": "Additional Info",
|
"name": "Path",
|
||||||
"value": "Some extra information"
|
"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
|
### Docker
|
||||||
1. Build the Docker image:
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t haven-notify .
|
docker build -t haven-notify .
|
||||||
```
|
docker run --rm -p 8080:8080 \
|
||||||
2. Run the container:
|
-e WEBHOOK_URL=https://discord.com/api/webhooks/... \
|
||||||
```sh
|
haven-notify
|
||||||
docker run -e WEBHOOK_URL=your_webhook_url haven-notify
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kubernetes
|
The runtime container uses a non-root `haven-notify` user and contains only the binary, Alpine runtime files, and CA certificates.
|
||||||
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.
|
## Tests
|
||||||
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=<your_webhook_url> -n <namespace>`
|
Run unit and HTTP contract tests:
|
||||||
3. Apply deployment (from the `haven` repo):
|
|
||||||
```sh
|
```sh
|
||||||
kubectl apply -f infra/haven-notify.yaml
|
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.
|
||||||
|
|||||||
281
haven-notify/discord.go
Normal file
281
haven-notify/discord.go
Normal file
@@ -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<<attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeout(err error) bool {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
return errors.As(err, &netErr) && netErr.Timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDiscordPayload(payload *discordPayload) {
|
||||||
|
payload.AllowedMentions.Parse = []string{}
|
||||||
|
payload.Content = truncateRunes(payload.Content, 2000)
|
||||||
|
if len(payload.Embeds) > 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
|
||||||
|
}
|
||||||
243
haven-notify/discord_test.go
Normal file
243
haven-notify/discord_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
322
haven-notify/e2e_test.go
Normal file
322
haven-notify/e2e_test.go
Normal file
@@ -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\" <tag>","critical":true}`,
|
||||||
|
want: discordPayload{
|
||||||
|
Embeds: []discordEmbed{{
|
||||||
|
Title: "❌ Error",
|
||||||
|
Description: "**backup** encountered an error!",
|
||||||
|
Color: 15158332,
|
||||||
|
Fields: []discordEmbedField{
|
||||||
|
{Name: "📄 Message", Value: "line 1\n\"quoted\" <tag>", 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)
|
||||||
|
}
|
||||||
61
haven-notify/go.mod
Normal file
61
haven-notify/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
145
haven-notify/go.sum
Normal file
145
haven-notify/go.sum
Normal file
@@ -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=
|
||||||
@@ -1,214 +1,37 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"encoding/json"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"os/signal"
|
||||||
"time"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification payload
|
const listenAddress = ":8080"
|
||||||
type Notification struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
http.HandleFunc("/notify", notifyHandler)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
http.HandleFunc("/ready", readinessHandler)
|
defer stop()
|
||||||
http.HandleFunc("/live", livenessHandler)
|
|
||||||
http.HandleFunc("/template/notify/", templateNotifyHandler)
|
logger := log.Default()
|
||||||
log.Println("Starting server on :8080...")
|
if err := run(ctx, os.Getenv("WEBHOOK_URL"), listenAddress, logger); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
logger.Fatalf("Server stopped unexpectedly: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyHandler(w http.ResponseWriter, r *http.Request) {
|
func run(ctx context.Context, webhookURL, address string, logger *log.Logger) error {
|
||||||
log.Printf("Incoming %s request from %s to %s", r.Method, r.RemoteAddr, r.URL.Path)
|
app, err := newApplication(applicationConfig{WebhookURL: webhookURL, Logger: logger})
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to marshal Discord payload: %v", err)
|
return fmt.Errorf("configuration failed: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
listener, err := netListen("tcp", address)
|
||||||
log.Printf("Sending Discord notification: Title='%s', Message='%s'", title, message)
|
|
||||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error posting to Discord webhook: %v", err)
|
return fmt.Errorf("listen on %s: %w", address, err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
logger.Printf("Starting server on %s", address)
|
||||||
|
return serve(ctx, newHTTPServer(app.handler()), listener)
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
392
haven-notify/server.go
Normal file
392
haven-notify/server.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
450
haven-notify/server_test.go
Normal file
450
haven-notify/server_test.go
Normal file
@@ -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\" <tag>","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\" <tag>" {
|
||||||
|
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\" <tag>","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(""))}
|
||||||
|
}
|
||||||
@@ -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": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
"title": "📦 Backup - {{.title}}",
|
"title": {{json (printf "📦 Backup - %s" .Title)}},
|
||||||
"description": "**{{.asset}}** has been backup-ed successfully! ✅🫡\n",
|
"description": {{json (printf "**%s** has been backed up successfully! ✅🫡\n" .Asset)}},
|
||||||
"color": 3066993,
|
"color": 3066993,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"name": "💾 Backup Size",
|
"name": "💾 Backup Size",
|
||||||
"value": "{{if .backupsizeinmb}}{{formatSize .backupsizeinmb}}{{else}}Unknown{{end}}",
|
"value": {{json (formatSize .BackupSizeInMB)}},
|
||||||
"inline": true
|
"inline": true
|
||||||
}
|
}
|
||||||
{{- if .extra}}
|
{{- range .Extra}},
|
||||||
{{- range $index, $field := .extra}},
|
|
||||||
{
|
{
|
||||||
"name": "{{$field.name}}",
|
"name": {{json .Name}},
|
||||||
"value": "{{$field.value}}",
|
"value": {{json .Value}},
|
||||||
"inline": true
|
"inline": true
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
|
||||||
],
|
],
|
||||||
"footer": {
|
"footer": {
|
||||||
"text": "✨ Haven Notify ✨"
|
"text": "✨ Haven Notify ✨"
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
{{/*
|
|
||||||
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": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
"title": "❌ Error",
|
"title": "❌ Error",
|
||||||
"description": "**{{.caller}}** encountered an error!",
|
"description": {{json (printf "**%s** encountered an error!" .Caller)}},
|
||||||
"color": {{if .critical}}15158332{{else}}15844367{{end}},
|
"color": {{if .Critical}}15158332{{else}}15844367{{end}},
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"name": "📄 Message",
|
"name": "📄 Message",
|
||||||
"value": "{{.message}}",
|
"value": {{json .Message}},
|
||||||
"inline": false
|
"inline": false
|
||||||
}
|
}
|
||||||
{{- if .extra}}
|
{{- range .Extra}},
|
||||||
{{- range $index, $field := .extra}},
|
|
||||||
{
|
{
|
||||||
"name": "{{$field.name}}",
|
"name": {{json .Name}},
|
||||||
"value": "{{$field.value}}",
|
"value": {{json .Value}},
|
||||||
"inline": true
|
"inline": true
|
||||||
}
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
|
||||||
],
|
],
|
||||||
"footer": {
|
"footer": {
|
||||||
"text": "✨ Haven Notify ✨"
|
"text": "✨ Haven Notify ✨"
|
||||||
|
|||||||
@@ -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": [
|
"embeds": [
|
||||||
{
|
{
|
||||||
"title": "🔄 Update - {{.asset}}",
|
"title": {{json (printf "🔄 Update - %s" .Asset)}},
|
||||||
"description": "**{{.host}}** has successfully updated **{{.asset}}**! ✅",
|
"description": {{json (printf "**%s** has successfully updated **%s**! ✅" .Host .Asset)}},
|
||||||
"color": 3447003,
|
"color": 3447003,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"name": "⏱️ Time Taken",
|
"name": "⏱️ Time Taken",
|
||||||
"value": "{{if .time}}{{.time}}{{else}}Unknown{{end}} seconds",
|
"value": {{json (formatSeconds .Time)}},
|
||||||
"inline": true
|
"inline": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
135
haven-notify/templates.go
Normal file
135
haven-notify/templates.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
121
haven-notify/templates_test.go
Normal file
121
haven-notify/templates_test.go
Normal file
@@ -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\" <tag>"), Critical: boolPointer(false),
|
||||||
|
})
|
||||||
|
if err != nil || errorPayload.Embeds[0].Fields[0].Value != "\"quoted\" <tag>" || 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user