Compare commits
No commits in common. "main" and "v1.0.0-rc8" have entirely different histories.
main
...
v1.0.0-rc8
47
CHANGELOG.md
47
CHANGELOG.md
@ -1,52 +1,5 @@
|
||||
# Ä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
|
||||
|
||||
@ -89,14 +89,6 @@ 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"`
|
||||
}
|
||||
@ -135,14 +127,6 @@ 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.
|
||||
|
||||
@ -57,8 +57,6 @@ 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,
|
||||
@ -154,8 +152,6 @@ 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,
|
||||
@ -194,33 +190,3 @@ 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
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* könnte er sich als ein anderes System ausgeben.
|
||||
*/
|
||||
|
||||
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { Ban, Copy, KeyRound, Plus, RefreshCw } 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 { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import {
|
||||
createEnrollmentToken,
|
||||
listAgents,
|
||||
@ -300,17 +300,6 @@ 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,
|
||||
@ -319,199 +308,64 @@ function IssuedTokenDialog({
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
// 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) => {
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedKey(entryKey);
|
||||
window.setTimeout(() => setCopiedKey(null), 2000);
|
||||
await navigator.clipboard.writeText(token.enrollment_token);
|
||||
setHasCopied(true);
|
||||
window.setTimeout(() => setHasCopied(false), 2000);
|
||||
} catch {
|
||||
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
|
||||
toast.showInfo(
|
||||
'Kopieren nicht möglich',
|
||||
'Markieren Sie das Token und kopieren Sie es 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="xl">
|
||||
<DialogContent size="lg">
|
||||
<DialogHeader
|
||||
title="Agent aufnehmen"
|
||||
description={`Für „${token.agent_name}"`}
|
||||
title="Aufnahme-Token"
|
||||
description={`Für den Agenten „${token.agent_name}“`}
|
||||
/>
|
||||
<DialogBody>
|
||||
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
||||
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
||||
wenn der Agent aufgenommen ist.
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
<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 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>
|
||||
|
||||
{/* 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>
|
||||
<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://<dieser-server> --token
|
||||
<token> --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>
|
||||
</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}>
|
||||
Der Agent ist aufgenommen
|
||||
Ich habe das Token hinterlegt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -197,22 +197,15 @@ export interface Agent {
|
||||
enrolled_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
|
||||
export interface EnrollmentToken {
|
||||
id: string;
|
||||
/**
|
||||
* Das Token im Klartext — **einmalig**.
|
||||
*
|
||||
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen.
|
||||
* 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.
|
||||
*/
|
||||
token: string;
|
||||
enrollment_token: string;
|
||||
agent_name: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { createJob, listRepositories, WEEKDAY_LABELS } from './jobsApi';
|
||||
import { createJob, listRepositories } from './jobsApi';
|
||||
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
||||
import {
|
||||
buildCreateRequest,
|
||||
@ -516,71 +516,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,7 +45,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJobRun,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -277,15 +276,6 @@ 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">
|
||||
|
||||
@ -47,7 +47,6 @@ import {
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
WEEKDAY_LABELS,
|
||||
type BackupJob,
|
||||
} from './jobsApi';
|
||||
|
||||
@ -134,14 +133,7 @@ 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}
|
||||
{job.backup_mode === 'always_full'
|
||||
? ' · immer voll'
|
||||
: job.full_backup_weekday !== undefined
|
||||
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
|
||||
: ''}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@ -82,35 +82,8 @@ 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. */
|
||||
@ -139,10 +112,6 @@ 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. */
|
||||
|
||||
Binary file not shown.
@ -89,46 +89,6 @@ 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:
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
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;
|
||||
@ -1,48 +0,0 @@
|
||||
-- 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';
|
||||
@ -37,5 +37,3 @@ 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
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -423,13 +423,6 @@ 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()
|
||||
@ -446,7 +439,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
||||
EncryptionEnabled: executor.options.SecretStore != nil,
|
||||
ChainID: chainIdentifier.String(),
|
||||
CreatedByVersion: executor.options.CreatedByVersion,
|
||||
Incremental: parentBackupInRepository != "" && !forceFullBackup,
|
||||
Incremental: parentBackupInRepository != "",
|
||||
ParentBackupID: parentBackupInRepository,
|
||||
BandwidthLimiter: backupRequest.Limiter,
|
||||
}
|
||||
@ -698,41 +691,3 @@ 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
|
||||
}
|
||||
|
||||
@ -82,22 +82,6 @@ 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.
|
||||
@ -126,18 +110,6 @@ 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.
|
||||
|
||||
@ -72,9 +72,8 @@ 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,
|
||||
backup_mode, full_backup_weekday
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
||||
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)
|
||||
RETURNING id`
|
||||
|
||||
var createdJobID uuid.UUID
|
||||
@ -95,8 +94,6 @@ 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 {
|
||||
@ -178,7 +175,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, backup_mode, full_backup_weekday,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy,
|
||||
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`
|
||||
@ -222,10 +219,6 @@ 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(
|
||||
@ -242,8 +235,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
||||
&bandwidthLimit,
|
||||
&loadedJob.MaximumConcurrency,
|
||||
&retryJSON,
|
||||
&backupModeText,
|
||||
&fullBackupWeekday,
|
||||
&loadedJob.NextRunAt,
|
||||
&loadedJob.LastRunAt,
|
||||
&lastOutcomeText,
|
||||
@ -259,8 +250,6 @@ 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)
|
||||
@ -417,7 +406,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, backup_mode, full_backup_weekday,
|
||||
bandwidth_limit_bps, max_concurrency, retry_policy,
|
||||
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at,
|
||||
COUNT(*) OVER () AS total_count
|
||||
FROM backup_jobs
|
||||
@ -484,17 +473,12 @@ 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,
|
||||
@ -506,8 +490,6 @@ 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
|
||||
@ -732,51 +714,3 @@ 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
|
||||
}
|
||||
|
||||
@ -30,16 +30,8 @@ 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
|
||||
@ -427,41 +419,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user