syncova-backup/packages/jobs/model.go
Jerrit Fritzsche 8e98cc7510
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:13:22 +02:00

311 lines
12 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"`
}
// BackupMode benennt die Sicherungsart eines Auftrags.
type BackupMode string
const (
// BackupModeIncremental sichert nach dem ersten Lauf inkrementell.
BackupModeIncremental BackupMode = "incremental"
// BackupModeAlwaysFull liest bei jedem Lauf die gesamte Quelle.
//
// Der Platzbedarf steigt dadurch **nicht** nennenswert: Unveraenderte
// Bloecke werden dedupliziert und liegen weiterhin nur einmal im
// Repository. Was steigt, ist die Laufzeit — jeder Lauf liest, hasht,
// komprimiert und verschluesselt alles neu. Wer das verwechselt, plant
// seinen Nachtbetrieb falsch.
BackupModeAlwaysFull BackupMode = "always_full"
)
// 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"`
// BackupMode bestimmt, ob nach dem ersten Lauf inkrementell gesichert wird.
//
// Leer bedeutet `incremental` — das bisherige Verhalten und der richtige
// Standard: Der Gewinn ist Lesezeit, und die ist nach dem ersten Lauf der
// begrenzende Faktor.
BackupMode BackupMode `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// nil bedeutet: keiner. Gerechnet in der Zeitzone des Zeitplans; ohne
// Angabe in UTC — sonst liefe derselbe Auftrag auf zwei Servern an
// verschiedenen Tagen voll.
FullBackupWeekday *time.Weekday `json:"full_backup_weekday,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
}