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>
193 lines
7.3 KiB
Go
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
|
|
}
|