This commit is contained in:
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: Build sanity
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- '**/*.go'
|
||||||
|
- 'go.mod'
|
||||||
|
- 'go.sum'
|
||||||
|
- '.gitea/workflows/build.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.22'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: |
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o sanity ./cmd/sanity
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
SHA: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="build-${SHA::8}"
|
||||||
|
|
||||||
|
# Delete existing release + tag with this name if present (idempotent re-runs)
|
||||||
|
EXISTING=$(curl -sf \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases/tags/$TAG" || true)
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id')
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases/$EXISTING_ID" || true
|
||||||
|
curl -sf -X DELETE \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/tags/$TAG" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create a pre-release tagged with the short commit SHA
|
||||||
|
RELEASE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":true,\"draft\":false}")
|
||||||
|
RELEASE_ID=$(echo "$RELEASE" | jq -r '.id')
|
||||||
|
|
||||||
|
# Upload binary as release asset
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=sanity" \
|
||||||
|
--data-binary @sanity
|
||||||
27
README.md
Normal file
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# sanity
|
||||||
|
|
||||||
|
Sanity runs scheduled compressed database backups and sends Discord notifications for every run.
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- Every `backup.interval`, Sanity reads `backup.source_file`.
|
||||||
|
- It writes a gzip artifact in `backup.output_dir` named `file_prefix-YYYYMMDD-HHMMSS.gz`.
|
||||||
|
- On success, it sends a green Discord embed with artifact path, size, and duration.
|
||||||
|
- On failure, it sends a red Discord embed with the error reason and pings `notify_role_id` when configured.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Copy `config.example.yaml` to `config.yaml` and set:
|
||||||
|
- `server_name`
|
||||||
|
- `discord_webhook_url`
|
||||||
|
- `notify_role_id` (optional)
|
||||||
|
- `backup.interval` (for example `24h`)
|
||||||
|
- `backup.source_file`
|
||||||
|
- `backup.output_dir`
|
||||||
|
- `backup.file_prefix`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o sanity ./cmd/sanity
|
||||||
|
./sanity --config ./config.yaml
|
||||||
|
```
|
||||||
34
cmd/sanity/main.go
Normal file
34
cmd/sanity/main.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"sanity/internal/app"
|
||||||
|
"sanity/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "./config.yaml", "Path to config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := app.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("initialize app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if err := runner.Run(ctx); err != nil {
|
||||||
|
log.Fatalf("run sanity: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
config.example.yaml
Normal file
11
config.example.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server_name: db-prod-1
|
||||||
|
discord_webhook_url: https://discord.com/api/webhooks/replace/me
|
||||||
|
notify_role_id: "123456789012345678"
|
||||||
|
|
||||||
|
request_timeout: 10s
|
||||||
|
|
||||||
|
backup:
|
||||||
|
interval: 24h
|
||||||
|
source_file: /var/lib/postgresql/data/app.db
|
||||||
|
output_dir: /var/backups/postgres
|
||||||
|
file_prefix: db
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module sanity
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
63
internal/app/app.go
Normal file
63
internal/app/app.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sanity/internal/backup"
|
||||||
|
"sanity/internal/config"
|
||||||
|
"sanity/internal/discord"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
cfg config.Config
|
||||||
|
executor *backup.Executor
|
||||||
|
discord *discord.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config) (*Runner, error) {
|
||||||
|
return &Runner{
|
||||||
|
cfg: cfg,
|
||||||
|
executor: backup.NewExecutor(),
|
||||||
|
discord: discord.New(cfg.ServerName, cfg.DiscordWebhookURL, cfg.NotifyRoleID, cfg.RequestTimeout),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Run(ctx context.Context) error {
|
||||||
|
if err := r.runBackup(ctx); err != nil {
|
||||||
|
log.Printf("initial backup run failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(r.cfg.Backup.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := r.runBackup(ctx); err != nil {
|
||||||
|
log.Printf("backup run failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) runBackup(ctx context.Context) error {
|
||||||
|
result, err := r.executor.Run(ctx, r.cfg)
|
||||||
|
if err != nil {
|
||||||
|
dirSizeBytes := int64(-1)
|
||||||
|
if totalDirSize, totalDirSizeErr := backup.DirectorySize(r.cfg.Backup.OutputDir); totalDirSizeErr == nil {
|
||||||
|
dirSizeBytes = totalDirSize
|
||||||
|
}
|
||||||
|
if notifyErr := r.discord.SendFailure(ctx, time.Now().UTC(), err.Error(), r.cfg.Backup.SourceFile, r.cfg.Backup.OutputDir, dirSizeBytes); notifyErr != nil {
|
||||||
|
log.Printf("send failure notification failed: %v", notifyErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.discord.SendSuccess(ctx, result, r.cfg.Backup.Interval); err != nil {
|
||||||
|
log.Printf("send success notification failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
165
internal/backup/backup.go
Normal file
165
internal/backup/backup.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sanity/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor struct{}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
SourceFile string
|
||||||
|
ArtifactPath string
|
||||||
|
SizeBytes int64
|
||||||
|
TotalBackupDirSizeBytes int64
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor() *Executor {
|
||||||
|
return &Executor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) Run(ctx context.Context, cfg config.Config) (Result, error) {
|
||||||
|
start := time.Now().UTC()
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceInfo, err := os.Stat(cfg.Backup.SourceFile)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("stat source file: %w", err)
|
||||||
|
}
|
||||||
|
if sourceInfo.IsDir() {
|
||||||
|
return Result{}, fmt.Errorf("source_file must be a file, got directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.Backup.OutputDir, 0o755); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("create output_dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactName := fmt.Sprintf("%s-%s.gz", cfg.Backup.FilePrefix, start.Format("20060102-150405"))
|
||||||
|
artifactPath := filepath.Join(cfg.Backup.OutputDir, artifactName)
|
||||||
|
tempPath := artifactPath + ".tmp"
|
||||||
|
|
||||||
|
result, err := compressFile(ctx, cfg.Backup.SourceFile, tempPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tempPath, artifactPath); err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return Result{}, fmt.Errorf("finalize artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Timestamp = start
|
||||||
|
result.SourceFile = cfg.Backup.SourceFile
|
||||||
|
result.ArtifactPath = artifactPath
|
||||||
|
dirSize, dirSizeErr := DirectorySize(cfg.Backup.OutputDir)
|
||||||
|
if dirSizeErr != nil {
|
||||||
|
result.TotalBackupDirSizeBytes = -1
|
||||||
|
} else {
|
||||||
|
result.TotalBackupDirSizeBytes = dirSize
|
||||||
|
}
|
||||||
|
result.Duration = time.Since(start)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DirectorySize(root string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += info.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressFile(ctx context.Context, sourcePath string, tempPath string) (Result, error) {
|
||||||
|
source, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("open source_file: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("create temp artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gz, err := gzip.NewWriterLevel(target, gzip.BestCompression)
|
||||||
|
if err != nil {
|
||||||
|
target.Close()
|
||||||
|
return Result{}, fmt.Errorf("create gzip writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
copyBuffer := make([]byte, 1024*1024)
|
||||||
|
written, copyErr := copyWithContext(ctx, gz, source, copyBuffer)
|
||||||
|
closeErr := gz.Close()
|
||||||
|
syncErr := target.Sync()
|
||||||
|
fileCloseErr := target.Close()
|
||||||
|
|
||||||
|
if copyErr != nil {
|
||||||
|
return Result{}, copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return Result{}, fmt.Errorf("close gzip writer: %w", closeErr)
|
||||||
|
}
|
||||||
|
if syncErr != nil {
|
||||||
|
return Result{}, fmt.Errorf("sync temp artifact: %w", syncErr)
|
||||||
|
}
|
||||||
|
if fileCloseErr != nil {
|
||||||
|
return Result{}, fmt.Errorf("close temp artifact: %w", fileCloseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{SizeBytes: written}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader, buffer []byte) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, readErr := src.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
written, writeErr := dst.Write(buffer[:n])
|
||||||
|
total += int64(written)
|
||||||
|
if writeErr != nil {
|
||||||
|
return total, fmt.Errorf("write compressed data: %w", writeErr)
|
||||||
|
}
|
||||||
|
if written != n {
|
||||||
|
return total, fmt.Errorf("short write while compressing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if readErr == io.EOF {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return total, fmt.Errorf("read source data: %w", readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/config/config.go
Normal file
77
internal/config/config.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerName string `yaml:"server_name"`
|
||||||
|
DiscordWebhookURL string `yaml:"discord_webhook_url"`
|
||||||
|
NotifyRoleID string `yaml:"notify_role_id"`
|
||||||
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
|
Backup Backup `yaml:"backup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Backup struct {
|
||||||
|
Interval time.Duration `yaml:"interval"`
|
||||||
|
SourceFile string `yaml:"source_file"`
|
||||||
|
OutputDir string `yaml:"output_dir"`
|
||||||
|
FilePrefix string `yaml:"file_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) {
|
||||||
|
if cfg.RequestTimeout == 0 {
|
||||||
|
cfg.RequestTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Backup.Interval == 0 {
|
||||||
|
cfg.Backup.Interval = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if cfg.Backup.FilePrefix == "" {
|
||||||
|
cfg.Backup.FilePrefix = "db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg Config) Validate() error {
|
||||||
|
if cfg.ServerName == "" {
|
||||||
|
return fmt.Errorf("server_name is required")
|
||||||
|
}
|
||||||
|
if cfg.DiscordWebhookURL == "" {
|
||||||
|
return fmt.Errorf("discord_webhook_url is required")
|
||||||
|
}
|
||||||
|
if cfg.Backup.Interval <= 0 {
|
||||||
|
return fmt.Errorf("backup.interval must be > 0")
|
||||||
|
}
|
||||||
|
if cfg.Backup.SourceFile == "" {
|
||||||
|
return fmt.Errorf("backup.source_file is required")
|
||||||
|
}
|
||||||
|
if cfg.Backup.OutputDir == "" {
|
||||||
|
return fmt.Errorf("backup.output_dir is required")
|
||||||
|
}
|
||||||
|
if cfg.Backup.FilePrefix == "" {
|
||||||
|
return fmt.Errorf("backup.file_prefix is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
170
internal/discord/discord.go
Normal file
170
internal/discord/discord.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sanity/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
webhookURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
serverName string
|
||||||
|
notifyRoleID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookPayload struct {
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
AllowedMentions allowedMentions `json:"allowed_mentions,omitempty"`
|
||||||
|
Embeds []embed `json:"embeds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type allowedMentions struct {
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embed struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Color int `json:"color"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
Fields []embedField `json:"fields,omitempty"`
|
||||||
|
Footer *embedFooter `json:"footer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedField struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Inline bool `json:"inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedFooter struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(serverName string, webhookURL string, notifyRoleID string, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
serverName: serverName,
|
||||||
|
webhookURL: webhookURL,
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
notifyRoleID: notifyRoleID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendSuccess(ctx context.Context, result backup.Result, interval time.Duration) error {
|
||||||
|
fields := []embedField{
|
||||||
|
{Name: "Server", Value: c.serverName, Inline: true},
|
||||||
|
{Name: "Source", Value: result.SourceFile, Inline: false},
|
||||||
|
{Name: "Artifact", Value: result.ArtifactPath, Inline: false},
|
||||||
|
{Name: "Artifact size", Value: formatBytes(result.SizeBytes), Inline: true},
|
||||||
|
{Name: "Backup dir total", Value: formatOptionalBytes(result.TotalBackupDirSizeBytes), Inline: true},
|
||||||
|
{Name: "Duration", Value: result.Duration.Round(time.Millisecond).String(), Inline: true},
|
||||||
|
{Name: "Next run", Value: formatSummaryInterval(interval), Inline: true},
|
||||||
|
{Name: "Timestamp", Value: formatDiscordTime(result.Timestamp), Inline: true},
|
||||||
|
}
|
||||||
|
return c.send(ctx, webhookPayload{Embeds: []embed{{
|
||||||
|
Title: fmt.Sprintf("sanity backup successful - %s", c.serverName),
|
||||||
|
Description: "A scheduled backup completed successfully.",
|
||||||
|
Color: 0x27AE60,
|
||||||
|
Fields: fields,
|
||||||
|
Footer: &embedFooter{Text: fmt.Sprintf("sanity - %s", c.serverName)},
|
||||||
|
}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendFailure(ctx context.Context, timestamp time.Time, reason string, sourceFile string, outputDir string, totalBackupDirSizeBytes int64) error {
|
||||||
|
fields := []embedField{
|
||||||
|
{Name: "Server", Value: c.serverName, Inline: true},
|
||||||
|
{Name: "Source", Value: sourceFile, Inline: false},
|
||||||
|
{Name: "Output dir", Value: outputDir, Inline: false},
|
||||||
|
{Name: "Backup dir total", Value: formatOptionalBytes(totalBackupDirSizeBytes), Inline: true},
|
||||||
|
{Name: "Timestamp", Value: formatDiscordTime(timestamp), Inline: true},
|
||||||
|
{Name: "Reason", Value: reason, Inline: false},
|
||||||
|
}
|
||||||
|
payload := webhookPayload{Embeds: []embed{{
|
||||||
|
Title: fmt.Sprintf("sanity backup failed - %s", c.serverName),
|
||||||
|
Description: "A scheduled backup run failed.",
|
||||||
|
Color: 0xE74C3C,
|
||||||
|
Timestamp: timestamp.Format(time.RFC3339),
|
||||||
|
Fields: fields,
|
||||||
|
}}}
|
||||||
|
if c.notifyRoleID != "" {
|
||||||
|
payload.Content = fmt.Sprintf("<@&%s>", c.notifyRoleID)
|
||||||
|
payload.AllowedMentions = allowedMentions{Roles: []string{c.notifyRoleID}}
|
||||||
|
}
|
||||||
|
return c.send(ctx, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDiscordTime(timestamp time.Time) string {
|
||||||
|
unixSeconds := timestamp.Unix()
|
||||||
|
return fmt.Sprintf("<t:%d:f>", unixSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSummaryInterval(interval time.Duration) string {
|
||||||
|
if interval%time.Hour == 0 {
|
||||||
|
return fmt.Sprintf("%dh", interval/time.Hour)
|
||||||
|
}
|
||||||
|
if interval%time.Minute == 0 {
|
||||||
|
return fmt.Sprintf("%dm", interval/time.Minute)
|
||||||
|
}
|
||||||
|
return interval.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOptionalBytes(sizeBytes int64) string {
|
||||||
|
if sizeBytes < 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return formatBytes(sizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(sizeBytes int64) string {
|
||||||
|
const (
|
||||||
|
kib = 1024
|
||||||
|
mib = 1024 * 1024
|
||||||
|
gib = 1024 * 1024 * 1024
|
||||||
|
tib = 1024 * 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
if sizeBytes <= 0 {
|
||||||
|
return "0 B"
|
||||||
|
}
|
||||||
|
if sizeBytes < kib {
|
||||||
|
return fmt.Sprintf("%d B", sizeBytes)
|
||||||
|
}
|
||||||
|
if sizeBytes < mib {
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(sizeBytes)/kib)
|
||||||
|
}
|
||||||
|
if sizeBytes < gib {
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(sizeBytes)/mib)
|
||||||
|
}
|
||||||
|
if sizeBytes < tib {
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(sizeBytes)/gib)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f TB", float64(sizeBytes)/tib)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(ctx context.Context, payload webhookPayload) error {
|
||||||
|
buffer := &bytes.Buffer{}
|
||||||
|
if err := json.NewEncoder(buffer).Encode(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("discord webhook returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
14
sanity.service
Normal file
14
sanity.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=sanity backup monitor
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/sanity
|
||||||
|
ExecStart=/opt/sanity/sanity --config /opt/sanity/config.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user