init
All checks were successful
Build sanity / build (push) Successful in 11s

This commit is contained in:
2026-05-31 18:00:20 +02:00
commit ac836d0b67
11 changed files with 638 additions and 0 deletions

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
module sanity
go 1.22.0
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View 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
View 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
View 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
View 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
View 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
View 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