feat: archivmail status CLI mit Healthcheck für PostgreSQL, Manticore und Storage

Prüft DB-Erreichbarkeit + Mail-Statistiken, Manticore-Verbindung/Version
und Storage (Keyfile, Pfad, freier Speicherplatz). --json für maschinenlesbare Ausgabe.
This commit is contained in:
sysops
2026-06-13 00:01:40 +02:00
parent 0ecde0c1ef
commit bc82854165
3 changed files with 194 additions and 0 deletions
+5
View File
@@ -288,6 +288,7 @@ Commands:
rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen
ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status) ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status)
update Auf neueste Version aktualisieren (führt update.sh aus) update Auf neueste Version aktualisieren (führt update.sh aus)
status Healthcheck für DB, Manticore und Storage
version Version anzeigen version Version anzeigen
help Diese Hilfe anzeigen help Diese Hilfe anzeigen
@@ -334,5 +335,9 @@ archivmail recompress [flags]
archivmail rethread [flags] archivmail rethread [flags]
--config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml)
--dry-run Simulation: zeigt wie viele Mails gethreaded würden, ohne DB zu ändern --dry-run Simulation: zeigt wie viele Mails gethreaded würden, ohne DB zu ändern
archivmail status [flags]
--config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml)
--json Maschinenlesbare JSON-Ausgabe
`, AppVersion) `, AppVersion)
} }
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgxpool"
"archivmail/config"
)
// checkResult holds the outcome of a single health check.
type checkResult struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
Latency string `json:"latency,omitempty"`
}
// runStatus performs health checks against PostgreSQL, Manticore Search, and
// the mail storage directory, and prints a summary.
// Usage: archivmail status [-config /path/to/config.yml] [-json]
func runStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file")
jsonOut := fs.Bool("json", false, "machine-readable JSON output")
fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "archivmail status: config laden fehlgeschlagen: %v\n", err)
os.Exit(1)
}
results := []checkResult{
checkPostgres(cfg),
checkManticore(cfg),
checkStorage(cfg),
}
allOK := true
for _, r := range results {
if !r.OK {
allOK = false
}
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]interface{}{"ok": allOK, "checks": results})
} else {
for _, r := range results {
status := "OK"
if !r.OK {
status = "FEHLER"
}
if r.Latency != "" {
fmt.Printf("[%-6s] %-12s %s (%s)\n", status, r.Name, r.Detail, r.Latency)
} else {
fmt.Printf("[%-6s] %-12s %s\n", status, r.Name, r.Detail)
}
}
}
if !allOK {
os.Exit(1)
}
}
func checkPostgres(cfg *config.Config) checkResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
if err != nil {
return checkResult{Name: "PostgreSQL", OK: false, Detail: fmt.Sprintf("Verbindung fehlgeschlagen: %v", err)}
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
return checkResult{Name: "PostgreSQL", OK: false, Detail: fmt.Sprintf("Ping fehlgeschlagen: %v", err)}
}
latency := time.Since(start)
var totalMails int64
var firstMail, lastMail *time.Time
_ = pool.QueryRow(ctx, `SELECT COUNT(*), MIN(received_at), MAX(received_at) FROM emails`).
Scan(&totalMails, &firstMail, &lastMail)
detail := fmt.Sprintf("%d Mails", totalMails)
if firstMail != nil && lastMail != nil {
detail += fmt.Sprintf(", erste %s, letzte %s", firstMail.Format("2006-01-02"), lastMail.Format("2006-01-02 15:04"))
}
return checkResult{Name: "PostgreSQL", OK: true, Detail: detail, Latency: latency.Round(time.Millisecond).String()}
}
func checkManticore(cfg *config.Config) checkResult {
dsn := cfg.Index.ManticoreDSN
if dsn == "" {
dsn = "manticore@tcp(127.0.0.1:9306)/"
}
start := time.Now()
db, err := sql.Open("mysql", dsn)
if err != nil {
return checkResult{Name: "Manticore", OK: false, Detail: fmt.Sprintf("Verbindung fehlgeschlagen: %v", err)}
}
defer db.Close()
if err := db.Ping(); err != nil {
return checkResult{Name: "Manticore", OK: false, Detail: fmt.Sprintf("Ping fehlgeschlagen: %v", err)}
}
latency := time.Since(start)
var version string
_ = db.QueryRow(`SHOW STATUS LIKE 'version'`).Scan(new(string), &version)
detail := "erreichbar"
if version != "" {
detail = "Version " + version
}
return checkResult{Name: "Manticore", OK: true, Detail: detail, Latency: latency.Round(time.Millisecond).String()}
}
func checkStorage(cfg *config.Config) checkResult {
keyfile := cfg.Storage.Keyfile
data, err := os.ReadFile(keyfile)
if err != nil {
return checkResult{Name: "Storage", OK: false, Detail: fmt.Sprintf("Keyfile %s nicht lesbar: %v", keyfile, err)}
}
raw := strings.TrimSpace(string(data))
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
decoded = []byte(raw)
}
if len(decoded) != 32 {
return checkResult{Name: "Storage", OK: false, Detail: fmt.Sprintf("Keyfile %s ergibt %d Bytes, erwartet 32", keyfile, len(decoded))}
}
storePath := cfg.Storage.StorePath
if _, err := os.Stat(storePath); err != nil {
return checkResult{Name: "Storage", OK: false, Detail: fmt.Sprintf("store_path %s nicht erreichbar: %v", storePath, err)}
}
var stat syscall.Statfs_t
if err := syscall.Statfs(storePath, &stat); err != nil {
return checkResult{Name: "Storage", OK: false, Detail: fmt.Sprintf("Statfs %s fehlgeschlagen: %v", storePath, err)}
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
var usedPct float64
if total > 0 {
usedPct = (1 - float64(free)/float64(total)) * 100
}
detail := fmt.Sprintf("%s, %.1f%% belegt, %s frei", storePath, usedPct, formatBytes(free))
ok := usedPct < 95
if !ok {
detail += " — Speicherplatz kritisch knapp"
}
return checkResult{Name: "Storage", OK: ok, Detail: detail}
}
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
+3
View File
@@ -70,6 +70,9 @@ func main() {
case "update": case "update":
runUpdate(os.Args[2:]) runUpdate(os.Args[2:])
return return
case "status":
runStatus(os.Args[2:])
return
case "version": case "version":
fmt.Printf("archivmail %s\n", AppVersion) fmt.Printf("archivmail %s\n", AppVersion)
for mod, ver := range Modules { for mod, ver := range Modules {