Compare commits
No commits in common. "main" and "v1.0.0-rc7" have entirely different histories.
main
...
v1.0.0-rc7
101
CHANGELOG.md
101
CHANGELOG.md
@ -1,106 +1,5 @@
|
|||||||
# Änderungen
|
# Ä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
|
|
||||||
Erklärung, warum vorher gar keine Wiederherstellung funktionierte.
|
|
||||||
|
|
||||||
### Warum keine Wiederherstellung ging
|
|
||||||
|
|
||||||
Nicht die Rechte des Zielverzeichnisses, sondern die Härtung des Dienstes: Er
|
|
||||||
läuft mit `ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
|
|
||||||
Sicherungsordner. Jedes Ziel außerhalb endete mit `mkdir: permission denied` —
|
|
||||||
und zwar **nach** der Vorabprüfung, an der unangenehmsten Stelle. `/tmp`
|
|
||||||
scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein eigenes `/tmp`, und
|
|
||||||
was dort landet, ist von außen unsichtbar.
|
|
||||||
|
|
||||||
`setup.sh` legt jetzt `/srv/syncova-restore` an und trägt es ein;
|
|
||||||
`--wiederherstellungsziel` ergänzt weitere. Der Ort liegt unter `/srv`, weil
|
|
||||||
`/var/lib` auf der Sperrliste des Zielschutzes steht — beide Regeln zugleich zu
|
|
||||||
erfüllen lässt genau `/srv` übrig.
|
|
||||||
|
|
||||||
### Auswahl statt Textfeld
|
|
||||||
|
|
||||||
- **Ordnerbaum für das Ziel.** Er meldet je Verzeichnis, ob der Dienst dort
|
|
||||||
schreiben darf — **gemessen** durch eine Probedatei, nicht aus den Rechtebits
|
|
||||||
abgeleitet. Gesperrte Orte werden gezeigt, nicht versteckt: Sonst bliebe
|
|
||||||
offen, warum ein Pfad fehlt.
|
|
||||||
- **Browser für den Backup-Inhalt.** Ordner **und** einzelne Dateien lassen
|
|
||||||
sich zurückholen. Der Baum entsteht aus den Pfaden, nicht aus
|
|
||||||
Verzeichniseinträgen — ein Manifest kann eine Datei enthalten, deren
|
|
||||||
Elternordner nicht als eigener Eintrag vorliegt.
|
|
||||||
|
|
||||||
Zwei neue Endpunkte, der eingefrorene Vertrag ist entsprechend erweitert:
|
|
||||||
`GET /filesystem/browse` und `GET /backups/{id}/contents`.
|
|
||||||
|
|
||||||
### Behoben
|
|
||||||
|
|
||||||
- **„can't access property toLocaleString, chunks_checked is undefined"** beim
|
|
||||||
Integritätslauf. Die Ergebnisse liegen unter `details`, und die Felder heißen
|
|
||||||
`missing_chunks`/`corrupted_chunks`. Betrifft alle vier Prüfendpunkte — sie
|
|
||||||
tragen dieselbe Hülle. Derselbe Fehler wie zuvor bei `/retention-policies`:
|
|
||||||
die Antwortform angenommen statt geprüft. Alle konsumierten Endpunkte sind
|
|
||||||
jetzt gegen den laufenden Dienst abgeglichen.
|
|
||||||
|
|
||||||
### Geist Mono liegt im Paket
|
|
||||||
|
|
||||||
Drei Schnitte, 128 KB, OFL-Lizenz dabei. Ausgeliefert vom eigenen Ursprung —
|
|
||||||
das verlangt die CSP, und ein Backup-Server, dessen Oberfläche von der
|
|
||||||
Erreichbarkeit eines CDN abhängt, wäre auch ohne CSP falsch.
|
|
||||||
|
|
||||||
### Bekannte Grenze
|
|
||||||
|
|
||||||
**Eine Auswahl je Lauf**, kein Mehrfachhaken. Eine Liste ausgewählter Pfade
|
|
||||||
kennt die API nicht; mehrere Läufe hintereinander ergäben mehrere Ausgänge, und
|
|
||||||
ein „teilweise fehlgeschlagen" ließe sich dann nicht mehr erklären.
|
|
||||||
|
|
||||||
## V1 — Release Candidate 7, 18. August 2026
|
## V1 — Release Candidate 7, 18. August 2026
|
||||||
|
|
||||||
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
|
Behebt einen Absturz, macht die Sitzung brauchbar und stellt das Aussehen um.
|
||||||
|
|||||||
@ -1,422 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/syncova/syncova/packages/platform/logging"
|
|
||||||
"github.com/syncova/syncova/packages/recovery"
|
|
||||||
"github.com/syncova/syncova/packages/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// browseHandler bedient die beiden Blätterendpunkte.
|
|
||||||
//
|
|
||||||
// Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich
|
|
||||||
// mit einem Textfeld nicht lösen lassen:
|
|
||||||
//
|
|
||||||
// 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit
|
|
||||||
// `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für
|
|
||||||
// ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt
|
|
||||||
// „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort
|
|
||||||
// überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das
|
|
||||||
// — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann,
|
|
||||||
// geprüft durch einen tatsächlichen Schreibversuch.
|
|
||||||
//
|
|
||||||
// 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder
|
|
||||||
// eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET
|
|
||||||
// /backups/{id}/contents` liefert das Manifest als Ebene eines Baums.
|
|
||||||
//
|
|
||||||
// Beide lesen nur.
|
|
||||||
type browseHandler struct {
|
|
||||||
restoreHandlerReference *restoreHandler
|
|
||||||
targetGuard *recovery.TargetGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
// filesystemEntry ist ein Eintrag des Dateisystems.
|
|
||||||
type filesystemEntry struct {
|
|
||||||
// Name ist der letzte Pfadbestandteil.
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Path ist der vollständige absolute Pfad.
|
|
||||||
Path string `json:"path"`
|
|
||||||
// IsDirectory unterscheidet Verzeichnis von Datei.
|
|
||||||
IsDirectory bool `json:"is_directory"`
|
|
||||||
// SizeBytes ist die Größe bei Dateien.
|
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
||||||
// IsWritable meldet, ob der Dienst hier anlegen darf.
|
|
||||||
//
|
|
||||||
// Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten:
|
|
||||||
// Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der
|
|
||||||
// Namensraum zulässt.
|
|
||||||
IsWritable bool `json:"is_writable"`
|
|
||||||
// ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt.
|
|
||||||
ForbiddenReason string `json:"forbidden_reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem.
|
|
||||||
type browseFilesystemResponse struct {
|
|
||||||
// Path ist das aufgelistete Verzeichnis.
|
|
||||||
Path string `json:"path"`
|
|
||||||
// ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel.
|
|
||||||
ParentPath string `json:"parent_path,omitempty"`
|
|
||||||
// Entries sind die enthaltenen Verzeichnisse.
|
|
||||||
Entries []filesystemEntry `json:"entries"`
|
|
||||||
// SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf.
|
|
||||||
//
|
|
||||||
// Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren
|
|
||||||
// Startpunkt anbieten kann, statt den Betreiber suchen zu lassen.
|
|
||||||
SuggestedPaths []string `json:"suggested_paths,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups.
|
|
||||||
type backupContentEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Path ist der Pfad im Manifest — genau der Wert, den eine
|
|
||||||
// Wiederherstellung als `path_prefix` erwartet.
|
|
||||||
Path string `json:"path"`
|
|
||||||
IsDirectory bool `json:"is_directory"`
|
|
||||||
EntryType string `json:"entry_type"`
|
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
||||||
ModifiedAt string `json:"modified_at,omitempty"`
|
|
||||||
Mode string `json:"mode,omitempty"`
|
|
||||||
// ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses.
|
|
||||||
ChildCount int `json:"child_count,omitempty"`
|
|
||||||
// TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses.
|
|
||||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseBackupResponse ist die Antwort auf das Blättern im Backup.
|
|
||||||
type browseBackupResponse struct {
|
|
||||||
BackupID string `json:"backup_id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
ParentPath string `json:"parent_path,omitempty"`
|
|
||||||
// Entries sind die Einträge auf dieser Ebene.
|
|
||||||
Entries []backupContentEntry `json:"entries"`
|
|
||||||
// TotalEntryCount ist die Zahl aller Einträge im Backup.
|
|
||||||
TotalEntryCount int `json:"total_entry_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBrowseFilesystem bedient GET /filesystem/browse.
|
|
||||||
func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) {
|
|
||||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
|
||||||
|
|
||||||
requestedPath := strings.TrimSpace(request.URL.Query().Get("path"))
|
|
||||||
if requestedPath == "" {
|
|
||||||
requestedPath = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !filepath.IsAbs(requestedPath) {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Der Pfad muss absolut sein."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über
|
|
||||||
// einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen.
|
|
||||||
resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath))
|
|
||||||
if resolveError != nil {
|
|
||||||
resolvedPath = filepath.Clean(requestedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
directoryEntries, readError := os.ReadDir(resolvedPath)
|
|
||||||
if readError != nil {
|
|
||||||
if errors.Is(readError, os.ErrNotExist) {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewNotFoundError("Das Verzeichnis wurde nicht gefunden."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error()))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries := make([]filesystemEntry, 0, len(directoryEntries))
|
|
||||||
|
|
||||||
for _, directoryEntry := range directoryEntries {
|
|
||||||
// Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein
|
|
||||||
// Verzeichnis, und die Dateien daneben wären nur Rauschen.
|
|
||||||
if !directoryEntry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt
|
|
||||||
// den Pfad — die Liste soll den Normalfall zeigen.
|
|
||||||
if strings.HasPrefix(directoryEntry.Name(), ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
childPath := filepath.Join(resolvedPath, directoryEntry.Name())
|
|
||||||
|
|
||||||
entry := filesystemEntry{
|
|
||||||
Name: directoryEntry.Name(),
|
|
||||||
Path: childPath,
|
|
||||||
IsDirectory: true,
|
|
||||||
IsWritable: directoryIsWritable(childPath),
|
|
||||||
}
|
|
||||||
|
|
||||||
if handler.targetGuard != nil {
|
|
||||||
if guardError := handler.targetGuard.Validate(childPath); guardError != nil {
|
|
||||||
entry.ForbiddenReason = guardError.Error()
|
|
||||||
// Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das
|
|
||||||
// Dateisystem ihn zuließe.
|
|
||||||
entry.IsWritable = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
|
||||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
response := browseFilesystemResponse{
|
|
||||||
Path: resolvedPath,
|
|
||||||
Entries: entries,
|
|
||||||
SuggestedPaths: writableSuggestions(handler.targetGuard),
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolvedPath != "/" {
|
|
||||||
response.ParentPath = filepath.Dir(resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleBrowseBackupContents bedient GET /backups/{id}/contents.
|
|
||||||
func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) {
|
|
||||||
requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger)
|
|
||||||
|
|
||||||
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
|
||||||
if parseError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Die Backup-Kennung ist keine gültige UUID."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup(
|
|
||||||
request.Context(), backupIdentifier)
|
|
||||||
if resolveError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger, resolveError)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer
|
|
||||||
// laufenden Sicherung erstellt werden können.
|
|
||||||
openedRepository, openError := repository.Open(request.Context(), repositoryPath,
|
|
||||||
repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger)
|
|
||||||
if openError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar."))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = openedRepository.Close() }()
|
|
||||||
|
|
||||||
backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository)
|
|
||||||
if manifestError != nil {
|
|
||||||
WriteError(responseWriter, request, requestLogger,
|
|
||||||
NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error()))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/")
|
|
||||||
|
|
||||||
response := browseBackupResponse{
|
|
||||||
BackupID: backupIdentifier.String(),
|
|
||||||
Path: currentPath,
|
|
||||||
Entries: collectLevel(backupManifest.Entries, currentPath),
|
|
||||||
TotalEntryCount: len(backupManifest.Entries),
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentPath != "" {
|
|
||||||
parentPath := path.Dir(currentPath)
|
|
||||||
if parentPath == "." {
|
|
||||||
parentPath = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
response.ParentPath = parentPath
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteSuccess(responseWriter, request, http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene.
|
|
||||||
//
|
|
||||||
// Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden.
|
|
||||||
// Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu
|
|
||||||
// verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis
|
|
||||||
// nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory`
|
|
||||||
// auflistet, verliert dann ganze Teilbäume.
|
|
||||||
func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry {
|
|
||||||
prefix := ""
|
|
||||||
if currentPath != "" {
|
|
||||||
prefix = currentPath + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verzeichnisse werden über ihre Kinder erkannt und dabei gleich
|
|
||||||
// aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an
|
|
||||||
// einem Ordner hängt.
|
|
||||||
directories := make(map[string]*backupContentEntry)
|
|
||||||
files := make([]backupContentEntry, 0, 32)
|
|
||||||
|
|
||||||
for _, manifestEntry := range manifestEntries {
|
|
||||||
entryPath := strings.Trim(manifestEntry.Path, "/")
|
|
||||||
|
|
||||||
if prefix != "" && !strings.HasPrefix(entryPath, prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
remainder := strings.TrimPrefix(entryPath, prefix)
|
|
||||||
if remainder == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
separatorIndex := strings.Index(remainder, "/")
|
|
||||||
|
|
||||||
if separatorIndex < 0 {
|
|
||||||
// Direktes Kind dieser Ebene.
|
|
||||||
if manifestEntry.EntryType == "directory" {
|
|
||||||
directoryPath := prefix + remainder
|
|
||||||
|
|
||||||
if _, exists := directories[remainder]; !exists {
|
|
||||||
directories[remainder] = &backupContentEntry{
|
|
||||||
Name: remainder,
|
|
||||||
Path: directoryPath,
|
|
||||||
IsDirectory: true,
|
|
||||||
EntryType: "directory",
|
|
||||||
Mode: manifestEntry.Mode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, backupContentEntry{
|
|
||||||
Name: remainder,
|
|
||||||
Path: entryPath,
|
|
||||||
IsDirectory: false,
|
|
||||||
EntryType: manifestEntry.EntryType,
|
|
||||||
SizeBytes: manifestEntry.SizeBytes,
|
|
||||||
ModifiedAt: formatOptionalTime(manifestEntry),
|
|
||||||
Mode: manifestEntry.Mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn
|
|
||||||
// kein eigener Eintrag dafür existiert.
|
|
||||||
directoryName := remainder[:separatorIndex]
|
|
||||||
|
|
||||||
existing, exists := directories[directoryName]
|
|
||||||
if !exists {
|
|
||||||
existing = &backupContentEntry{
|
|
||||||
Name: directoryName,
|
|
||||||
Path: prefix + directoryName,
|
|
||||||
IsDirectory: true,
|
|
||||||
EntryType: "directory",
|
|
||||||
}
|
|
||||||
directories[directoryName] = existing
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.ChildCount++
|
|
||||||
existing.TotalBytes += manifestEntry.SizeBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
entries := make([]backupContentEntry, 0, len(directories)+len(files))
|
|
||||||
|
|
||||||
for _, directoryEntry := range directories {
|
|
||||||
entries = append(entries, *directoryEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, files...)
|
|
||||||
|
|
||||||
// Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder
|
|
||||||
// Dateimanager verwendet.
|
|
||||||
sort.Slice(entries, func(firstIndex, secondIndex int) bool {
|
|
||||||
if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory {
|
|
||||||
return entries[firstIndex].IsDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries[firstIndex].Name < entries[secondIndex].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt.
|
|
||||||
func formatOptionalTime(manifestEntry repository.ManifestEntry) string {
|
|
||||||
if manifestEntry.ModifiedAt.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
// directoryIsWritable prüft durch einen echten Schreibversuch.
|
|
||||||
//
|
|
||||||
// Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das
|
|
||||||
// Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und
|
|
||||||
// davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine
|
|
||||||
// Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl
|
|
||||||
// das Verzeichnis dem Anschein nach beschreibbar war.
|
|
||||||
func directoryIsWritable(directoryPath string) bool {
|
|
||||||
probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*")
|
|
||||||
if createError != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
probeName := probeFile.Name()
|
|
||||||
|
|
||||||
_ = probeFile.Close()
|
|
||||||
_ = os.Remove(probeName)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf.
|
|
||||||
//
|
|
||||||
// Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse
|
|
||||||
// eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der
|
|
||||||
// systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht.
|
|
||||||
func writableSuggestions(targetGuard *recovery.TargetGuard) []string {
|
|
||||||
// Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und
|
|
||||||
// in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es
|
|
||||||
// ist im Zielschutz gesperrt.
|
|
||||||
candidates := []string{
|
|
||||||
"/srv/syncova-restore",
|
|
||||||
"/srv",
|
|
||||||
"/var/tmp",
|
|
||||||
"/home",
|
|
||||||
}
|
|
||||||
|
|
||||||
suggestions := make([]string, 0, len(candidates))
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if targetGuard != nil {
|
|
||||||
if guardError := targetGuard.Validate(candidate); guardError != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if directoryIsWritable(candidate) {
|
|
||||||
suggestions = append(suggestions, candidate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return suggestions
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
package httpapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/syncova/syncova/packages/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum
|
|
||||||
// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen.
|
|
||||||
//
|
|
||||||
// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als
|
|
||||||
// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer
|
|
||||||
// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze
|
|
||||||
// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar.
|
|
||||||
func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) {
|
|
||||||
manifestEntries := []repository.ManifestEntry{
|
|
||||||
// Kein Eintrag fuer "berichte" selbst.
|
|
||||||
{Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900},
|
|
||||||
{Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100},
|
|
||||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 6},
|
|
||||||
}
|
|
||||||
|
|
||||||
rootLevel := collectLevel(manifestEntries, "")
|
|
||||||
|
|
||||||
if len(rootLevel) != 2 {
|
|
||||||
testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verzeichnisse stehen vorn.
|
|
||||||
if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" {
|
|
||||||
testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem
|
|
||||||
// Zurueckholen wissen, wie viel an einem Ordner haengt.
|
|
||||||
if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 {
|
|
||||||
testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte",
|
|
||||||
rootLevel[0].ChildCount, rootLevel[0].TotalBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Und eine Ebene tiefer erscheinen die Jahresordner.
|
|
||||||
deeperLevel := collectLevel(manifestEntries, "berichte")
|
|
||||||
|
|
||||||
if len(deeperLevel) != 2 {
|
|
||||||
testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest.
|
|
||||||
//
|
|
||||||
// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine
|
|
||||||
// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat.
|
|
||||||
func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) {
|
|
||||||
manifestEntries := []repository.ManifestEntry{
|
|
||||||
{Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1},
|
|
||||||
{Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
level := collectLevel(manifestEntries, "dokumente")
|
|
||||||
|
|
||||||
if len(level) != 1 || level[0].Name != "a.txt" {
|
|
||||||
testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei.
|
|
||||||
func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) {
|
|
||||||
modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
level := collectLevel([]repository.ManifestEntry{
|
|
||||||
{Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime},
|
|
||||||
}, "")
|
|
||||||
|
|
||||||
if len(level) != 1 {
|
|
||||||
testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der Pfad ist genau der Wert, den eine Wiederherstellung als
|
|
||||||
// `path_prefix` erwartet — eine Abweichung faellt sonst erst beim
|
|
||||||
// Zurueckschreiben auf.
|
|
||||||
if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" {
|
|
||||||
testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
if level[0].ModifiedAt != "2026-08-18T10:00:00Z" {
|
|
||||||
testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -122,5 +122,3 @@ POST /api/v1/users users.write
|
|||||||
POST /api/v1/users/{id}/mfa/disable users.write
|
POST /api/v1/users/{id}/mfa/disable users.write
|
||||||
POST /api/v1/verification verification.write
|
POST /api/v1/verification verification.write
|
||||||
POST /api/v1/verification/{id}/cancel verification.write
|
POST /api/v1/verification/{id}/cancel verification.write
|
||||||
GET /api/v1/filesystem/browse restores.read
|
|
||||||
GET /api/v1/backups/{id}/contents restores.read
|
|
||||||
|
|||||||
@ -89,14 +89,6 @@ type jobRequest struct {
|
|||||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
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 begrenzt gleichzeitige Läufe.
|
||||||
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
||||||
}
|
}
|
||||||
@ -135,14 +127,6 @@ type jobResponse struct {
|
|||||||
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
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 begrenzt gleichzeitige Läufe.
|
||||||
MaximumConcurrency int `json:"max_concurrency"`
|
MaximumConcurrency int `json:"max_concurrency"`
|
||||||
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
// 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,
|
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
||||||
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
||||||
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
||||||
BackupMode: jobs.BackupMode(jobPayload.BackupMode),
|
|
||||||
FullBackupWeekday: weekdayFromPayload(jobPayload.FullBackupWeekday),
|
|
||||||
MaximumConcurrency: maximumConcurrency,
|
MaximumConcurrency: maximumConcurrency,
|
||||||
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
||||||
CreatedBy: &creatorID,
|
CreatedBy: &creatorID,
|
||||||
@ -154,8 +152,6 @@ func buildJobResponse(sourceJob *jobs.Job) jobResponse {
|
|||||||
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
||||||
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
||||||
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
||||||
BackupMode: string(sourceJob.BackupMode),
|
|
||||||
FullBackupWeekday: weekdayToPayload(sourceJob.FullBackupWeekday),
|
|
||||||
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
||||||
NextRunAt: sourceJob.NextRunAt,
|
NextRunAt: sourceJob.NextRunAt,
|
||||||
LastRunAt: sourceJob.LastRunAt,
|
LastRunAt: sourceJob.LastRunAt,
|
||||||
@ -194,33 +190,3 @@ func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
|
|||||||
|
|
||||||
return scheduleData
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -377,19 +377,6 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
|
|||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
||||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
||||||
|
|
||||||
// Blättern in Dateisystem und Backup.
|
|
||||||
//
|
|
||||||
// Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht:
|
|
||||||
// Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im
|
|
||||||
// Backup steckt und wohin sich zurückschreiben lässt.
|
|
||||||
browseHandlerInstance := &browseHandler{
|
|
||||||
restoreHandlerReference: restoreHandlerInstance,
|
|
||||||
targetGuard: restoreHandlerInstance.targetGuard,
|
|
||||||
}
|
|
||||||
|
|
||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem))
|
|
||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,92 +0,0 @@
|
|||||||
Copyright (c) 2023 Vercel, in collaboration with basement.studio
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@ -10,7 +10,7 @@
|
|||||||
* könnte er sich als ein anderes System ausgeben.
|
* 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 { useCallback, useState } from 'react';
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
import { useApiResource } from '@/api/useApiResource';
|
||||||
import { describeApiError, useMutation } from '@/api/useMutation';
|
import { describeApiError, useMutation } from '@/api/useMutation';
|
||||||
@ -32,7 +32,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
|
import { formatRelativeTime } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
createEnrollmentToken,
|
createEnrollmentToken,
|
||||||
listAgents,
|
listAgents,
|
||||||
@ -300,17 +300,6 @@ export function AgentsPage({
|
|||||||
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
|
||||||
* Schaltfläche, und sie sagt, was sie bewirkt.
|
* 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({
|
function IssuedTokenDialog({
|
||||||
token,
|
token,
|
||||||
onClose,
|
onClose,
|
||||||
@ -319,199 +308,64 @@ function IssuedTokenDialog({
|
|||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const [hasCopied, setHasCopied] = useState(false);
|
||||||
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
|
|
||||||
|
|
||||||
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
|
const copyToken = async () => {
|
||||||
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
|
|
||||||
const serverAddress = window.location.origin;
|
|
||||||
|
|
||||||
const copyText = async (textToCopy: string, entryKey: string) => {
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
await navigator.clipboard.writeText(token.enrollment_token);
|
||||||
setCopiedKey(entryKey);
|
setHasCopied(true);
|
||||||
window.setTimeout(() => setCopiedKey(null), 2000);
|
window.setTimeout(() => setHasCopied(false), 2000);
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<DialogRoot open onOpenChange={() => undefined}>
|
<DialogRoot open onOpenChange={() => undefined}>
|
||||||
<DialogContent size="xl">
|
<DialogContent size="lg">
|
||||||
<DialogHeader
|
<DialogHeader
|
||||||
title="Agent aufnehmen"
|
title="Aufnahme-Token"
|
||||||
description={`Für „${token.agent_name}"`}
|
description={`Für den Agenten „${token.agent_name}“`}
|
||||||
/>
|
/>
|
||||||
<DialogBody>
|
<DialogBody>
|
||||||
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
|
||||||
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
|
Es wird nur als Hash gespeichert und lässt sich nicht wieder
|
||||||
wenn der Agent aufgenommen ist.
|
abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher
|
||||||
|
hinterlegt haben — sonst müssen Sie ein neues erzeugen.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<div className="border border-line bg-sunken p-3">
|
<div className="rounded-md border border-line bg-sunken p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<code className="block break-all font-mono text-sm text-fg">
|
||||||
<code className="min-w-0 break-all font-mono text-sm text-fg">
|
{token.enrollment_token}
|
||||||
{token.token}
|
</code>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Systemwahl */}
|
<Button onClick={() => void copyToken()}>
|
||||||
<div className="flex gap-2 border-b border-line pb-3">
|
{hasCopied ? <KeyRound /> : <Copy />}
|
||||||
<Button
|
{hasCopied ? 'Kopiert' : 'Token kopieren'}
|
||||||
size="sm"
|
</Button>
|
||||||
variant={platform === 'linux' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setPlatform('linux')}
|
<div className="border-t border-line pt-4">
|
||||||
>
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||||
<Terminal />
|
Auf dem zu sichernden System
|
||||||
Linux
|
</p>
|
||||||
</Button>
|
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
|
||||||
<Button
|
syncova-agent enroll --server https://<dieser-server> --token
|
||||||
size="sm"
|
<token> --state /var/lib/syncova-agent/state.json
|
||||||
variant={platform === 'windows' ? 'primary' : 'ghost'}
|
</code>
|
||||||
onClick={() => setPlatform('windows')}
|
<p className="mt-1.5 text-xs text-fg-muted">
|
||||||
>
|
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
|
||||||
<MonitorCog />
|
Mit einem Verzeichnis hält sich der Agent für registriert und
|
||||||
Windows (PowerShell)
|
läuft ohne Token.
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
</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>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="primary" onClick={onClose}>
|
<Button variant="primary" onClick={onClose}>
|
||||||
Der Agent ist aufgenommen
|
Ich habe das Token hinterlegt
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -197,22 +197,15 @@ export interface Agent {
|
|||||||
enrolled_at?: string;
|
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 {
|
export interface EnrollmentToken {
|
||||||
id: string;
|
|
||||||
/**
|
/**
|
||||||
* Das Token im Klartext — **einmalig**.
|
* 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;
|
agent_name: string;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ApiError } from '../../api/client';
|
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 type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
||||||
import {
|
import {
|
||||||
buildCreateRequest,
|
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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,6 @@ import {
|
|||||||
pauseJob,
|
pauseJob,
|
||||||
resumeJob,
|
resumeJob,
|
||||||
runJob,
|
runJob,
|
||||||
WEEKDAY_LABELS,
|
|
||||||
type BackupJobRun,
|
type BackupJobRun,
|
||||||
} from './jobsApi';
|
} from './jobsApi';
|
||||||
|
|
||||||
@ -277,15 +276,6 @@ export function JobDetailPage({
|
|||||||
: 'Ohne Grenze'}
|
: 'Ohne Grenze'}
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Quellen">{job.sources.length}</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>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-5 border-t border-line pt-4">
|
<div className="mt-5 border-t border-line pt-4">
|
||||||
|
|||||||
@ -47,7 +47,6 @@ import {
|
|||||||
pauseJob,
|
pauseJob,
|
||||||
resumeJob,
|
resumeJob,
|
||||||
runJob,
|
runJob,
|
||||||
WEEKDAY_LABELS,
|
|
||||||
type BackupJob,
|
type BackupJob,
|
||||||
} from './jobsApi';
|
} from './jobsApi';
|
||||||
|
|
||||||
@ -134,14 +133,7 @@ export function JobsPage({
|
|||||||
render: (job) => (
|
render: (job) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium text-fg">{job.name}</p>
|
<p className="truncate font-medium text-fg">{job.name}</p>
|
||||||
<p className="truncate text-xs text-fg-muted">
|
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
|
||||||
{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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -82,35 +82,8 @@ export interface CreateJobRequest {
|
|||||||
rto_seconds?: number;
|
rto_seconds?: number;
|
||||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
bandwidth_limit_bps?: number;
|
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. */
|
/** Auftrag in der Antwort der API. */
|
||||||
export interface BackupJob {
|
export interface BackupJob {
|
||||||
/** Öffentlicher Bezeichner. */
|
/** Öffentlicher Bezeichner. */
|
||||||
@ -139,10 +112,6 @@ export interface BackupJob {
|
|||||||
last_outcome?: string;
|
last_outcome?: string;
|
||||||
/** Bandbreitengrenze in Byte je Sekunde. */
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
bandwidth_limit_bps?: number;
|
bandwidth_limit_bps?: number;
|
||||||
/** Sicherungsart. */
|
|
||||||
backup_mode?: BackupMode;
|
|
||||||
/** Wochentag einer erzwungenen Vollsicherung. */
|
|
||||||
full_backup_weekday?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sicherungsziel in der Antwort der API. */
|
/** Sicherungsziel in der Antwort der API. */
|
||||||
|
|||||||
Binary file not shown.
@ -36,7 +36,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
adoptRepository,
|
adoptRepository,
|
||||||
checkRepositoryHealth,
|
checkRepositoryHealth,
|
||||||
@ -44,7 +44,7 @@ import {
|
|||||||
measureEnforcement,
|
measureEnforcement,
|
||||||
rebuildCatalog,
|
rebuildCatalog,
|
||||||
startIntegrityScan,
|
startIntegrityScan,
|
||||||
type IntegrityScanDetails,
|
type IntegrityScanResult,
|
||||||
type Repository,
|
type Repository,
|
||||||
} from './repositoriesApi';
|
} from './repositoriesApi';
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ export function RepositoriesPage({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
||||||
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
|
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
|
||||||
const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null);
|
const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null);
|
||||||
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
|
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
|
||||||
|
|
||||||
const [adoptName, setAdoptName] = useState('');
|
const [adoptName, setAdoptName] = useState('');
|
||||||
@ -96,10 +96,10 @@ export function RepositoriesPage({
|
|||||||
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
||||||
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
||||||
// wird bald nicht mehr ernst genommen.
|
// wird bald nicht mehr ernst genommen.
|
||||||
if (result.missing_chunks > 0 || result.corrupted_chunks > 0) {
|
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
'Der Integritätslauf hat Befunde',
|
'Der Integritätslauf hat Befunde',
|
||||||
`${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`,
|
`${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
@ -119,21 +119,11 @@ export function RepositoriesPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const healthMutation = useMutation(checkRepositoryHealth, {
|
const healthMutation = useMutation(checkRepositoryHealth, {
|
||||||
onSuccess: (checkResponse) => {
|
onSuccess: (health) => {
|
||||||
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
|
toast.showSuccess(
|
||||||
// Hülle, die zweite in `details`.
|
'Gesundheitsprüfung abgeschlossen',
|
||||||
if (!checkResponse.reachable) {
|
health.message ?? `Zustand: ${health.status}`,
|
||||||
toast.showError(
|
);
|
||||||
'Das Repository ist nicht erreichbar',
|
|
||||||
checkResponse.error ?? 'Ohne nähere Angabe.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.showSuccess(
|
|
||||||
'Gesundheitsprüfung abgeschlossen',
|
|
||||||
checkResponse.details?.message ?? 'Das Repository ist erreichbar.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
repositoriesResource.reload();
|
repositoriesResource.reload();
|
||||||
},
|
},
|
||||||
onError: (apiError) =>
|
onError: (apiError) =>
|
||||||
@ -153,10 +143,11 @@ export function RepositoriesPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rebuildMutation = useMutation(rebuildCatalog, {
|
const rebuildMutation = useMutation(rebuildCatalog, {
|
||||||
onSuccess: (checkResponse) => {
|
onSuccess: (result) => {
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
'Katalog neu aufgebaut',
|
'Katalog neu aufgebaut',
|
||||||
`${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
result.summary ??
|
||||||
|
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
||||||
);
|
);
|
||||||
repositoriesResource.reload();
|
repositoriesResource.reload();
|
||||||
},
|
},
|
||||||
@ -419,10 +410,10 @@ function IntegrityScanCard({
|
|||||||
result,
|
result,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
readonly result: IntegrityScanDetails;
|
readonly result: IntegrityScanResult;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
|
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-4">
|
<Card className="mt-4">
|
||||||
@ -438,7 +429,7 @@ function IntegrityScanCard({
|
|||||||
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
||||||
{hasFindings
|
{hasFindings
|
||||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
||||||
: result.summary}
|
: 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'}
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@ -446,36 +437,27 @@ function IntegrityScanCard({
|
|||||||
{result.chunks_checked.toLocaleString('de-DE')}
|
{result.chunks_checked.toLocaleString('de-DE')}
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Fehlend">
|
<DetailItem label="Fehlend">
|
||||||
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
|
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
|
||||||
{result.missing_chunks.toLocaleString('de-DE')}
|
{result.chunks_missing.toLocaleString('de-DE')}
|
||||||
</span>
|
</span>
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Beschädigt">
|
<DetailItem label="Beschädigt">
|
||||||
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
|
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
|
||||||
{result.corrupted_chunks.toLocaleString('de-DE')}
|
{result.chunks_corrupted.toLocaleString('de-DE')}
|
||||||
</span>
|
</span>
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Backups">
|
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
|
||||||
{result.backups_healthy} von {result.backups_checked} vollständig
|
|
||||||
</DetailItem>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{!result.verified_chunk_contents ? (
|
{result.findings && result.findings.length > 0 ? (
|
||||||
<Callout tone="warning" className="mt-4">
|
|
||||||
Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist
|
|
||||||
ein halber Nachweis.
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||||
Betroffene Backups
|
Betroffene Objekte
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1.5 space-y-1">
|
<ul className="mt-1.5 space-y-1">
|
||||||
{result.affected_backup_ids.map((backupIdentifier) => (
|
{result.findings.map((finding) => (
|
||||||
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
|
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
|
||||||
{backupIdentifier}
|
{finding}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -32,49 +32,21 @@ export interface Repository {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Ergebnis eines Integritätslaufs. */
|
||||||
* Antworthülle der Repository-Prüfungen.
|
export interface IntegrityScanResult {
|
||||||
*
|
/** Geprüft. */
|
||||||
* **Alle vier Prüfendpunkte antworten in dieser Form** — Integritätslauf,
|
|
||||||
* Gesundheitsprüfung, Verbindungstest und Katalog-Neuaufbau. Das eigentliche
|
|
||||||
* Ergebnis steckt unter `details`, nicht an der Oberfläche der Antwort.
|
|
||||||
*
|
|
||||||
* Das falsch anzunehmen kostete eine Fehlermeldung, die wie ein Defekt der
|
|
||||||
* Anlage aussah: „can't access property toLocaleString, chunks_checked is
|
|
||||||
* undefined — das ist ein Problem der Prüfung, kein Befund am Repository."
|
|
||||||
* Genau das Gegenteil war der Fall; die Prüfung war einwandfrei gelaufen.
|
|
||||||
*/
|
|
||||||
export interface RepositoryCheckResponse<TDetails> {
|
|
||||||
repository_id: string;
|
|
||||||
reachable: boolean;
|
|
||||||
repository_uuid?: string;
|
|
||||||
error?: string;
|
|
||||||
details?: TDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ergebnis eines Integritätslaufs.
|
|
||||||
*
|
|
||||||
* Die Feldnamen stammen aus `repository.ScanReport` und heißen anders herum als
|
|
||||||
* erwartet: `missing_chunks`, nicht `chunks_missing`.
|
|
||||||
*/
|
|
||||||
export interface IntegrityScanDetails {
|
|
||||||
/** Geprüfte Blöcke. */
|
|
||||||
chunks_checked: number;
|
chunks_checked: number;
|
||||||
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
|
/** Nicht auffindbar. */
|
||||||
missing_chunks: number;
|
chunks_missing: number;
|
||||||
/** Prüfsumme stimmt nicht. */
|
/** Prüfsumme stimmt nicht. */
|
||||||
corrupted_chunks: number;
|
chunks_corrupted: number;
|
||||||
/** Blöcke ohne Verweis aus einem Manifest. */
|
manifests_checked?: number;
|
||||||
orphaned_chunks: number;
|
manifests_invalid?: number;
|
||||||
backups_checked: number;
|
duration_seconds?: number;
|
||||||
backups_healthy: number;
|
/** Zusammenfassung im Klartext. */
|
||||||
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
|
summary?: string;
|
||||||
verified_chunk_contents: boolean;
|
/** Betroffene Objekte, sofern benennbar. */
|
||||||
healthy: boolean;
|
findings?: string[];
|
||||||
summary: string;
|
|
||||||
/** Betroffene Backups, sofern benennbar. */
|
|
||||||
affected_backup_ids?: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis einer Gesundheitsprüfung. */
|
/** Ergebnis einer Gesundheitsprüfung. */
|
||||||
@ -154,8 +126,8 @@ export async function updateRepository(
|
|||||||
/** Prüft die Erreichbarkeit. */
|
/** Prüft die Erreichbarkeit. */
|
||||||
export async function testRepository(
|
export async function testRepository(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
|
): Promise<RepositoryHealth> {
|
||||||
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
|
return requestApi<RepositoryHealth>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
@ -164,8 +136,8 @@ export async function testRepository(
|
|||||||
/** Führt eine Gesundheitsprüfung aus. */
|
/** Führt eine Gesundheitsprüfung aus. */
|
||||||
export async function checkRepositoryHealth(
|
export async function checkRepositoryHealth(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
|
): Promise<RepositoryHealth> {
|
||||||
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
|
return requestApi<RepositoryHealth>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
@ -180,21 +152,11 @@ export async function checkRepositoryHealth(
|
|||||||
*/
|
*/
|
||||||
export async function startIntegrityScan(
|
export async function startIntegrityScan(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<IntegrityScanDetails> {
|
): Promise<IntegrityScanResult> {
|
||||||
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
|
return requestApi<IntegrityScanResult>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
||||||
{ method: 'POST', idempotencyKey: true },
|
{ method: 'POST', idempotencyKey: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ein nicht erreichbares Repository ist kein Befund am Bestand, sondern ein
|
|
||||||
// Fehler des Laufs — die Unterscheidung, die diese Seite durchgehend macht.
|
|
||||||
if (!response.reachable || !response.details) {
|
|
||||||
throw new Error(
|
|
||||||
response.error || 'Das Repository war während der Prüfung nicht erreichbar.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.details;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -206,8 +168,8 @@ export async function startIntegrityScan(
|
|||||||
*/
|
*/
|
||||||
export async function rebuildCatalog(
|
export async function rebuildCatalog(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
|
): Promise<{ backups_found?: number; summary?: string }> {
|
||||||
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
|
return requestApi<{ backups_found?: number; summary?: string }>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
||||||
{ method: 'POST', idempotencyKey: true },
|
{ method: 'POST', idempotencyKey: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auswahl dessen, was zurückgeholt werden soll.
|
|
||||||
*
|
|
||||||
* Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte,
|
|
||||||
* dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern.
|
|
||||||
*
|
|
||||||
* Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für
|
|
||||||
* beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit
|
|
||||||
* Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine
|
|
||||||
* einzelne Datei. „dokumente" trifft dabei nicht „dokumentation".
|
|
||||||
*
|
|
||||||
* Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste
|
|
||||||
* ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch
|
|
||||||
* mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit
|
|
||||||
* getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem
|
|
||||||
* mehr erklären.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import { Button, Callout, ErrorState, LoadingState } from '@/components/ui';
|
|
||||||
import { formatBytes, formatDateTime } from '@/lib/utils';
|
|
||||||
import { browseBackupContents } from './browseApi';
|
|
||||||
|
|
||||||
export function BackupContentPicker({
|
|
||||||
backupIdentifier,
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
readonly backupIdentifier: string;
|
|
||||||
/** Leer bedeutet: das gesamte Backup. */
|
|
||||||
readonly selectedPath: string;
|
|
||||||
readonly onSelect: (contentPath: string) => void;
|
|
||||||
}) {
|
|
||||||
const [currentPath, setCurrentPath] = useState('');
|
|
||||||
|
|
||||||
const contentsResource = useApiResource(
|
|
||||||
useCallback(
|
|
||||||
(abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal),
|
|
||||||
[backupIdentifier, currentPath],
|
|
||||||
),
|
|
||||||
`${backupIdentifier}|${currentPath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const listing = contentsResource.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
|
|
||||||
Wiederherstellungen holen alles zurück. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === '' ? 'primary' : 'secondary'}
|
|
||||||
onClick={() => onSelect('')}
|
|
||||||
>
|
|
||||||
Gesamtes Backup
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pfadleiste */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath('')}
|
|
||||||
>
|
|
||||||
Wurzel
|
|
||||||
</button>
|
|
||||||
{currentPath
|
|
||||||
.split('/')
|
|
||||||
.filter((segment) => segment !== '')
|
|
||||||
.map((segment, segmentIndex, allSegments) => (
|
|
||||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
|
||||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-72 overflow-y-auto border border-line">
|
|
||||||
{contentsResource.loadState === 'loading' ? (
|
|
||||||
<LoadingState label="Inhalt wird gelesen …" />
|
|
||||||
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(contentsResource.loadError)}
|
|
||||||
requestId={contentsResource.loadError.requestId}
|
|
||||||
onRetry={contentsResource.reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-line">
|
|
||||||
{currentPath !== '' ? (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
|
||||||
… eine Ebene höher
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).map((entry) => (
|
|
||||||
<li key={entry.path} className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
|
||||||
onClick={() =>
|
|
||||||
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{entry.is_directory ? (
|
|
||||||
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
|
|
||||||
|
|
||||||
<span className="shrink-0 text-[11px] tabular text-fg-muted">
|
|
||||||
{entry.is_directory
|
|
||||||
? entry.child_count
|
|
||||||
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
|
|
||||||
: ''
|
|
||||||
: formatBytes(entry.size_bytes)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
|
||||||
className="mr-1 shrink-0"
|
|
||||||
onClick={() => onSelect(entry.path)}
|
|
||||||
title={
|
|
||||||
entry.is_directory
|
|
||||||
? 'Diesen Ordner mit allem darin zurückholen'
|
|
||||||
: 'Nur diese Datei zurückholen'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).length === 0 ? (
|
|
||||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
|
||||||
Dieser Ordner ist im Backup leer.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPath ? (
|
|
||||||
<Callout tone="info">
|
|
||||||
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
|
|
||||||
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
|
|
||||||
Backup.
|
|
||||||
</p>
|
|
||||||
</Callout>
|
|
||||||
) : (
|
|
||||||
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{listing?.entries.some((entry) => entry.modified_at) ? (
|
|
||||||
<p className="text-xs text-fg-subtle">
|
|
||||||
Stand der Dateien:{' '}
|
|
||||||
{formatDateTime(
|
|
||||||
listing.entries.find((entry) => entry.modified_at)?.modified_at,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auswahl des Zielverzeichnisses.
|
|
||||||
*
|
|
||||||
* Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der
|
|
||||||
* Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf.
|
|
||||||
*
|
|
||||||
* Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server
|
|
||||||
* legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie
|
|
||||||
* sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon
|
|
||||||
* steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach
|
|
||||||
* `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar
|
|
||||||
* aussah.
|
|
||||||
*
|
|
||||||
* Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie
|
|
||||||
* lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie
|
|
||||||
* wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { useApiResource } from '@/api/useApiResource';
|
|
||||||
import { describeApiError } from '@/api/useMutation';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Callout,
|
|
||||||
ErrorState,
|
|
||||||
LoadingState,
|
|
||||||
TextInput,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { browseFilesystem } from './browseApi';
|
|
||||||
|
|
||||||
export function DirectoryPicker({
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
readonly selectedPath: string;
|
|
||||||
readonly onSelect: (directoryPath: string) => void;
|
|
||||||
}) {
|
|
||||||
// Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die
|
|
||||||
// Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat.
|
|
||||||
const [currentPath, setCurrentPath] = useState(() => {
|
|
||||||
const trimmedSelection = selectedPath.trim();
|
|
||||||
|
|
||||||
if (!trimmedSelection.startsWith('/')) {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = trimmedSelection.replace(/\/[^/]*$/, '');
|
|
||||||
|
|
||||||
return parentPath === '' ? '/' : parentPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
|
||||||
|
|
||||||
const browseResource = useApiResource(
|
|
||||||
useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]),
|
|
||||||
currentPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
const listing = browseResource.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
|
|
||||||
brauchbaren Ort zu kommen. */}
|
|
||||||
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{listing.suggested_paths.map((suggestedPath) => (
|
|
||||||
<Button
|
|
||||||
key={suggestedPath}
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
|
|
||||||
onClick={() => {
|
|
||||||
onSelect(suggestedPath);
|
|
||||||
setCurrentPath(suggestedPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{suggestedPath}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Pfadleiste */}
|
|
||||||
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() => setCurrentPath('/')}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
{currentPath
|
|
||||||
.split('/')
|
|
||||||
.filter((segment) => segment !== '')
|
|
||||||
.map((segment, segmentIndex, allSegments) => (
|
|
||||||
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
|
|
||||||
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-fg-muted hover:text-fg"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Liste */}
|
|
||||||
<div className="max-h-64 overflow-y-auto border border-line">
|
|
||||||
{browseResource.loadState === 'loading' ? (
|
|
||||||
<LoadingState label="Verzeichnis wird gelesen …" />
|
|
||||||
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
|
|
||||||
<ErrorState
|
|
||||||
message={describeApiError(browseResource.loadError)}
|
|
||||||
requestId={browseResource.loadError.requestId}
|
|
||||||
onRetry={browseResource.reload}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-line">
|
|
||||||
{listing?.parent_path ? (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(listing.parent_path!)}
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4 shrink-0" aria-hidden />
|
|
||||||
… eine Ebene höher
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).map((entry) => (
|
|
||||||
<li key={entry.path} className="flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
|
|
||||||
onClick={() => setCurrentPath(entry.path)}
|
|
||||||
title={entry.forbidden_reason}
|
|
||||||
>
|
|
||||||
{entry.is_writable ? (
|
|
||||||
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
{!entry.is_writable ? (
|
|
||||||
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
|
|
||||||
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{entry.is_writable ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
|
|
||||||
className="mr-1 shrink-0"
|
|
||||||
onClick={() => onSelect(entry.path)}
|
|
||||||
>
|
|
||||||
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
|
|
||||||
<li className="px-3 py-6 text-center text-sm text-fg-muted">
|
|
||||||
Keine Unterverzeichnisse.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
|
|
||||||
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
|
|
||||||
an den aktuellen Pfad. */}
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<TextInput
|
|
||||||
label="Neues Unterverzeichnis"
|
|
||||||
className="flex-1"
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
|
|
||||||
placeholder="wiederherstellung-2026-08-18"
|
|
||||||
hint="Es wird beim Zurückschreiben angelegt."
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="mb-0.5"
|
|
||||||
disabled={!newFolderName.trim()}
|
|
||||||
onClick={() => {
|
|
||||||
const combinedPath =
|
|
||||||
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
|
|
||||||
onSelect(combinedPath);
|
|
||||||
setNewFolderName('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
Übernehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPath ? (
|
|
||||||
<Callout tone="info">
|
|
||||||
Ziel: <code className="font-mono">{selectedPath}</code>
|
|
||||||
</Callout>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -41,8 +41,6 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
||||||
import { BackupContentPicker } from './BackupContentPicker';
|
|
||||||
import { DirectoryPicker } from './DirectoryPicker';
|
|
||||||
import {
|
import {
|
||||||
createRestore,
|
createRestore,
|
||||||
validateRestore,
|
validateRestore,
|
||||||
@ -172,15 +170,35 @@ export function RestoreWizard({
|
|||||||
|
|
||||||
<DialogBody className="min-h-64">
|
<DialogBody className="min-h-64">
|
||||||
{currentStepIndex === 0 ? (
|
{currentStepIndex === 0 ? (
|
||||||
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
|
<div className="space-y-4">
|
||||||
|
<TextInput
|
||||||
|
label="Zielverzeichnis"
|
||||||
|
required
|
||||||
|
placeholder="/srv/wiederherstellung"
|
||||||
|
value={targetPath}
|
||||||
|
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
|
||||||
|
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
|
||||||
|
error={
|
||||||
|
targetPath.trim() && !targetPath.trim().startsWith('/')
|
||||||
|
? 'Der Pfad muss absolut sein und mit / beginnen.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Callout tone="info" title="Wohin am besten?">
|
||||||
|
Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben.
|
||||||
|
</Callout>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStepIndex === 1 ? (
|
{currentStepIndex === 1 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<BackupContentPicker
|
<TextInput
|
||||||
backupIdentifier={backupIdentifier}
|
label="Nur ein Teilbaum (optional)"
|
||||||
selectedPath={pathPrefix}
|
placeholder="daten/projekte"
|
||||||
onSelect={setPathPrefix}
|
value={pathPrefix}
|
||||||
|
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
|
||||||
|
hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-line pt-4">
|
<div className="space-y-3 border-t border-line pt-4">
|
||||||
@ -191,8 +209,8 @@ export function RestoreWizard({
|
|||||||
label="Vorhandene Dateien überschreiben"
|
label="Vorhandene Dateien überschreiben"
|
||||||
hint={
|
hint={
|
||||||
mayOverwrite
|
mayOverwrite
|
||||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
|
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
|
||||||
: 'Ihrer Rolle fehlt das Recht restores.overwrite.'
|
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -200,16 +218,24 @@ export function RestoreWizard({
|
|||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onCheckedChange={setSkipPermissions}
|
onCheckedChange={setSkipPermissions}
|
||||||
label="Rechte nicht zurückschreiben"
|
label="Rechte nicht zurückschreiben"
|
||||||
hint="Dateien entstehen mit den Standardrechten des Dienstkontos."
|
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
checked={skipDeepCheck}
|
checked={skipDeepCheck}
|
||||||
onCheckedChange={setSkipDeepCheck}
|
onCheckedChange={setSkipDeepCheck}
|
||||||
label="Blockprüfung überspringen"
|
label="Blockprüfung überspringen"
|
||||||
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
|
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{skipDeepCheck ? (
|
||||||
|
<Callout tone="warning">
|
||||||
|
Ohne Blockprüfung sagt die Vorabprüfung nur, dass das
|
||||||
|
Manifest lesbar ist — nicht, dass die Daten dazu noch
|
||||||
|
existieren. Der Bericht weist das aus.
|
||||||
|
</Callout>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Blättern im Dateisystem des Servers und im Inhalt eines Backups.
|
|
||||||
*
|
|
||||||
* Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und
|
|
||||||
* was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine
|
|
||||||
* noch das andere.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { requestApi } from '../../api/client';
|
|
||||||
|
|
||||||
/** Ein Verzeichnis auf dem Server. */
|
|
||||||
export interface FilesystemEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
is_directory: boolean;
|
|
||||||
/**
|
|
||||||
* Meldet, ob der **Dienst** hier anlegen darf.
|
|
||||||
*
|
|
||||||
* Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der
|
|
||||||
* Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus.
|
|
||||||
*/
|
|
||||||
is_writable: boolean;
|
|
||||||
/** Grund, wenn der Zielschutz den Ort ausschließt. */
|
|
||||||
forbidden_reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort auf das Blättern im Dateisystem. */
|
|
||||||
export interface BrowseFilesystemResponse {
|
|
||||||
path: string;
|
|
||||||
parent_path?: string;
|
|
||||||
entries: FilesystemEntry[];
|
|
||||||
/** Orte, an denen der Dienst nachweislich schreiben darf. */
|
|
||||||
suggested_paths?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */
|
|
||||||
export interface BackupContentEntry {
|
|
||||||
name: string;
|
|
||||||
/** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */
|
|
||||||
path: string;
|
|
||||||
is_directory: boolean;
|
|
||||||
entry_type: string;
|
|
||||||
size_bytes?: number;
|
|
||||||
modified_at?: string;
|
|
||||||
mode?: string;
|
|
||||||
/** Zahl der Einträge unterhalb eines Verzeichnisses. */
|
|
||||||
child_count?: number;
|
|
||||||
/** Datenmenge unterhalb eines Verzeichnisses. */
|
|
||||||
total_bytes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Antwort auf das Blättern im Backup. */
|
|
||||||
export interface BrowseBackupResponse {
|
|
||||||
backup_id: string;
|
|
||||||
path: string;
|
|
||||||
parent_path?: string;
|
|
||||||
entries: BackupContentEntry[];
|
|
||||||
total_entry_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Listet die Verzeichnisse unterhalb eines Pfades. */
|
|
||||||
export async function browseFilesystem(
|
|
||||||
directoryPath: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<BrowseFilesystemResponse> {
|
|
||||||
return requestApi<BrowseFilesystemResponse>(
|
|
||||||
`/filesystem/browse?path=${encodeURIComponent(directoryPath)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Listet den Inhalt eines Backups auf einer Ebene. */
|
|
||||||
export async function browseBackupContents(
|
|
||||||
backupIdentifier: string,
|
|
||||||
contentPath: string,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<BrowseBackupResponse> {
|
|
||||||
return requestApi<BrowseBackupResponse>(
|
|
||||||
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
|
|
||||||
abortSignal ? { signal: abortSignal } : {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -15,10 +15,11 @@
|
|||||||
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
|
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
|
||||||
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
|
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
|
||||||
* Schreibweisen werden unterstützt.
|
* Schreibweisen werden unterstützt.
|
||||||
* 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert,
|
* 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und
|
||||||
* nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein
|
* wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die
|
||||||
* Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden
|
* System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet
|
||||||
* Hosts abhängt, wäre auch ohne CSP falsch.
|
* die Content-Security-Policy der Auslieferung — und ein Backup-Server, der
|
||||||
|
* für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
|
||||||
*
|
*
|
||||||
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
|
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
|
||||||
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
|
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
|
||||||
@ -28,45 +29,6 @@
|
|||||||
|
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/*
|
|
||||||
* Geist Mono liegt **im Paket**, nicht auf einem fremden Host.
|
|
||||||
*
|
|
||||||
* Vite bündelt die drei Schnitte mit und vergibt ihnen einen Hash; ausgeliefert
|
|
||||||
* wird ausschließlich vom eigenen Ursprung. Das ist die einzige Form, die die
|
|
||||||
* Content-Security-Policy zulässt — und die einzige, die für einen
|
|
||||||
* Backup-Server vertretbar ist: Seine Oberfläche darf nicht davon abhängen,
|
|
||||||
* dass ein CDN erreichbar ist. Kosten: rund 128 KB für drei Schnitte.
|
|
||||||
*
|
|
||||||
* `font-display: swap` zeigt den Text sofort in der System-Monospace und
|
|
||||||
* tauscht die Schrift nach. Ein unsichtbarer Text, bis eine Schriftdatei da
|
|
||||||
* ist, wäre in einer Störungskonsole der falsche Kompromiss.
|
|
||||||
*
|
|
||||||
* Lizenz: SIL Open Font License 1.1, siehe assets/fonts/LICENSE.txt.
|
|
||||||
*/
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist Mono';
|
|
||||||
src: url('../assets/fonts/GeistMono-Regular.woff2') format('woff2');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist Mono';
|
|
||||||
src: url('../assets/fonts/GeistMono-Medium.woff2') format('woff2');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist Mono';
|
|
||||||
src: url('../assets/fonts/GeistMono-SemiBold.woff2') format('woff2');
|
|
||||||
font-weight: 600;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
|
/* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
|
||||||
* shadcn-übliche Form und kostet nichts. */
|
* shadcn-übliche Form und kostet nichts. */
|
||||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
|
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
|
||||||
|
|||||||
@ -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.
|
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
|
## Grenzen des aktuellen Stands
|
||||||
|
|
||||||
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:
|
**Das Manifest wird vollständig im Speicher gehalten.** Das ist die wichtigste offene Baustelle. Jeder Blockverweis kostet rund 200 Byte:
|
||||||
|
|||||||
@ -187,17 +187,12 @@ ExecStart=/opt/syncova/bin/syncova-api
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
# Härtung: Der Dienst braucht Netz, sein Repository und eine Fläche für
|
# Härtung: Der Dienst braucht Netz und sein Repository, sonst nichts.
|
||||||
# Wiederherstellungen — sonst nichts.
|
|
||||||
#
|
|
||||||
# ReadWritePaths ist die Stelle, an der eine Wiederherstellung scheitert, wenn
|
|
||||||
# man sie vergisst: Mit ProtectSystem=strict ist alles andere für den Dienst
|
|
||||||
# schreibgeschützt, und die Rechte des Zielverzeichnisses helfen dann nicht.
|
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
ReadWritePaths=/srv/syncova-repository /srv/syncova-restore
|
ReadWritePaths=/srv/syncova-repository
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
@ -290,25 +285,6 @@ sudo firewall-cmd --permanent --add-service=https # firewalld
|
|||||||
sudo firewall-cmd --reload
|
sudo firewall-cmd --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8b. Wohin Wiederherstellungen schreiben dürfen
|
|
||||||
|
|
||||||
`setup.sh` legt `/srv/syncova-restore` an und trägt es in `ReadWritePaths` der
|
|
||||||
systemd-Einheit ein. Ohne diesen Eintrag scheitert **jede** Wiederherstellung:
|
|
||||||
Der Dienst läuft mit `ProtectSystem=strict`, und die Rechte des
|
|
||||||
Zielverzeichnisses helfen dann nicht.
|
|
||||||
|
|
||||||
Weitere Ziele beim Einrichten nennen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./setup.sh --wiederherstellungsziel /srv/wiederherstellung \
|
|
||||||
--wiederherstellungsziel /mnt/nas/restore
|
|
||||||
```
|
|
||||||
|
|
||||||
Nicht möglich sind `/tmp` (privater Namensraum des Dienstes) und die
|
|
||||||
Systemverzeichnisse `/etc`, `/usr`, `/var/lib`, `/root` — Letztere sperrt der
|
|
||||||
Zielschutz, weil eine Wiederherstellung dorthin das System überschriebe, auf dem
|
|
||||||
die Anlage läuft.
|
|
||||||
|
|
||||||
## 9. Erstes Repository
|
## 9. Erstes Repository
|
||||||
|
|
||||||
Ein Repository entsteht **auf einem Datenträger**, nicht in einer
|
Ein Repository entsteht **auf einem Datenträger**, nicht in einer
|
||||||
|
|||||||
@ -51,44 +51,9 @@ Original nicht mehr.
|
|||||||
|
|
||||||
## 1. Dateien und Ordner
|
## 1. Dateien und Ordner
|
||||||
|
|
||||||
### Wohin darf zurückgeschrieben werden?
|
|
||||||
|
|
||||||
**Die wichtigste Frage, und sie ist nicht offensichtlich.** Der Dienst läuft mit
|
|
||||||
`ProtectSystem=strict`: Außerhalb weniger Pfade ist das Dateisystem für ihn
|
|
||||||
schreibgeschützt, unabhängig von den Rechten des Verzeichnisses. Ein Ziel
|
|
||||||
außerhalb endet mit `mkdir: permission denied` — und zwar erst **nach** der
|
|
||||||
Vorabprüfung.
|
|
||||||
|
|
||||||
| Ort | Ergebnis |
|
|
||||||
| --- | --- |
|
|
||||||
| `/srv/syncova-restore` | ✓ von `setup.sh` angelegt und eingetragen |
|
|
||||||
| weitere aus `--wiederherstellungsziel` | ✓ |
|
|
||||||
| `/tmp/…` | ✗ landet im privaten `/tmp` des Dienstes und ist von außen unsichtbar |
|
|
||||||
| `/etc`, `/usr`, `/var/lib`, `/root` … | ✗ vom Zielschutz gesperrt |
|
|
||||||
| alles andere | ✗ schreibgeschützt durch `ProtectSystem=strict` |
|
|
||||||
|
|
||||||
Der Ordnerbaum in der Oberfläche beantwortet das direkt: Er meldet je
|
|
||||||
Verzeichnis, ob der Dienst dort schreiben darf — **gemessen** durch eine
|
|
||||||
Probedatei, nicht aus den Rechtebits abgeleitet.
|
|
||||||
|
|
||||||
Ein weiteres Ziel nachträglich freigeben:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl edit syncova-api # ReadWritePaths= ergänzen
|
|
||||||
sudo systemctl restart syncova-api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Über die Oberfläche
|
### Über die Oberfläche
|
||||||
|
|
||||||
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen*.
|
Wiederherstellungspunkte → Punkt wählen → *Wiederherstellen* → Zielpfad angeben.
|
||||||
|
|
||||||
1. **Ziel** über den Ordnerbaum wählen. Beschreibbare Orte stehen oben als
|
|
||||||
Vorschlag; ein Unterverzeichnis lässt sich anlegen.
|
|
||||||
2. **Umfang** über den Backup-Browser wählen — das gesamte Backup, ein Ordner
|
|
||||||
oder eine einzelne Datei.
|
|
||||||
3. **Vorabprüfung** — sie schreibt nichts und stellt fest, ob jeder benötigte
|
|
||||||
Block noch da ist.
|
|
||||||
4. **Ausführen.**
|
|
||||||
|
|
||||||
### Über die API
|
### Über die API
|
||||||
|
|
||||||
@ -103,16 +68,7 @@ curl -X POST https://<server>/api/v1/restores \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`path_prefix` trifft **einen Ordner oder eine einzelne Datei**: Der Server
|
`path_prefix` beschränkt auf einen Teilbaum. Ohne ihn kommt alles zurück.
|
||||||
vergleicht auf Gleichheit oder Präfix mit Verzeichnisgrenze — `dokumente`
|
|
||||||
trifft dabei nicht `dokumentation`. Ohne ihn kommt alles zurück.
|
|
||||||
|
|
||||||
Den Inhalt eines Backups durchsehen, ohne etwas zurückzuschreiben:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -H "Authorization: Bearer <token>" \
|
|
||||||
"https://<server>/api/v1/backups/<id>/contents?path=berichte" | jq '.data.entries'
|
|
||||||
```
|
|
||||||
|
|
||||||
Fortschritt:
|
Fortschritt:
|
||||||
|
|
||||||
|
|||||||
@ -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
|
e6568d4614f3ebf5a66b75f2b8b3b0ee80786c04c6165216964395c7c1834db8 000009_alerts.up.sql
|
||||||
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
|
f38b3d5cd5a1d0143ce633feef4ad75334c702067a1c200f99f4cb791595ca40 000002_identity.up.sql
|
||||||
ffbd9245f8031c902f30a81cbaffcf9e61e515cc77ccaf9e885186e737cca3d0 000003_agents.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
|
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)
|
backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
|
||||||
|
|
||||||
startTime := time.Now().UTC()
|
startTime := time.Now().UTC()
|
||||||
@ -446,7 +439,7 @@ func (executor *Executor) backupSingleSource(backupContext context.Context, back
|
|||||||
EncryptionEnabled: executor.options.SecretStore != nil,
|
EncryptionEnabled: executor.options.SecretStore != nil,
|
||||||
ChainID: chainIdentifier.String(),
|
ChainID: chainIdentifier.String(),
|
||||||
CreatedByVersion: executor.options.CreatedByVersion,
|
CreatedByVersion: executor.options.CreatedByVersion,
|
||||||
Incremental: parentBackupInRepository != "" && !forceFullBackup,
|
Incremental: parentBackupInRepository != "",
|
||||||
ParentBackupID: parentBackupInRepository,
|
ParentBackupID: parentBackupInRepository,
|
||||||
BandwidthLimiter: backupRequest.Limiter,
|
BandwidthLimiter: backupRequest.Limiter,
|
||||||
}
|
}
|
||||||
@ -698,41 +691,3 @@ func hasServerSideSource(executionJob *jobs.Job) bool {
|
|||||||
|
|
||||||
return false
|
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"`
|
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.
|
// Job ist ein Sicherungsauftrag.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
// ID ist der öffentliche Bezeichner.
|
// ID ist der öffentliche Bezeichner.
|
||||||
@ -126,18 +110,6 @@ type Job struct {
|
|||||||
RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
|
RecoveryTimeObjective time.Duration `json:"rto,omitempty"`
|
||||||
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz; 0 bedeutet unbegrenzt.
|
||||||
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
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 begrenzt gleichzeitige Läufe dieses Auftrags.
|
||||||
MaximumConcurrency int `json:"max_concurrency"`
|
MaximumConcurrency int `json:"max_concurrency"`
|
||||||
// RetryPolicy beschreibt das Wiederholungsverhalten.
|
// RetryPolicy beschreibt das Wiederholungsverhalten.
|
||||||
|
|||||||
@ -72,9 +72,8 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
|
|||||||
INSERT INTO backup_jobs (
|
INSERT INTO backup_jobs (
|
||||||
name, description, status, priority, schedule_type, schedule_config,
|
name, description, status, priority, schedule_type, schedule_config,
|
||||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
||||||
bandwidth_limit_bps, max_concurrency, retry_policy, next_run_at, created_by,
|
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)
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
|
||||||
RETURNING id`
|
RETURNING id`
|
||||||
|
|
||||||
var createdJobID uuid.UUID
|
var createdJobID uuid.UUID
|
||||||
@ -95,8 +94,6 @@ func (store *PostgresStore) CreateJob(createContext context.Context, newJob *Job
|
|||||||
retryJSON,
|
retryJSON,
|
||||||
newJob.NextRunAt,
|
newJob.NextRunAt,
|
||||||
newJob.CreatedBy,
|
newJob.CreatedBy,
|
||||||
string(normalizeBackupMode(newJob.BackupMode)),
|
|
||||||
nullableWeekday(newJob.FullBackupWeekday),
|
|
||||||
).Scan(&createdJobID)
|
).Scan(&createdJobID)
|
||||||
|
|
||||||
if scanError != nil {
|
if scanError != nil {
|
||||||
@ -178,7 +175,7 @@ func (store *PostgresStore) GetJob(readContext context.Context, jobIdentifier uu
|
|||||||
const selectJobStatement = `
|
const selectJobStatement = `
|
||||||
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
||||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
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
|
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at
|
||||||
FROM backup_jobs
|
FROM backup_jobs
|
||||||
WHERE id = $1 AND deleted_at IS NULL`
|
WHERE id = $1 AND deleted_at IS NULL`
|
||||||
@ -222,10 +219,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
|||||||
bandwidthLimit *int64
|
bandwidthLimit *int64
|
||||||
lastOutcomeText *string
|
lastOutcomeText *string
|
||||||
descriptionValue 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(
|
scanError := scanner.Scan(
|
||||||
@ -242,8 +235,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
|||||||
&bandwidthLimit,
|
&bandwidthLimit,
|
||||||
&loadedJob.MaximumConcurrency,
|
&loadedJob.MaximumConcurrency,
|
||||||
&retryJSON,
|
&retryJSON,
|
||||||
&backupModeText,
|
|
||||||
&fullBackupWeekday,
|
|
||||||
&loadedJob.NextRunAt,
|
&loadedJob.NextRunAt,
|
||||||
&loadedJob.LastRunAt,
|
&loadedJob.LastRunAt,
|
||||||
&lastOutcomeText,
|
&lastOutcomeText,
|
||||||
@ -259,8 +250,6 @@ func scanJobRow(scanner rowScanner) (*Job, error) {
|
|||||||
loadedJob.Description = descriptionValue
|
loadedJob.Description = descriptionValue
|
||||||
loadedJob.Status = JobStatus(statusText)
|
loadedJob.Status = JobStatus(statusText)
|
||||||
loadedJob.Priority = scheduler.Priority(priorityText)
|
loadedJob.Priority = scheduler.Priority(priorityText)
|
||||||
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
|
|
||||||
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
|
|
||||||
|
|
||||||
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
||||||
return nil, fmt.Errorf("der zeitplan des auftrags %s ist unlesbar: %w", loadedJob.ID, unmarshalError)
|
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 = `
|
const selectStatement = `
|
||||||
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
SELECT id, name, COALESCE(description,''), status, priority, schedule_config,
|
||||||
repository_id, retention_policy_id, rpo_seconds, rto_seconds,
|
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,
|
next_run_at, last_run_at, last_outcome, paused_at, created_by, created_at, updated_at,
|
||||||
COUNT(*) OVER () AS total_count
|
COUNT(*) OVER () AS total_count
|
||||||
FROM backup_jobs
|
FROM backup_jobs
|
||||||
@ -484,17 +473,12 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
|
|||||||
bandwidthLimit *int64
|
bandwidthLimit *int64
|
||||||
lastOutcomeText *string
|
lastOutcomeText *string
|
||||||
descriptionValue 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(
|
scanError := jobRows.Scan(
|
||||||
&loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON,
|
&loadedJob.ID, &loadedJob.Name, &descriptionValue, &statusText, &priorityText, &scheduleJSON,
|
||||||
&loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds,
|
&loadedJob.RepositoryID, &loadedJob.RetentionPolicyID, &rpoSeconds, &rtoSeconds,
|
||||||
&bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON,
|
&bandwidthLimit, &loadedJob.MaximumConcurrency, &retryJSON,
|
||||||
&backupModeText, &fullBackupWeekday,
|
|
||||||
&loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt,
|
&loadedJob.NextRunAt, &loadedJob.LastRunAt, &lastOutcomeText, &loadedJob.PausedAt,
|
||||||
&loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt,
|
&loadedJob.CreatedBy, &loadedJob.CreatedAt, &loadedJob.UpdatedAt,
|
||||||
totalCount,
|
totalCount,
|
||||||
@ -506,8 +490,6 @@ func scanJobRowWithTotal(jobRows pgx.Rows, totalCount *int) (*Job, error) {
|
|||||||
loadedJob.Description = descriptionValue
|
loadedJob.Description = descriptionValue
|
||||||
loadedJob.Status = JobStatus(statusText)
|
loadedJob.Status = JobStatus(statusText)
|
||||||
loadedJob.Priority = scheduler.Priority(priorityText)
|
loadedJob.Priority = scheduler.Priority(priorityText)
|
||||||
loadedJob.BackupMode = normalizeBackupMode(BackupMode(backupModeText))
|
|
||||||
loadedJob.FullBackupWeekday = weekdayFromDatabase(fullBackupWeekday)
|
|
||||||
|
|
||||||
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
if unmarshalError := json.Unmarshal(scheduleJSON, &loadedJob.Schedule); unmarshalError != nil {
|
||||||
return nil, unmarshalError
|
return nil, unmarshalError
|
||||||
@ -732,51 +714,3 @@ func defaultToEmptySlice(patternList []string) []string {
|
|||||||
|
|
||||||
return patternList
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -40,28 +40,6 @@ readonly defaultRepositoryPath="/srv/syncova-repository"
|
|||||||
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
|
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
|
||||||
readonly backupDirectory="/var/backups/syncova"
|
readonly backupDirectory="/var/backups/syncova"
|
||||||
|
|
||||||
# additionalRestoreRoots sind weitere Ziele fuer Wiederherstellungen.
|
|
||||||
#
|
|
||||||
# Wer nach /srv/wiederherstellung oder auf eine Freigabe zurueckschreiben will,
|
|
||||||
# nennt sie beim Einrichten mit --wiederherstellungsziel. Sie landen in
|
|
||||||
# ReadWritePaths der Einheit; ohne diesen Eintrag nuetzen die Rechte des
|
|
||||||
# Verzeichnisses nichts.
|
|
||||||
additionalRestoreRoots=""
|
|
||||||
|
|
||||||
# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf.
|
|
||||||
#
|
|
||||||
# Sie ist noetig, weil der Dienst mit ProtectSystem=strict laeuft: Ausserhalb
|
|
||||||
# der in ReadWritePaths genannten Pfade ist das Dateisystem fuer ihn
|
|
||||||
# schreibgeschuetzt. Ohne eine solche Flaeche endet **jede** Wiederherstellung
|
|
||||||
# mit "mkdir: permission denied" — und zwar erst nach der Vorabpruefung, also
|
|
||||||
# an der unangenehmsten Stelle.
|
|
||||||
#
|
|
||||||
# Der Ort liegt unter /srv und nicht unter /var/lib: Letzteres steht auf der
|
|
||||||
# Sperrliste des Zielschutzes (packages/recovery/targetguard.go), weil eine
|
|
||||||
# Wiederherstellung dorthin den Zustand der Anlage selbst ueberschreiben
|
|
||||||
# koennte. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig.
|
|
||||||
readonly restoreDirectory="/srv/syncova-restore"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ausgabe
|
# Ausgabe
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -234,10 +212,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--paket) packageDirectory="${2:-}"; shift 2 ;;
|
--paket) packageDirectory="${2:-}"; shift 2 ;;
|
||||||
--repository) repositoryPath="${2:-}"; shift 2 ;;
|
--repository) repositoryPath="${2:-}"; shift 2 ;;
|
||||||
--wiederherstellungsziel)
|
|
||||||
# Mehrfach angebbar. Die Pfade kommen in ReadWritePaths der Einheit;
|
|
||||||
# ohne diesen Eintrag nuetzen die Rechte des Verzeichnisses nichts.
|
|
||||||
additionalRestoreRoots="${additionalRestoreRoots} ${2:-}"; shift 2 ;;
|
|
||||||
--adresse) listenAddress="${2:-}"; shift 2 ;;
|
--adresse) listenAddress="${2:-}"; shift 2 ;;
|
||||||
--admin) administratorName="${2:-}"; shift 2 ;;
|
--admin) administratorName="${2:-}"; shift 2 ;;
|
||||||
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
||||||
@ -1073,11 +1047,6 @@ noteCreated "directory:${configurationDirectory}"
|
|||||||
|
|
||||||
install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}"
|
install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}"
|
||||||
|
|
||||||
# Die Wiederherstellungsflaeche. Ohne sie scheitert jede Wiederherstellung an
|
|
||||||
# ProtectSystem=strict — und der Betreiber sucht den Fehler bei den Rechten des
|
|
||||||
# Zielverzeichnisses statt bei der Haertung des Dienstes.
|
|
||||||
install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}"
|
|
||||||
|
|
||||||
cp -R "${packageDirectory}/bin" "${installationRoot}/"
|
cp -R "${packageDirectory}/bin" "${installationRoot}/"
|
||||||
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
|
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
|
||||||
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
|
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
|
||||||
@ -1327,7 +1296,7 @@ NoNewPrivileges=yes
|
|||||||
PrivateTmp=${privateTmpSetting}
|
PrivateTmp=${privateTmpSetting}
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots}
|
ReadWritePaths=${repositoryPath} ${backupDirectory}
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
|
|||||||
@ -30,16 +30,8 @@ readonly installationRoot="/opt/syncova"
|
|||||||
readonly configurationDirectory="/etc/syncova"
|
readonly configurationDirectory="/etc/syncova"
|
||||||
readonly configurationFile="${configurationDirectory}/syncova.env"
|
readonly configurationFile="${configurationDirectory}/syncova.env"
|
||||||
readonly serviceName="syncova-api"
|
readonly serviceName="syncova-api"
|
||||||
readonly serviceAccount="syncova"
|
|
||||||
readonly backupDirectory="/var/backups/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.
|
# previousReleaseRoot nimmt die abgeloeste Fassung auf.
|
||||||
#
|
#
|
||||||
# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte
|
# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte
|
||||||
@ -427,41 +419,6 @@ fi
|
|||||||
|
|
||||||
writeSuccess "Schema aktuell"
|
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
|
# 7. Starten und pruefen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user