syncova-backup/packages/jobs/model.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

283 lines
10 KiB
Go

// Package jobs verwaltet Sicherungsaufträge und ihre Läufe.
//
// Die Trennung zum Paket scheduler ist bewusst: Dort liegt die Rechenlogik
// (wann läuft was, in welcher Reihenfolge, wie oft wird wiederholt), hier die
// Beständigkeit und die fachlichen Regeln rund um einen Auftrag. So bleibt die
// Zeitplanung ohne Datenbank prüfbar.
package jobs
import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/scheduler"
)
// JobStatus ist der Zustand eines Auftrags.
type JobStatus string
const (
// JobStatusActive läuft nach Zeitplan.
JobStatusActive JobStatus = "active"
// JobStatusPaused ist vorübergehend ausgesetzt.
//
// Ein ausgesetzter Auftrag verliert seinen Zeitplan nicht; er wird nur
// nicht ausgeführt. Ihn zu löschen und neu anzulegen verlöre die Historie.
JobStatusPaused JobStatus = "paused"
// JobStatusDisabled ist dauerhaft abgeschaltet.
JobStatusDisabled JobStatus = "disabled"
// JobStatusError meldet einen Auftrag mit fehlerhafter Konfiguration.
JobStatusError JobStatus = "error"
)
// SourceType benennt die Art einer Sicherungsquelle.
type SourceType string
const (
// SourceTypeFilesystem ist ein Verzeichnisbaum.
SourceTypeFilesystem SourceType = "filesystem"
// SourceTypeProxmoxVM ist eine virtuelle Maschine.
SourceTypeProxmoxVM SourceType = "proxmox_vm"
// SourceTypeProxmoxContainer ist ein Container.
SourceTypeProxmoxContainer SourceType = "proxmox_container"
// SourceTypeWindowsSystem ist ein Windows-System.
SourceTypeWindowsSystem SourceType = "windows_system"
// SourceTypeLinuxSystem ist ein Linux-System.
SourceTypeLinuxSystem SourceType = "linux_system"
)
// knownSourceTypes sind die zugelassenen Quellarten.
var knownSourceTypes = map[SourceType]bool{
SourceTypeFilesystem: true,
SourceTypeProxmoxVM: true,
SourceTypeProxmoxContainer: true,
SourceTypeWindowsSystem: true,
SourceTypeLinuxSystem: true,
}
// JobSource ist eine zu sichernde Quelle.
type JobSource struct {
// ID ist der Bezeichner des Eintrags.
ID uuid.UUID `json:"id"`
// SourceType ist die Art der Quelle.
SourceType SourceType `json:"source_type"`
// SourceID ist die Kennung innerhalb ihrer Art.
SourceID string `json:"source_id"`
// SourceName ist die sprechende Bezeichnung.
SourceName string `json:"source_name,omitempty"`
// AgentID ist der ausführende Agent, sofern nötig.
AgentID *uuid.UUID `json:"agent_id,omitempty"`
// ClusterID ist die Virtualisierungsumgebung einer Proxmox-Quelle.
//
// Bei genau einem eingerichteten Verbund liesse sie sich raten — bei zweien
// sicherte der Lauf die falsche Maschine. Deshalb verpflichtend, und die
// Datenbank setzt das über einen CHECK durch.
ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
// IncludePatterns beschränken die Erfassung.
IncludePatterns []string `json:"include_patterns,omitempty"`
// ExcludePatterns nehmen Pfade aus.
ExcludePatterns []string `json:"exclude_patterns,omitempty"`
}
// Job ist ein Sicherungsauftrag.
type Job struct {
// ID ist der öffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// Name ist die eindeutige Bezeichnung.
Name string `json:"name"`
// Description erläutert den Zweck.
Description string `json:"description,omitempty"`
// Status ist der Zustand.
Status JobStatus `json:"status"`
// Priority ist die Dringlichkeit.
Priority scheduler.Priority `json:"priority"`
// Schedule ist der Zeitplan.
Schedule scheduler.Schedule `json:"schedule"`
// Sources sind die zu sichernden Quellen.
Sources []JobSource `json:"sources"`
// RepositoryID ist das Ziel-Repository.
RepositoryID uuid.UUID `json:"repository_id"`
// RetentionPolicyID ist die Aufbewahrungsregel.
RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"`
// DependsOnJobIDs sind vorausgesetzte Aufträge.
DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"`
// RecoveryPointObjective ist der zulässige Datenverlust.
RecoveryPointObjective time.Duration `json:"rpo,omitempty"`
// RecoveryTimeObjective ist die zulässige Wiederherstellungsdauer.
RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe dieses Auftrags.
MaximumConcurrency int `json:"max_concurrency"`
// RetryPolicy beschreibt das Wiederholungsverhalten.
RetryPolicy scheduler.RetryPolicy `json:"retry_policy"`
// NextRunAt ist der berechnete nächste Zeitpunkt in UTC.
NextRunAt *time.Time `json:"next_run_at,omitempty"`
// LastRunAt ist der Beginn des letzten Laufs in UTC.
LastRunAt *time.Time `json:"last_run_at,omitempty"`
// LastOutcome ist der Ausgang des letzten Laufs.
LastOutcome scheduler.JobOutcome `json:"last_outcome,omitempty"`
// PausedAt ist der Zeitpunkt einer Aussetzung in UTC.
PausedAt *time.Time `json:"paused_at,omitempty"`
// CreatedBy benennt den Anleger.
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC.
UpdatedAt time.Time `json:"updated_at"`
}
// ErrInvalidJob meldet einen unbrauchbaren Auftrag.
var ErrInvalidJob = errors.New("der sicherungsauftrag ist unbrauchbar")
// maximumJobNameLength begrenzt die Länge des Namens.
const maximumJobNameLength = 200
// Validate prüft einen Auftrag auf Ausführbarkeit.
//
// Die Prüfung geschieht beim Anlegen. Ein Auftrag, dessen Fehler sich erst
// nachts um zwei zeigt, fällt genau dann aus, wenn niemand hinsieht — und die
// Lücke in der Sicherungskette fällt erst auf, wenn man wiederherstellen will.
func (job *Job) Validate() error {
trimmedName := strings.TrimSpace(job.Name)
if trimmedName == "" {
return fmt.Errorf("%w: der auftrag braucht einen namen", ErrInvalidJob)
}
if len(trimmedName) > maximumJobNameLength {
return fmt.Errorf("%w: der name darf höchstens %d zeichen lang sein", ErrInvalidJob, maximumJobNameLength)
}
if job.RepositoryID == uuid.Nil {
return fmt.Errorf("%w: der auftrag braucht ein ziel-repository", ErrInvalidJob)
}
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern —
// die gefährlichste Art von Fehlkonfiguration, weil sie wie ein Erfolg
// aussieht (PROMPT.md §138).
if len(job.Sources) == 0 {
return fmt.Errorf("%w: der auftrag braucht mindestens eine quelle", ErrInvalidJob)
}
seenSources := make(map[string]bool, len(job.Sources))
for sourceIndex, jobSource := range job.Sources {
if !knownSourceTypes[jobSource.SourceType] {
return fmt.Errorf("%w: die quellart %q der %d. quelle ist unbekannt",
ErrInvalidJob, jobSource.SourceType, sourceIndex+1)
}
if strings.TrimSpace(jobSource.SourceID) == "" {
return fmt.Errorf("%w: die %d. quelle hat keine kennung", ErrInvalidJob, sourceIndex+1)
}
// Dieselbe Quelle zweimal verdoppelte die Datenmenge, ohne mehr zu
// sichern.
sourceKey := string(jobSource.SourceType) + "\x00" + jobSource.SourceID
if seenSources[sourceKey] {
return fmt.Errorf("%w: die quelle %s kommt mehrfach vor", ErrInvalidJob, jobSource.SourceID)
}
seenSources[sourceKey] = true
}
if !job.Priority.IsValid() {
return fmt.Errorf("%w: die dringlichkeit %q ist unbekannt", ErrInvalidJob, job.Priority)
}
if scheduleError := job.Schedule.Validate(); scheduleError != nil {
return fmt.Errorf("%w: %v", ErrInvalidJob, scheduleError)
}
if job.MaximumConcurrency < 1 {
return fmt.Errorf("%w: es muss mindestens ein gleichzeitiger lauf zugelassen sein", ErrInvalidJob)
}
if job.BandwidthLimitBytesPerSecond < 0 {
return fmt.Errorf("%w: die bandbreitengrenze darf nicht negativ sein", ErrInvalidJob)
}
if retryError := job.RetryPolicy.Validate(); retryError != nil {
return fmt.Errorf("%w: %v", ErrInvalidJob, retryError)
}
// Ein Auftrag, der von sich selbst abhängt, liefe nie an.
for _, dependencyID := range job.DependsOnJobIDs {
if dependencyID == job.ID && job.ID != uuid.Nil {
return fmt.Errorf("%w: der auftrag hängt von sich selbst ab", ErrInvalidJob)
}
}
return job.validateObjectives()
}
// validateObjectives prüft die Schutzziele auf Widerspruchsfreiheit.
//
// Ein RPO, den der Zeitplan nicht einhalten kann, ist keine Kleinigkeit: Der
// Betreiber glaubt, höchstens vier Stunden zu verlieren, während der Auftrag
// nur täglich läuft. Das gehört beim Anlegen gesagt, nicht nach dem Ausfall.
func (job *Job) validateObjectives() error {
if job.RecoveryPointObjective <= 0 {
return nil
}
if job.Schedule.ScheduleType == scheduler.ScheduleTypeManual {
return fmt.Errorf("%w: ein zeitplan auf anforderung kann keinen wiederherstellungspunkt von %s einhalten",
ErrInvalidJob, job.RecoveryPointObjective)
}
// Der Abstand zweier Läufe wird an einem festen Bezugspunkt gemessen. Er
// schwankt bei Monatsplänen, taugt aber für die Größenordnung — und darum
// geht es hier.
referenceTime := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
firstRun, firstError := job.Schedule.NextRun(referenceTime)
if firstError != nil {
return nil
}
secondRun, secondError := job.Schedule.NextRun(firstRun)
if secondError != nil {
return nil
}
scheduleInterval := secondRun.Sub(firstRun)
if scheduleInterval > job.RecoveryPointObjective {
return fmt.Errorf("%w: der zeitplan läuft nur alle %s und kann den geforderten wiederherstellungspunkt von %s nicht einhalten",
ErrInvalidJob, formatDurationForHumans(scheduleInterval), formatDurationForHumans(job.RecoveryPointObjective))
}
return nil
}
// formatDurationForHumans gibt eine Dauer lesbar aus.
func formatDurationForHumans(duration time.Duration) string {
switch {
case duration >= 24*time.Hour:
return fmt.Sprintf("%.0f Tage", duration.Hours()/24)
case duration >= time.Hour:
return fmt.Sprintf("%.0f Stunden", duration.Hours())
default:
return fmt.Sprintf("%.0f Minuten", duration.Minutes())
}
}
// IsRunnable meldet, ob der Auftrag nach Zeitplan ausgeführt wird.
func (job *Job) IsRunnable() bool {
return job.Status == JobStatusActive
}
// SourceReference bildet die Quellkennung für die Sicherungskette.
//
// Ein Auftrag mit mehreren Quellen erzeugt je Quelle eine eigene Kette: Wären
// sie in einer, ließe sich eine einzelne Quelle nicht mehr eigenständig
// wiederherstellen.
func (jobSource *JobSource) SourceReference() string {
return string(jobSource.SourceType) + ":" + jobSource.SourceID
}