**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>
227 lines
8.4 KiB
Go
227 lines
8.4 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,
|
|
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
|
|
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
|
|
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,
|
|
BackupMode: string(sourceJob.BackupMode),
|
|
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
|
|
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
|
|
}
|
|
|
|
// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage.
|
|
//
|
|
// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein
|
|
// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand
|
|
// bemerkt — die Vollsicherung liefe dann am falschen Tag.
|
|
func weekdayFromPayload(requestedWeekday *int) *time.Weekday {
|
|
if requestedWeekday == nil {
|
|
return nil
|
|
}
|
|
|
|
if *requestedWeekday < 0 || *requestedWeekday > 6 {
|
|
return nil
|
|
}
|
|
|
|
convertedWeekday := time.Weekday(*requestedWeekday)
|
|
|
|
return &convertedWeekday
|
|
}
|
|
|
|
// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort.
|
|
func weekdayToPayload(storedWeekday *time.Weekday) *int {
|
|
if storedWeekday == nil {
|
|
return nil
|
|
}
|
|
|
|
convertedValue := int(*storedWeekday)
|
|
|
|
return &convertedValue
|
|
}
|