Establish pre-production record disposition and add the migration tool
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

AUDIT-WP-0005-T04.

Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.

The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.

audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.

Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.

Tests 71 -> 77.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-10 17:31:18 +02:00
parent bd274f6269
commit 88d16847ff
5 changed files with 455 additions and 2 deletions

View file

@ -69,9 +69,53 @@ def build_parser() -> argparse.ArgumentParser:
cleanup_parser.add_argument("--retention-days", type=int, default=None)
cleanup_parser.set_defaults(func=cleanup)
migrate_parser = sub.add_parser(
"migrate-store", help="Move audit records from a SQLite store into PostgreSQL."
)
migrate_parser.add_argument("--from-sqlite", required=True, dest="source")
migrate_parser.add_argument(
"--to-postgres", dest="destination", default=None,
help="DSN; defaults to AUDIT_CORE_DATABASE_URL",
)
migrate_parser.add_argument("--schema", default="audit_core")
migrate_parser.add_argument(
"--dry-run", action="store_true",
help="Report what would move without writing anything.",
)
migrate_parser.set_defaults(func=migrate_store)
return parser
def migrate_store(args: argparse.Namespace) -> int:
import os
from audit_core.migrate_store import migrate
if args.dry_run:
import sqlite3
with sqlite3.connect(f"file:{args.source}?mode=ro", uri=True) as source:
count = source.execute("SELECT count(*) FROM events").fetchone()[0]
print(json.dumps({"dry_run": True, "events_in_source": count}, sort_keys=True))
return 0
from audit_core.postgres_backend import PostgresAuditBackend
dsn = args.destination or os.environ.get("AUDIT_CORE_DATABASE_URL")
if not dsn:
raise SystemExit("--to-postgres or AUDIT_CORE_DATABASE_URL is required")
backend = PostgresAuditBackend(dsn, schema=args.schema)
try:
report = migrate(args.source, backend)
finally:
backend.close()
print(report.summary())
# A conflict or a failed verification is not a partial success.
return 0 if report.ok else 1
def main() -> int:
parser = build_parser()
args = parser.parse_args()