diff --git a/apps/api/internal/httpapi/job_handler.go b/apps/api/internal/httpapi/job_handler.go index 890361b..ed3fafc 100644 --- a/apps/api/internal/httpapi/job_handler.go +++ b/apps/api/internal/httpapi/job_handler.go @@ -89,6 +89,14 @@ type jobRequest struct { RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` + // BackupMode ist "incremental" (Standard) oder "always_full". + BackupMode string `json:"backup_mode,omitempty"` + // FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung. + // + // 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger + // Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt" + // unterscheiden. + FullBackupWeekday *int `json:"full_backup_weekday,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency,omitempty"` } @@ -127,6 +135,14 @@ type jobResponse struct { RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` + // BackupMode ist "incremental" (Standard) oder "always_full". + BackupMode string `json:"backup_mode,omitempty"` + // FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung. + // + // 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger + // Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt" + // unterscheiden. + FullBackupWeekday *int `json:"full_backup_weekday,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency"` // NextRunAt ist der nächste Zeitpunkt in UTC. diff --git a/apps/api/internal/httpapi/job_mapping.go b/apps/api/internal/httpapi/job_mapping.go index bee8086..826450b 100644 --- a/apps/api/internal/httpapi/job_mapping.go +++ b/apps/api/internal/httpapi/job_mapping.go @@ -57,6 +57,8 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job, 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, @@ -152,6 +154,8 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse { 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, @@ -190,3 +194,33 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest { 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 +} diff --git a/apps/web/src/features/infrastructure/AgentsPage.tsx b/apps/web/src/features/infrastructure/AgentsPage.tsx index ebb3f20..ad1b52f 100644 --- a/apps/web/src/features/infrastructure/AgentsPage.tsx +++ b/apps/web/src/features/infrastructure/AgentsPage.tsx @@ -10,7 +10,7 @@ * könnte er sich als ein anderes System ausgeben. */ -import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react'; +import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react'; import { useCallback, useState } from 'react'; import { useApiResource } from '@/api/useApiResource'; import { describeApiError, useMutation } from '@/api/useMutation'; @@ -32,7 +32,7 @@ import { useToast, type TableColumn, } from '@/components/ui'; -import { formatRelativeTime } from '@/lib/utils'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils'; import { createEnrollmentToken, listAgents, @@ -300,6 +300,17 @@ export function AgentsPage({ * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine * Schaltfläche, und sie sagt, was sie bewirkt. */ +/** + * Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme. + * + * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine + * Schaltfläche, und sie sagt, was sie bewirkt. + * + * Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token + * eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass + * jemand `` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die + * nichts mit seinem Problem zu tun hat. + */ function IssuedTokenDialog({ token, onClose, @@ -308,64 +319,199 @@ function IssuedTokenDialog({ readonly onClose: () => void; }) { const toast = useToast(); - const [hasCopied, setHasCopied] = useState(false); + const [copiedKey, setCopiedKey] = useState(null); + const [platform, setPlatform] = useState<'linux' | 'windows'>('linux'); - const copyToken = async () => { + // Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der + // der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx. + const serverAddress = window.location.origin; + + const copyText = async (textToCopy: string, entryKey: string) => { try { - await navigator.clipboard.writeText(token.enrollment_token); - setHasCopied(true); - window.setTimeout(() => setHasCopied(false), 2000); + await navigator.clipboard.writeText(textToCopy); + setCopiedKey(entryKey); + window.setTimeout(() => setCopiedKey(null), 2000); } catch { - toast.showInfo( - 'Kopieren nicht möglich', - 'Markieren Sie das Token und kopieren Sie es von Hand.', - ); + toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.'); } }; + const linuxSteps = [ + { + key: 'linux-paket', + title: '1. Paket auspacken', + command: `sudo mkdir -p /opt/syncova-agent +sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp +sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`, + }, + { + key: 'linux-konto', + title: '2. Dienstkonto und Verzeichnisse', + command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent +sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`, + }, + { + key: 'linux-enroll', + title: '3. Aufnehmen', + command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\ + --server ${serverAddress} \\ + --token ${token.token} \\ + --state /var/lib/syncova-agent/state.json`, + }, + { + key: 'linux-dienst', + title: '4. Als Dienst einrichten', + command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF' +[Unit] +Description=Syncova Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=syncova-agent +ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json +Restart=on-failure +RestartSec=10 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/syncova-agent +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +EOF +sudo systemctl daemon-reload +sudo systemctl enable --now syncova-agent`, + }, + ]; + + const windowsSteps = [ + { + key: 'win-paket', + title: '1. Paket auspacken', + command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent" +Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova +Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`, + }, + { + key: 'win-enroll', + title: '2. Aufnehmen', + command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova" +& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \` + --server ${serverAddress} \` + --token ${token.token} \` + --state "C:\\ProgramData\\Syncova\\state.json"`, + }, + { + key: 'win-dienst', + title: '3. Als Dienst einrichten', + command: `New-Service -Name SyncovaAgent \` + -DisplayName "Syncova Agent" \` + -BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \` + -StartupType Automatic +Start-Service SyncovaAgent`, + }, + ]; + + const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps; + return ( undefined}> - + - Es wird nur als Hash gespeichert und lässt sich nicht wieder - abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher - hinterlegt haben — sonst müssen Sie ein neues erzeugen. + Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst, + wenn der Agent aufgenommen ist. -
- - {token.enrollment_token} - +
+
+ + {token.token} + + +
+ {token.expires_at ? ( +

+ Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen. +

+ ) : null}
- - -
-

- Auf dem zu sichernden System -

- - syncova-agent enroll --server https://<dieser-server> --token - <token> --state /var/lib/syncova-agent/state.json - -

- `--state` erwartet eine Datei, kein Verzeichnis. - Mit einem Verzeichnis hält sich der Agent für registriert und - läuft ohne Token. -

+ {/* Systemwahl */} +
+ +
+ + {platform === 'windows' ? ( + + Der Windows-Dienst ist gebaut und übersetzt, aber{' '} + nie auf echter Hardware gefahren. Der + Kommandozeilenweg ist nachgewiesen. + + ) : null} + + {activeSteps.map((step) => ( +
+
+

{step.title}

+ +
+
+                {step.command}
+              
+
+ ))} + + +
    +
  • + --state erwartet eine{' '} + Datei, kein Verzeichnis. Mit einem Verzeichnis + hält sich der Agent für registriert und läuft ohne Token. +
  • +
  • + Der Agent braucht Schreibzugriff auf das Repository. + Auf einem gemeinsamen Server ist das der lokale Pfad, bei + getrennten Maschinen eine Freigabe. +
  • +
+
diff --git a/apps/web/src/features/infrastructure/infrastructureApi.ts b/apps/web/src/features/infrastructure/infrastructureApi.ts index 88120b2..54725f1 100644 --- a/apps/web/src/features/infrastructure/infrastructureApi.ts +++ b/apps/web/src/features/infrastructure/infrastructureApi.ts @@ -197,15 +197,22 @@ export interface Agent { enrolled_at?: string; } -/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */ +/** + * Antwort auf die Erzeugung eines Aufnahme-Tokens. + * + * Das Feld heißt **`token`**, nicht `enrollment_token` — Letzteres ist der Name + * im *Anfrage*körper der Registrierung. Die Verwechslung ließ die Oberfläche + * „undefined" anzeigen, und der Betreiber hatte kein Token, obwohl der Server + * eines ausgestellt hatte. + */ export interface EnrollmentToken { + id: string; /** * Das Token im Klartext — **einmalig**. * - * Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die - * Oberfläche muss das sagen, sonst schließt jemand das Fenster. + * Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. */ - enrollment_token: string; + token: string; agent_name: string; expires_at?: string; } diff --git a/apps/web/src/features/jobs/BackupWizard.tsx b/apps/web/src/features/jobs/BackupWizard.tsx index ad721b1..cf5c15c 100644 --- a/apps/web/src/features/jobs/BackupWizard.tsx +++ b/apps/web/src/features/jobs/BackupWizard.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { ApiError } from '../../api/client'; -import { createJob, listRepositories } from './jobsApi'; +import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi'; import type { BackupJob, BackupRepository, SourceType } from './jobsApi'; import { buildCreateRequest, @@ -516,6 +516,71 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea )}

Ergibt: {describeDraftSchedule(jobDraft)}

+ + {/* --- Sicherungsart --- + Sie gehört zum Zeitplan, nicht zur Quelle: Beides zusammen + beantwortet die Frage „was passiert wann". */} +
+

Sicherungsart

+ + + + + + {jobDraft.backupMode === 'incremental' ? ( + + ) : null} +
); } diff --git a/apps/web/src/features/jobs/JobDetailPage.tsx b/apps/web/src/features/jobs/JobDetailPage.tsx index 193994b..fd4ae12 100644 --- a/apps/web/src/features/jobs/JobDetailPage.tsx +++ b/apps/web/src/features/jobs/JobDetailPage.tsx @@ -45,6 +45,7 @@ import { pauseJob, resumeJob, runJob, + WEEKDAY_LABELS, type BackupJobRun, } from './jobsApi'; @@ -276,6 +277,15 @@ export function JobDetailPage({ : 'Ohne Grenze'} {job.sources.length} + + {job.backup_mode === 'always_full' ? ( + 'Immer voll' + ) : job.full_backup_weekday !== undefined ? ( + <>Inkrementell, {WEEKDAY_LABELS[job.full_backup_weekday]}s voll + ) : ( + 'Inkrementell' + )} +
diff --git a/apps/web/src/features/jobs/JobsPage.tsx b/apps/web/src/features/jobs/JobsPage.tsx index 0ada982..cec6422 100644 --- a/apps/web/src/features/jobs/JobsPage.tsx +++ b/apps/web/src/features/jobs/JobsPage.tsx @@ -47,6 +47,7 @@ import { pauseJob, resumeJob, runJob, + WEEKDAY_LABELS, type BackupJob, } from './jobsApi'; @@ -133,7 +134,14 @@ export function JobsPage({ render: (job) => (

{job.name}

-

{job.schedule_description}

+

+ {job.schedule_description} + {job.backup_mode === 'always_full' + ? ' · immer voll' + : job.full_backup_weekday !== undefined + ? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll` + : ''} +

), }, diff --git a/apps/web/src/features/jobs/jobsApi.ts b/apps/web/src/features/jobs/jobsApi.ts index 96b91f0..0b8444f 100644 --- a/apps/web/src/features/jobs/jobsApi.ts +++ b/apps/web/src/features/jobs/jobsApi.ts @@ -82,8 +82,35 @@ export interface CreateJobRequest { rto_seconds?: number; /** Bandbreitengrenze in Byte je Sekunde. */ bandwidth_limit_bps?: number; + /** + * Sicherungsart: `incremental` (Standard) oder `always_full`. + * + * Der Platzbedarf steigt bei `always_full` **nicht** nennenswert — + * unveränderte Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. + */ + backup_mode?: BackupMode; + /** + * Wochentag einer erzwungenen Vollsicherung. + * + * 0 = Sonntag … 6 = Samstag. Gerechnet in der Zeitzone des Zeitplans. + */ + full_backup_weekday?: number; } +/** Sicherungsart eines Auftrags. */ +export type BackupMode = 'incremental' | 'always_full'; + +/** Wochentage in der Zählung der API (0 = Sonntag). */ +export const WEEKDAY_LABELS: readonly string[] = [ + 'Sonntag', + 'Montag', + 'Dienstag', + 'Mittwoch', + 'Donnerstag', + 'Freitag', + 'Samstag', +]; + /** Auftrag in der Antwort der API. */ export interface BackupJob { /** Öffentlicher Bezeichner. */ @@ -112,6 +139,10 @@ export interface BackupJob { last_outcome?: string; /** Bandbreitengrenze in Byte je Sekunde. */ bandwidth_limit_bps?: number; + /** Sicherungsart. */ + backup_mode?: BackupMode; + /** Wochentag einer erzwungenen Vollsicherung. */ + full_backup_weekday?: number; } /** Sicherungsziel in der Antwort der API. */ diff --git a/apps/web/src/features/jobs/wizardModel.ts b/apps/web/src/features/jobs/wizardModel.ts index 6972b3f..fc7f1a3 100644 Binary files a/apps/web/src/features/jobs/wizardModel.ts and b/apps/web/src/features/jobs/wizardModel.ts differ diff --git a/migrations/000014_backup_mode.down.sql b/migrations/000014_backup_mode.down.sql new file mode 100644 index 0000000..ab1c682 --- /dev/null +++ b/migrations/000014_backup_mode.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE backup_jobs + DROP CONSTRAINT IF EXISTS backup_jobs_full_weekday_only_incremental, + DROP CONSTRAINT IF EXISTS backup_jobs_full_backup_weekday_valid, + DROP CONSTRAINT IF EXISTS backup_jobs_backup_mode_valid; + +ALTER TABLE backup_jobs + DROP COLUMN IF EXISTS full_backup_weekday, + DROP COLUMN IF EXISTS backup_mode; diff --git a/migrations/000014_backup_mode.up.sql b/migrations/000014_backup_mode.up.sql new file mode 100644 index 0000000..0cda114 --- /dev/null +++ b/migrations/000014_backup_mode.up.sql @@ -0,0 +1,48 @@ +-- Sicherungsart je Auftrag. +-- +-- Bisher entschied der Executor allein: Liegt ein Elternbackup vor, wird +-- inkrementell gesichert. Das ist der richtige Standard — der Gewinn ist +-- Lesezeit, und die ist nach dem ersten Lauf der begrenzende Faktor. +-- +-- Es gibt aber zwei Gruende, davon abzuweichen, und beide sind betrieblich: +-- +-- 1. **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen +-- Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass +-- ein Wiederherstellungspunkt an einem frueheren haengt. +-- 2. **Wöchentlich voll.** Der uebliche Kompromiss: unter der Woche schnell, +-- am Wochenende einmal vollstaendig. +-- +-- Hinweis zur Einordnung: Syncova-Manifeste sind **vollstaendig** (Phase 6). +-- Eine Zusatzsicherung traegt die Blockverweise des Elternbackups mit, ein +-- Restore liest genau ein Manifest, und das Loeschen eines alten Backups kann +-- ein neueres nicht beschaedigen. Der Unterschied liegt also in der Laufzeit, +-- nicht in der Wiederherstellbarkeit. + +ALTER TABLE backup_jobs + -- 'incremental' = nach dem ersten Lauf inkrementell (Standard, bisheriges + -- Verhalten). 'always_full' = jeder Lauf liest die Quelle vollstaendig. + ADD COLUMN backup_mode TEXT NOT NULL DEFAULT 'incremental', + + -- Wochentag, an dem zusaetzlich eine Vollsicherung erzwungen wird. + -- 0 = Sonntag … 6 = Samstag, NULL = keiner. Gerechnet in der Zeitzone des + -- Zeitplans; ohne Angabe in UTC — sonst liefe derselbe Auftrag auf zwei + -- Servern an verschiedenen Tagen voll. + ADD COLUMN full_backup_weekday SMALLINT; + +ALTER TABLE backup_jobs + ADD CONSTRAINT backup_jobs_backup_mode_valid + CHECK (backup_mode IN ('incremental', 'always_full')), + + ADD CONSTRAINT backup_jobs_full_backup_weekday_valid + CHECK (full_backup_weekday IS NULL OR full_backup_weekday BETWEEN 0 AND 6), + + -- Ein Wochentag bei 'always_full' ist widersprüchlich: Dann ist ohnehin + -- jeder Lauf voll. Die Regel steht in der Datenbank, weil im Code jede + -- Stelle sie einhalten muesste — und eine vergisst es. + ADD CONSTRAINT backup_jobs_full_weekday_only_incremental + CHECK (backup_mode = 'incremental' OR full_backup_weekday IS NULL); + +COMMENT ON COLUMN backup_jobs.backup_mode IS + 'incremental = nach dem ersten Lauf inkrementell; always_full = jeder Lauf vollstaendig'; +COMMENT ON COLUMN backup_jobs.full_backup_weekday IS + 'Wochentag einer erzwungenen Vollsicherung (0=Sonntag), NULL = keiner'; diff --git a/migrations/checksums.txt b/migrations/checksums.txt index 4a7806f..32199f7 100644 --- a/migrations/checksums.txt +++ b/migrations/checksums.txt @@ -37,3 +37,5 @@ e559f3d4443b9057a3f40a685141d92f3e05817da725857d2f0cba231aef3035 000005_restore e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.up.sql +f25274d148996038f5716f315470c4f9371629ad734a03f0ee49ee760ea4c2d5 000014_backup_mode.up.sql +9a48a9de411d6af07461c236967525469c73d820a4d282fab8a61ed6eb56f9f3 000014_backup_mode.down.sql diff --git a/packages/backupexecutor/backupmode_test.go b/packages/backupexecutor/backupmode_test.go new file mode 100644 index 0000000..7339c23 --- /dev/null +++ b/packages/backupexecutor/backupmode_test.go @@ -0,0 +1,91 @@ +package backupexecutor + +import ( + "testing" + "time" + + "github.com/syncova/syncova/packages/jobs" + "github.com/syncova/syncova/packages/scheduler" +) + +// TestAlwaysFullForcesEveryRun haelt die dauerhafte Vollsicherung fest. +func TestAlwaysFullForcesEveryRun(testInstance *testing.T) { + fullJob := &jobs.Job{BackupMode: jobs.BackupModeAlwaysFull} + + // An jedem beliebigen Tag. + for dayOffset := 0; dayOffset < 7; dayOffset++ { + runTime := time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC).AddDate(0, 0, dayOffset) + + if !shouldForceFullBackup(fullJob, runTime) { + testInstance.Errorf("am %s wurde keine Vollsicherung erzwungen", runTime.Weekday()) + } + } +} + +// TestIncrementalIsTheDefault haelt fest, dass ohne Angabe nichts erzwungen wird. +// +// Der Standard ist das bisherige Verhalten: Liegt ein Elternbackup vor, wird +// inkrementell gesichert. Ein Auftrag aus der Zeit vor dieser Einstellung darf +// sich nicht ploetzlich anders verhalten. +func TestIncrementalIsTheDefault(testInstance *testing.T) { + if shouldForceFullBackup(&jobs.Job{}, time.Now()) { + testInstance.Error("ohne Angabe wurde eine Vollsicherung erzwungen") + } + + if shouldForceFullBackup(nil, time.Now()) { + testInstance.Error("ohne Auftrag wurde eine Vollsicherung erzwungen") + } +} + +// TestFullBackupWeekdayUsesScheduleTimeZone ist der eigentliche Punkt. +// +// Der Wochentag muss in der Zeitzone des Zeitplans bestimmt werden. Rechnete +// der Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am +// Donnerstagabend — und wunderte sich, warum sie freitags fehlt. +func TestFullBackupWeekdayUsesScheduleTimeZone(testInstance *testing.T) { + friday := time.Friday + + berlinJob := &jobs.Job{ + FullBackupWeekday: &friday, + Schedule: scheduler.Schedule{TimeZone: "Europe/Berlin"}, + } + + // Donnerstag, 23:30 UTC — in Berlin ist es bereits Freitag, 01:30. + thursdayNightUTC := time.Date(2026, 8, 20, 23, 30, 0, 0, time.UTC) + + if thursdayNightUTC.Weekday() != time.Thursday { + testInstance.Fatalf("Testvoraussetzung falsch: %s", thursdayNightUTC.Weekday()) + } + + if !shouldForceFullBackup(berlinJob, thursdayNightUTC) { + testInstance.Error("die Zeitzone des Zeitplans wird nicht beachtet: " + + "in Berlin ist Freitag, in UTC noch Donnerstag") + } + + // Und umgekehrt: Freitag 23:30 UTC ist in Berlin schon Samstag. + fridayNightUTC := time.Date(2026, 8, 21, 23, 30, 0, 0, time.UTC) + + if shouldForceFullBackup(berlinJob, fridayNightUTC) { + testInstance.Error("es wurde am Samstag (Berliner Zeit) voll gesichert") + } +} + +// TestFullBackupWeekdayFallsBackToUTC haelt den Fall ohne Zeitzone fest. +// +// Ohne Angabe gilt UTC, nicht die Ortszeit des Servers — sonst liefe dieselbe +// Konfiguration auf zwei Servern an verschiedenen Tagen voll. +func TestFullBackupWeekdayFallsBackToUTC(testInstance *testing.T) { + sunday := time.Sunday + + utcJob := &jobs.Job{FullBackupWeekday: &sunday} + + sundayUTC := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) + + if sundayUTC.Weekday() != time.Sunday { + testInstance.Fatalf("Testvoraussetzung falsch: %s", sundayUTC.Weekday()) + } + + if !shouldForceFullBackup(utcJob, sundayUTC) { + testInstance.Error("am Sonntag wurde keine Vollsicherung erzwungen") + } +} diff --git a/packages/backupexecutor/executor.go b/packages/backupexecutor/executor.go index d0d7c81..3142730 100644 --- a/packages/backupexecutor/executor.go +++ b/packages/backupexecutor/executor.go @@ -423,6 +423,13 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back return jobs.ExecutionResult{}, parentError } + // Der Auftrag kann davon abweichen — dauerhaft oder an einem Wochentag. + // + // Der Platzbedarf steigt dadurch nicht nennenswert: Unveraenderte Bloecke + // werden dedupliziert und liegen weiterhin nur einmal im Repository. Was + // steigt, ist die Laufzeit. + forceFullBackup := shouldForceFullBackup(backupRequest.Job, time.Now()) + backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source) startTime := time.Now().UTC() @@ -439,7 +446,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back EncryptionEnabled: executor.options.SecretStore != nil, ChainID: chainIdentifier.String(), CreatedByVersion: executor.options.CreatedByVersion, - Incremental: parentBackupInRepository != "", + Incremental: parentBackupInRepository != "" && !forceFullBackup, ParentBackupID: parentBackupInRepository, BandwidthLimiter: backupRequest.Limiter, } @@ -691,3 +698,41 @@ func hasServerSideSource(executionJob *jobs.Job) bool { return false } + +// shouldForceFullBackup meldet, ob dieser Lauf die Quelle vollstaendig lesen soll. +// +// Zwei Gruende, beide betrieblich: +// +// - **Immer voll.** Wer sein Backup ausser Haus gibt oder auf einen +// Datentraeger schreibt, der einzeln weggetragen wird, will nicht, dass ein +// Wiederherstellungspunkt an einem frueheren haengt. +// - **Woechentlich voll.** Der uebliche Kompromiss: unter der Woche schnell, +// an einem festen Tag einmal vollstaendig. +// +// Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Ohne diese +// Umrechnung liefe derselbe Auftrag auf zwei Servern an verschiedenen Tagen +// voll — und ein Betreiber in Berlin bekaeme seine Vollsicherung am +// Donnerstagabend, weil der Server in UTC rechnet. +func shouldForceFullBackup(executionJob *jobs.Job, currentTime time.Time) bool { + if executionJob == nil { + return false + } + + if executionJob.BackupMode == jobs.BackupModeAlwaysFull { + return true + } + + if executionJob.FullBackupWeekday == nil { + return false + } + + scheduleLocation := time.UTC + + if executionJob.Schedule.TimeZone != "" { + if loadedLocation, loadError := time.LoadLocation(executionJob.Schedule.TimeZone); loadError == nil { + scheduleLocation = loadedLocation + } + } + + return currentTime.In(scheduleLocation).Weekday() == *executionJob.FullBackupWeekday +} diff --git a/packages/jobs/model.go b/packages/jobs/model.go index 30e9078..8739926 100644 --- a/packages/jobs/model.go +++ b/packages/jobs/model.go @@ -82,6 +82,22 @@ type JobSource struct { 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. @@ -110,6 +126,18 @@ type Job struct { 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. diff --git a/packages/jobs/store_postgres.go b/packages/jobs/store_postgres.go index 99f3ec5..dcb51c7 100644 --- a/packages/jobs/store_postgres.go +++ b/packages/jobs/store_postgres.go @@ -72,8 +72,9 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job INSERT INTO backup_jobs ( name, description, status, priority, schedule_type, schedule_config, repository_id, retention_policy_id, rpo_seconds, rto_seconds, - bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by, + backup_mode, full_backup_weekday + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id` var createdJobID uuid.UUID @@ -94,6 +95,8 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job retryJSON, newJob.NextRunAt, newJob.CreatedBy, + string(normalizeBackupMode(newJob.BackupMode)), + nullableWeekday(newJob.FullBackupWeekday), ).Scan(&createdJobID) if scanError != nil { @@ -175,7 +178,7 @@ func (store *PostgresStore) GetJob(readContext context.Context, jobIdentifier uu const selectJobStatement = ` SELECT id, name, COALESCE(description,''), status, priority, schedule_config, repository_id, retention_policy_id, rpo_seconds, rto_seconds, - bandwidth_limit_bps, max_concurrency, retry_policy, + bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday, next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at FROM backup_jobs WHERE id = $1 AND deleted_at IS NULL` @@ -219,6 +222,10 @@ func scanJobRow(scanner rowScanner) (*Job, error) { bandwidthLimit *int64 lastOutcomeText *string descriptionValue string + // Leerer Text und NULL bedeuten beide "incremental" — der Standard, + // der auch fuer Auftraege aus der Zeit vor dieser Spalte gilt. + backupModeText string + fullBackupWeekday *int16 ) scanError := scanner.Scan( @@ -235,6 +242,8 @@ func scanJobRow(scanner rowScanner) (*Job, error) { &bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON, + &backupModeText, + &fullBackupWeekday, &loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, @@ -250,6 +259,8 @@ func scanJobRow(scanner rowScanner) (*Job, error) { loadedJob.Description = descriptionValue loadedJob.Status = JobStatus(statusText) loadedJob.Priority = scheduler.Priority(priorityText) + loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText)) + loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday) if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil { return nil, fmt.Errorf("der zeitplan des auftrags %s ist unlesbar: %w", loadedJob.ID, unmarshalError) @@ -406,7 +417,7 @@ func (store *PostgresStore) ListJobs(listContext context.Context, listFilter Lis const selectStatement = ` SELECT id, name, COALESCE(description,''), status, priority, schedule_config, repository_id, retention_policy_id, rpo_seconds, rto_seconds, - bandwidth_limit_bps, max_concurrency, retry_policy, + bandwidth_limit_bps, max_concurrency, retry_policy, backup_mode, full_backup_weekday, next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at, COUNT(*) OVER () AS total_count FROM backup_jobs @@ -473,12 +484,17 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) { bandwidthLimit *int64 lastOutcomeText *string descriptionValue string + // Leerer Text und NULL bedeuten beide "incremental" — der Standard, + // der auch fuer Auftraege aus der Zeit vor dieser Spalte gilt. + backupModeText string + fullBackupWeekday *int16 ) scanError := jobRows.Scan( &loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON, &loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds, &bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON, + &backupModeText, &fullBackupWeekday, &loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt, &loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt, totalCount, @@ -490,6 +506,8 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) { loadedJob.Description = descriptionValue loadedJob.Status = JobStatus(statusText) loadedJob.Priority = scheduler.Priority(priorityText) + loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText)) + loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday) if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil { return nil, unmarshalError @@ -714,3 +732,51 @@ func defaultToEmptySlice(patternList []string) []string { return patternList } + +// normalizeBackupMode fuellt eine fehlende Angabe mit dem Standard. +// +// Leer bedeutet `incremental`. Das gilt fuer neue Auftraege ohne Angabe **und** +// fuer alle, die vor der Migration 000014 entstanden sind — ihre Spalte traegt +// den Vorgabewert, aber ein leerer Wert aus einem Aufrufer darf nicht zu einem +// ungueltigen Zustand fuehren. +func normalizeBackupMode(requestedMode BackupMode) BackupMode { + if requestedMode == BackupModeAlwaysFull { + return BackupModeAlwaysFull + } + + return BackupModeIncremental +} + +// nullableWeekday uebersetzt einen Wochentag fuer die Datenbank. +// +// nil bedeutet: kein erzwungener Volltag. Ein Wochentag ausserhalb von 0..6 +// wird verworfen statt gekappt — ein stillschweigend auf Sonntag gesetzter +// Montag waere ein Fehler, den niemand bemerkt. +func nullableWeekday(requestedWeekday *time.Weekday) *int16 { + if requestedWeekday == nil { + return nil + } + + if *requestedWeekday < time.Sunday || *requestedWeekday > time.Saturday { + return nil + } + + storedValue := int16(*requestedWeekday) + + return &storedValue +} + +// weekdayFromDatabase uebersetzt einen gespeicherten Wochentag zurueck. +func weekdayFromDatabase(storedValue *int16) *time.Weekday { + if storedValue == nil { + return nil + } + + if *storedValue < 0 || *storedValue > 6 { + return nil + } + + loadedWeekday := time.Weekday(*storedValue) + + return &loadedWeekday +} diff --git a/scripts/update.sh b/scripts/update.sh index cb94b92..2b2ae68 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -30,8 +30,16 @@ readonly installationRoot="/opt/syncova" readonly configurationDirectory="/etc/syncova" readonly configurationFile="${configurationDirectory}/syncova.env" readonly serviceName="syncova-api" +readonly serviceAccount="syncova" readonly backupDirectory="/var/backups/syncova" +# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf. +# +# setup.sh legt sie an; bei einer Anlage aus einer aelteren Fassung fehlt sie. +# Ohne sie scheitert jede Wiederherstellung an ProtectSystem=strict — und der +# Betreiber sucht den Fehler bei den Rechten des Zielverzeichnisses. +readonly restoreDirectory="/srv/syncova-restore" + # previousReleaseRoot nimmt die abgeloeste Fassung auf. # # Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte @@ -419,6 +427,41 @@ fi writeSuccess "Schema aktuell" +# --------------------------------------------------------------------------- +# 6b. Wiederherstellungsflaeche nachruesten +# --------------------------------------------------------------------------- +# +# Sie kam erst mit rc8 dazu. Eine Anlage aus einer aelteren Fassung hat sie +# nicht, und ohne sie schlaegt jede Wiederherstellung fehl. Das Nachruesten +# gehoert hierher und nicht in eine Anleitung: Ein Schritt, den man von Hand +# ausfuehren muss, wird uebersehen — und faellt erst im Ernstfall auf. + +printf '\n' +writeStep "Wiederherstellungsflaeche" + +if [[ -d "${restoreDirectory}" ]]; then + writeDetail "Vorhanden: ${restoreDirectory}" +else + install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}" + writeSuccess "Angelegt: ${restoreDirectory}" +fi + +# Der Eintrag in der Einheit ist der eigentliche Punkt. Die Rechte des +# Verzeichnisses nuetzen nichts, solange ProtectSystem=strict den Pfad nicht +# freigibt. +unitFile="/etc/systemd/system/${serviceName}.service" + +if [[ -f "${unitFile}" ]] && ! grep -q "${restoreDirectory}" "${unitFile}"; then + if grep -q "^ReadWritePaths=" "${unitFile}"; then + sed -i "s|^ReadWritePaths=.*|& ${restoreDirectory}|" "${unitFile}" + writeSuccess "In ReadWritePaths eingetragen" + else + writeWarning "Die Einheit hat kein ReadWritePaths — bitte von Hand pruefen." + fi +else + writeDetail "ReadWritePaths ist bereits eingetragen." +fi + # --------------------------------------------------------------------------- # 7. Starten und pruefen # ---------------------------------------------------------------------------