syncova-backup/packages/platform/database/postgres.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

142 lines
5.4 KiB
Go

// Package database kapselt den Zugriff auf die PostgreSQL-Control-Plane-Datenbank.
//
// Diese Datenbank enthält Konfiguration, Jobs, Metadaten und Statistiken —
// niemals Backup-Nutzdaten (SYNCOVA_DATABASE.md §1). Der Verlust der Datenbank
// darf ein Repository nicht unbrauchbar machen (PROMPT.md §2.3).
package database
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/syncova/syncova/packages/platform/config"
"github.com/syncova/syncova/packages/platform/health"
"github.com/syncova/syncova/packages/platform/logging"
)
// Pool ist der Verbindungspool zur Control-Plane-Datenbank.
type Pool struct {
// connectionPool ist der zugrunde liegende pgx-Pool.
connectionPool *pgxpool.Pool
// logger protokolliert Verbindungsereignisse.
logger *slog.Logger
// redactedConnectionString beschreibt das Ziel ohne Passwort und darf geloggt werden.
redactedConnectionString string
}
// Connect baut den Verbindungspool auf und prüft die Erreichbarkeit sofort.
//
// Ein Dienst startet nicht mit einer unbestätigten Datenbankverbindung: sonst
// zeigte er Betriebsbereitschaft, ohne arbeiten zu können.
func Connect(connectContext context.Context, databaseConfig config.DatabaseConfig, baseLogger *slog.Logger) (*Pool, error) {
databaseLogger := logging.WithComponent(baseLogger, "database")
poolConfig, parseError := pgxpool.ParseConfig(databaseConfig.ConnectionString())
if parseError != nil {
// Der Fehlertext von pgx kann die DSN samt Passwort enthalten und wird
// deshalb bewusst nicht weitergereicht.
return nil, fmt.Errorf("die Datenbankkonfiguration ist ungültig (Ziel: %s)", databaseConfig.RedactedConnectionString())
}
poolConfig.MaxConns = databaseConfig.MaxOpenConnections
poolConfig.MaxConnIdleTime = databaseConfig.MaxConnectionIdleTime
connectionPool, poolError := pgxpool.NewWithConfig(connectContext, poolConfig)
if poolError != nil {
return nil, fmt.Errorf("der Verbindungspool zur Datenbank konnte nicht erstellt werden (Ziel: %s): %w",
databaseConfig.RedactedConnectionString(), poolError)
}
// Ein erster Ping belegt, dass Adresse, Zugangsdaten und TLS-Modus stimmen.
pingContext, cancelPingContext := context.WithTimeout(connectContext, databaseConfig.ConnectTimeout)
defer cancelPingContext()
if pingError := connectionPool.Ping(pingContext); pingError != nil {
connectionPool.Close()
return nil, fmt.Errorf("die Datenbank ist nicht erreichbar (Ziel: %s): %w",
databaseConfig.RedactedConnectionString(), pingError)
}
databaseLogger.Info("datenbankverbindung hergestellt",
slog.String("target", databaseConfig.RedactedConnectionString()),
slog.Int("max_connections", int(databaseConfig.MaxOpenConnections)),
)
return &Pool{
connectionPool: connectionPool,
logger: databaseLogger,
redactedConnectionString: databaseConfig.RedactedConnectionString(),
}, nil
}
// Connections gibt den zugrunde liegenden pgx-Pool für Abfragen frei.
func (pool *Pool) Connections() *pgxpool.Pool {
return pool.connectionPool
}
// Close schließt alle Verbindungen des Pools.
func (pool *Pool) Close() {
pool.connectionPool.Close()
pool.logger.Info("datenbankverbindung geschlossen")
}
// HealthCheck liefert eine Prüffunktion für die Health Engine.
//
// Sie misst die Antwortzeit mit, weil eine langsame Datenbank die häufigste
// Vorstufe eines echten Ausfalls ist (PROMPT.md §64).
func (pool *Pool) HealthCheck() health.CheckFunc {
return func(checkContext context.Context) health.CheckResult {
if pingError := pool.connectionPool.Ping(checkContext); pingError != nil {
return health.CheckResult{
Status: health.StatusOffline,
Message: "Syncova kann die Control-Plane-Datenbank nicht erreichen.",
RecommendedAction: "Erreichbarkeit von PostgreSQL sowie Zugangsdaten und TLS-Einstellungen prüfen.",
}
}
poolStatistics := pool.connectionPool.Stat()
// Sind alle Verbindungen belegt, warten neue Anfragen — ein Frühwarnzeichen.
if poolStatistics.AcquiredConns() >= poolStatistics.MaxConns() {
return health.CheckResult{
Status: health.StatusWarning,
Message: "Alle Datenbankverbindungen sind belegt.",
RecommendedAction: "Gleichzeitige Jobs verringern oder SYNCOVA_DB_MAX_OPEN_CONNECTIONS erhöhen.",
}
}
return health.CheckResult{Status: health.StatusHealthy}
}
}
// AwaitAvailable wartet, bis die Datenbank erreichbar ist oder die Frist abläuft.
//
// Beim gemeinsamen Start von Datenbank und Anwendung (etwa via docker compose)
// ist PostgreSQL oft wenige Sekunden später bereit als der Dienst.
func AwaitAvailable(waitContext context.Context, databaseConfig config.DatabaseConfig, baseLogger *slog.Logger, maximumWaitDuration time.Duration) (*Pool, error) {
deadlineContext, cancelDeadlineContext := context.WithTimeout(waitContext, maximumWaitDuration)
defer cancelDeadlineContext()
// retryInterval ist bewusst kurz: der Start soll sich nicht unnötig verzögern.
const retryInterval = time.Second
var lastConnectError error
for {
connectionPool, connectError := Connect(deadlineContext, databaseConfig, baseLogger)
if connectError == nil {
return connectionPool, nil
}
lastConnectError = connectError
select {
case <-deadlineContext.Done():
return nil, fmt.Errorf("die Datenbank war innerhalb von %s nicht erreichbar: %w", maximumWaitDuration, lastConnectError)
case <-time.After(retryInterval):
}
}
}