Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
596 lines
22 KiB
Go
596 lines
22 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/syncova/syncova/packages/metrics"
|
|
)
|
|
|
|
// RepositoryStatus ist der Betriebszustand eines Repositorys.
|
|
type RepositoryStatus string
|
|
|
|
const (
|
|
// RepositoryStatusActive nimmt Sicherungen an.
|
|
RepositoryStatusActive RepositoryStatus = "active"
|
|
// RepositoryStatusReadOnly erlaubt nur das Lesen.
|
|
RepositoryStatusReadOnly RepositoryStatus = "read_only"
|
|
// RepositoryStatusUnavailable ist nicht erreichbar.
|
|
RepositoryStatusUnavailable RepositoryStatus = "unavailable"
|
|
// RepositoryStatusMaintenance ist vorübergehend gesperrt.
|
|
RepositoryStatusMaintenance RepositoryStatus = "maintenance"
|
|
)
|
|
|
|
// AcceptsWrites meldet, ob in dieses Repository gesichert werden darf.
|
|
func (repositoryStatus RepositoryStatus) AcceptsWrites() bool {
|
|
return repositoryStatus == RepositoryStatusActive
|
|
}
|
|
|
|
// Repository ist ein in der Control Plane bekanntes Sicherungsziel.
|
|
type Repository struct {
|
|
// ID ist der öffentliche Bezeichner.
|
|
ID uuid.UUID `json:"id"`
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string `json:"name"`
|
|
// RepositoryType benennt die Ablageart.
|
|
RepositoryType string `json:"repository_type"`
|
|
// Location ist der Pfad oder die Adresse der Ablage.
|
|
Location string `json:"location"`
|
|
// RepositoryUUID ist die im Repository selbst hinterlegte Kennung.
|
|
RepositoryUUID string `json:"repository_uuid,omitempty"`
|
|
// Status ist der Betriebszustand.
|
|
Status RepositoryStatus `json:"status"`
|
|
// Hardened meldet den gehärteten Modus.
|
|
Hardened bool `json:"hardened"`
|
|
// EnforcementLevel ist die zuletzt gemessene Durchsetzungsstufe.
|
|
//
|
|
// Leer bedeutet: nie gemessen. Der Unterschied zu „advisory" ist wichtig —
|
|
// „wir wissen es nicht" ist keine Aussage über den Schutz.
|
|
EnforcementLevel string `json:"enforcement_level,omitempty"`
|
|
// EnforcementMeasuredAt ist der Zeitpunkt der Messung in UTC.
|
|
EnforcementMeasuredAt *time.Time `json:"enforcement_measured_at,omitempty"`
|
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ErrRepositoryNotFound meldet ein nicht vorhandenes Repository.
|
|
var ErrRepositoryNotFound = errors.New("das repository wurde nicht gefunden")
|
|
|
|
// GetRepository liest ein Repository.
|
|
func (store *PostgresStore) GetRepository(readContext context.Context, repositoryIdentifier uuid.UUID) (*Repository, error) {
|
|
const selectStatement = `
|
|
SELECT id, name, repository_type, location, COALESCE(repository_uuid,''), status, hardened,
|
|
COALESCE(enforcement_level,''), enforcement_measured_at, created_at
|
|
FROM repositories
|
|
WHERE id = $1`
|
|
|
|
var (
|
|
loadedRepository Repository
|
|
statusText string
|
|
)
|
|
|
|
scanError := store.connectionPool.QueryRow(readContext, selectStatement, repositoryIdentifier).Scan(
|
|
&loadedRepository.ID, &loadedRepository.Name, &loadedRepository.RepositoryType,
|
|
&loadedRepository.Location, &loadedRepository.RepositoryUUID, &statusText,
|
|
&loadedRepository.Hardened, &loadedRepository.EnforcementLevel,
|
|
&loadedRepository.EnforcementMeasuredAt, &loadedRepository.CreatedAt)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", ErrRepositoryNotFound, repositoryIdentifier)
|
|
}
|
|
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("das repository konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
loadedRepository.Status = RepositoryStatus(statusText)
|
|
|
|
return &loadedRepository, nil
|
|
}
|
|
|
|
// RecordRepositoryIdentity vermerkt die im Repository hinterlegte Kennung.
|
|
//
|
|
// Sie wird beim ersten Öffnen übernommen. Weicht sie später ab, wurde das
|
|
// Verzeichnis ausgetauscht — dann zeigt der Katalog der Control Plane auf einen
|
|
// fremden Bestand, und die Angaben zu Backups und Belegung stimmen nicht mehr.
|
|
func (store *PostgresStore) RecordRepositoryIdentity(updateContext context.Context, repositoryIdentifier uuid.UUID, repositoryUUID string) error {
|
|
const updateStatement = `
|
|
UPDATE repositories SET repository_uuid = $2, last_checked_at = now(), updated_at = now() WHERE id = $1`
|
|
|
|
if _, execError := store.connectionPool.Exec(updateContext, updateStatement,
|
|
repositoryIdentifier, repositoryUUID); execError != nil {
|
|
return fmt.Errorf("die repository-kennung konnte nicht vermerkt werden: %w", execError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BackupRecord beschreibt ein in einem Repository abgelegtes Backup.
|
|
type BackupRecord struct {
|
|
// JobRunID ist der erzeugende Lauf.
|
|
JobRunID uuid.UUID
|
|
// RepositoryID ist das Repository.
|
|
RepositoryID uuid.UUID
|
|
// ChainID ist die Sicherungskette.
|
|
ChainID uuid.UUID
|
|
// ParentBackupID ist das Elternbackup einer Zusatzsicherung.
|
|
ParentBackupID *uuid.UUID
|
|
// BackupIDInRepository ist die Kennung innerhalb des Repositorys.
|
|
BackupIDInRepository string
|
|
// BackupType ist die Art des Backups.
|
|
BackupType string
|
|
// ConsistencyLevel beschreibt die erreichte Konsistenz.
|
|
ConsistencyLevel string
|
|
// ManifestRef verweist auf das Manifest im Repository.
|
|
ManifestRef string
|
|
// LogicalBytes ist die Menge der Ursprungsdaten.
|
|
LogicalBytes int64
|
|
// UniqueBytes ist die nach Deduplizierung verbleibende Menge.
|
|
UniqueBytes int64
|
|
// EncryptedBytes ist die abgelegte Menge.
|
|
EncryptedBytes int64
|
|
// IncompressibleChunks ist die Zahl nicht verkleinerbarer neuer Blöcke.
|
|
//
|
|
// Der Entropie-Indikator der Ransomware-Erkennung (Phase 16): Normale
|
|
// Nutzdaten lassen sich fast immer verkleinern, verschlüsselte nie.
|
|
IncompressibleChunks int64
|
|
// NewChunkCount ist die Zahl neu geschriebener Blöcke.
|
|
//
|
|
// Der Nenner zum Entropie-Indikator: „500 inkompressible Blöcke" ist bei
|
|
// 500 neuen alarmierend und bei 500 000 nicht.
|
|
NewChunkCount int64
|
|
// ChangedFileCount ist die Zahl geänderter oder neuer Objekte.
|
|
ChangedFileCount int64
|
|
// DeletedFileCount ist die Zahl seit dem Elternbackup verschwundener Objekte.
|
|
DeletedFileCount int64
|
|
// ExtensionDistribution hält die häufigsten Dateiendungen des Laufs.
|
|
ExtensionDistribution map[string]int64
|
|
// StartedAt ist der Beginn in UTC.
|
|
StartedAt time.Time
|
|
// CompletedAt ist das Ende in UTC.
|
|
CompletedAt time.Time
|
|
}
|
|
|
|
// EnsureChain findet oder erzeugt die Sicherungskette einer Quelle.
|
|
//
|
|
// Je Quelle und Repository gibt es genau eine offene Kette. Zwei Quellen in
|
|
// einer Kette zu führen machte die eigenständige Wiederherstellung einer
|
|
// einzelnen Quelle unmöglich.
|
|
func (store *PostgresStore) EnsureChain(chainContext context.Context, sourceReference string, repositoryIdentifier uuid.UUID) (uuid.UUID, error) {
|
|
const selectStatement = `
|
|
SELECT id FROM backup_chains
|
|
WHERE source_reference = $1 AND repository_id = $2 AND status = 'active'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`
|
|
|
|
var chainIdentifier uuid.UUID
|
|
|
|
scanError := store.connectionPool.QueryRow(chainContext, selectStatement,
|
|
sourceReference, repositoryIdentifier).Scan(&chainIdentifier)
|
|
|
|
if scanError == nil {
|
|
return chainIdentifier, nil
|
|
}
|
|
|
|
if !errors.Is(scanError, pgx.ErrNoRows) {
|
|
return uuid.Nil, fmt.Errorf("die sicherungskette konnte nicht gesucht werden: %w", scanError)
|
|
}
|
|
|
|
const insertStatement = `
|
|
INSERT INTO backup_chains (source_reference, repository_id) VALUES ($1, $2) RETURNING id`
|
|
|
|
if createError := store.connectionPool.QueryRow(chainContext, insertStatement,
|
|
sourceReference, repositoryIdentifier).Scan(&chainIdentifier); createError != nil {
|
|
return uuid.Nil, fmt.Errorf("die sicherungskette konnte nicht angelegt werden: %w", createError)
|
|
}
|
|
|
|
return chainIdentifier, nil
|
|
}
|
|
|
|
// FindLatestBackup liefert das jüngste vollständige Backup einer Kette.
|
|
//
|
|
// Es dient als Elternbackup einer Zusatzsicherung. Nur abgeschlossene Backups
|
|
// kommen infrage: Auf ein unvollständiges aufzubauen ergäbe eine Kette, die
|
|
// vollständig aussieht und es nicht ist.
|
|
func (store *PostgresStore) FindLatestBackup(searchContext context.Context, chainIdentifier uuid.UUID) (uuid.UUID, string, error) {
|
|
const selectStatement = `
|
|
SELECT id, backup_id_in_repository
|
|
FROM backups
|
|
WHERE chain_id = $1 AND status = 'complete'
|
|
ORDER BY completed_at DESC
|
|
LIMIT 1`
|
|
|
|
var (
|
|
backupIdentifier uuid.UUID
|
|
backupIDInRepository string
|
|
)
|
|
|
|
scanError := store.connectionPool.QueryRow(searchContext, selectStatement, chainIdentifier).
|
|
Scan(&backupIdentifier, &backupIDInRepository)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return uuid.Nil, "", nil
|
|
}
|
|
|
|
if scanError != nil {
|
|
return uuid.Nil, "", fmt.Errorf("das elternbackup konnte nicht gesucht werden: %w", scanError)
|
|
}
|
|
|
|
return backupIdentifier, backupIDInRepository, nil
|
|
}
|
|
|
|
// RecordBackup vermerkt ein abgelegtes Backup in der Control Plane.
|
|
//
|
|
// Der Eintrag ist ein **Verweis**, keine Kopie: Er hält fest, wo das Manifest
|
|
// liegt, nicht seinen Inhalt. Das Repository bleibt ohne die Datenbank
|
|
// rekonstruierbar (SYNCOVA_ARCHITECTURE.md §10); dieser Eintrag beschleunigt
|
|
// nur die Suche.
|
|
func (store *PostgresStore) RecordBackup(recordContext context.Context, backupRecord BackupRecord) (uuid.UUID, error) {
|
|
const insertStatement = `
|
|
INSERT INTO backups (
|
|
job_run_id, chain_id, repository_id, parent_backup_id, backup_id_in_repository,
|
|
backup_type, consistency_level, status, manifest_ref,
|
|
logical_bytes, unique_bytes, encrypted_bytes, started_at, completed_at,
|
|
incompressible_chunks, new_chunk_count, changed_file_count,
|
|
deleted_file_count, extension_distribution
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,'complete',$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
|
RETURNING id`
|
|
|
|
var backupIdentifier uuid.UUID
|
|
|
|
scanError := store.connectionPool.QueryRow(recordContext, insertStatement,
|
|
backupRecord.JobRunID,
|
|
backupRecord.ChainID,
|
|
backupRecord.RepositoryID,
|
|
backupRecord.ParentBackupID,
|
|
backupRecord.BackupIDInRepository,
|
|
backupRecord.BackupType,
|
|
nullableText(backupRecord.ConsistencyLevel),
|
|
backupRecord.ManifestRef,
|
|
backupRecord.LogicalBytes,
|
|
backupRecord.UniqueBytes,
|
|
backupRecord.EncryptedBytes,
|
|
backupRecord.StartedAt,
|
|
backupRecord.CompletedAt,
|
|
backupRecord.IncompressibleChunks,
|
|
backupRecord.NewChunkCount,
|
|
backupRecord.ChangedFileCount,
|
|
backupRecord.DeletedFileCount,
|
|
encodeExtensionDistribution(backupRecord.ExtensionDistribution),
|
|
).Scan(&backupIdentifier)
|
|
|
|
if scanError != nil {
|
|
return uuid.Nil, fmt.Errorf("das backup konnte nicht vermerkt werden: %w", scanError)
|
|
}
|
|
|
|
return backupIdentifier, nil
|
|
}
|
|
|
|
// ListRepositories liefert die bekannten Sicherungsziele.
|
|
//
|
|
// Bewusst ohne Pagination: Ein Betrieb hat eine Handvoll Repositories, nicht
|
|
// tausende. Eine Seitenaufteilung wäre hier Aufwand ohne Nutzen — und die
|
|
// Auswahl im Backup-Wizard müsste über Seiten blättern.
|
|
func (store *PostgresStore) ListRepositories(listContext context.Context) ([]Repository, error) {
|
|
const selectStatement = `
|
|
SELECT id, name, repository_type, location, COALESCE(repository_uuid,''), status, hardened,
|
|
COALESCE(enforcement_level,''), enforcement_measured_at, created_at
|
|
FROM repositories
|
|
ORDER BY name`
|
|
|
|
repositoryRows, queryError := store.connectionPool.Query(listContext, selectStatement)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die repositoryliste konnte nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer repositoryRows.Close()
|
|
|
|
loadedRepositories := make([]Repository, 0, 8)
|
|
|
|
for repositoryRows.Next() {
|
|
var (
|
|
loadedRepository Repository
|
|
statusText string
|
|
)
|
|
|
|
if scanError := repositoryRows.Scan(&loadedRepository.ID, &loadedRepository.Name,
|
|
&loadedRepository.RepositoryType, &loadedRepository.Location,
|
|
&loadedRepository.RepositoryUUID, &statusText,
|
|
&loadedRepository.Hardened, &loadedRepository.EnforcementLevel,
|
|
&loadedRepository.EnforcementMeasuredAt, &loadedRepository.CreatedAt); scanError != nil {
|
|
return nil, fmt.Errorf("ein repository konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
loadedRepository.Status = RepositoryStatus(statusText)
|
|
|
|
loadedRepositories = append(loadedRepositories, loadedRepository)
|
|
}
|
|
|
|
return loadedRepositories, repositoryRows.Err()
|
|
}
|
|
|
|
// StoredBackup ist ein in der Control Plane vermerktes Backup.
|
|
type StoredBackup struct {
|
|
// ID ist der oeffentliche Bezeichner.
|
|
ID uuid.UUID `json:"id"`
|
|
// RepositoryID ist das Repository.
|
|
RepositoryID uuid.UUID `json:"repository_id"`
|
|
// ChainID ist die Sicherungskette.
|
|
ChainID *uuid.UUID `json:"chain_id,omitempty"`
|
|
// BackupIDInRepository ist die Kennung innerhalb des Repositorys.
|
|
BackupIDInRepository string `json:"backup_id_in_repository"`
|
|
// BackupType ist die Art des Backups.
|
|
BackupType string `json:"backup_type"`
|
|
// Status ist der Zustand.
|
|
Status string `json:"status"`
|
|
// LogicalBytes ist die Menge der Ursprungsdaten.
|
|
LogicalBytes int64 `json:"logical_bytes"`
|
|
// CompletedAt ist das Ende in UTC.
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
// ImmutableUntil ist das Ende der Aufbewahrungspflicht in UTC.
|
|
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
|
|
}
|
|
|
|
// ErrBackupNotFound meldet ein nicht vorhandenes Backup.
|
|
var ErrBackupNotFound = errors.New("das backup wurde nicht gefunden")
|
|
|
|
// GetBackup liest ein vermerktes Backup.
|
|
func (store *PostgresStore) GetBackup(readContext context.Context, backupIdentifier uuid.UUID) (*StoredBackup, error) {
|
|
const selectStatement = `
|
|
SELECT id, repository_id, chain_id, backup_id_in_repository, backup_type, status,
|
|
COALESCE(logical_bytes, 0), completed_at, immutable_until
|
|
FROM backups
|
|
WHERE id = $1`
|
|
|
|
var loadedBackup StoredBackup
|
|
|
|
scanError := store.connectionPool.QueryRow(readContext, selectStatement, backupIdentifier).Scan(
|
|
&loadedBackup.ID, &loadedBackup.RepositoryID, &loadedBackup.ChainID,
|
|
&loadedBackup.BackupIDInRepository, &loadedBackup.BackupType, &loadedBackup.Status,
|
|
&loadedBackup.LogicalBytes, &loadedBackup.CompletedAt, &loadedBackup.ImmutableUntil)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", ErrBackupNotFound, backupIdentifier)
|
|
}
|
|
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("das backup konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
return &loadedBackup, nil
|
|
}
|
|
|
|
// ListBackupsForJob liefert die Backups eines Auftrags, neueste zuerst.
|
|
//
|
|
// Sie sind die Wiederherstellungspunkte: Was hier steht, laesst sich
|
|
// zurueckholen.
|
|
func (store *PostgresStore) ListBackupsForJob(listContext context.Context, jobIdentifier uuid.UUID, maximumCount int) ([]StoredBackup, error) {
|
|
if maximumCount <= 0 || maximumCount > 200 {
|
|
maximumCount = 50
|
|
}
|
|
|
|
const selectStatement = `
|
|
SELECT b.id, b.repository_id, b.chain_id, b.backup_id_in_repository, b.backup_type, b.status,
|
|
COALESCE(b.logical_bytes, 0), b.completed_at, b.immutable_until
|
|
FROM backups b
|
|
JOIN backup_job_runs r ON r.id = b.job_run_id
|
|
WHERE r.job_id = $1 AND b.status = 'complete'
|
|
ORDER BY b.completed_at DESC
|
|
LIMIT $2`
|
|
|
|
backupRows, queryError := store.connectionPool.Query(listContext, selectStatement, jobIdentifier, maximumCount)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die backupliste konnte nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer backupRows.Close()
|
|
|
|
loadedBackups := make([]StoredBackup, 0, maximumCount)
|
|
|
|
for backupRows.Next() {
|
|
var loadedBackup StoredBackup
|
|
|
|
if scanError := backupRows.Scan(&loadedBackup.ID, &loadedBackup.RepositoryID, &loadedBackup.ChainID,
|
|
&loadedBackup.BackupIDInRepository, &loadedBackup.BackupType, &loadedBackup.Status,
|
|
&loadedBackup.LogicalBytes, &loadedBackup.CompletedAt, &loadedBackup.ImmutableUntil); scanError != nil {
|
|
return nil, fmt.Errorf("ein backup konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
loadedBackups = append(loadedBackups, loadedBackup)
|
|
}
|
|
|
|
return loadedBackups, backupRows.Err()
|
|
}
|
|
|
|
// MeasurableRepositories liefert die zu erfassenden Repositories.
|
|
//
|
|
// Auch ein als nicht erreichbar geführtes wird versucht: Der Zustand kann
|
|
// veraltet sein, und eine Verlaufsreihe, die ein zurückgekehrtes Repository
|
|
// nicht wieder aufnimmt, bliebe für immer leer. Was tatsächlich nicht messbar
|
|
// ist, meldet die Erfassung als Warnung — und trägt keine Null ein.
|
|
func (store *PostgresStore) MeasurableRepositories(sourceContext context.Context) ([]metrics.MeasurableRepository, error) {
|
|
const selectStatement = `
|
|
SELECT id, name, location
|
|
FROM repositories
|
|
ORDER BY name`
|
|
|
|
repositoryRows, queryError := store.connectionPool.Query(sourceContext, selectStatement)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("die zu erfassenden repositories konnten nicht gelesen werden: %w", queryError)
|
|
}
|
|
|
|
defer repositoryRows.Close()
|
|
|
|
measurableRepositories := make([]metrics.MeasurableRepository, 0, 8)
|
|
|
|
for repositoryRows.Next() {
|
|
var measurableRepository metrics.MeasurableRepository
|
|
|
|
if scanError := repositoryRows.Scan(&measurableRepository.ID, &measurableRepository.Name,
|
|
&measurableRepository.Location); scanError != nil {
|
|
return nil, fmt.Errorf("ein repository konnte nicht gelesen werden: %w", scanError)
|
|
}
|
|
|
|
measurableRepositories = append(measurableRepositories, measurableRepository)
|
|
}
|
|
|
|
return measurableRepositories, repositoryRows.Err()
|
|
}
|
|
|
|
// encodeExtensionDistribution wandelt die Endungsverteilung in einen Datenbankwert.
|
|
//
|
|
// Eine leere Verteilung wird zu NULL und nicht zu "{}": Der Unterschied ist der
|
|
// zwischen „nicht erhoben" und „keine Dateien mit Endung" — und die
|
|
// Ransomware-Erkennung darf beides nicht verwechseln.
|
|
func encodeExtensionDistribution(distribution map[string]int64) []byte {
|
|
if len(distribution) == 0 {
|
|
return nil
|
|
}
|
|
|
|
encodedDistribution, encodeError := json.Marshal(distribution)
|
|
if encodeError != nil {
|
|
return nil
|
|
}
|
|
|
|
return encodedDistribution
|
|
}
|
|
|
|
// ErrRepositoryNameTaken meldet einen bereits vergebenen Namen.
|
|
var ErrRepositoryNameTaken = errors.New("unter diesem namen ist bereits ein repository eingetragen")
|
|
|
|
// ErrRepositoryLocationTaken meldet einen bereits eingetragenen Ort.
|
|
//
|
|
// Zwei Einträge auf dasselbe Verzeichnis wären der sichere Weg in eine
|
|
// Doppelbuchung: Zwei Aufträge hielten sie für verschiedene Ziele und rissen
|
|
// sich um dieselbe Schreibsperre.
|
|
var ErrRepositoryLocationTaken = errors.New("unter diesem ort ist bereits ein repository eingetragen")
|
|
|
|
// RepositoryRegistration beschreibt ein einzutragendes Repository.
|
|
type RepositoryRegistration struct {
|
|
// Name ist die sprechende Bezeichnung.
|
|
Name string
|
|
// RepositoryType benennt die Ablageart.
|
|
RepositoryType string
|
|
// Location ist der Pfad oder die Adresse der Ablage.
|
|
Location string
|
|
// Status ist der Anfangszustand.
|
|
Status RepositoryStatus
|
|
// Hardened meldet den gehärteten Modus.
|
|
Hardened bool
|
|
// RepositoryUUID ist die im Repository selbst hinterlegte Kennung.
|
|
//
|
|
// Sie stammt aus dem Descriptor und nicht vom Aufrufer: Das Repository
|
|
// beschreibt sich selbst, und ein abweichender Wert wäre eine Behauptung.
|
|
RepositoryUUID string
|
|
}
|
|
|
|
// RegisterRepository trägt ein vorhandenes Repository in die Control Plane ein.
|
|
//
|
|
// **Es wird nichts angelegt, sondern übernommen.** Ein Repository entsteht auf
|
|
// einem Datenträger — mit `syncova-repo create`, das den Descriptor schreibt,
|
|
// die Verzeichnisse anlegt und den gehärteten Modus setzt. Ein Datenbankeintrag,
|
|
// hinter dem kein Repository liegt, wäre ein Ziel, das erst um zwei Uhr nachts
|
|
// als nicht vorhanden auffällt.
|
|
func (store *PostgresStore) RegisterRepository(registerContext context.Context,
|
|
registration RepositoryRegistration) (*Repository, error) {
|
|
if registration.Status == "" {
|
|
registration.Status = RepositoryStatusActive
|
|
}
|
|
|
|
const insertStatement = `
|
|
INSERT INTO repositories (name, repository_type, location, status, hardened, repository_uuid)
|
|
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''))
|
|
RETURNING id, name, repository_type, location, COALESCE(repository_uuid,''), status, hardened,
|
|
COALESCE(enforcement_level,''), enforcement_measured_at, created_at`
|
|
|
|
var (
|
|
createdRepository Repository
|
|
statusText string
|
|
)
|
|
|
|
scanError := store.connectionPool.QueryRow(registerContext, insertStatement,
|
|
registration.Name, registration.RepositoryType, registration.Location,
|
|
string(registration.Status), registration.Hardened, registration.RepositoryUUID).Scan(
|
|
&createdRepository.ID, &createdRepository.Name, &createdRepository.RepositoryType,
|
|
&createdRepository.Location, &createdRepository.RepositoryUUID, &statusText,
|
|
&createdRepository.Hardened, &createdRepository.EnforcementLevel,
|
|
&createdRepository.EnforcementMeasuredAt, &createdRepository.CreatedAt)
|
|
|
|
if scanError != nil {
|
|
// Der Grund wird unterschieden, weil die Abhilfe eine andere ist: ein
|
|
// vergebener Name verlangt einen anderen Namen, ein vergebener Ort
|
|
// dagegen den Blick auf den bestehenden Eintrag.
|
|
if isUniqueViolationOn(scanError, "repositories_name_key") {
|
|
return nil, ErrRepositoryNameTaken
|
|
}
|
|
|
|
if isUniqueViolationOn(scanError, "repositories_location_key") {
|
|
return nil, ErrRepositoryLocationTaken
|
|
}
|
|
|
|
return nil, fmt.Errorf("das repository konnte nicht eingetragen werden: %w", scanError)
|
|
}
|
|
|
|
createdRepository.Status = RepositoryStatus(statusText)
|
|
|
|
return &createdRepository, nil
|
|
}
|
|
|
|
// UpdateRepositoryStatus ändert den Betriebszustand eines Repositorys.
|
|
//
|
|
// Der Weg, ein Ziel vorübergehend aus dem Betrieb zu nehmen, ohne es zu
|
|
// löschen: Ein gelöschter Eintrag nähme allen Backups darin ihre Zuordnung.
|
|
func (store *PostgresStore) UpdateRepositoryStatus(updateContext context.Context,
|
|
repositoryIdentifier uuid.UUID, newStatus RepositoryStatus) (*Repository, error) {
|
|
const updateStatement = `
|
|
UPDATE repositories SET status = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING id, name, repository_type, location, COALESCE(repository_uuid,''), status, hardened,
|
|
COALESCE(enforcement_level,''), enforcement_measured_at, created_at`
|
|
|
|
var (
|
|
updatedRepository Repository
|
|
statusText string
|
|
)
|
|
|
|
scanError := store.connectionPool.QueryRow(updateContext, updateStatement,
|
|
repositoryIdentifier, string(newStatus)).Scan(
|
|
&updatedRepository.ID, &updatedRepository.Name, &updatedRepository.RepositoryType,
|
|
&updatedRepository.Location, &updatedRepository.RepositoryUUID, &statusText,
|
|
&updatedRepository.Hardened, &updatedRepository.EnforcementLevel,
|
|
&updatedRepository.EnforcementMeasuredAt, &updatedRepository.CreatedAt)
|
|
|
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
|
return nil, ErrRepositoryNotFound
|
|
}
|
|
|
|
if scanError != nil {
|
|
return nil, fmt.Errorf("der zustand konnte nicht geändert werden: %w", scanError)
|
|
}
|
|
|
|
updatedRepository.Status = RepositoryStatus(statusText)
|
|
|
|
return &updatedRepository, nil
|
|
}
|
|
|
|
// isUniqueViolationOn erkennt eine verletzte Eindeutigkeitsbedingung.
|
|
//
|
|
// Über den Fehlercode und den Namen der Bedingung, nicht über den Meldungstext:
|
|
// Ein Textvergleich bräche bei der ersten übersetzten Meldung von PostgreSQL,
|
|
// und zwar unbemerkt.
|
|
func isUniqueViolationOn(occurredError error, constraintName string) bool {
|
|
var databaseError *pgconn.PgError
|
|
|
|
if !errors.As(occurredError, &databaseError) {
|
|
return false
|
|
}
|
|
|
|
return databaseError.Code == "23505" && databaseError.ConstraintName == constraintName
|
|
}
|