From 8e98cc75109ae63471bcedff4ddc4de5045e145f Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Tue, 18 Aug 2026 17:13:22 +0200 Subject: [PATCH] Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 `` 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 --- apps/api/internal/httpapi/job_handler.go | 16 ++ apps/api/internal/httpapi/job_mapping.go | 34 +++ .../features/infrastructure/AgentsPage.tsx | 226 ++++++++++++++---- .../infrastructure/infrastructureApi.ts | 15 +- apps/web/src/features/jobs/BackupWizard.tsx | 67 +++++- apps/web/src/features/jobs/JobDetailPage.tsx | 10 + apps/web/src/features/jobs/JobsPage.tsx | 10 +- apps/web/src/features/jobs/jobsApi.ts | 31 +++ apps/web/src/features/jobs/wizardModel.ts | Bin 15953 -> 17168 bytes migrations/000014_backup_mode.down.sql | 8 + migrations/000014_backup_mode.up.sql | 48 ++++ migrations/checksums.txt | 2 + packages/backupexecutor/backupmode_test.go | 91 +++++++ packages/backupexecutor/executor.go | 47 +++- packages/jobs/model.go | 28 +++ packages/jobs/store_postgres.go | 74 +++++- scripts/update.sh | 43 ++++ 17 files changed, 699 insertions(+), 51 deletions(-) create mode 100644 migrations/000014_backup_mode.down.sql create mode 100644 migrations/000014_backup_mode.up.sql create mode 100644 packages/backupexecutor/backupmode_test.go diff --git a/apps/api/internal/httpapi/job_handler.go b/apps/api/internal/httpapi/job_handler.go index 890361b..ed3fafc 100644 --- a/apps/api/internal/httpapi/job_handler.go +++ b/apps/api/internal/httpapi/job_handler.go @@ -89,6 +89,14 @@ type jobRequest struct { RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` + // BackupMode ist "incremental" (Standard) oder "always_full". + BackupMode string `json:"backup_mode,omitempty"` + // FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung. + // + // 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger + // Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt" + // unterscheiden. + FullBackupWeekday *int `json:"full_backup_weekday,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency,omitempty"` } @@ -127,6 +135,14 @@ type jobResponse struct { RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` + // BackupMode ist "incremental" (Standard) oder "always_full". + BackupMode string `json:"backup_mode,omitempty"` + // FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung. + // + // 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger + // Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt" + // unterscheiden. + FullBackupWeekday *int `json:"full_backup_weekday,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency"` // NextRunAt ist der nächste Zeitpunkt in UTC. diff --git a/apps/api/internal/httpapi/job_mapping.go b/apps/api/internal/httpapi/job_mapping.go index bee8086..826450b 100644 --- a/apps/api/internal/httpapi/job_mapping.go +++ b/apps/api/internal/httpapi/job_mapping.go @@ -57,6 +57,8 @@ func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job, RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second, RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second, BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond, + BackupMode: jobs.BackupMode(jobPayload.BackupMode), + FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday), MaximumConcurrency: maximumConcurrency, RetryPolicy: scheduler.DefaultRetryPolicy(), CreatedBy: &creatorID, @@ -152,6 +154,8 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse { RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()), RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()), BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond, + BackupMode: string(sourceJob.BackupMode), + FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday), MaximumConcurrency: sourceJob.MaximumConcurrency, NextRunAt: sourceJob.NextRunAt, LastRunAt: sourceJob.LastRunAt, @@ -190,3 +194,33 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest { return scheduleData } + +// weekdayFromPayload uebersetzt einen Wochentag aus der Anfrage. +// +// Ein Wert ausserhalb von 0..6 wird verworfen statt gekappt: Ein +// stillschweigend auf Sonntag gesetzter Montag waere ein Fehler, den niemand +// bemerkt — die Vollsicherung liefe dann am falschen Tag. +func weekdayFromPayload(requestedWeekday *int) *time.Weekday { + if requestedWeekday == nil { + return nil + } + + if *requestedWeekday < 0 || *requestedWeekday > 6 { + return nil + } + + convertedWeekday := time.Weekday(*requestedWeekday) + + return &convertedWeekday +} + +// weekdayToPayload uebersetzt einen Wochentag fuer die Antwort. +func weekdayToPayload(storedWeekday *time.Weekday) *int { + if storedWeekday == nil { + return nil + } + + convertedValue := int(*storedWeekday) + + return &convertedValue +} diff --git a/apps/web/src/features/infrastructure/AgentsPage.tsx b/apps/web/src/features/infrastructure/AgentsPage.tsx index ebb3f20..ad1b52f 100644 --- a/apps/web/src/features/infrastructure/AgentsPage.tsx +++ b/apps/web/src/features/infrastructure/AgentsPage.tsx @@ -10,7 +10,7 @@ * könnte er sich als ein anderes System ausgeben. */ -import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react'; +import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } from 'lucide-react'; import { useCallback, useState } from 'react'; import { useApiResource } from '@/api/useApiResource'; import { describeApiError, useMutation } from '@/api/useMutation'; @@ -32,7 +32,7 @@ import { useToast, type TableColumn, } from '@/components/ui'; -import { formatRelativeTime } from '@/lib/utils'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils'; import { createEnrollmentToken, listAgents, @@ -300,6 +300,17 @@ export function AgentsPage({ * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine * Schaltfläche, und sie sagt, was sie bewirkt. */ +/** + * Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme. + * + * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine + * Schaltfläche, und sie sagt, was sie bewirkt. + * + * Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token + * eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass + * jemand `` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die + * nichts mit seinem Problem zu tun hat. + */ function IssuedTokenDialog({ token, onClose, @@ -308,64 +319,199 @@ function IssuedTokenDialog({ readonly onClose: () => void; }) { const toast = useToast(); - const [hasCopied, setHasCopied] = useState(false); + const [copiedKey, setCopiedKey] = useState(null); + const [platform, setPlatform] = useState<'linux' | 'windows'>('linux'); - const copyToken = async () => { + // Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der + // der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx. + const serverAddress = window.location.origin; + + const copyText = async (textToCopy: string, entryKey: string) => { try { - await navigator.clipboard.writeText(token.enrollment_token); - setHasCopied(true); - window.setTimeout(() => setHasCopied(false), 2000); + await navigator.clipboard.writeText(textToCopy); + setCopiedKey(entryKey); + window.setTimeout(() => setCopiedKey(null), 2000); } catch { - toast.showInfo( - 'Kopieren nicht möglich', - 'Markieren Sie das Token und kopieren Sie es von Hand.', - ); + toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.'); } }; + const linuxSteps = [ + { + key: 'linux-paket', + title: '1. Paket auspacken', + command: `sudo mkdir -p /opt/syncova-agent +sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp +sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`, + }, + { + key: 'linux-konto', + title: '2. Dienstkonto und Verzeichnisse', + command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent +sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`, + }, + { + key: 'linux-enroll', + title: '3. Aufnehmen', + command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\ + --server ${serverAddress} \\ + --token ${token.token} \\ + --state /var/lib/syncova-agent/state.json`, + }, + { + key: 'linux-dienst', + title: '4. Als Dienst einrichten', + command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF' +[Unit] +Description=Syncova Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=syncova-agent +ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json +Restart=on-failure +RestartSec=10 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/syncova-agent +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +EOF +sudo systemctl daemon-reload +sudo systemctl enable --now syncova-agent`, + }, + ]; + + const windowsSteps = [ + { + key: 'win-paket', + title: '1. Paket auspacken', + command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent" +Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova +Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`, + }, + { + key: 'win-enroll', + title: '2. Aufnehmen', + command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova" +& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \` + --server ${serverAddress} \` + --token ${token.token} \` + --state "C:\\ProgramData\\Syncova\\state.json"`, + }, + { + key: 'win-dienst', + title: '3. Als Dienst einrichten', + command: `New-Service -Name SyncovaAgent \` + -DisplayName "Syncova Agent" \` + -BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \` + -StartupType Automatic +Start-Service SyncovaAgent`, + }, + ]; + + const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps; + return ( undefined}> - + - Es wird nur als Hash gespeichert und lässt sich nicht wieder - abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher - hinterlegt haben — sonst müssen Sie ein neues erzeugen. + Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst, + wenn der Agent aufgenommen ist. -
- - {token.enrollment_token} - +
+
+ + {token.token} + + +
+ {token.expires_at ? ( +

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

+ ) : null}
- - -
-

- Auf dem zu sichernden System -

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

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

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

{step.title}

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

Ergibt: {describeDraftSchedule(jobDraft)}

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

Sicherungsart

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

{job.name}

-

{job.schedule_description}

+

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

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