Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8100fa3d14 | ||
|
|
ae214f1731 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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: alle drei Ergebnislisten
|
||||
(`missing_in_storage`, `orphaned_in_storage`, `existing_in_storage`)
|
||||
nach `storage_key` aufsteigend sortiert.
|
||||
|
||||
**Nachtrag (nach Rückfrage vor BAK-08-Start):** Der ursprüngliche Report
|
||||
enthielt nur die beiden Abweichungslisten – keine Liste der bestätigt
|
||||
existierenden Objekte. Für BAK-08 als Stichprobengrundlage reicht
|
||||
"keine Abweichung" nicht, es braucht die tatsächliche, deterministisch
|
||||
sortierte Liste. Ergänzt: `Report.ExistingInStorage` – DB-Eintrag UND
|
||||
Storage-Objekt beide vorhanden, reine Existenzbestätigung (keine
|
||||
Inhaltsprüfung, Scope-Trennung zu BAK-08 bleibt gewahrt), aufsteigend
|
||||
nach `storage_key` sortiert. BAK-08 zieht seine Stichprobe daraus, ohne
|
||||
selbst zu sortieren/filtern. Neuer Test
|
||||
`TestReconcile_ExistingInStorageIsStableSamplingBasis` beweist Inhalt
|
||||
und Sortierung. Real neu gebaut, getestet (9/9) und auf 131 erneut
|
||||
ausgelöst – Journal zeigt das Feld `existing_in_storage` im Report.
|
||||
|
||||
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: 9/9 Tests bestanden (6 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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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"`
|
||||
// ExistingInStorage: Datenbankeintrag UND Storage-Objekt beide
|
||||
// vorhanden — reine Existenzbestätigung, KEINE Inhaltsprüfung. Dient
|
||||
// Archive BAK-08 als stabile, deterministisch sortierte
|
||||
// Stichprobengrundlage (nach StorageKey aufsteigend, siehe Report-
|
||||
// Dokumentation oben) — BAK-08 muss dafür selbst nicht mehr
|
||||
// sortieren/filtern.
|
||||
ExistingInStorage []Finding `json:"existing_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, existing []Finding
|
||||
for _, e := range dbEntries {
|
||||
if !storageSet[e.StorageKey] {
|
||||
missing = append(missing, Finding(e))
|
||||
} else {
|
||||
existing = append(existing, 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 })
|
||||
sort.Slice(existing, func(i, j int) bool { return existing[i].StorageKey < existing[j].StorageKey })
|
||||
|
||||
return Report{
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
MissingInStorage: missing,
|
||||
OrphanedInStorage: orphaned,
|
||||
ExistingInStorage: existing,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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_ExistingInStorageIsStableSamplingBasis ist der Nachweis,
|
||||
// dass Reconcile eine deterministisch sortierte Liste ALLER bestaetigt
|
||||
// existierenden Objekte liefert (DB-Eintrag UND Storage-Objekt vorhanden)
|
||||
// - dies ist die Stichprobengrundlage, die Archive BAK-08 weiterverwendet,
|
||||
// ohne selbst neu zu sortieren/filtern.
|
||||
func TestReconcile_ExistingInStorageIsStableSamplingBasis(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/fehlt/revisions/r1", DocumentID: "fehlt", RevisionID: "r1"},
|
||||
}
|
||||
storage := []string{
|
||||
"documents/z/revisions/r1",
|
||||
"documents/a/revisions/r1",
|
||||
}
|
||||
|
||||
report := Reconcile(db, storage)
|
||||
|
||||
want := []string{"documents/a/revisions/r1", "documents/z/revisions/r1"}
|
||||
if len(report.ExistingInStorage) != len(want) {
|
||||
t.Fatalf("erwartet %d bestaetigt existierende objekte, habe %d: %+v", len(want), len(report.ExistingInStorage), report.ExistingInStorage)
|
||||
}
|
||||
for i, w := range want {
|
||||
if report.ExistingInStorage[i].StorageKey != w {
|
||||
t.Fatalf("sortierreihenfolge falsch: %v, want beginnend mit %v", report.ExistingInStorage, want)
|
||||
}
|
||||
}
|
||||
if len(report.MissingInStorage) != 1 || report.MissingInStorage[0].StorageKey != "documents/fehlt/revisions/r1" {
|
||||
t.Fatalf("missing_in_storage unerwartet: %+v", report.MissingInStorage)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user