// 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 }