// scrub-cli ist der Aufrufpunkt fuer BAK-08 (systemd-Timer, konfigurierbare // Kadenz) — zieht eine Stichprobe existierender Objekte (BAK-05 als // Existenz-Quelle), prueft deren Inhalt per SHA-256 gegen // file_revisions.checksum_sha256, meldet Abweichungen (kein Auto-Repair) // und schreibt den Befund-Zaehler fuer den OPS-05/OPS-03-Metrik-Export. package main import ( "context" "encoding/json" "log" "os" "strconv" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcile" "gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub" ) type finding struct { StorageKey string `json:"storage_key"` DocumentID string `json:"document_id"` RevisionID string `json:"revision_id"` Expected string `json:"expected_checksum"` Actual string `json:"actual_checksum,omitempty"` Error string `json:"error,omitempty"` } type report struct { GeneratedAt time.Time `json:"generated_at"` Sampled int `json:"sampled"` Findings []finding `json:"findings"` } func main() { dsn := os.Getenv("NEXARCH_SCRUB_TENANT_DSN") storageDir := os.Getenv("NEXARCH_SCRUB_STORAGE_DIR") if dsn == "" || storageDir == "" { log.Fatal("NEXARCH_SCRUB_TENANT_DSN und NEXARCH_SCRUB_STORAGE_DIR muessen gesetzt sein") } sampleSize := envInt("NEXARCH_SCRUB_SAMPLE_SIZE", 10) cooldown := envDuration("NEXARCH_SCRUB_COOLDOWN", 24*time.Hour) 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) } rec := reconcile.Reconcile(dbEntries, storageKeys) lastScrubbed, err := scrub.LoadLastScrubbed(ctx, pool) if err != nil { log.Fatalf("scrub-zustand lesen: %v", err) } now := time.Now().UTC() candidates := scrub.Sample(rec.ExistingInStorage, lastScrubbed, cooldown, sampleSize, now) keys := make([]string, 0, len(candidates)) for _, c := range candidates { keys = append(keys, c.StorageKey) } expected, err := scrub.ExpectedChecksums(ctx, pool, keys) if err != nil { log.Fatalf("erwartete pruefsummen lesen: %v", err) } rep := report{GeneratedAt: now, Sampled: len(candidates)} for _, c := range candidates { exp, known := expected[c.StorageKey] if !known { // Objekt in DB nicht (mehr) auffindbar - das ist BAK-05s // Zustaendigkeit (existiert der Datenbankeintrag?), nicht // dieses Jobs; ueberspringen ohne Markierung. continue } actual, readErr := scrub.ActualChecksum(storageDir, c.StorageKey) ok := readErr == nil && actual == exp if err := scrub.MarkScrubbed(ctx, pool, c.StorageKey, ok, now); err != nil { log.Fatalf("scrub-zustand schreiben: %v", err) } if !ok { f := finding{StorageKey: c.StorageKey, DocumentID: c.DocumentID, RevisionID: c.RevisionID, Expected: exp, Actual: actual} if readErr != nil { f.Error = readErr.Error() } rep.Findings = append(rep.Findings, f) if err := scrub.RecordFinding(ctx, pool); err != nil { log.Fatalf("befund-zaehler erhoehen: %v", err) } } } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") if err := encoder.Encode(rep); err != nil { log.Fatalf("bericht ausgeben: %v", err) } // Befund wird gemeldet, nicht automatisch repariert (Akzeptanzkriterium // 3) - der Exit-Code macht das fuer systemd/Monitoring sichtbar, ohne // selbst etwas zu reparieren; die tatsaechliche Meldung an OPS-05 // laeuft ueber den separaten /metrics-Export (cmd/scrub-metrics), nicht // ueber diesen Exit-Code. if len(rep.Findings) > 0 { os.Exit(1) } } func envInt(name string, def int) int { v := os.Getenv(name) if v == "" { return def } n, err := strconv.Atoi(v) if err != nil { log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err) } return n } func envDuration(name string, def time.Duration) time.Duration { v := os.Getenv(name) if v == "" { return def } d, err := time.ParseDuration(v) if err != nil { log.Fatalf("%s: ungueltiger wert %q: %v", name, v, err) } return d }