syncova-backup/apps/api/internal/httpapi/job_mapping.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

193 lines
7.3 KiB
Go

package httpapi
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/scheduler"
)
// buildJobFromRequest wandelt einen Anfragerumpf in einen Auftrag.
//
// Die Prüfung der Werte bleibt der Fachschicht überlassen; hier wird nur
// übersetzt. Zwei Prüfstellen mit eigenen Regeln liefen unweigerlich
// auseinander.
func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job, *APIError) {
parsedSchedule, scheduleError := buildScheduleFromRequest(jobPayload.Schedule)
if scheduleError != nil {
return nil, scheduleError
}
jobPriority := scheduler.Priority(jobPayload.Priority)
if jobPayload.Priority == "" {
jobPriority = scheduler.PriorityNormal
}
maximumConcurrency := jobPayload.MaximumConcurrency
if maximumConcurrency < 1 {
maximumConcurrency = 1
}
jobSources := make([]jobs.JobSource, 0, len(jobPayload.Sources))
for _, requestedSource := range jobPayload.Sources {
jobSources = append(jobSources, jobs.JobSource{
SourceType: jobs.SourceType(requestedSource.Type),
SourceID: requestedSource.ID,
SourceName: requestedSource.Name,
AgentID: requestedSource.AgentID,
IncludePatterns: requestedSource.IncludePatterns,
ExcludePatterns: requestedSource.ExcludePatterns,
})
}
builtJob := &jobs.Job{
Name: strings.TrimSpace(jobPayload.Name),
Description: strings.TrimSpace(jobPayload.Description),
Status: jobs.JobStatusActive,
Priority: jobPriority,
Schedule: parsedSchedule,
Sources: jobSources,
RepositoryID: jobPayload.RepositoryID,
RetentionPolicyID: jobPayload.RetentionPolicyID,
DependsOnJobIDs: jobPayload.DependsOnJobIDs,
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
MaximumConcurrency: maximumConcurrency,
RetryPolicy: scheduler.DefaultRetryPolicy(),
CreatedBy: &creatorID,
}
// Die fachliche Prüfung läuft hier, damit ein fehlerhafter Auftrag mit 422
// beantwortet wird statt mit einem Datenbankfehler als 500.
if validationError := builtJob.Validate(); validationError != nil {
return nil, NewValidationError(validationError.Error())
}
return builtJob, nil
}
// buildScheduleFromRequest wandelt eine Zeitplanangabe.
func buildScheduleFromRequest(scheduleData scheduleRequest) (scheduler.Schedule, *APIError) {
builtSchedule := scheduler.Schedule{
ScheduleType: scheduler.ScheduleType(scheduleData.Type),
CronExpression: scheduleData.CronExpression,
TimeZone: scheduleData.TimeZone,
MonthDays: scheduleData.MonthDays,
Interval: time.Duration(scheduleData.IntervalSeconds) * time.Second,
}
if scheduleData.Time != "" {
parsedHour, parsedMinute, timeError := parseClockTime(scheduleData.Time)
if timeError != nil {
return scheduler.Schedule{}, NewValidationError(timeError.Error())
}
builtSchedule.Hour = parsedHour
builtSchedule.Minute = parsedMinute
}
for _, weekdayNumber := range scheduleData.Weekdays {
if weekdayNumber < 0 || weekdayNumber > 6 {
return scheduler.Schedule{}, NewValidationError(
fmt.Sprintf("Der Wochentag %d ist unbekannt; zulässig sind 0 (Sonntag) bis 6 (Samstag).", weekdayNumber))
}
builtSchedule.Weekdays = append(builtSchedule.Weekdays, time.Weekday(weekdayNumber))
}
return builtSchedule, nil
}
// parseClockTime liest eine Uhrzeit im Format "HH:MM".
func parseClockTime(clockText string) (int, int, error) {
hourText, minuteText, hasSeparator := strings.Cut(strings.TrimSpace(clockText), ":")
if !hasSeparator {
return 0, 0, fmt.Errorf("Die Uhrzeit %q muss im Format HH:MM angegeben werden.", clockText)
}
parsedHour, hourError := strconv.Atoi(hourText)
if hourError != nil || parsedHour < 0 || parsedHour > 23 {
return 0, 0, fmt.Errorf("Die Stunde in %q liegt nicht zwischen 00 und 23.", clockText)
}
parsedMinute, minuteError := strconv.Atoi(minuteText)
if minuteError != nil || parsedMinute < 0 || parsedMinute > 59 {
return 0, 0, fmt.Errorf("Die Minute in %q liegt nicht zwischen 00 und 59.", clockText)
}
return parsedHour, parsedMinute, nil
}
// buildJobResponse wandelt einen Auftrag in seine Darstellung nach außen.
func buildJobResponse(sourceJob *jobs.Job) jobResponse {
sourceResponses := make([]sourceRequest, 0, len(sourceJob.Sources))
for _, jobSource := range sourceJob.Sources {
sourceResponses = append(sourceResponses, sourceRequest{
Type: string(jobSource.SourceType),
ID: jobSource.SourceID,
Name: jobSource.SourceName,
AgentID: jobSource.AgentID,
IncludePatterns: jobSource.IncludePatterns,
ExcludePatterns: jobSource.ExcludePatterns,
})
}
return jobResponse{
ID: sourceJob.ID,
Name: sourceJob.Name,
Description: sourceJob.Description,
Status: string(sourceJob.Status),
Priority: string(sourceJob.Priority),
Schedule: buildScheduleResponse(sourceJob.Schedule),
ScheduleDescription: sourceJob.Schedule.Describe(),
Sources: sourceResponses,
RepositoryID: sourceJob.RepositoryID,
RetentionPolicyID: sourceJob.RetentionPolicyID,
DependsOnJobIDs: sourceJob.DependsOnJobIDs,
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
MaximumConcurrency: sourceJob.MaximumConcurrency,
NextRunAt: sourceJob.NextRunAt,
LastRunAt: sourceJob.LastRunAt,
LastOutcome: string(sourceJob.LastOutcome),
PausedAt: sourceJob.PausedAt,
CreatedAt: sourceJob.CreatedAt,
UpdatedAt: sourceJob.UpdatedAt,
}
}
// buildScheduleResponse wandelt einen Zeitplan in seine Darstellung.
func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
scheduleData := scheduleRequest{
Type: string(sourceSchedule.ScheduleType),
CronExpression: sourceSchedule.CronExpression,
TimeZone: sourceSchedule.TimeZone,
MonthDays: sourceSchedule.MonthDays,
}
if sourceSchedule.Interval > 0 {
scheduleData.IntervalSeconds = int64(sourceSchedule.Interval.Seconds())
}
// Die Uhrzeit erscheint nur bei den Zeitplänen, die eine haben. Bei einem
// Intervallplan wäre "00:00" eine Angabe, die es nicht gibt.
switch sourceSchedule.ScheduleType {
case scheduler.ScheduleTypeDaily, scheduler.ScheduleTypeWeekly, scheduler.ScheduleTypeMonthly:
scheduleData.Time = fmt.Sprintf("%02d:%02d", sourceSchedule.Hour, sourceSchedule.Minute)
case scheduler.ScheduleTypeHourly:
scheduleData.Time = fmt.Sprintf(":%02d", sourceSchedule.Minute)
}
for _, scheduleWeekday := range sourceSchedule.Weekdays {
scheduleData.Weekdays = append(scheduleData.Weekdays, int(scheduleWeekday))
}
return scheduleData
}