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>
283 lines
10 KiB
Go
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
|
|
}
|