syncova-backup/packages/jobs/repository_store.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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
}