commit ac836d0b67d23794733d1fce5b616d1c3fef0219 Author: todor Date: Sun May 31 18:00:20 2026 +0200 init diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..028b6a4 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..482cf1b --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/cmd/sanity/main.go b/cmd/sanity/main.go new file mode 100644 index 0000000..930d867 --- /dev/null +++ b/cmd/sanity/main.go @@ -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) + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..42d811e --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c67997 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module sanity + +go 1.22.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..0e8e2d2 --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..5da1096 --- /dev/null +++ b/internal/backup/backup.go @@ -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) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..18a0d8a --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/discord/discord.go b/internal/discord/discord.go new file mode 100644 index 0000000..5ec7f5a --- /dev/null +++ b/internal/discord/discord.go @@ -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("", 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 +} diff --git a/sanity.service b/sanity.service new file mode 100644 index 0000000..8a65767 --- /dev/null +++ b/sanity.service @@ -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