From ae214f17317aa5c44e7dcbe268f5f1303b505b95 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 29 Aug 2026 23:24:02 +0200 Subject: [PATCH] feat(archive): BAK-05 Reconciliation Storage vs. DB Deterministischer Abgleich zwischen DMS file_revisions und Objekt-Storage-Verzeichnis, existenz-only (keine Inhaltspruefung, saubere Abgrenzung zu BAK-08). Report sortiert nach storage_key fuer stabile Weiterverarbeitung durch BAK-08. Systemd-Timer taeglich, real auf 192.168.1.131 verdrahtet und ausgeloest. --- archive/cmd/reconcile-cli/main.go | 55 +++++++ archive/docs/BAK-05-PRUEFPROTOKOLL.md | 108 ++++++++++++++ archive/go.mod | 11 ++ archive/go.sum | 28 ++++ archive/internal/reconcile/reconcile.go | 95 ++++++++++++ archive/internal/reconcile/reconcile_test.go | 137 ++++++++++++++++++ archive/internal/reconcile/sources.go | 65 +++++++++ archive/internal/reconcile/sources_test.go | 132 +++++++++++++++++ .../nexarch-archive-reconcile.service.tmpl | 10 ++ .../nexarch-archive-reconcile.timer.tmpl | 9 ++ 10 files changed, 650 insertions(+) create mode 100644 archive/cmd/reconcile-cli/main.go create mode 100644 archive/docs/BAK-05-PRUEFPROTOKOLL.md create mode 100644 archive/go.sum create mode 100644 archive/internal/reconcile/reconcile.go create mode 100644 archive/internal/reconcile/reconcile_test.go create mode 100644 archive/internal/reconcile/sources.go create mode 100644 archive/internal/reconcile/sources_test.go create mode 100644 deploy/systemd/nexarch-archive-reconcile.service.tmpl create mode 100644 deploy/systemd/nexarch-archive-reconcile.timer.tmpl diff --git a/archive/cmd/reconcile-cli/main.go b/archive/cmd/reconcile-cli/main.go new file mode 100644 index 0000000..af2aab9 --- /dev/null +++ b/archive/cmd/reconcile-cli/main.go @@ -0,0 +1,55 @@ +// reconcile-cli ist der Aufrufpunkt für BAK-05, für systemd-Timer gedacht +// (siehe deploy/systemd/) — "geplanter Abgleichs-Job" (Ticket-Vorgabe) +// entsteht durch die Timer-Definition, kein eigener Dauerprozess. +package main + +import ( + "context" + "encoding/json" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile" +) + +func main() { + dsn := os.Getenv("NEXARCH_RECONCILE_TENANT_DSN") + storageDir := os.Getenv("NEXARCH_RECONCILE_STORAGE_DIR") + if dsn == "" || storageDir == "" { + log.Fatal("NEXARCH_RECONCILE_TENANT_DSN und NEXARCH_RECONCILE_STORAGE_DIR muessen gesetzt sein") + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("datenbankverbindung: %v", err) + } + defer pool.Close() + + dbEntries, err := reconcile.ListDBStorageKeys(ctx, pool) + if err != nil { + log.Fatalf("datenbank-eintraege lesen: %v", err) + } + storageKeys, err := reconcile.ListStorageObjects(storageDir) + if err != nil { + log.Fatalf("objekt-storage durchlaufen: %v", err) + } + + report := reconcile.Reconcile(dbEntries, storageKeys) + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + log.Fatalf("bericht ausgeben: %v", err) + } + + // Nicht-null-Exit-Code bei Abweichungen (Akzeptanzkriterium 3: + // Abweichungen werden BERICHTET, nicht automatisch behoben — der + // Exit-Code macht das fuer systemd/Monitoring sichtbar, OHNE selbst + // irgendetwas zu reparieren). + if !report.IsClean() { + os.Exit(1) + } +} diff --git a/archive/docs/BAK-05-PRUEFPROTOKOLL.md b/archive/docs/BAK-05-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..ff71fbd --- /dev/null +++ b/archive/docs/BAK-05-PRUEFPROTOKOLL.md @@ -0,0 +1,108 @@ +# BAK-05 – Prüfprotokoll: Reconciliation / Konsistenzprüfung Storage vs. DB + +Voraussetzung BAK-01, BAK-02 (Welle 1) – erledigt, siehe eigene Protokolle. + +## Grundsatzentscheidung: reine Funktion + zwei Quell-Adapter + +`internal/reconcile.Reconcile` ist eine reine Funktion ohne DB-/Storage- +Zugriff (leicht ohne echte Infrastruktur testbar), die Ein- und +Auslesen echter Systeme ist strikt in `sources.go` getrennt +(`ListDBStorageKeys` gegen echtes Postgres, `ListStorageObjects` gegen +echtes Dateisystem). Beide Seiten liefern nur SCHLÜSSEL – niemals Inhalt +– dadurch bleibt BAK-05 sauber getrennt von BAK-08 (Inhalts-/Prüfsummen- +verifikation, eigene Fehlerklasse, eigenes Ticket). + +Report-Format bewusst deterministisch: beide Ergebnislisten +(`missing_in_storage`, `orphaned_in_storage`) nach `storage_key` +aufsteigend sortiert, damit BAK-08 später dieselbe Objektliste als +stabile Stichprobenquelle weiterverarbeiten kann, ohne selbst neu zu +sortieren/filtern (Nutzervorgabe). + +Meldeweg über OPS-05 (wie später BAK-08) wurde als offene Design-Frage +aufgeworfen, aber nicht zur Vorbedingung gemacht – hier bewusst noch +nicht umgesetzt (kein OPS-05-Abhängigkeitseintrag im Board für BAK-05); +Report wird aktuell nur als JSON auf stdout ausgegeben und per +Exit-Code (1 bei Abweichungen) für systemd/Monitoring sichtbar gemacht. +Anbindung an OPS-05 kann bei Bedarf nachgezogen werden, ohne +`Reconcile` selbst zu ändern. + +## Umsetzung + +- `internal/reconcile.Reconcile(dbEntries, storageKeys) Report` – reine + Vergleichsfunktion, liefert `MissingInStorage`/`OrphanedInStorage`, + `Report.IsClean()` als eindeutiges Sauber-Merkmal. +- `internal/reconcile.ListDBStorageKeys` – liest `file_revisions` + (DMS FDN-02) per direktem SQL aus derselben physischen Tenant-DB + (Modell C, Core TEN-01) – kein Import von DMS-Go-Paketen möglich + (eigenes Go-Modul), daher reiner SQL-Zugriff gegen das dokumentierte + Schema. +- `internal/reconcile.ListStorageObjects` – durchläuft den lokalen + FDN-03-`LocalDriver`-Basisordner (`filepath.WalkDir`), liefert `nil, + nil` bei fehlendem Verzeichnis statt Fehler (noch keine Objekte ist + kein Fehlerzustand). +- `cmd/reconcile-cli` – liest `NEXARCH_RECONCILE_TENANT_DSN` und + `NEXARCH_RECONCILE_STORAGE_DIR`, gibt Report als JSON auf stdout aus, + Exit-Code 1 bei Abweichungen. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Datenbankeintrag ohne Storage-Objekt wird erkannt | **bestanden** — `TestReconcile_DetectsMissingInStorage` | +| 2 | Storage-Objekt ohne Datenbankeintrag wird erkannt | **bestanden** — `TestReconcile_DetectsOrphanedInStorage` | +| 3 | Lauf ohne Abweichungen liefert leeren, eindeutig sauberen Bericht | **bestanden** — `TestReconcile_CleanRunProducesEmptyReport` (zusätzlich `IsClean()`-Konsistenzprüfung) | + +Zusätzlich (Nutzervorgaben, nicht explizit im Ticket als Pflichtprüfung +benannt, aber zentral für die Abgrenzung/Weiterverwendbarkeit): + +- `TestReconcile_ExistingButCorruptedObjectProducesNoFinding` – Nachweis, + dass Reconcile AUSSCHLIESSLICH Existenz prüft, niemals Inhalt (Trennung + von BAK-08). +- `TestReconcile_DeterministicOrdering` – zwei Läufe mit identischer + Eingabe liefern identische Reihenfolge, aufsteigend nach `storage_key`. +- `TestListDBStorageKeys_ReadsRealFileRevisions` – liest echt gegen die + gemeinsame Tenant-Testdatenbank `dms_tenant_test` (reales DMS-FDN-02- + Schema, kein Mock). +- `TestListStorageObjects_WalksRealDirectory` / + `_MissingDirectoryReturnsEmpty` – echtes Dateisystem, kein Mock. + +## Echte Verdrahtung auf 192.168.1.131 + +- `reconcile-cli` gebaut nach `/opt/nexarch-archive/bin/` +- `/etc/nexarch/archive-reconcile.env` (0600): `NEXARCH_RECONCILE_TENANT_DSN` + zeigt auf die gemeinsame Tenant-Testdatenbank `dms_tenant_test` + (DMS selbst läuft auf 192.168.1.131 noch nicht als eigener systemd- + Dienst mit persistenter Konfiguration – dies ist die real verfügbare + Tenant-DB mit echtem FDN-02-Schema, dokumentierter bekannter Stand, + kein stiller Mock); `NEXARCH_RECONCILE_STORAGE_DIR` zeigt auf + `/var/nexarch-archiv/dms-objects` (persistentes ZFS-Dataset, NICHT + `/var/nexarch-test/`). +- Timer `nexarch-archive-reconcile.timer` installiert und aktiviert + (täglich 05:00 UTC), `systemctl list-timers` bestätigt scharf. +- `systemctl start nexarch-archive-reconcile.service` real ausgelöst: + `status=0/SUCCESS`, Journal zeigt echten JSON-Report + (`missing_in_storage: null, orphaned_in_storage: null` – Tenant-DB + aktuell leer, daher sauberer Bericht, keine synthetische Ausgabe). + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 3/3 Pakete mit Tests ok (internal/backup, internal/objectbackup, internal/reconcile), 0 Fehlschläge +``` + +`internal/reconcile`-Tests separat mit gesetzter `TEST_TENANT_DSN` gegen +`dms_tenant_test` verifiziert: 8/8 Tests bestanden (5 reine +`Reconcile`-Tests + 3 `sources.go`-Integrationstests). + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle Pflicht- sowie +Nutzervorgaben-Prüfungen real erfüllt (echte Postgres-Instanz, echtes +Dateisystem, echter systemd-Lauf). Zwei Testfehler während der +Entwicklung (Schema-Abweichung `revision_number` NOT NULL in der realen +`dms_tenant_test`-Tabelle; inkonsistente Fixture-Daten in +`TestReconcile_DeterministicOrdering`) gefunden und korrigiert – beide +waren Testautorenfehler, keine Fehler in `Reconcile` selbst. diff --git a/archive/go.mod b/archive/go.mod index 58807a5..fb53095 100644 --- a/archive/go.mod +++ b/archive/go.mod @@ -1,3 +1,14 @@ module gitea.perlbach24.de/scripte/nexarch/archive go 1.22 + +require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/archive/go.sum b/archive/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/archive/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/archive/internal/reconcile/reconcile.go b/archive/internal/reconcile/reconcile.go new file mode 100644 index 0000000..06d3d18 --- /dev/null +++ b/archive/internal/reconcile/reconcile.go @@ -0,0 +1,95 @@ +// Package reconcile implementiert BAK-05: periodischer Abgleich, ob jeder +// in der Datenbank referenzierte Objekt-Storage-Eintrag tatsächlich +// existiert und umgekehrt. Prüft AUSSCHLIESSLICH Existenz — niemals +// Inhalt (das ist Archive BAK-08, eine eigene Fehlerklasse, bewusst nicht +// hier mit hineingezogen, siehe reconcile_test.go +// TestReconcile_ExistingButCorruptedObjectProducesNoFinding). +package reconcile + +import ( + "sort" + "time" +) + +// Finding ist EIN Abweichungsfund — entweder ein Datenbankeintrag ohne +// Storage-Objekt oder umgekehrt. +type Finding struct { + StorageKey string `json:"storage_key"` + DocumentID string `json:"document_id,omitempty"` + RevisionID string `json:"revision_id,omitempty"` +} + +// Report ist das Ergebnis EINES Abgleichslaufs (Akzeptanzkriterium 3: +// Abweichungen werden BERICHTET, nicht automatisch behoben — Report ist +// reine Information, keine Reparaturfunktion existiert in diesem Paket). +// +// Beide Listen sind nach StorageKey aufsteigend sortiert — bei gleicher +// Eingabe liefert Reconcile IMMER dieselbe Reihenfolge (deterministisch), +// damit ein nachgelagerter Verbraucher (Archive BAK-08: zieht seine +// Stichprobe aus der Liste der EXISTIERENDEN Objekte) sich auf eine +// stabile Sortierung verlassen kann, statt bei jedem Lauf neu zu +// filtern/sortieren. +type Report struct { + GeneratedAt time.Time `json:"generated_at"` + // MissingInStorage: Datenbankeintrag vorhanden, Objekt im Storage fehlt + // (Akzeptanzkriterium 1). + MissingInStorage []Finding `json:"missing_in_storage"` + // OrphanedInStorage: Objekt im Storage vorhanden, kein Datenbankeintrag + // (Akzeptanzkriterium 2). + OrphanedInStorage []Finding `json:"orphaned_in_storage"` +} + +// IsClean liefert true, wenn der Lauf keine Abweichungen fand (Pflicht- +// prüfung 3: "Lauf ohne Abweichungen liefert einen leeren, eindeutig als +// sauber erkennbaren Bericht" — IsClean ist genau dieses eindeutige +// Erkennungsmerkmal, statt dass ein Aufrufer beide Listen selbst auf +// Leere prüfen muss). +func (r Report) IsClean() bool { + return len(r.MissingInStorage) == 0 && len(r.OrphanedInStorage) == 0 +} + +// DBEntry ist ein Datenbankeintrag, wie ihn ListDBStorageKeys liefert. +type DBEntry struct { + StorageKey string + DocumentID string + RevisionID string +} + +// Reconcile vergleicht dbEntries (aus file_revisions.storage_key, DMS +// FDN-02) gegen storageKeys (tatsächlich im Objekt-Storage vorhandene +// Schlüssel, z.B. per Verzeichnis-Walk des FDN-03-LocalDriver- +// Basisverzeichnisses) und liefert die Abweichungen in beide Richtungen. +// Reine Funktion — kein Datenbank-/Storage-Zugriff hier, dadurch ohne +// echte Infrastruktur testbar (siehe reconcile_test.go). +func Reconcile(dbEntries []DBEntry, storageKeys []string) Report { + storageSet := make(map[string]bool, len(storageKeys)) + for _, k := range storageKeys { + storageSet[k] = true + } + dbSet := make(map[string]DBEntry, len(dbEntries)) + for _, e := range dbEntries { + dbSet[e.StorageKey] = e + } + + var missing []Finding + for _, e := range dbEntries { + if !storageSet[e.StorageKey] { + missing = append(missing, Finding(e)) + } + } + var orphaned []Finding + for _, k := range storageKeys { + if _, ok := dbSet[k]; !ok { + orphaned = append(orphaned, Finding{StorageKey: k}) + } + } + + sort.Slice(missing, func(i, j int) bool { return missing[i].StorageKey < missing[j].StorageKey }) + sort.Slice(orphaned, func(i, j int) bool { return orphaned[i].StorageKey < orphaned[j].StorageKey }) + + return Report{ + GeneratedAt: time.Now().UTC(), + MissingInStorage: missing, + OrphanedInStorage: orphaned, + } +} diff --git a/archive/internal/reconcile/reconcile_test.go b/archive/internal/reconcile/reconcile_test.go new file mode 100644 index 0000000..30be1e4 --- /dev/null +++ b/archive/internal/reconcile/reconcile_test.go @@ -0,0 +1,137 @@ +package reconcile + +import "testing" + +// TestReconcile_DetectsMissingInStorage ist Akzeptanzkriterium 1 / Pruefung +// 1: ein Datenbankeintrag ohne zugehoeriges Objekt im Storage wird erkannt. +func TestReconcile_DetectsMissingInStorage(t *testing.T) { + db := []DBEntry{ + {StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"}, + {StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"}, + } + storage := []string{"documents/d1/revisions/r1"} // d2/r1 fehlt absichtlich + + report := Reconcile(db, storage) + + if len(report.MissingInStorage) != 1 { + t.Fatalf("erwartet 1 fund in missing_in_storage, habe %d: %+v", len(report.MissingInStorage), report.MissingInStorage) + } + if report.MissingInStorage[0].StorageKey != "documents/d2/revisions/r1" { + t.Fatalf("unerwarteter fund: %+v", report.MissingInStorage[0]) + } + if len(report.OrphanedInStorage) != 0 { + t.Fatalf("erwartet 0 funde in orphaned_in_storage, habe %d", len(report.OrphanedInStorage)) + } +} + +// TestReconcile_DetectsOrphanedInStorage ist Akzeptanzkriterium 2 / +// Pruefung 2: ein Storage-Objekt ohne Datenbankeintrag wird erkannt. +func TestReconcile_DetectsOrphanedInStorage(t *testing.T) { + db := []DBEntry{ + {StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"}, + } + storage := []string{ + "documents/d1/revisions/r1", + "documents/verwaist/revisions/r1", // kein DB-Eintrag dafuer + } + + report := Reconcile(db, storage) + + if len(report.OrphanedInStorage) != 1 { + t.Fatalf("erwartet 1 fund in orphaned_in_storage, habe %d: %+v", len(report.OrphanedInStorage), report.OrphanedInStorage) + } + if report.OrphanedInStorage[0].StorageKey != "documents/verwaist/revisions/r1" { + t.Fatalf("unerwarteter fund: %+v", report.OrphanedInStorage[0]) + } + if len(report.MissingInStorage) != 0 { + t.Fatalf("erwartet 0 funde in missing_in_storage, habe %d", len(report.MissingInStorage)) + } +} + +// TestReconcile_CleanRunProducesEmptyReport ist Pruefung 3: Lauf ohne +// Abweichungen liefert einen leeren, eindeutig als sauber erkennbaren +// Bericht. +func TestReconcile_CleanRunProducesEmptyReport(t *testing.T) { + db := []DBEntry{ + {StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"}, + {StorageKey: "documents/d2/revisions/r1", DocumentID: "d2", RevisionID: "r1"}, + } + storage := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"} + + report := Reconcile(db, storage) + + if !report.IsClean() { + t.Fatalf("erwartet sauberen bericht, habe missing=%v orphaned=%v", report.MissingInStorage, report.OrphanedInStorage) + } + if len(report.MissingInStorage) != 0 || len(report.OrphanedInStorage) != 0 { + t.Fatal("IsClean()==true, aber listen sind nicht leer - widerspruch") + } +} + +// TestReconcile_ExistingButCorruptedObjectProducesNoFinding ist der +// Nachweis, dass BAK-05 AUSSCHLIESSLICH Existenz prueft, niemals Inhalt +// (die Fehlerklasse "existiert, aber Inhalt beschaedigt" ist Archive +// BAK-08, bewusst nicht hier) — Reconcile bekommt nur SCHLUESSEL, hat gar +// keine Moeglichkeit, auf Inhalt zuzugreifen; dieser Test dokumentiert die +// Absicht explizit, damit sie nicht versehentlich spaeter aufgeweicht wird. +func TestReconcile_ExistingButCorruptedObjectProducesNoFinding(t *testing.T) { + db := []DBEntry{ + {StorageKey: "documents/d1/revisions/r1", DocumentID: "d1", RevisionID: "r1"}, + } + // "korruptes" Objekt hier rein simuliert durch denselben Schluessel - + // Reconcile kennt und prueft keinen Inhalt, nur den Schluessel selbst. + storage := []string{"documents/d1/revisions/r1"} + + report := Reconcile(db, storage) + + if !report.IsClean() { + t.Fatalf("ein existierendes (wenn auch inhaltlich korruptes) objekt haette KEINEN befund ausloesen duerfen, habe: %+v", report) + } +} + +// TestReconcile_DeterministicOrdering ist der Nachweis fuer die +// Stabilitaets-Anforderung: gleiche Eingabe liefert bei mehreren Laeufen +// IMMER dieselbe Reihenfolge (Voraussetzung dafuer, dass Archive BAK-08 +// die Liste der existierenden Objekte stabil weiterverarbeiten kann, ohne +// selbst neu zu sortieren/filtern). +func TestReconcile_DeterministicOrdering(t *testing.T) { + db := []DBEntry{ + {StorageKey: "documents/z/revisions/r1", DocumentID: "z", RevisionID: "r1"}, + {StorageKey: "documents/a/revisions/r1", DocumentID: "a", RevisionID: "r1"}, + {StorageKey: "documents/m/revisions/r1", DocumentID: "m", RevisionID: "r1"}, + } + storage := []string{ + "documents/a/revisions/r1", // deckt genau den DB-Eintrag "a" ab + "documents/y/revisions/r1", + "documents/n/revisions/r1", + } + + first := Reconcile(db, storage) + second := Reconcile(db, storage) + + if len(first.MissingInStorage) != len(second.MissingInStorage) { + t.Fatal("unterschiedliche anzahl funde zwischen zwei laeufen mit identischer eingabe") + } + for i := range first.MissingInStorage { + if first.MissingInStorage[i].StorageKey != second.MissingInStorage[i].StorageKey { + t.Fatalf("reihenfolge in missing_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q", + i, first.MissingInStorage[i].StorageKey, i, second.MissingInStorage[i].StorageKey) + } + } + for i := range first.OrphanedInStorage { + if first.OrphanedInStorage[i].StorageKey != second.OrphanedInStorage[i].StorageKey { + t.Fatalf("reihenfolge in orphaned_in_storage nicht deterministisch: lauf1[%d]=%q lauf2[%d]=%q", + i, first.OrphanedInStorage[i].StorageKey, i, second.OrphanedInStorage[i].StorageKey) + } + } + // Aufsteigend sortiert (a < m < z), nicht Einfuegereihenfolge. + wantOrder := []string{"documents/m/revisions/r1", "documents/z/revisions/r1"} + if len(first.MissingInStorage) != len(wantOrder) { + t.Fatalf("erwartet %d funde, habe %d", len(wantOrder), len(first.MissingInStorage)) + } + for i, w := range wantOrder { + if first.MissingInStorage[i].StorageKey != w { + t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", first.MissingInStorage, wantOrder) + } + } +} diff --git a/archive/internal/reconcile/sources.go b/archive/internal/reconcile/sources.go new file mode 100644 index 0000000..9092a0c --- /dev/null +++ b/archive/internal/reconcile/sources.go @@ -0,0 +1,65 @@ +package reconcile + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ListDBStorageKeys liest alle storage_key-Werte aus file_revisions +// (DMS FDN-02) — Archive liest direkt aus derselben physischen +// Tenant-Datenbank (Modell C, Core TEN-01), OHNE DMS-Go-Pakete zu +// importieren (Archive ist ein eigenes Go-Modul) — reiner SQL-Zugriff +// gegen das dokumentierte Schema, sortiert nach storage_key für +// deterministische Reconcile-Ergebnisse. +func ListDBStorageKeys(ctx context.Context, pool *pgxpool.Pool) ([]DBEntry, error) { + rows, err := pool.Query(ctx, ` + SELECT storage_key, document_id, id FROM file_revisions ORDER BY storage_key + `) + if err != nil { + return nil, fmt.Errorf("reconcile: file_revisions abfragen: %w", err) + } + defer rows.Close() + + var entries []DBEntry + for rows.Next() { + var e DBEntry + if err := rows.Scan(&e.StorageKey, &e.DocumentID, &e.RevisionID); err != nil { + return nil, fmt.Errorf("reconcile: file_revisions-zeile lesen: %w", err) + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// ListStorageObjects durchläuft den lokalen FDN-03-LocalDriver- +// Basisordner und liefert alle vorhandenen Objektschlüssel (Pfad relativ +// zu baseDir, mit "/" als Trenner — dasselbe Format wie +// storage.ObjectKey aus FDN-03), sortiert. +func ListStorageObjects(baseDir string) ([]string, error) { + var keys []string + err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(baseDir, path) + if err != nil { + return err + } + keys = append(keys, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reconcile: objekt-storage durchlaufen: %w", err) + } + return keys, nil +} diff --git a/archive/internal/reconcile/sources_test.go b/archive/internal/reconcile/sources_test.go new file mode 100644 index 0000000..4cca265 --- /dev/null +++ b/archive/internal/reconcile/sources_test.go @@ -0,0 +1,132 @@ +package reconcile + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func requireTestPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + // Minimalschema, das exakt DMS FDN-02s file_revisions-Spalten spiegelt + // (Archive kann DMS' internal/-Pakete als eigenes Go-Modul nicht + // importieren, daher hier als Testfixture kopiert statt real migriert). + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS file_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + storage_key TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, size_bytes BIGINT NOT NULL, + mime_type TEXT NOT NULL, revision_number INTEGER NOT NULL, created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE file_revisions, documents, users CASCADE`) + }) + return pool +} + +// TestListDBStorageKeys_ReadsRealFileRevisions ist der Nachweis, dass +// ListDBStorageKeys tatsaechlich gegen eine echte Postgres-Instanz mit +// DMS-FDN-02-Schema liest — kein Mock. +func TestListDBStorageKeys_ReadsRealFileRevisions(t *testing.T) { + pool := requireTestPool(t) + ctx := context.Background() + + var userID, docID string + if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('reconcile-test@example.test', 'Test') RETURNING id`).Scan(&userID); err != nil { + t.Fatalf("testbenutzer anlegen: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO documents (title, created_by) VALUES ('doc', $1) RETURNING id`, userID).Scan(&docID); err != nil { + t.Fatalf("testdokument anlegen: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO file_revisions (document_id, storage_key, checksum_sha256, size_bytes, mime_type, revision_number, created_by) + VALUES ($1, 'documents/x/revisions/1', 'abc', 10, 'text/plain', 1, $2) + `, docID, userID); err != nil { + t.Fatalf("testrevision anlegen: %v", err) + } + + entries, err := ListDBStorageKeys(ctx, pool) + if err != nil { + t.Fatalf("listdbstoragekeys: %v", err) + } + if len(entries) != 1 { + t.Fatalf("erwartet 1 eintrag, habe %d", len(entries)) + } + if entries[0].StorageKey != "documents/x/revisions/1" { + t.Fatalf("storage_key = %q, want %q", entries[0].StorageKey, "documents/x/revisions/1") + } + if entries[0].DocumentID != docID { + t.Fatalf("document_id = %q, want %q", entries[0].DocumentID, docID) + } +} + +// TestListStorageObjects_WalksRealDirectory ist der Nachweis, dass +// ListStorageObjects tatsaechlich das Dateisystem durchlaeuft. +func TestListStorageObjects_WalksRealDirectory(t *testing.T) { + baseDir := t.TempDir() + mustWriteFile(t, filepath.Join(baseDir, "documents", "d1", "revisions", "r1"), "inhalt") + mustWriteFile(t, filepath.Join(baseDir, "documents", "d2", "revisions", "r1"), "inhalt") + + keys, err := ListStorageObjects(baseDir) + if err != nil { + t.Fatalf("liststorageobjects: %v", err) + } + if len(keys) != 2 { + t.Fatalf("erwartet 2 objektschluessel, habe %d: %v", len(keys), keys) + } + want := []string{"documents/d1/revisions/r1", "documents/d2/revisions/r1"} + for i, w := range want { + if keys[i] != w { + t.Fatalf("schluessel[%d] = %q, want %q (voll: %v)", i, keys[i], w, keys) + } + } +} + +// TestListStorageObjects_MissingDirectoryReturnsEmpty prueft das +// Verhalten, wenn das Basisverzeichnis (noch) gar nicht existiert - +// sollte als "keine Objekte", nicht als Fehler behandelt werden. +func TestListStorageObjects_MissingDirectoryReturnsEmpty(t *testing.T) { + keys, err := ListStorageObjects("/pfad/der/nicht/existiert/fuer/diesen/test") + if err != nil { + t.Fatalf("erwartet keinen fehler bei fehlendem verzeichnis, habe: %v", err) + } + if len(keys) != 0 { + t.Fatalf("erwartet 0 schluessel, habe %d", len(keys)) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("verzeichnis anlegen: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("datei schreiben: %v", err) + } +} diff --git a/deploy/systemd/nexarch-archive-reconcile.service.tmpl b/deploy/systemd/nexarch-archive-reconcile.service.tmpl new file mode 100644 index 0000000..944bcb3 --- /dev/null +++ b/deploy/systemd/nexarch-archive-reconcile.service.tmpl @@ -0,0 +1,10 @@ +[Unit] +Description=NEXARCH Archive - Konsistenzpruefung Storage vs. DB (BAK-05) +After=network.target postgresql.service + +[Service] +Type=oneshot +User=nexarch +EnvironmentFile=/etc/nexarch/archive-reconcile.env +ExecStart=__INSTALL_DIR__/bin/reconcile-cli +StandardOutput=journal diff --git a/deploy/systemd/nexarch-archive-reconcile.timer.tmpl b/deploy/systemd/nexarch-archive-reconcile.timer.tmpl new file mode 100644 index 0000000..ab9a866 --- /dev/null +++ b/deploy/systemd/nexarch-archive-reconcile.timer.tmpl @@ -0,0 +1,9 @@ +[Unit] +Description=Taeglicher Zeitplan fuer NEXARCH Archive Konsistenzpruefung (BAK-05) + +[Timer] +OnCalendar=*-*-* 05:00:00 +Persistent=true + +[Install] +WantedBy=timers.target