Compare commits

...

2 Commits

Author SHA1 Message Date
d94debac4d Dokumentation und Aenderungsliste fuer rc9
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 27s
Die Sicherungsart steht in backup-engine.md, weil dort der Unterschied zwischen
voll und inkrementell erklaert ist — mit der Einordnung, die am haeufigsten
verwechselt wird: Der Platzbedarf steigt bei "immer voll" nicht nennenswert,
die Laufzeit schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:06 +02:00
8e98cc7510 Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
**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>
2026-08-18 17:13:22 +02:00
19 changed files with 786 additions and 51 deletions

View File

@ -1,5 +1,52 @@
# Änderungen
## V1 — Release Candidate 9, 18. August 2026
### Sicherungsart je Auftrag
Bisher entschied die Anlage allein: Liegt ein Elternbackup vor, wird
inkrementell gesichert. Jetzt wählbar —
- **inkrementell** (Standard, bisheriges Verhalten),
- **immer voll**, oder
- inkrementell **mit festem Volltag**, etwa „immer freitags".
Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekäme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt.
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert** — unveränderte
Blöcke werden dedupliziert. Was steigt, ist die Laufzeit. Das steht so in der
Maske, weil es die häufigste Verwechslung ist.
Migration 000014 mit drei CHECKs. Der dritte lehnt „immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll.
### Behoben
- **Das Aufnahme-Token eines Agenten zeigte „undefined".** Das Feld heißt
`token`, nicht `enrollment_token` — Letzteres ist der Name im *Anfrage*körper
der Registrierung. Der dritte Formfehler dieser Art; alle konsumierten
Endpunkte sind jetzt gegen den laufenden Dienst abgeglichen statt aus der
Struktur abgeleitet.
### Aufnahmedialog mit Anleitung
Vollständige Anleitung für **Linux und Windows**, umschaltbar, mit fertig
ausgefüllten Befehlen — Serveradresse und Token eingesetzt, jeder Schritt
einzeln kopierbar. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
jemand `<token>` wörtlich einsetzt.
Dazu die beiden Stolperstellen: `--state` erwartet eine **Datei**, und der
Agent braucht Schreibzugriff auf das Repository.
### update.sh rüstet die Wiederherstellungsfläche nach
Sie kam mit rc8 dazu; eine Anlage aus einer älteren Fassung hat sie nicht. Ohne
sie scheitert jede Wiederherstellung an `ProtectSystem=strict`. `update.sh`
legt sie jetzt an und trägt sie in `ReadWritePaths` ein — ein Schritt, den man
von Hand ausführen muss, wird übersehen und fällt erst im Ernstfall auf.
## V1 — Release Candidate 8, 18. August 2026
Wiederherstellung von Dateien und Ordnern mit Auswahl statt Textfeld — und die

View File

@ -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.

View File

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

View File

@ -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 `<token>` 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<string | null>(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 (
<DialogRoot open onOpenChange={() => undefined}>
<DialogContent size="lg">
<DialogContent size="xl">
<DialogHeader
title="Aufnahme-Token"
description={`Für den Agenten „${token.agent_name}“`}
title="Agent aufnehmen"
description={`Für „${token.agent_name}"`}
/>
<DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
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.
</Callout>
<div className="rounded-md border border-line bg-sunken p-3">
<code className="block break-all font-mono text-sm text-fg">
{token.enrollment_token}
</code>
<div className="border border-line bg-sunken p-3">
<div className="flex items-start justify-between gap-3">
<code className="min-w-0 break-all font-mono text-sm text-fg">
{token.token}
</code>
<Button
size="sm"
className="shrink-0"
onClick={() => void copyText(token.token, 'token')}
>
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
{token.expires_at ? (
<p className="mt-2 text-xs text-fg-muted">
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
</p>
) : null}
</div>
<Button onClick={() => void copyToken()}>
{hasCopied ? <KeyRound /> : <Copy />}
{hasCopied ? 'Kopiert' : 'Token kopieren'}
</Button>
<div className="border-t border-line pt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Auf dem zu sichernden System
</p>
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
syncova-agent enroll --server https://&lt;dieser-server&gt; --token
&lt;token&gt; --state /var/lib/syncova-agent/state.json
</code>
<p className="mt-1.5 text-xs text-fg-muted">
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
Mit einem Verzeichnis hält sich der Agent für registriert und
läuft ohne Token.
</p>
{/* Systemwahl */}
<div className="flex gap-2 border-b border-line pb-3">
<Button
size="sm"
variant={platform === 'linux' ? 'primary' : 'ghost'}
onClick={() => setPlatform('linux')}
>
<Terminal />
Linux
</Button>
<Button
size="sm"
variant={platform === 'windows' ? 'primary' : 'ghost'}
onClick={() => setPlatform('windows')}
>
<MonitorCog />
Windows (PowerShell)
</Button>
</div>
{platform === 'windows' ? (
<Callout tone="warning">
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
<strong>nie auf echter Hardware gefahren</strong>. Der
Kommandozeilenweg ist nachgewiesen.
</Callout>
) : null}
{activeSteps.map((step) => (
<div key={step.key}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-fg">{step.title}</p>
<Button
size="sm"
variant="ghost"
onClick={() => void copyText(step.command, step.key)}
>
{copiedKey === step.key ? <KeyRound /> : <Copy />}
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
{step.command}
</pre>
</div>
))}
<Callout tone="info" title="Zwei Stolperstellen">
<ul className="mt-1 space-y-1 text-xs">
<li>
<code className="font-mono">--state</code> erwartet eine{' '}
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
hält sich der Agent für registriert und läuft ohne Token.
</li>
<li>
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
getrennten Maschinen eine Freigabe.
</li>
</ul>
</Callout>
</DialogBody>
<DialogFooter>
<Button variant="primary" onClick={onClose}>
Ich habe das Token hinterlegt
Der Agent ist aufgenommen
</Button>
</DialogFooter>
</DialogContent>

View File

@ -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;
}

View File

@ -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
)}
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p>
{/* --- Sicherungsart ---
Sie gehört zum Zeitplan, nicht zur Quelle: Beides zusammen
beantwortet die Frage „was passiert wann". */}
<div className="mt-4 space-y-3 border-t border-line pt-4">
<p className="text-sm font-medium text-fg">Sicherungsart</p>
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
<input
type="radio"
name="backup-mode"
className="mt-0.5"
checked={jobDraft.backupMode === 'incremental'}
onChange={() => updateDraft({ backupMode: 'incremental' })}
/>
<span className="min-w-0">
<span className="block font-medium text-fg">Inkrementell</span>
<span className="block text-xs text-fg-muted">
Erster Lauf voll, danach nur Geändertes. Empfohlen.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
<input
type="radio"
name="backup-mode"
className="mt-0.5"
checked={jobDraft.backupMode === 'always_full'}
onChange={() => updateDraft({ backupMode: 'always_full', fullBackupWeekday: -1 })}
/>
<span className="min-w-0">
<span className="block font-medium text-fg">Immer voll</span>
<span className="block text-xs text-fg-muted">
Jeder Lauf liest die gesamte Quelle. Kostet Laufzeit, kaum Platz
— unveränderte Blöcke werden dedupliziert.
</span>
</span>
</label>
{jobDraft.backupMode === 'incremental' ? (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">
Zusätzlich voll sichern an
</span>
<select
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg"
value={String(jobDraft.fullBackupWeekday)}
onChange={(changeEvent) =>
updateDraft({ fullBackupWeekday: Number(changeEvent.target.value) })
}
>
<option value="-1">Keinem festen Tag</option>
{WEEKDAY_LABELS.map((weekdayLabel, weekdayIndex) => (
<option key={weekdayLabel} value={String(weekdayIndex)}>
{weekdayLabel}s
</option>
))}
</select>
<span className="text-xs text-fg-muted">
Gerechnet in der Zeitzone des Zeitplans.
</span>
</label>
) : null}
</div>
</div>
);
}

View File

@ -45,6 +45,7 @@ import {
pauseJob,
resumeJob,
runJob,
WEEKDAY_LABELS,
type BackupJobRun,
} from './jobsApi';
@ -276,6 +277,15 @@ export function JobDetailPage({
: 'Ohne Grenze'}
</DetailItem>
<DetailItem label="Quellen">{job.sources.length}</DetailItem>
<DetailItem label="Sicherungsart">
{job.backup_mode === 'always_full' ? (
'Immer voll'
) : job.full_backup_weekday !== undefined ? (
<>Inkrementell, {WEEKDAY_LABELS[job.full_backup_weekday]}s voll</>
) : (
'Inkrementell'
)}
</DetailItem>
</dl>
<div className="mt-5 border-t border-line pt-4">

View File

@ -47,6 +47,7 @@ import {
pauseJob,
resumeJob,
runJob,
WEEKDAY_LABELS,
type BackupJob,
} from './jobsApi';
@ -133,7 +134,14 @@ export function JobsPage({
render: (job) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{job.name}</p>
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
<p className="truncate text-xs text-fg-muted">
{job.schedule_description}
{job.backup_mode === 'always_full'
? ' · immer voll'
: job.full_backup_weekday !== undefined
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
: ''}
</p>
</div>
),
},

View File

@ -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. */

View File

@ -89,6 +89,46 @@ Wiederherstellung: 355 MiB/s, Ergebnis bitgenau identisch zur Quelle.
Ein früherer Messlauf zeigte 1021-fache Kompression. Diese Zahl war wertlos: die Testdaten waren periodisch erzeugt und damit unrealistisch gut komprimierbar. Belastbar sind nur Messungen mit inkompressiblen Daten.
## Sicherungsart je Auftrag
Der Standard ist **inkrementell**: Liegt ein Elternbackup vor, wird nur
Geändertes gelesen. Zwei Abweichungen lassen sich je Auftrag einstellen.
| Einstellung | Wirkung |
| --- | --- |
| `incremental` | Erster Lauf voll, danach inkrementell. Standard |
| `always_full` | Jeder Lauf liest die gesamte Quelle |
| `full_backup_weekday` | Zusätzlich an einem festen Wochentag voll |
**Der Platzbedarf steigt bei „immer voll" nicht nennenswert.** Unveränderte
Blöcke werden dedupliziert und liegen weiterhin nur einmal im Repository. Was
steigt, ist die **Laufzeit**: Jeder Lauf liest, hasht, komprimiert und
verschlüsselt alles neu. Gemessen (Phase 6): 190,7 MiB in 1 815 ms gegen
4,8 MiB in 133 ms bei einer geänderten von 41 Dateien.
Wer das verwechselt, hält „immer voll" für teuer im Speicher und plant seinen
Nachtbetrieb falsch.
Wann eine Abweichung sinnvoll ist:
- **Immer voll**, wenn das Repository außer Haus geht oder auf einen
Datenträger geschrieben wird, der einzeln weggetragen wird.
- **Wöchentlich voll** als üblicher Kompromiss: unter der Woche schnell, an
einem festen Tag einmal vollständig.
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 bekäme seine Vollsicherung am Donnerstagabend.
Ein Widerspruch — „immer voll" **und** ein Wochentag — wird von der Datenbank
abgelehnt, nicht stillschweigend aufgelöst.
Zur Einordnung: Syncova-Manifeste sind vollständig (Phase 6). Eine
Zusatzsicherung trägt die Blockverweise des Elternbackups mit, ein Restore liest
genau **ein** Manifest, und das Löschen eines alten Backups kann ein neueres
nicht beschädigen. Der Unterschied liegt also in der Laufzeit, nicht in der
Wiederherstellbarkeit.
## Grenzen des aktuellen Stands
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:

View File

@ -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;

View File

@ -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';

View File

@ -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

View File

@ -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")
}
}

View File

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

View File

@ -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.

View File

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

View File

@ -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
# ---------------------------------------------------------------------------