Prepare independent ledger backup and isolated no-send recovery
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d
This commit is contained in:
parent
c8c018496a
commit
2ff24e991d
11 changed files with 1837 additions and 0 deletions
21
deploy/backup/email-connect-ledger-backup.service
Normal file
21
deploy/backup/email-connect-ledger-backup.service
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[Unit]
|
||||
Description=Coherent encrypted email-connect transactional ledger backup
|
||||
After=network-online.target k3s.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
UMask=0077
|
||||
StateDirectory=email-connect-backup
|
||||
StateDirectoryMode=0700
|
||||
EnvironmentFile=/etc/email-connect-backup/primary.env
|
||||
ExecStart=/usr/bin/python3 /opt/email-connect-backup/backup-ledger.py backup --execute
|
||||
TimeoutStartSec=40min
|
||||
TimeoutStopSec=15min
|
||||
KillMode=mixed
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/email-connect-backup
|
||||
ProtectHome=true
|
||||
11
deploy/backup/email-connect-ledger-backup.timer
Normal file
11
deploy/backup/email-connect-ledger-backup.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[Unit]
|
||||
Description=Daily independent email-connect ledger recovery point
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:15:00 UTC
|
||||
Persistent=true
|
||||
RandomizedDelaySec=5min
|
||||
Unit=email-connect-ledger-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
10
deploy/backup/email-connect-ledger-freshness.service
Normal file
10
deploy/backup/email-connect-ledger-freshness.service
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=Check email-connect ledger archive age and local ciphertext hash
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
ExecStart=/usr/bin/python3 /opt/email-connect-backup/backup-ledger.py freshness
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
10
deploy/backup/email-connect-ledger-freshness.timer
Normal file
10
deploy/backup/email-connect-ledger-freshness.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[Unit]
|
||||
Description=Hourly email-connect ledger backup freshness check
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
Unit=email-connect-ledger-freshness.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
142
docs/ledger-backup-review-2026-10-08.md
Normal file
142
docs/ledger-backup-review-2026-10-08.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Email ledger backup/recovery candidate — 2026-10-08
|
||||
|
||||
EMAIL-WP-0007 implements the independent transactional SQLite recovery scope
|
||||
requested by CSOC-WP-0010-T04/T05. The verification retry release remains live;
|
||||
this candidate does not change that deployment, SMTP/caller credentials or inbox
|
||||
access. The standalone node scripts and four systemd units are prepared for
|
||||
platform admission. No live capture, primary upload or timer activation occurred.
|
||||
|
||||
## Scope and coherence
|
||||
|
||||
The ledger contains deliveries, send_attempts, verification_attempts,
|
||||
verifications and suppressions. Preserve every row, including uncertain outcomes,
|
||||
consumed/cancelled links and failed reservation history. Scanner databases,
|
||||
provider mailboxes/message bodies, SMTP passwords and ingest tokens are separate
|
||||
scopes and are not included. Application database/content backup cannot recover
|
||||
this ledger.
|
||||
|
||||
capture-ledger.py is default server dry-run. It checks exact cluster UID,
|
||||
current immutable receiver image, single Ready replica, Bound email-connect-data
|
||||
PVC and /data/email-connect.db configuration. The helper runs UID10001, mounts
|
||||
only the read-only PVC and bounded scratch space, has no environment credential
|
||||
or ServiceAccount token, and must be selected by no-egress NetworkPolicies.
|
||||
No SMTP code is started in the helper.
|
||||
|
||||
Under admitted --execute, a guarded replica patch pauses the receiver, waits for
|
||||
its pods to disappear and rejects any other ledger PVC consumer. SQLite's backup
|
||||
API reads committed data including WAL; copying only the main file would miss
|
||||
uncheckpointed records. Integrity checks, exact five-table inventory and schema/
|
||||
row fingerprints validate the private download. The default 128MiB SQLite ceiling
|
||||
is a reviewed candidate budget, not a promise of unlimited ledger growth.
|
||||
|
||||
Finally removes the helper and resumes the unchanged receiver. Replica patches
|
||||
test the original deployment template and expected count; a foreign rollout is
|
||||
never overwritten or silently resumed. Failed cleanup/resume cannot declare a
|
||||
successful capture. A nonblocking lock prevents overlapping captures. SIGTERM
|
||||
attempts the same cleanup; host loss/SIGKILL still needs an explicit replica/helper
|
||||
check. Receipts contain counts/hashes/booleans, never row contents or credentials.
|
||||
|
||||
## Publication and recovery
|
||||
|
||||
backup-ledger.py validates the capture, packages only database.sqlite3 and
|
||||
receipt.json, then seals once using the platform public age recipient. A failed
|
||||
publication preserves one private pending directory and blocks another capture;
|
||||
reviewed retry reuses the ciphertext and object key without another pause.
|
||||
Partial encryption remains for review. Successful publication clears only its
|
||||
own staging directory; historical private pre-release copies remain untouched.
|
||||
|
||||
The platform primary_application_archive.py email-connect profile uses
|
||||
platform-pg/application-archives/email-connect/ in the existing primary bucket.
|
||||
Conditional multipart completion, returned version GET and ciphertext size/hash
|
||||
verification gate last-success.json and inventory.json. Independent retrieval
|
||||
requires only a separately retained version/hash/size receipt and recovery caller.
|
||||
No publisher may delete retained archives, access other app prefixes, or hold an
|
||||
age private identity. The dedicated hash-locked platform SDK runtime is reused.
|
||||
|
||||
The candidate node worker expects owner-delivered AWS_ACCESS_KEY_ID and
|
||||
AWS_SECRET_ACCESS_KEY in root-owned mode 0600
|
||||
/etc/email-connect-backup/primary.env, parent mode 0700. A metadata-only node check found
|
||||
that file absent. Existing SMTP custody and the databases backup Secret do not
|
||||
admit this consumer. RPF-WP-0053-T05 tracks its separate prefix/caller, retention/
|
||||
capacity, independently retained receipt and recovery identity admission. No
|
||||
Secret was fetched/copied, ESO scope widened or provider key minted in this work.
|
||||
|
||||
After retrieving ciphertext, the attended custodian runs:
|
||||
|
||||
```bash
|
||||
python3 scripts/backup-ledger.py recover --execute \
|
||||
--archive /private/retrieved.tar.age --transfer-receipt /private/last-success.json \
|
||||
--identity /private/recovery-identity --destination /private/recovered-ledger
|
||||
python3 scripts/rehearse-ledger.py --execute \
|
||||
--recovered /private/recovered-ledger --receipt /private/recovery-proof.json
|
||||
```
|
||||
|
||||
Recovery verifies exact provider key/version/hash/size evidence, age-decrypts
|
||||
privately, inspects the entire packet before extraction, accepts exactly two
|
||||
regular files and rejects traversal, links, duplicates, extras and oversized
|
||||
entries. It checks the inner image/capture/database/schema/row bindings. Files
|
||||
are mode 0600 and directories mode 0700. A failed drill preserves private evidence; no
|
||||
production database path is modified.
|
||||
|
||||
Rehearsal uses the already-cached pinned receiver image under UID10001, read-only
|
||||
root filesystem, bounded tmpfs, dropped capabilities and --network none. It
|
||||
compares schema/all-table row fingerprints before and after opening the current
|
||||
SQLiteDeliveryStore, probes accepted/unknown reservations without a provider,
|
||||
checks invalidated handles/suppression, and verifies the ledger remains unchanged.
|
||||
No SMTP configuration is supplied. Only its own named disposable container is
|
||||
removed on completion/failure. application_restore_verified means this isolated
|
||||
ledger rehearsal; production_restored remains false and recipient inbox/reply
|
||||
operations are not inferred from it.
|
||||
|
||||
## Historical-point recovery gate
|
||||
|
||||
A backup preserves state at its capture point. Operations after that point may
|
||||
include new provider submissions, suppressions, or challenge consumption/
|
||||
cancellation. Restoring an older point alone can lose a reservation and revive a
|
||||
handle consumed later. Therefore an isolated successful drill never releases
|
||||
production traffic automatically. Incident recovery must first fence callers and
|
||||
outbox replay, keep sending/challenge consumption disabled, reconcile post-capture
|
||||
operations with user-engine/provider evidence and the identity owner, then
|
||||
invalidate or reconcile recovered challenges under an explicit incident decision.
|
||||
Only that owner-approved reconciliation may release the receiver. Unknown outcomes
|
||||
must remain unknown; no automatic resend or inference of non-delivery is allowed.
|
||||
An RPO 24h proposal needs explicit acceptance of this loss window; consider a
|
||||
shorter schedule if the ledger owner requires it. Daily timing is not a guarantee
|
||||
of acceptable authentication recovery.
|
||||
|
||||
## Scheduling and operating acceptance
|
||||
|
||||
Proposed daily 04:15 UTC (+up to 5min jitter), hourly freshness threshold 26h and
|
||||
provisional attended RTO 60min are pending targets. Install reviewed scripts in
|
||||
/opt/email-connect-backup/ and four deploy/backup units disabled. Use root-owned mode 0700
|
||||
/var/lib/email-connect-backup. Record source and lock hashes and custody delivery
|
||||
reference. Update both capture/rehearsal image pins under a release review.
|
||||
|
||||
Publisher local encrypted inventory grows until separate owner retention acts.
|
||||
The platform retention preview can propose 14 daily/4 weekly distinct generations
|
||||
and protects newest plus the exact recovered version/hash, but owns no deletion.
|
||||
Admit capacity/headroom and compatibility with the bucket's documented 30-day
|
||||
lifecycle before enabling a schedule. Connect service/freshness failures to the
|
||||
established evidence owner and prove reception/response. Retain public recovery
|
||||
receipts and the recovery image/key independently of node/workstation/provider
|
||||
loss. Production restore and private-copy expiration are separate owner actions.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Full email suite: 120 passed, zero skips, with the local GreenMail SMTP/IMAP harness;
|
||||
19 new synthetic backup/recovery cases. No production account, URL or mail send
|
||||
is used by those tests. Existing 39 transactional regression cases pass.
|
||||
- Actual age synthetic seal/decrypt and packaged rehearsal pass: all five tables,
|
||||
schema and rows preserved; accepted verification replay, unknown verification,
|
||||
invalidated handles, suppression and invitation reservations checked without
|
||||
sending. This is local synthetic evidence, no provider retrieval claim.
|
||||
- Node default server dry-run passes: current pinned image/PVC, no-egress helper,
|
||||
and unchanged receiver specification. No pause, helper creation or upload.
|
||||
- All four systemd units pass systemd-analyze verify. Dedicated primary SDK source
|
||||
is already hash-locked and offline-verified by RPF-WP-0053; actual email caller
|
||||
admission remains missing.
|
||||
|
||||
T01/T02 implementation/rehearsal can close on this evidence; T03/T04 remain open
|
||||
for actual custody, offsite capture/recovery, post-capture reconciliation contract,
|
||||
monitored operation and founder activation. CSOC-WP-0010 remains active for those
|
||||
and caller/sender/rate-limit, reply/bounce/inbox and cross-owner journey acceptance.
|
||||
260
scripts/backup-ledger.py
Normal file
260
scripts/backup-ledger.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Node ledger backup or attended recovery; default performs preview only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ledger_archive import (
|
||||
PREFIX,
|
||||
Refused,
|
||||
checkpoint,
|
||||
command,
|
||||
digest,
|
||||
private_directory,
|
||||
private_file,
|
||||
recover,
|
||||
seal,
|
||||
)
|
||||
|
||||
NAME = re.compile(r"\d{8}T\d{6}Z-[a-f0-9]{24}\.tar\.age")
|
||||
PRIMARY_TOOL = Path("/opt/railiance-platform/scripts/primary_application_archive.py")
|
||||
PRIMARY_PYTHON = Path("/opt/railiance-platform/backup-venv/bin/python")
|
||||
RECIPIENT = "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz"
|
||||
|
||||
|
||||
def publish(encrypted, fetched, receipt_path, captured, tool, python):
|
||||
command(
|
||||
[
|
||||
str(python),
|
||||
str(tool),
|
||||
"transfer",
|
||||
"--application",
|
||||
"email-connect",
|
||||
"--source",
|
||||
str(encrypted),
|
||||
"--output",
|
||||
str(fetched),
|
||||
"--receipt",
|
||||
str(receipt_path),
|
||||
"--captured-at",
|
||||
captured["captured_at"],
|
||||
"--execute",
|
||||
]
|
||||
)
|
||||
private_file(fetched)
|
||||
receipt = json.loads(receipt_path.read_text())
|
||||
if (
|
||||
receipt.get("schema") != "platform.application-primary-archive.v1"
|
||||
or receipt.get("status") != "offsite_fetched_verified"
|
||||
or receipt.get("application") != "email-connect"
|
||||
or receipt.get("captured_at") != captured["captured_at"]
|
||||
or receipt.get("remote_retrieval_verified") is not True
|
||||
or receipt.get("immutable_create") is not True
|
||||
or not receipt.get("version_id")
|
||||
or receipt["version_id"] == "null"
|
||||
or receipt.get("ciphertext_sha256") != digest(encrypted)
|
||||
or receipt.get("ciphertext_sha256") != digest(fetched)
|
||||
or receipt.get("ciphertext_bytes") != encrypted.stat().st_size
|
||||
or not receipt.get("destination", "").startswith(PREFIX)
|
||||
):
|
||||
raise Refused("primary_receipt_not_verified")
|
||||
return receipt
|
||||
|
||||
|
||||
def backup(state, primary_tool, primary_python, recipient, resume=False):
|
||||
state.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
private_directory(state)
|
||||
fd = os.open(state / "backup.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise Refused("backup_already_running") from None
|
||||
pending = list(state.glob("pending-*"))
|
||||
if resume:
|
||||
if len(pending) != 1:
|
||||
raise Refused("one_pending_capture_required")
|
||||
stage = pending[0]
|
||||
private_directory(stage)
|
||||
else:
|
||||
if pending:
|
||||
raise Refused("pending_backup_requires_review")
|
||||
stage = Path(tempfile.mkdtemp(prefix="pending-", dir=state))
|
||||
command(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("capture-ledger.py")),
|
||||
"--execute",
|
||||
"--destination",
|
||||
str(stage / "capture"),
|
||||
],
|
||||
timeout=1800,
|
||||
)
|
||||
encrypted, captured = seal(stage / "capture", stage, recipient)
|
||||
fd, name = tempfile.mkstemp(prefix="readback-", dir=stage)
|
||||
os.close(fd)
|
||||
fetched = Path(name)
|
||||
fetched.unlink()
|
||||
transfer = publish(
|
||||
encrypted,
|
||||
fetched,
|
||||
stage / "primary-transfer.json",
|
||||
captured,
|
||||
primary_tool,
|
||||
primary_python,
|
||||
)
|
||||
filename = transfer["object_key"].rsplit("/", 1)[-1]
|
||||
if not NAME.fullmatch(filename):
|
||||
raise Refused("invalid_archive_name")
|
||||
receipt = dict(
|
||||
transfer,
|
||||
image=captured["image"],
|
||||
database_sha256=captured["database_sha256"],
|
||||
database_bytes=captured["database_bytes"],
|
||||
ledger_fingerprint=captured["ledger_fingerprint"],
|
||||
pause_and_capture_seconds=captured["elapsed_seconds"],
|
||||
local_archive=filename,
|
||||
application_restore_verified=False,
|
||||
completed_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
output = state / filename
|
||||
if output.exists() or output.is_symlink():
|
||||
private_file(output)
|
||||
if digest(output) != receipt["ciphertext_sha256"]:
|
||||
raise Refused("local_archive_collision")
|
||||
else:
|
||||
# Create private file from the start, including manual invocations.
|
||||
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as dst, fetched.open("rb") as src:
|
||||
shutil.copyfileobj(src, dst)
|
||||
dst.flush()
|
||||
os.fsync(dst.fileno())
|
||||
inventory_path = state / "inventory.json"
|
||||
inventory = json.loads(inventory_path.read_text()) if inventory_path.exists() else []
|
||||
if not any(item["object_key"] == receipt["object_key"] for item in inventory):
|
||||
inventory.append(receipt)
|
||||
checkpoint(inventory_path, inventory)
|
||||
checkpoint(state / "last-success.json", receipt)
|
||||
shutil.rmtree(stage)
|
||||
return receipt
|
||||
|
||||
|
||||
def freshness(state, now=None, hours=26):
|
||||
now = now or datetime.now(UTC)
|
||||
private_directory(state)
|
||||
private_file(state / "last-success.json")
|
||||
receipt = json.loads((state / "last-success.json").read_text())
|
||||
stamp = datetime.fromisoformat(receipt["captured_at"].replace("Z", "+00:00"))
|
||||
age = (now - stamp).total_seconds()
|
||||
if not 0 <= age <= hours * 3600:
|
||||
raise Refused("backup_stale")
|
||||
name = receipt.get("local_archive", "")
|
||||
if (
|
||||
receipt.get("status") != "offsite_fetched_verified"
|
||||
or receipt.get("application") != "email-connect"
|
||||
or receipt.get("remote_retrieval_verified") is not True
|
||||
or not receipt.get("version_id")
|
||||
or receipt["version_id"] == "null"
|
||||
or not NAME.fullmatch(name)
|
||||
):
|
||||
raise Refused("verified_backup_required")
|
||||
private_file(state / name)
|
||||
if digest(state / name) != receipt["ciphertext_sha256"]:
|
||||
raise Refused("local_ciphertext_mismatch")
|
||||
return {"status": "fresh", "age_hours": round(age / 3600, 2), "remote_retrieval_verified": True}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["backup", "recover", "freshness"])
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--resume-pending", action="store_true")
|
||||
parser.add_argument("--state-dir", type=Path, default=Path("/var/lib/email-connect-backup"))
|
||||
parser.add_argument("--primary-tool", type=Path, default=PRIMARY_TOOL)
|
||||
parser.add_argument("--primary-python", type=Path, default=PRIMARY_PYTHON)
|
||||
parser.add_argument("--age-recipient", default=RECIPIENT)
|
||||
parser.add_argument("--archive", type=Path)
|
||||
parser.add_argument("--transfer-receipt", type=Path)
|
||||
parser.add_argument("--identity", type=Path)
|
||||
parser.add_argument("--destination", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.action == "freshness":
|
||||
print(json.dumps(freshness(args.state_dir)))
|
||||
elif args.action == "recover":
|
||||
if not args.execute:
|
||||
print(json.dumps({"status": "reviewed", "decryption_performed": False}))
|
||||
return 0
|
||||
if not all([args.archive, args.transfer_receipt, args.identity, args.destination]):
|
||||
raise Refused("recovery_arguments_required")
|
||||
print(
|
||||
json.dumps(
|
||||
recover(args.archive, args.transfer_receipt, args.identity, args.destination)
|
||||
)
|
||||
)
|
||||
elif args.execute:
|
||||
if (
|
||||
not args.state_dir.is_absolute()
|
||||
or not args.primary_tool.is_file()
|
||||
or not args.primary_python.is_file()
|
||||
):
|
||||
raise Refused("invalid_backup_configuration")
|
||||
command(["age", "--version"])
|
||||
command(
|
||||
[
|
||||
str(args.primary_python),
|
||||
str(args.primary_tool),
|
||||
"check",
|
||||
"--application",
|
||||
"email-connect",
|
||||
]
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
backup(
|
||||
args.state_dir,
|
||||
args.primary_tool,
|
||||
args.primary_python,
|
||||
args.age_recipient,
|
||||
args.resume_pending,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
if args.resume_pending:
|
||||
raise Refused("resume_requires_execute")
|
||||
command(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("capture-ledger.py")),
|
||||
"--destination",
|
||||
"/tmp/email-ledger-capture-review",
|
||||
]
|
||||
)
|
||||
print(
|
||||
json.dumps({"status": "reviewed", "live_changes": False, "provider_contacted": False})
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
def terminate(signum, frame):
|
||||
raise Refused("backup_interrupted")
|
||||
|
||||
signal.signal(signal.SIGTERM, terminate)
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "failed", "failure_type": type(error).__name__}))
|
||||
raise SystemExit(1)
|
||||
356
scripts/capture-ledger.py
Normal file
356
scripts/capture-ledger.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Node-only ledger capture, preview by default; execution briefly pauses email."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ledger_archive import MAX_BYTES, Refused, digest, fingerprint, private_directory, write_private
|
||||
|
||||
NS = "email-connect"
|
||||
CLUSTER_UID = "a553c742-0115-43d4-99a4-a5ca56fe0786"
|
||||
IMAGE = (
|
||||
"forgejo.coulomb.social/coulomb/email-connect@sha256:"
|
||||
"1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c"
|
||||
)
|
||||
LOCK_PATH = Path("/var/lib/email-connect-backup/capture.lock")
|
||||
SELECTOR = "app.kubernetes.io/name=email-connect"
|
||||
|
||||
|
||||
def k(*args, body=None, timeout=180):
|
||||
result = subprocess.run(["kubectl", *args], input=body, capture_output=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise Refused("cluster_command_failed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def document(*args, body=None):
|
||||
return json.loads(k(*args, "-o", "json", body=body, timeout=30))
|
||||
|
||||
|
||||
def helper(name):
|
||||
return {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"metadata": {"name": name, "namespace": NS, "labels": {"email-ledger-capture": name}},
|
||||
"spec": {
|
||||
"restartPolicy": "Never",
|
||||
"automountServiceAccountToken": False,
|
||||
"securityContext": {
|
||||
"runAsNonRoot": True,
|
||||
"runAsUser": 10001,
|
||||
"runAsGroup": 10001,
|
||||
"fsGroup": 10001,
|
||||
"fsGroupChangePolicy": "OnRootMismatch",
|
||||
"seccompProfile": {"type": "RuntimeDefault"},
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"name": "capture",
|
||||
"image": IMAGE,
|
||||
"command": ["python", "-c", "import time;time.sleep(1800)"],
|
||||
"resources": {
|
||||
"requests": {"cpu": "10m", "memory": "32Mi"},
|
||||
"limits": {"cpu": "100m", "memory": "256Mi"},
|
||||
},
|
||||
"securityContext": {
|
||||
"allowPrivilegeEscalation": False,
|
||||
"readOnlyRootFilesystem": True,
|
||||
"capabilities": {"drop": ["ALL"]},
|
||||
},
|
||||
"volumeMounts": [
|
||||
{"name": "data", "mountPath": "/data", "readOnly": True},
|
||||
{"name": "tmp", "mountPath": "/tmp"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"volumes": [
|
||||
{
|
||||
"name": "data",
|
||||
"persistentVolumeClaim": {"claimName": "email-connect-data", "readOnly": True},
|
||||
},
|
||||
{"name": "tmp", "emptyDir": {"sizeLimit": "160Mi"}},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def replica_patch(template, expected, desired):
|
||||
return json.dumps(
|
||||
[
|
||||
{"op": "test", "path": "/spec/template", "value": template},
|
||||
{"op": "test", "path": "/spec/replicas", "value": expected},
|
||||
{"op": "replace", "path": "/spec/replicas", "value": desired},
|
||||
]
|
||||
).encode()
|
||||
|
||||
|
||||
def selected(selector, labels):
|
||||
if any(labels.get(key) != value for key, value in selector.get("matchLabels", {}).items()):
|
||||
return False
|
||||
for expression in selector.get("matchExpressions", []):
|
||||
key, op, values = expression["key"], expression["operator"], expression.get("values", [])
|
||||
if op == "In" and labels.get(key) not in values:
|
||||
return False
|
||||
if op == "NotIn" and labels.get(key) in values:
|
||||
return False
|
||||
if op == "Exists" and key not in labels:
|
||||
return False
|
||||
if op == "DoesNotExist" and key in labels:
|
||||
return False
|
||||
if op not in {"In", "NotIn", "Exists", "DoesNotExist"}:
|
||||
raise Refused("unknown_network_selector")
|
||||
return True
|
||||
|
||||
|
||||
def require_no_helper_egress(policies, labels):
|
||||
applicable = [
|
||||
p["spec"]
|
||||
for p in policies
|
||||
if "Egress" in p["spec"].get("policyTypes", [])
|
||||
and selected(p["spec"].get("podSelector", {}), labels)
|
||||
]
|
||||
if not applicable or any(spec.get("egress") for spec in applicable):
|
||||
raise Refused("helper_egress_not_denied")
|
||||
|
||||
|
||||
def preflight():
|
||||
if document("get", "namespace", "kube-system")["metadata"]["uid"] != CLUSTER_UID:
|
||||
raise Refused("wrong_cluster")
|
||||
before = document("get", "deployment", "email-connect", "-n", NS)
|
||||
spec = before["spec"]
|
||||
containers = spec["template"]["spec"]["containers"]
|
||||
if (
|
||||
spec["replicas"] != 1
|
||||
or before["status"].get("readyReplicas") != 1
|
||||
or len(containers) != 1
|
||||
or containers[0]["image"] != IMAGE
|
||||
):
|
||||
raise Refused("unexpected_release")
|
||||
if document("get", "pvc", "email-connect-data", "-n", NS)["status"]["phase"] != "Bound":
|
||||
raise Refused("pvc_not_bound")
|
||||
config = document("get", "configmap", "email-connect-config", "-n", NS)["data"]
|
||||
if config.get("EMAIL_CONNECT_DATABASE_PATH") != "/data/email-connect.db":
|
||||
raise Refused("unexpected_database_path")
|
||||
name = "email-ledger-capture-" + secrets.token_hex(8)
|
||||
pod = helper(name)
|
||||
policies = document("get", "networkpolicies", "-n", NS)["items"]
|
||||
require_no_helper_egress(policies, pod["metadata"]["labels"])
|
||||
body = json.dumps(pod).encode()
|
||||
k("create", "--dry-run=server", "-f", "-", body=body)
|
||||
projected = document(
|
||||
"patch",
|
||||
"deployment",
|
||||
"email-connect",
|
||||
"-n",
|
||||
NS,
|
||||
"--type=json",
|
||||
"--patch-file=/dev/stdin",
|
||||
"--dry-run=server",
|
||||
body=replica_patch(spec["template"], 1, 0),
|
||||
)
|
||||
expected = copy.deepcopy(spec)
|
||||
expected["replicas"] = 0
|
||||
if projected["spec"] != expected:
|
||||
raise Refused("unexpected_pause_spec")
|
||||
return before, body
|
||||
|
||||
|
||||
def capture_database(name, destination):
|
||||
# No app startup, mounted secrets, SMTP import or provider request in helper.
|
||||
code = """import os,sqlite3,sys
|
||||
source=sqlite3.connect('file:/data/email-connect.db?mode=ro',uri=True)
|
||||
pages=source.execute('PRAGMA page_count').fetchone()[0]
|
||||
size=source.execute('PRAGMA page_size').fetchone()[0]
|
||||
assert pages*size<=LIMIT
|
||||
os.umask(0o077)
|
||||
output=sqlite3.connect('/tmp/ledger.sqlite3')
|
||||
source.backup(output,pages=256)
|
||||
assert output.execute('PRAGMA integrity_check').fetchall()==[('ok',)]
|
||||
output.close();source.close()
|
||||
with open('/tmp/ledger.sqlite3','rb') as stream:sys.stdout.buffer.write(stream.read())
|
||||
""".replace("LIMIT", str(MAX_BYTES))
|
||||
data = k("exec", "-n", NS, name, "--", "python", "-c", code)
|
||||
if not data.startswith(b"SQLite format 3\x00") or len(data) > MAX_BYTES:
|
||||
raise Refused("invalid_ledger_backup")
|
||||
path = destination / "database.sqlite3"
|
||||
write_private(path, data)
|
||||
return {
|
||||
"database_sha256": digest(path),
|
||||
"database_bytes": len(data),
|
||||
"ledger_fingerprint": fingerprint(path),
|
||||
"sqlite_integrity": "ok",
|
||||
"writers_quiesced": True,
|
||||
"source_pvc_read_only": True,
|
||||
}
|
||||
|
||||
|
||||
def capture(before, body, destination):
|
||||
if not destination.is_absolute() or destination.exists() or destination.is_symlink():
|
||||
raise Refused("fresh_absolute_destination_required")
|
||||
destination.mkdir(mode=0o700)
|
||||
private_directory(destination)
|
||||
receipt = {
|
||||
"status": "incomplete",
|
||||
"application": "email-connect",
|
||||
"image": IMAGE,
|
||||
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
started, paused, created, captured = time.monotonic(), False, False, False
|
||||
name = json.loads(body)["metadata"]["name"]
|
||||
cleanup_error = None
|
||||
try:
|
||||
paused = True # A failed response can still have changed replica count.
|
||||
k(
|
||||
"patch",
|
||||
"deployment",
|
||||
"email-connect",
|
||||
"-n",
|
||||
NS,
|
||||
"--type=json",
|
||||
"--patch-file=/dev/stdin",
|
||||
body=replica_patch(before["spec"]["template"], 1, 0),
|
||||
)
|
||||
k("wait", "--for=delete", "pod", "-n", NS, "-l", SELECTOR, "--timeout=120s")
|
||||
current = document("get", "deployment", "email-connect", "-n", NS)
|
||||
expected = copy.deepcopy(before["spec"])
|
||||
expected["replicas"] = 0
|
||||
if current["spec"] != expected:
|
||||
raise Refused("writers_not_quiesced")
|
||||
# Require no other pod mounting the ledger PVC, including a prior helper.
|
||||
pods = document("get", "pods", "-n", NS)["items"]
|
||||
if any(
|
||||
any(
|
||||
v.get("persistentVolumeClaim", {}).get("claimName") == "email-connect-data"
|
||||
for v in pod["spec"].get("volumes", [])
|
||||
)
|
||||
for pod in pods
|
||||
):
|
||||
raise Refused("other_ledger_pvc_consumer")
|
||||
created = True
|
||||
k("create", "-f", "-", body=body)
|
||||
k("wait", "--for=condition=Ready", "pod/" + name, "-n", NS, "--timeout=120s")
|
||||
receipt.update(capture_database(name, destination))
|
||||
captured = True
|
||||
except BaseException as error:
|
||||
receipt.update(failure_type=type(error).__name__)
|
||||
raise
|
||||
finally:
|
||||
if created:
|
||||
try:
|
||||
k(
|
||||
"delete",
|
||||
"pod",
|
||||
name,
|
||||
"-n",
|
||||
NS,
|
||||
"--ignore-not-found=true",
|
||||
"--wait=true",
|
||||
"--timeout=120s",
|
||||
)
|
||||
receipt["helper_removed"] = True
|
||||
except BaseException as error:
|
||||
receipt["helper_cleanup_failed"] = True
|
||||
cleanup_error = error
|
||||
if paused:
|
||||
try:
|
||||
current = document("get", "deployment", "email-connect", "-n", NS)
|
||||
expected = copy.deepcopy(before["spec"])
|
||||
expected["replicas"] = current["spec"]["replicas"]
|
||||
if current["spec"] != expected or current["spec"]["replicas"] not in {0, 1}:
|
||||
raise Refused("foreign_release_requires_receiver_check")
|
||||
if current["spec"]["replicas"] == 0:
|
||||
k(
|
||||
"patch",
|
||||
"deployment",
|
||||
"email-connect",
|
||||
"-n",
|
||||
NS,
|
||||
"--type=json",
|
||||
"--patch-file=/dev/stdin",
|
||||
body=replica_patch(before["spec"]["template"], 0, 1),
|
||||
)
|
||||
k(
|
||||
"rollout",
|
||||
"status",
|
||||
"deployment/email-connect",
|
||||
"-n",
|
||||
NS,
|
||||
"--timeout=180s",
|
||||
timeout=240,
|
||||
)
|
||||
current = document("get", "deployment", "email-connect", "-n", NS)
|
||||
if current["spec"] != before["spec"] or current["status"].get("readyReplicas") != 1:
|
||||
raise Refused("receiver_resume_not_verified")
|
||||
receipt["receiver_resumed"] = True
|
||||
receipt["unchanged_receiver_spec"] = True
|
||||
except BaseException as error:
|
||||
receipt["receiver_state_requires_check"] = True
|
||||
cleanup_error = cleanup_error or error
|
||||
receipt["status"] = (
|
||||
"captured"
|
||||
if captured and receipt.get("receiver_resumed") and not cleanup_error
|
||||
else "failed"
|
||||
)
|
||||
receipt["elapsed_seconds"] = round(time.monotonic() - started, 2)
|
||||
write_private(destination / "receipt.json", json.dumps(receipt, indent=2).encode())
|
||||
if cleanup_error:
|
||||
raise Refused("capture_cleanup_requires_check") from None
|
||||
return receipt
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--destination", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
os.environ["KUBECONFIG"] = "/etc/rancher/k3s/k3s.yaml"
|
||||
before, body = preflight()
|
||||
if not args.execute:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "reviewed",
|
||||
"live_changes": False,
|
||||
"server_dry_run": True,
|
||||
"image_pinned": True,
|
||||
"pvc_read_only": True,
|
||||
"receiver_spec_changed": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
private_directory(LOCK_PATH.parent)
|
||||
fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise Refused("capture_already_running") from None
|
||||
# Recheck after locking; do not use a stale preflight across another run.
|
||||
before, body = preflight()
|
||||
print(json.dumps(capture(before, body, args.destination)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
def terminate(signum, frame):
|
||||
raise Refused("capture_interrupted")
|
||||
|
||||
signal.signal(signal.SIGTERM, terminate)
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "failed", "failure_type": type(error).__name__}))
|
||||
raise SystemExit(1)
|
||||
284
scripts/ledger_archive.py
Normal file
284
scripts/ledger_archive.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private transactional-ledger archive primitives; no mail/provider code."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
MAX_BYTES = 128 * 1024**2
|
||||
TABLES = {"deliveries", "verifications", "suppressions", "send_attempts", "verification_attempts"}
|
||||
PREFIX = "s3://railiance-platform-pg-backup/platform-pg/application-archives/email-connect/"
|
||||
|
||||
|
||||
class Refused(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def private_directory(path):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise Refused("private_owner_directory_required")
|
||||
|
||||
|
||||
def private_file(path):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise Refused("private_owner_file_required")
|
||||
|
||||
|
||||
def write_private(path, value):
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(value)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def checkpoint(path, value):
|
||||
fd, name = tempfile.mkstemp(prefix="receipt-", dir=path.parent)
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
json.dump(value, stream, indent=2)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
Path(name).replace(path)
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
private_file(path)
|
||||
if not 0 < path.stat().st_size <= MAX_BYTES:
|
||||
raise Refused("ledger_size_outside_contract")
|
||||
with closing(sqlite3.connect(path.absolute().as_uri() + "?mode=ro", uri=True)) as database:
|
||||
database.execute("PRAGMA trusted_schema=OFF")
|
||||
if database.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
|
||||
raise Refused("ledger_integrity_failed")
|
||||
tables = {
|
||||
row[0]
|
||||
for row in database.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
)
|
||||
}
|
||||
if tables != TABLES:
|
||||
raise Refused("unexpected_ledger_schema")
|
||||
schema = list(
|
||||
database.execute("SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name")
|
||||
)
|
||||
result = {
|
||||
"schema_sha256": hashlib.sha256(json.dumps(schema).encode()).hexdigest(),
|
||||
"user_version": database.execute("PRAGMA user_version").fetchone()[0],
|
||||
"tables": {},
|
||||
}
|
||||
for name in sorted(TABLES):
|
||||
rows = [
|
||||
json.dumps(
|
||||
row, separators=(",", ":"), default=lambda value: {"bytes_hex": value.hex()}
|
||||
).encode()
|
||||
for row in database.execute('SELECT * FROM "' + name + '"')
|
||||
]
|
||||
result["tables"][name] = {
|
||||
"rows": len(rows),
|
||||
"sha256": hashlib.sha256(b"\n".join(sorted(rows))).hexdigest(),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def sqlite_backup(source, output):
|
||||
"""Use SQLite backup rather than copying a file that may still have WAL."""
|
||||
private_directory(output.parent)
|
||||
if source.is_symlink() or not source.is_file() or output.exists() or output.is_symlink():
|
||||
raise Refused("invalid_sqlite_backup_paths")
|
||||
# No source write or checkpoint; a read-only connection includes committed WAL.
|
||||
with closing(sqlite3.connect(source.absolute().as_uri() + "?mode=ro", uri=True)) as src:
|
||||
if (
|
||||
src.execute("PRAGMA page_count").fetchone()[0]
|
||||
* src.execute("PRAGMA page_size").fetchone()[0]
|
||||
> MAX_BYTES
|
||||
):
|
||||
raise Refused("ledger_size_outside_contract")
|
||||
write_private(output, b"")
|
||||
with closing(sqlite3.connect(output)) as dst:
|
||||
src.backup(dst, pages=256)
|
||||
return fingerprint(output)
|
||||
|
||||
|
||||
def validate_capture(directory):
|
||||
private_directory(directory)
|
||||
private_file(directory / "receipt.json")
|
||||
receipt = json.loads((directory / "receipt.json").read_text())
|
||||
if (
|
||||
receipt.get("status") != "captured"
|
||||
or receipt.get("application") != "email-connect"
|
||||
or receipt.get("writers_quiesced") is not True
|
||||
or receipt.get("receiver_resumed") is not True
|
||||
or receipt.get("source_pvc_read_only") is not True
|
||||
):
|
||||
raise Refused("capture_incomplete")
|
||||
path = directory / "database.sqlite3"
|
||||
actual = fingerprint(path)
|
||||
if (
|
||||
digest(path) != receipt.get("database_sha256")
|
||||
or path.stat().st_size != receipt.get("database_bytes")
|
||||
or actual != receipt.get("ledger_fingerprint")
|
||||
):
|
||||
raise Refused("capture_binding_mismatch")
|
||||
return receipt
|
||||
|
||||
|
||||
def command(argv, timeout=900):
|
||||
# Keep private child diagnostics out of both memory pipes and the journal.
|
||||
with tempfile.TemporaryFile() as output:
|
||||
child = subprocess.Popen(argv, stdout=output, stderr=output)
|
||||
try:
|
||||
result = child.wait(timeout=timeout)
|
||||
except BaseException:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=800)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
raise
|
||||
if result:
|
||||
raise Refused("archive_command_failed")
|
||||
|
||||
|
||||
def seal(directory, stage, recipient):
|
||||
captured = validate_capture(directory)
|
||||
encrypted, sealed_path = stage / "packet.tar.age", stage / "sealed.json"
|
||||
binding = {
|
||||
key: captured[key]
|
||||
for key in ["database_sha256", "captured_at", "image", "ledger_fingerprint"]
|
||||
}
|
||||
if sealed_path.exists():
|
||||
private_file(sealed_path)
|
||||
private_file(encrypted)
|
||||
receipt = json.loads(sealed_path.read_text())
|
||||
if receipt.get("binding") != binding or receipt.get("ciphertext_sha256") != digest(
|
||||
encrypted
|
||||
):
|
||||
raise Refused("sealed_packet_binding_mismatch")
|
||||
return encrypted, captured
|
||||
if encrypted.exists() or encrypted.is_symlink():
|
||||
raise Refused("partial_encryption_requires_review")
|
||||
archive = stage / "packet.tar"
|
||||
with archive.open("xb") as stream:
|
||||
os.chmod(archive, 0o600)
|
||||
with tarfile.open(fileobj=stream, mode="w") as packet:
|
||||
for name in ["database.sqlite3", "receipt.json"]:
|
||||
packet.add(directory / name, arcname=name, recursive=False)
|
||||
command(["age", "-r", recipient, "-o", str(encrypted), str(archive)])
|
||||
encrypted.chmod(0o600)
|
||||
private_file(encrypted)
|
||||
if not 0 < encrypted.stat().st_size <= MAX_BYTES + 1024**2:
|
||||
raise Refused("encrypted_packet_size_outside_contract")
|
||||
checkpoint(sealed_path, {"binding": binding, "ciphertext_sha256": digest(encrypted)})
|
||||
return encrypted, captured
|
||||
|
||||
|
||||
def unpack(packet, destination):
|
||||
if packet.stat().st_size > MAX_BYTES + 1024**2:
|
||||
raise Refused("packet_too_large")
|
||||
with tarfile.open(packet, "r:") as archive:
|
||||
entries = archive.getmembers()
|
||||
if (
|
||||
len(entries) != 2
|
||||
or {entry.name for entry in entries} != {"database.sqlite3", "receipt.json"}
|
||||
or any(
|
||||
not entry.isfile() or entry.sparse is not None or entry.size <= 0
|
||||
for entry in entries
|
||||
)
|
||||
or sum(entry.size for entry in entries) > MAX_BYTES + 1024**2
|
||||
or any(entry.size > 1024**2 for entry in entries if entry.name == "receipt.json")
|
||||
):
|
||||
raise Refused("invalid_packet_members")
|
||||
for entry in entries:
|
||||
with (
|
||||
archive.extractfile(entry) as source,
|
||||
(destination / entry.name).open("xb") as target,
|
||||
):
|
||||
os.chmod(target.name, 0o600)
|
||||
remaining = entry.size
|
||||
while remaining:
|
||||
block = source.read(min(remaining, 8 * 1024**2))
|
||||
if not block:
|
||||
raise Refused("truncated_packet_member")
|
||||
target.write(block)
|
||||
remaining -= len(block)
|
||||
return validate_capture(destination)
|
||||
|
||||
|
||||
def recover(encrypted, transfer_path, identity, destination):
|
||||
for path in [encrypted, transfer_path, identity]:
|
||||
private_file(path)
|
||||
transfer = json.loads(transfer_path.read_text())
|
||||
key = transfer.get("object_key", "")
|
||||
suffix = key.removeprefix("platform-pg/application-archives/email-connect/")
|
||||
version = transfer.get("version_id")
|
||||
if (
|
||||
not isinstance(version, str)
|
||||
or version in {"", "null"}
|
||||
or any(char in version for char in "\r\n")
|
||||
or not key.startswith("platform-pg/application-archives/email-connect/")
|
||||
or not re.fullmatch(r"\d{4}/\d{2}/\d{2}/\d{8}T\d{6}Z-[a-f0-9]{24}\.tar\.age", suffix)
|
||||
or transfer.get("destination") != "s3://railiance-platform-pg-backup/" + key
|
||||
):
|
||||
raise Refused("verified_transfer_required")
|
||||
if (
|
||||
transfer.get("schema") != "platform.application-primary-archive.v1"
|
||||
or transfer.get("application") != "email-connect"
|
||||
or transfer.get("status") != "offsite_fetched_verified"
|
||||
or transfer.get("remote_retrieval_verified") is not True
|
||||
or not transfer.get("version_id")
|
||||
or transfer["version_id"] == "null"
|
||||
or not transfer.get("destination", "").startswith(PREFIX)
|
||||
or transfer.get("ciphertext_sha256") != digest(encrypted)
|
||||
or transfer.get("ciphertext_bytes") != encrypted.stat().st_size
|
||||
):
|
||||
raise Refused("verified_transfer_required")
|
||||
if not destination.is_absolute() or destination.exists() or destination.is_symlink():
|
||||
raise Refused("fresh_absolute_destination_required")
|
||||
destination.mkdir(mode=0o700)
|
||||
packet = destination / "packet.tar"
|
||||
# age must create plaintext with private modes even in an interactive shell.
|
||||
result = subprocess.run(
|
||||
["age", "-d", "-i", str(identity), "-o", str(packet), str(encrypted)],
|
||||
capture_output=True,
|
||||
timeout=900,
|
||||
umask=0o077,
|
||||
)
|
||||
if result.returncode:
|
||||
raise Refused("decryption_failed")
|
||||
private_file(packet)
|
||||
captured = unpack(packet, destination)
|
||||
for key in ["captured_at", "image", "database_sha256", "ledger_fingerprint"]:
|
||||
if transfer.get(key) != captured.get(key):
|
||||
raise Refused("recovery_capture_binding_mismatch")
|
||||
packet.unlink()
|
||||
result = {
|
||||
"status": "decrypted_ledger_verified",
|
||||
"application": "email-connect",
|
||||
"object_key": transfer["object_key"],
|
||||
"version_id": transfer["version_id"],
|
||||
"ciphertext_sha256": transfer["ciphertext_sha256"],
|
||||
"database_sha256": captured["database_sha256"],
|
||||
"ledger_fingerprint": captured["ledger_fingerprint"],
|
||||
"mail_sent": False,
|
||||
"production_restored": False,
|
||||
"application_restore_verified": False,
|
||||
}
|
||||
checkpoint(destination / "unpack-receipt.json", result)
|
||||
return result
|
||||
199
scripts/rehearse-ledger.py
Normal file
199
scripts/rehearse-ledger.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended recovered-ledger rehearsal in the cached receiver image, no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ledger_archive import (
|
||||
Refused,
|
||||
checkpoint,
|
||||
private_directory,
|
||||
private_file,
|
||||
validate_capture,
|
||||
)
|
||||
|
||||
IMAGE = (
|
||||
"forgejo.coulomb.social/coulomb/email-connect@sha256:"
|
||||
"1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c"
|
||||
)
|
||||
|
||||
CODE = """import io,json,os,sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,'/opt/ledger-rehearsal')
|
||||
from ledger_archive import fingerprint
|
||||
from email_connect.transactional import SQLiteDeliveryStore,TransactionalApplication,ProviderError
|
||||
assert os.getuid()==10001
|
||||
path=Path('/tmp/recovered.sqlite3')
|
||||
os.umask(0o077)
|
||||
path.write_bytes(sys.stdin.buffer.read())
|
||||
path.chmod(0o600)
|
||||
before=fingerprint(path)
|
||||
store=SQLiteDeliveryStore(str(path))
|
||||
assert fingerprint(path)==before
|
||||
class ForbiddenProvider:
|
||||
def send(self,*args):raise AssertionError('recovery_provider_call_forbidden')
|
||||
app=TransactionalApplication(store,ForbiddenProvider(),'synthetic-recovery-token','https://issuer.example.test')
|
||||
checks={'accepted_verifications':0,'unknown_verifications':0,'invalidated_handles':0,
|
||||
'suppressions':0,'accepted_deliveries':0,'unknown_sends':0}
|
||||
query=("SELECT v.registration_id,v.email,v.username,v.client_id,v.tenant,v.display_name,a.state "
|
||||
"FROM verifications v JOIN verification_attempts a ON a.request_id=v.request_id "
|
||||
"WHERE a.state IN ('accepted','in_flight')")
|
||||
for row in store.db.execute(query).fetchall():
|
||||
payload=dict(zip(['registration_id','normalized_email','preferred_username','client_id','tenant','display_name'],row[:6]))
|
||||
payload['correlation_id']='synthetic-recovery-probe'
|
||||
raw=json.dumps(payload).encode();response={}
|
||||
body=json.loads(b''.join(app({'PATH_INFO':'/v1/registration-verifications','REQUEST_METHOD':'POST',
|
||||
'CONTENT_LENGTH':str(len(raw)),'wsgi.input':io.BytesIO(raw),
|
||||
'HTTP_AUTHORIZATION':'Bearer synthetic-recovery-token'},
|
||||
lambda status,headers:response.update(status=status))))
|
||||
if row[6]=='accepted':
|
||||
assert response['status'].startswith('202') and body['accepted']
|
||||
checks['accepted_verifications']+=1
|
||||
else:
|
||||
assert response['status'].startswith('422') and body['error']=='delivery_outcome_unknown'
|
||||
checks['unknown_verifications']+=1
|
||||
query=("SELECT handle_hash FROM verifications "
|
||||
"WHERE consumed_at IS NOT NULL OR canceled_at IS NOT NULL")
|
||||
for row in store.db.execute(query).fetchall():
|
||||
try:store.consume_verification(row[0]);raise AssertionError('invalid_handle_reactivated')
|
||||
except ValueError as error:assert str(error)=='verification_invalid'
|
||||
checks['invalidated_handles']+=1
|
||||
for row in store.db.execute('SELECT email FROM suppressions').fetchall():
|
||||
assert store.is_suppressed(row[0]);checks['suppressions']+=1
|
||||
query=("SELECT d.event_id,a.fingerprint FROM deliveries d "
|
||||
"LEFT JOIN send_attempts a ON a.event_id=d.event_id")
|
||||
for row in store.db.execute(query).fetchall():
|
||||
assert store.delivery_status(row[0])['state']=='provider_accepted'
|
||||
assert store.reserve_send(row[0],row[1] or 'synthetic-legacy-probe') is False
|
||||
checks['accepted_deliveries']+=1
|
||||
query=("SELECT event_id,fingerprint FROM send_attempts WHERE state='in_flight' "
|
||||
"AND event_id NOT IN (SELECT event_id FROM deliveries)")
|
||||
for row in store.db.execute(query).fetchall():
|
||||
assert store.delivery_status(row[0])['state']=='unknown'
|
||||
try:store.reserve_send(row[0],row[1]);raise AssertionError('unknown_send_retried')
|
||||
except ProviderError as error:assert error.code=='delivery_outcome_unknown'
|
||||
checks['unknown_sends']+=1
|
||||
store.db.close()
|
||||
assert fingerprint(path)==before
|
||||
print(json.dumps({'status':'isolated_ledger_verified','ledger_fingerprint':before,
|
||||
'checks':checks,'mail_sent':False,'uid':os.getuid()}))
|
||||
"""
|
||||
|
||||
|
||||
def rehearse(recovered, receipt_path):
|
||||
private_directory(recovered)
|
||||
private_directory(receipt_path.parent)
|
||||
if receipt_path.exists() or receipt_path.is_symlink():
|
||||
raise Refused("fresh_receipt_required")
|
||||
capture = validate_capture(recovered)
|
||||
private_file(recovered / "unpack-receipt.json")
|
||||
unpacked = json.loads((recovered / "unpack-receipt.json").read_text())
|
||||
if (
|
||||
capture["image"] != IMAGE
|
||||
or unpacked.get("status") != "decrypted_ledger_verified"
|
||||
or unpacked.get("ledger_fingerprint") != capture["ledger_fingerprint"]
|
||||
):
|
||||
raise Refused("pinned_recovered_ledger_required")
|
||||
# Never pull a recovery image from the production forge during this rehearsal.
|
||||
inspected = subprocess.run(
|
||||
["docker", "image", "inspect", IMAGE], capture_output=True, timeout=30
|
||||
)
|
||||
if inspected.returncode:
|
||||
raise Refused("cached_pinned_recovery_image_required")
|
||||
started = time.monotonic()
|
||||
module = Path(__file__).with_name("ledger_archive.py").resolve()
|
||||
name = "email-ledger-rehearsal-" + secrets.token_hex(8)
|
||||
try:
|
||||
with (recovered / "database.sqlite3").open("rb") as source:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"-i",
|
||||
"--network",
|
||||
"none",
|
||||
"--read-only",
|
||||
"--user",
|
||||
"10001:10001",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--memory",
|
||||
"512m",
|
||||
"--cpus",
|
||||
"0.5",
|
||||
"--tmpfs",
|
||||
"/tmp:rw,noexec,nosuid,size=160m,uid=10001,gid=10001,mode=0700",
|
||||
"--mount",
|
||||
"type=bind,source="
|
||||
+ str(module)
|
||||
+ ",target=/opt/ledger-rehearsal/ledger_archive.py,readonly",
|
||||
"-e",
|
||||
"PYTHONDONTWRITEBYTECODE=1",
|
||||
"--entrypoint",
|
||||
"python",
|
||||
IMAGE,
|
||||
"-c",
|
||||
CODE,
|
||||
],
|
||||
stdin=source,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
finally:
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise Refused("isolated_rehearsal_failed")
|
||||
verification = json.loads(result.stdout)
|
||||
if verification.get("ledger_fingerprint") != capture["ledger_fingerprint"]:
|
||||
raise Refused("isolated_fingerprint_mismatch")
|
||||
receipt = dict(
|
||||
unpacked,
|
||||
status="application_restored_verified",
|
||||
application_restore_verified=True,
|
||||
recovery_image=IMAGE,
|
||||
network="disabled",
|
||||
production_restored=False,
|
||||
checks=verification["checks"],
|
||||
uid=verification["uid"],
|
||||
mail_sent=False,
|
||||
evidence_ceiling="isolated_ledger_rehearsal",
|
||||
elapsed_seconds=round(time.monotonic() - started, 2),
|
||||
)
|
||||
checkpoint(receipt_path, receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--recovered", required=True, type=Path)
|
||||
parser.add_argument("--receipt", required=True, type=Path)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not args.execute:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "reviewed", "container_started": False, "production_restored": False}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
print(json.dumps(rehearse(args.recovered, args.receipt)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "failed", "failure_type": type(error).__name__}))
|
||||
raise SystemExit(1)
|
||||
435
tests/test_ledger_backup.py
Normal file
435
tests/test_ledger_backup.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""Only synthetic ledger records; no production URLs, custody or SMTP sends."""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from email_connect.transactional import SQLiteDeliveryStore, TransactionalApplication
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def modules(monkeypatch):
|
||||
scripts = Path(__file__).resolve().parents[1] / "scripts"
|
||||
monkeypatch.syspath_prepend(str(scripts))
|
||||
result = []
|
||||
for name in ["ledger_archive", "capture-ledger", "backup-ledger"]:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name.replace("-", "_"), scripts / (name + ".py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, name.replace("-", "_"), module)
|
||||
spec.loader.exec_module(module)
|
||||
result.append(module)
|
||||
return result
|
||||
|
||||
|
||||
def synthetic_store(path):
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
store = SQLiteDeliveryStore(str(path))
|
||||
path.chmod(0o600)
|
||||
accepted = {
|
||||
"registration_id": "accepted",
|
||||
"normalized_email": "accepted@example.test",
|
||||
"preferred_username": "synthetic",
|
||||
"client_id": "synthetic-client",
|
||||
"tenant": "synthetic-tenant",
|
||||
"correlation_id": "trace-1",
|
||||
}
|
||||
expires = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
||||
|
||||
def reserve(payload, handle):
|
||||
return store.reserve_verification(
|
||||
payload, hashlib.sha256(handle.encode()).hexdigest(), expires
|
||||
)[0]
|
||||
|
||||
request = reserve(accepted, "accepted-handle")
|
||||
store.verification_accepted(request, "synthetic-provider-reference")
|
||||
reserve(dict(accepted, registration_id="unknown"), "unknown-handle")
|
||||
consumed = reserve(dict(accepted, registration_id="consumed"), "consumed-handle")
|
||||
store.verification_accepted(consumed, "synthetic-consumed-reference")
|
||||
store.consume_verification(hashlib.sha256(b"consumed-handle").hexdigest())
|
||||
canceled = reserve(dict(accepted, registration_id="canceled"), "canceled-handle")
|
||||
store.verification_accepted(canceled, "synthetic-canceled-reference")
|
||||
store.cancel_verification(hashlib.sha256(b"canceled-handle").hexdigest())
|
||||
store.reserve_send("accepted-invitation", "synthetic-accepted-fingerprint")
|
||||
store.record("accepted-invitation", "synthetic-invitation-reference")
|
||||
store.reserve_send("unknown-invitation", "synthetic-unknown-fingerprint")
|
||||
store.suppress("suppressed@example.test", "synthetic complaint")
|
||||
store.db.close()
|
||||
return accepted
|
||||
|
||||
|
||||
def captured(directory, archive, image="synthetic@sha256:fixture"):
|
||||
directory.mkdir(mode=0o700)
|
||||
synthetic_store(directory / "database.sqlite3")
|
||||
receipt = {
|
||||
"status": "captured",
|
||||
"application": "email-connect",
|
||||
"image": image,
|
||||
"captured_at": "2026-10-07T20:00:00Z",
|
||||
"elapsed_seconds": 30,
|
||||
"writers_quiesced": True,
|
||||
"receiver_resumed": True,
|
||||
"source_pvc_read_only": True,
|
||||
"database_sha256": archive.digest(directory / "database.sqlite3"),
|
||||
"database_bytes": (directory / "database.sqlite3").stat().st_size,
|
||||
"ledger_fingerprint": archive.fingerprint(directory / "database.sqlite3"),
|
||||
}
|
||||
archive.write_private(directory / "receipt.json", json.dumps(receipt).encode())
|
||||
return receipt
|
||||
|
||||
|
||||
def invoke(app, payload, path):
|
||||
result = {}
|
||||
raw = json.dumps(payload).encode()
|
||||
environ = {
|
||||
"PATH_INFO": path,
|
||||
"REQUEST_METHOD": "POST",
|
||||
"CONTENT_LENGTH": str(len(raw)),
|
||||
"wsgi.input": io.BytesIO(raw),
|
||||
"HTTP_AUTHORIZATION": "Bearer synthetic-token",
|
||||
}
|
||||
body = json.loads(b"".join(app(environ, lambda status, headers: result.update(status=status))))
|
||||
return result["status"], body
|
||||
|
||||
|
||||
def test_sqlite_backup_includes_committed_wal_without_source_checkpoint(modules, tmp_path):
|
||||
archive, _, _ = modules
|
||||
source = tmp_path / "source.sqlite3"
|
||||
synthetic_store(source)
|
||||
with sqlite3.connect(source) as writer:
|
||||
writer.execute("PRAGMA journal_mode=WAL")
|
||||
writer.execute("INSERT INTO deliveries VALUES ('wal-event','wal-reference')")
|
||||
writer.commit()
|
||||
original_bytes = source.read_bytes()
|
||||
before = archive.fingerprint(source)
|
||||
output = tmp_path / "private" / "database.sqlite3"
|
||||
output.parent.mkdir(mode=0o700)
|
||||
assert archive.sqlite_backup(source, output) == before
|
||||
assert source.read_bytes() == original_bytes
|
||||
with sqlite3.connect(output) as restored:
|
||||
assert restored.execute(
|
||||
"SELECT provider_ref FROM deliveries WHERE event_id='wal-event'"
|
||||
).fetchone() == ("wal-reference",)
|
||||
writer.execute("INSERT INTO deliveries VALUES ('uncommitted','private')")
|
||||
output2 = output.parent / "another.sqlite3"
|
||||
archive.sqlite_backup(source, output2)
|
||||
with sqlite3.connect(output2) as restored:
|
||||
assert (
|
||||
restored.execute("SELECT 1 FROM deliveries WHERE event_id='uncommitted'").fetchone()
|
||||
is None
|
||||
)
|
||||
writer.rollback()
|
||||
|
||||
|
||||
def test_real_encryption_recovery_preserves_challenge_and_delivery_behavior(modules, tmp_path):
|
||||
archive, _, _ = modules
|
||||
stage = tmp_path / "stage"
|
||||
stage.mkdir(mode=0o700)
|
||||
receipt = captured(stage / "capture", archive)
|
||||
identity = tmp_path / "synthetic-identity"
|
||||
subprocess.run(["age-keygen", "-o", str(identity)], capture_output=True, check=True)
|
||||
public = (
|
||||
subprocess.run(["age-keygen", "-y", str(identity)], capture_output=True, check=True)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
encrypted, _ = archive.seal(stage / "capture", stage, public)
|
||||
first_ciphertext = encrypted.read_bytes()
|
||||
assert (
|
||||
archive.seal(stage / "capture", stage, "unused-retry-recipient")[0].read_bytes()
|
||||
== first_ciphertext
|
||||
)
|
||||
transfer = dict(
|
||||
receipt,
|
||||
schema="platform.application-primary-archive.v1",
|
||||
status="offsite_fetched_verified",
|
||||
remote_retrieval_verified=True,
|
||||
version_id="synthetic-version",
|
||||
object_key="platform-pg/application-archives/email-connect/2026/10/07/20261007T200000Z-"
|
||||
+ "a" * 24
|
||||
+ ".tar.age",
|
||||
destination=archive.PREFIX + "2026/10/07/20261007T200000Z-" + "a" * 24 + ".tar.age",
|
||||
ciphertext_sha256=archive.digest(encrypted),
|
||||
ciphertext_bytes=encrypted.stat().st_size,
|
||||
)
|
||||
transfer_path = tmp_path / "transfer.json"
|
||||
archive.write_private(transfer_path, json.dumps(transfer).encode())
|
||||
destination = tmp_path / "recovered"
|
||||
result = archive.recover(encrypted, transfer_path, identity, destination)
|
||||
assert result["status"] == "decrypted_ledger_verified" and not result["production_restored"]
|
||||
assert not (destination / "packet.tar").exists()
|
||||
assert all(p.stat().st_mode & 0o777 == 0o600 for p in destination.iterdir())
|
||||
path = destination / "database.sqlite3"
|
||||
before = archive.fingerprint(path)
|
||||
store = SQLiteDeliveryStore(str(path))
|
||||
assert archive.fingerprint(path) == before
|
||||
|
||||
class ForbiddenProvider:
|
||||
def send(self, *args):
|
||||
raise AssertionError("recovery must not resend")
|
||||
|
||||
app = TransactionalApplication(
|
||||
store, ForbiddenProvider(), "synthetic-token", "https://issuer.example.test"
|
||||
)
|
||||
payload = {
|
||||
"registration_id": "accepted",
|
||||
"normalized_email": "accepted@example.test",
|
||||
"preferred_username": "synthetic",
|
||||
"client_id": "synthetic-client",
|
||||
"tenant": "synthetic-tenant",
|
||||
"correlation_id": "another-trace",
|
||||
}
|
||||
status, response = invoke(app, payload, "/v1/registration-verifications")
|
||||
assert status.startswith("202") and response["accepted"]
|
||||
status, response = invoke(
|
||||
app, dict(payload, registration_id="unknown"), "/v1/registration-verifications"
|
||||
)
|
||||
assert status.startswith("422") and response["error"] == "delivery_outcome_unknown"
|
||||
for handle in ["consumed-handle", "canceled-handle"]:
|
||||
_, response = invoke(app, {"handle": handle}, "/v1/registration-verifications/consume")
|
||||
assert response["error"] == "verification_invalid"
|
||||
assert store.reference("accepted-invitation") == "synthetic-invitation-reference"
|
||||
assert store.is_suppressed("suppressed@example.test")
|
||||
store.db.close()
|
||||
assert archive.fingerprint(path) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["duplicate", "traversal", "link", "extra"])
|
||||
def test_packet_refuses_all_unsafe_members_before_extract(modules, tmp_path, bad):
|
||||
archive, _, _ = modules
|
||||
packet = tmp_path / "packet.tar"
|
||||
with tarfile.open(packet, "w") as output:
|
||||
for name in ["database.sqlite3", "receipt.json"]:
|
||||
entry = tarfile.TarInfo(name)
|
||||
entry.size = 1
|
||||
if bad == "link" and name == "database.sqlite3":
|
||||
entry.type, entry.linkname, entry.size = tarfile.SYMTYPE, "/tmp/private", 0
|
||||
output.addfile(entry, io.BytesIO(b"x"))
|
||||
if bad != "link":
|
||||
entry = tarfile.TarInfo(
|
||||
{"duplicate": "database.sqlite3", "traversal": "../escape", "extra": "smtp.env"}[
|
||||
bad
|
||||
]
|
||||
)
|
||||
entry.size = 1
|
||||
output.addfile(entry, io.BytesIO(b"x"))
|
||||
destination = tmp_path / "out"
|
||||
destination.mkdir(mode=0o700)
|
||||
with pytest.raises(archive.Refused, match="invalid_packet_members"):
|
||||
archive.unpack(packet, destination)
|
||||
assert not list(destination.iterdir())
|
||||
|
||||
|
||||
def deployment(capture):
|
||||
return {
|
||||
"spec": {"replicas": 1, "template": {"spec": {"containers": [{"image": capture.IMAGE}]}}},
|
||||
"status": {"readyReplicas": 1},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure", [None, "capture", "interrupt", "helper-cleanup", "foreign-image"]
|
||||
)
|
||||
def test_capture_resumes_unchanged_receiver_after_failures(modules, monkeypatch, tmp_path, failure):
|
||||
archive, capture, _ = modules
|
||||
before = deployment(capture)
|
||||
current = copy.deepcopy(before)
|
||||
calls = []
|
||||
|
||||
def k(*args, **kwargs):
|
||||
calls.append(args)
|
||||
if args[0] == "patch":
|
||||
patches = json.loads(kwargs["body"])
|
||||
assert patches[0]["value"] == before["spec"]["template"]
|
||||
assert patches[1]["value"] == current["spec"]["replicas"]
|
||||
current["spec"]["replicas"] = patches[2]["value"]
|
||||
if args[0] == "delete" and failure == "helper-cleanup":
|
||||
raise capture.Refused("cleanup failed")
|
||||
return b""
|
||||
|
||||
def document(*args, **kwargs):
|
||||
return {"items": []} if args[1] == "pods" else copy.deepcopy(current)
|
||||
|
||||
def capture_database(name, destination):
|
||||
if failure == "capture":
|
||||
raise capture.Refused("capture failed")
|
||||
if failure == "interrupt":
|
||||
raise KeyboardInterrupt()
|
||||
if failure == "foreign-image":
|
||||
current["spec"]["template"]["spec"]["containers"][0]["image"] = "foreign-image"
|
||||
synthetic_store(destination / "database.sqlite3")
|
||||
path = destination / "database.sqlite3"
|
||||
return {
|
||||
"database_sha256": archive.digest(path),
|
||||
"database_bytes": path.stat().st_size,
|
||||
"ledger_fingerprint": archive.fingerprint(path),
|
||||
"writers_quiesced": True,
|
||||
"source_pvc_read_only": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(capture, "k", k)
|
||||
monkeypatch.setattr(capture, "document", document)
|
||||
monkeypatch.setattr(capture, "capture_database", capture_database)
|
||||
destination = tmp_path / "capture"
|
||||
body = json.dumps(capture.helper("synthetic-capture")).encode()
|
||||
if failure:
|
||||
with pytest.raises((archive.Refused, KeyboardInterrupt)):
|
||||
capture.capture(before, body, destination)
|
||||
else:
|
||||
assert capture.capture(before, body, destination)["status"] == "captured"
|
||||
receipt = json.loads((destination / "receipt.json").read_text())
|
||||
assert any(args[0] == "delete" for args in calls)
|
||||
if failure == "foreign-image":
|
||||
assert current["spec"]["replicas"] == 0 and receipt["receiver_state_requires_check"]
|
||||
assert not receipt.get("receiver_resumed")
|
||||
else:
|
||||
assert current == before and receipt["receiver_resumed"]
|
||||
assert receipt["status"] == ("captured" if failure is None else "failed")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, "publish", "encrypt"])
|
||||
def test_backup_success_requires_readback_and_preserves_one_pending_capture(
|
||||
modules, monkeypatch, tmp_path, failure
|
||||
):
|
||||
archive, _, runner = modules
|
||||
captures, publications = [], []
|
||||
original_seal = runner.seal
|
||||
state = tmp_path / "state"
|
||||
|
||||
def command(argv, **kwargs):
|
||||
captures.append(argv)
|
||||
captured(Path(argv[-1]), archive)
|
||||
|
||||
def seal(directory, stage, recipient):
|
||||
if failure == "encrypt":
|
||||
raise archive.Refused("synthetic encryption failure")
|
||||
return original_seal(directory, stage, recipient)
|
||||
|
||||
def publish(encrypted, fetched, receipt_path, capture, tool, python):
|
||||
publications.append(encrypted.read_bytes())
|
||||
if failure == "publish":
|
||||
raise archive.Refused("synthetic readback failure")
|
||||
archive.write_private(fetched, encrypted.read_bytes())
|
||||
key = (
|
||||
"platform-pg/application-archives/email-connect/2026/10/07/20261007T200000Z-"
|
||||
+ "b" * 24
|
||||
+ ".tar.age"
|
||||
)
|
||||
return {
|
||||
"schema": "platform.application-primary-archive.v1",
|
||||
"application": "email-connect",
|
||||
"status": "offsite_fetched_verified",
|
||||
"remote_retrieval_verified": True,
|
||||
"version_id": "synthetic-version",
|
||||
"object_key": key,
|
||||
"destination": archive.PREFIX + key.rsplit("/", 1)[-1],
|
||||
"captured_at": capture["captured_at"],
|
||||
"ciphertext_sha256": archive.digest(encrypted),
|
||||
"ciphertext_bytes": encrypted.stat().st_size,
|
||||
"immutable_create": True,
|
||||
}
|
||||
|
||||
identity = tmp_path / "synthetic-identity"
|
||||
subprocess.run(["age-keygen", "-o", str(identity)], capture_output=True, check=True)
|
||||
public = (
|
||||
subprocess.run(["age-keygen", "-y", str(identity)], capture_output=True, check=True)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
monkeypatch.setattr(runner, "command", command)
|
||||
monkeypatch.setattr(runner, "seal", seal)
|
||||
monkeypatch.setattr(runner, "publish", publish)
|
||||
if failure:
|
||||
with pytest.raises(archive.Refused):
|
||||
runner.backup(state, Path("synthetic-tool"), Path("synthetic-python"), public)
|
||||
assert not (state / "last-success.json").exists()
|
||||
assert len(list(state.glob("pending-*"))) == 1
|
||||
with pytest.raises(archive.Refused, match="pending_backup_requires_review"):
|
||||
runner.backup(state, Path("synthetic-tool"), Path("synthetic-python"), public)
|
||||
if failure == "publish":
|
||||
ciphertext = publications[0]
|
||||
failure = None
|
||||
result = runner.backup(
|
||||
state, Path("synthetic-tool"), Path("synthetic-python"), public, resume=True
|
||||
)
|
||||
assert len(captures) == 1 and publications == [ciphertext, ciphertext]
|
||||
assert result["application_restore_verified"] is False
|
||||
else:
|
||||
result = runner.backup(state, Path("synthetic-tool"), Path("synthetic-python"), public)
|
||||
assert not list(state.glob("pending-*"))
|
||||
assert not list(state.rglob("database.sqlite3"))
|
||||
assert json.loads((state / "inventory.json").read_text()) == [result]
|
||||
assert runner.freshness(state, datetime(2026, 10, 8, 20, tzinfo=UTC))["status"] == "fresh"
|
||||
with pytest.raises(archive.Refused, match="backup_stale"):
|
||||
runner.freshness(state, datetime(2026, 10, 9, 20, tzinfo=UTC))
|
||||
(state / result["local_archive"]).write_bytes(b"corruption")
|
||||
with pytest.raises(archive.Refused, match="local_ciphertext_mismatch"):
|
||||
runner.freshness(state, datetime(2026, 10, 8, 20, tzinfo=UTC))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("receiver_resumed", False),
|
||||
("database_sha256", "bad"),
|
||||
("ledger_fingerprint", {}),
|
||||
("source_pvc_read_only", False),
|
||||
],
|
||||
)
|
||||
def test_invalid_capture_cannot_be_sealed(modules, tmp_path, field, value):
|
||||
archive, _, _ = modules
|
||||
stage = tmp_path / "stage"
|
||||
stage.mkdir(mode=0o700)
|
||||
receipt = captured(stage / "capture", archive)
|
||||
receipt[field] = value
|
||||
(stage / "capture" / "receipt.json").write_text(json.dumps(receipt))
|
||||
with pytest.raises(archive.Refused):
|
||||
archive.seal(stage / "capture", stage, "unused public recipient")
|
||||
assert not (stage / "packet.tar").exists()
|
||||
|
||||
|
||||
def test_helper_has_no_secret_or_token_mount_and_requires_no_egress(modules):
|
||||
_, capture, _ = modules
|
||||
pod = capture.helper("synthetic-helper")
|
||||
container = pod["spec"]["containers"][0]
|
||||
assert not pod["spec"]["automountServiceAccountToken"]
|
||||
assert "env" not in container and "envFrom" not in container
|
||||
assert container["volumeMounts"][0]["readOnly"]
|
||||
labels = pod["metadata"]["labels"]
|
||||
deny = {"spec": {"policyTypes": ["Ingress", "Egress"], "podSelector": {}}}
|
||||
scoped = {
|
||||
"spec": {
|
||||
"policyTypes": ["Egress"],
|
||||
"podSelector": {"matchLabels": {"app.kubernetes.io/name": "email-connect"}},
|
||||
"egress": [{}],
|
||||
}
|
||||
}
|
||||
capture.require_no_helper_egress([deny, scoped], labels)
|
||||
with pytest.raises(capture.Refused, match="helper_egress_not_denied"):
|
||||
capture.require_no_helper_egress([], labels)
|
||||
broad = copy.deepcopy(scoped)
|
||||
broad["spec"]["podSelector"] = {}
|
||||
with pytest.raises(capture.Refused, match="helper_egress_not_denied"):
|
||||
capture.require_no_helper_egress([deny, broad], labels)
|
||||
assert capture.selected(
|
||||
{"matchExpressions": [{"key": "email-ledger-capture", "operator": "Exists"}]}, labels
|
||||
)
|
||||
assert not capture.selected(
|
||||
{
|
||||
"matchExpressions": [
|
||||
{"key": "email-ledger-capture", "operator": "In", "values": ["other"]}
|
||||
]
|
||||
},
|
||||
labels,
|
||||
)
|
||||
109
workplans/EMAIL-WP-0007-ledger-backup-recovery.md
Normal file
109
workplans/EMAIL-WP-0007-ledger-backup-recovery.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
id: EMAIL-WP-0007
|
||||
type: workplan
|
||||
title: "Back up and recover the transactional delivery ledger"
|
||||
domain: infotech
|
||||
repo: email-connect
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: email-connect
|
||||
created: "2026-10-07"
|
||||
updated: "2026-10-08"
|
||||
origin: residual
|
||||
origin_ref: EMAIL-WP-0006
|
||||
---
|
||||
|
||||
CSOC-WP-0010-T04/T05 request durable recovery of the SQLite delivery/challenge
|
||||
ledger independently of Coulomb's metadata/content pair. EMAIL-WP-0006 completed
|
||||
the verification retry release; its temporary private pre-release copies are not
|
||||
a recurring backup. This work does not change SMTP/caller custody or send mail.
|
||||
|
||||
## T01 — Implement coherent ledger capture and verified publication
|
||||
|
||||
```task
|
||||
id: EMAIL-WP-0007-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Prepare a default-dry-run node capture, pinned to the current cluster/image/PVC.
|
||||
Quiesce the single receiver, use SQLite's backup API against the read-only PVC,
|
||||
verify integrity/all five transactional tables and resume the unchanged receiver
|
||||
in finally. Lock overlap and preserve private failure evidence. Package SQLite
|
||||
and its capture receipt only, age-encrypt once, publish through platform's
|
||||
immutable email-connect archive profile, and require version/hash readback before
|
||||
advancing success. Reviewed retries reuse ciphertext without another pause.
|
||||
No SMTP password, ingest token, scanner mailbox or provider message body belongs
|
||||
in this archive. Tests use only synthetic records and no production endpoints.
|
||||
|
||||
## T02 — Prove isolated recovery preserves delivery and challenge state
|
||||
|
||||
```task
|
||||
id: EMAIL-WP-0007-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Add attended guarded decrypt/unpack and all-table/schema/row fingerprint checks.
|
||||
Exercise current packaged service under UID10001/network-disabled conditions
|
||||
with synthetic recovered SQLite. Accepted sends/verifications replay without a
|
||||
provider call; unknown outcomes remain blocked; suppression and consumed/cancelled
|
||||
links remain effective. A historical snapshot can lose newer reservations or
|
||||
revive a handle consumed after capture: production recovery must freeze delivery,
|
||||
reconcile post-capture operations/issuer state and explicitly invalidate/reconcile
|
||||
challenges before releasing traffic. Never auto-restore into a running service.
|
||||
|
||||
## T03 — Admit the independent primary caller and retention custody
|
||||
|
||||
```task
|
||||
id: EMAIL-WP-0007-T03
|
||||
status: wait
|
||||
priority: high
|
||||
needs_human: true
|
||||
intervention_note: "Platform must admit the separate email-connect archive prefix, caller delivery, retention/capacity, independently retained receipts and recovery escrow; shared uploader support alone is not custody admission."
|
||||
```
|
||||
|
||||
Platform profile: platform-pg/application-archives/email-connect/ in the current
|
||||
Scaleway primary bucket. Request a separately scoped caller; do not copy another
|
||||
workload's Secret or inherit its write/delete authority. Worker has only public
|
||||
age recipient, no escrow identity or retained-object deletion rights. Bind the
|
||||
platform hash-locked SDK runtime and separate recovery/retention owner. RPF-WP-0053-T05
|
||||
tracks admission; founder continuation authorizes source preparation, not an
|
||||
invented owner review or credential grant.
|
||||
|
||||
## T04 — Complete the admitted first run, recovery and monitoring
|
||||
|
||||
```task
|
||||
id: EMAIL-WP-0007-T04
|
||||
status: wait
|
||||
priority: high
|
||||
needs_human: true
|
||||
intervention_note: "Needs T03 admission and the exact first receiver pause/upload/schedule decision; real offsite retrieval, attended recovery and failure reception remain unproved."
|
||||
```
|
||||
|
||||
Present pinned source hashes, dry-run evidence, coherent ledger scope, bounded
|
||||
pause and rollback/resume protocol before exact activation. First capture must
|
||||
resume current service before encryption/upload. Independently retrieve/decrypt
|
||||
with escrow and validate all rows/schema and no-send recovery behavior. Record
|
||||
RPO/RTO and retained recovery point, then admit schedule and monitored failure
|
||||
response. Temporary private release copies retain their existing custody until
|
||||
the owner explicitly disposes of them; no expiry is implied here.
|
||||
|
||||
## Implementation evidence — 2026-10-08
|
||||
|
||||
Standalone scripts/capture-ledger.py, ledger_archive.py, backup-ledger.py and
|
||||
rehearse-ledger.py plus four disabled unit candidates implement T01/T02. Full
|
||||
email suite 120 passed with the local GreenMail harness, zero skips, including 19
|
||||
new synthetic ledger cases. Actual age roundtrip and pinned receiver-image
|
||||
rehearsal under UID10001/network none preserve all five tables/schema/rows and
|
||||
verify accepted/unknown reservation and consumed/cancelled/suppression behavior
|
||||
without provider calls. Isolated rehearsal is not live recovery acceptance.
|
||||
|
||||
Node server dry-run verifies current cluster/image/PVC, no-egress/no-secret helper
|
||||
and replica-only guarded pause projection; receiver remains unchanged, no helper
|
||||
created. All four units verify. Metadata-only check: email primary.env absent;
|
||||
no credentials fetched/copied, live pause/capture/provider upload, deploy or timer
|
||||
activation. Complete review: docs/ledger-backup-review-2026-10-08.md. Platform
|
||||
admission handoff RPF-WP-0053-T05 is a live record, not an owner approval. T03/T04
|
||||
remain wait for custody, retention/capacity, actual offsite/recovery and monitoring
|
||||
plus the incident post-capture reconciliation gate. The workplan remains active.
|
||||
Loading…
Add table
Add a link
Reference in a new issue