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 }