email-connect/scripts/sender_transition.py
tegwick 422590934b
Some checks failed
Transactional mail acceptance / mail (push) Failing after 1m11s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Prepare durable sender transition and current-ledger recovery protocol
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 16:41:30 +01:00

330 lines
15 KiB
Python

"""Durable sender-change protocol; adapters must supply native fencing and reads.
This module has no CLI or cluster credentials. The native adapter must bind a
reviewed proposal, hold the existing capture lock and verify owner admission.
No protocol method writes or restores the delivery ledger.
"""
from __future__ import annotations
import copy
import fcntl
import hashlib
import json
import math
import os
import sqlite3
import stat
from contextlib import closing
from datetime import datetime
from pathlib import Path
IMAGE = ("forgejo.coulomb.social/coulomb/email-connect@sha256:"
"5d79fd1fec15d3a279383820f170d263de3a55f5308df01a0a34732ebaea6d3d")
TABLES = {"deliveries", "verifications", "suppressions", "send_attempts",
"verification_attempts", "caller_buckets"}
SCHEMA = "email-connect.sender-transition.v1"
class Refused(RuntimeError):
pass
def require(condition, reason):
if not condition:
raise Refused(reason)
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def desired(before):
"""The only admitted configuration delta; never accepts a free-form patch."""
after = copy.deepcopy(before)
spec = after["deployment_spec"]
containers = spec["template"]["spec"]["containers"]
require(spec["replicas"] == 1 and len(containers) == 1, "unexpected_receiver_shape")
container = containers[0]
require(container["name"] == "email-connect" and container["image"] == IMAGE,
"unexpected_receiver_image")
require(container["envFrom"] == [
{"configMapRef": {"name": "email-connect-config"}},
{"secretRef": {"name": "email-connect-runtime", "optional": False}},
], "unexpected_old_environment")
require(container["env"] == [
{"name": "EMAIL_CONNECT_CALLERS_FILE", "value": "/etc/email-connect-callers/callers.json"},
], "unexpected_environment_override")
require(after["config_data"]["EMAIL_CONNECT_SENDER"] == "forgejo@coulomb.social",
"unexpected_old_sender")
container["envFrom"] = [{"configMapRef": {"name": "email-connect-config"}}]
container["env"] += [
{"name": "EMAIL_CONNECT_SMTP_USERNAME", "value": "platform@coulomb.social"},
{"name": "EMAIL_CONNECT_SMTP_PASSWORD", "valueFrom": {"secretKeyRef": {
"name": "email-connect-platform-smtp", "key": "password", "optional": False}}},
]
after["config_data"]["EMAIL_CONNECT_SENDER"] = "platform@coulomb.social"
return after
def with_replicas(spec, replicas):
result = copy.deepcopy(spec)
result["replicas"] = replicas
return result
def known(observed, before, after):
"""Recognize lost-response/mixed phases, but never unrelated live drift."""
for key in ("identity", "protected"):
require(observed[key] == before[key] == after[key], "protected_runtime_changed")
require(observed["config_data"] in (before["config_data"], after["config_data"]),
"configuration_changed_outside_proposal")
require(observed["deployment_spec"] in [with_replicas(item["deployment_spec"], replicas)
for item in (before, after) for replicas in (0, 1)],
"deployment_changed_outside_proposal")
def ledger(path):
path = Path(path).absolute()
require(not any(p.is_symlink() for p in [path, *path.parents]), "symlink_snapshot")
info = path.stat()
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
and not info.st_mode & 0o077 and 0 < info.st_size <= 128 * 1024**2,
"private_bounded_snapshot_required")
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as db:
db.execute("PRAGMA trusted_schema=OFF")
db.execute("PRAGMA query_only=ON")
db.execute("BEGIN")
require(db.execute("PRAGMA integrity_check").fetchall() == [("ok",)],
"snapshot_integrity_failed")
schema = db.execute(
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name").fetchall()
require({r[1] for r in schema if r[0] == "table" and not r[1].startswith("sqlite_")}
== TABLES, "unexpected_snapshot_schema")
rows = {}
for table in sorted(TABLES):
columns = db.execute('PRAGMA table_info("' + table + '")').fetchall()
keys = [r[1] for r in sorted(columns, key=lambda r: r[5]) if r[5]]
require(len(keys) == 1, "unexpected_snapshot_key")
names = [r[1] for r in columns]
values = [dict(zip(names, r, strict=True))
for r in db.execute('SELECT * FROM "' + table + '"')]
rows[table] = {r[keys[0]]: r for r in values}
require(len(rows[table]) == len(values) and None not in rows[table],
"ambiguous_snapshot_row_identity")
return schema, rows
def same_except(old, new, fields):
return {k: v for k, v in old.items() if k not in fields} == {
k: v for k, v in new.items() if k not in fields}
def row_preserved(table, old, new):
if old == new:
return True
if table == "verifications":
if not same_except(old, new, {"consumed_at", "canceled_at"}):
return False
if new["consumed_at"] is not None and new["canceled_at"] is not None:
return False
for key in ("consumed_at", "canceled_at"):
if old[key] == new[key]:
continue
if old[key] is not None or not isinstance(new[key], str):
return False
try:
if datetime.fromisoformat(new[key]).tzinfo is None:
return False
except ValueError:
return False
return True
if table == "caller_buckets":
return (same_except(old, new, {"tokens", "updated_at"})
and all(isinstance(new[k], (int, float)) and math.isfinite(new[k])
for k in ("tokens", "updated_at"))
and new["tokens"] >= 0 and new["updated_at"] >= old["updated_at"])
if table in {"send_attempts", "verification_attempts"}:
mutable = {"state", "error", "retryable"}
if table == "verification_attempts":
mutable.add("provider_ref")
# Only a definitive retryable failure permits a fresh verification handle.
retry = old["state"] == "failed" and old["retryable"] == 1
if retry and table == "verification_attempts":
mutable.add("request_id")
states = {"in_flight", "failed"}
if table == "verification_attempts":
states.add("accepted")
return (same_except(old, new, mutable)
and (retry or old["state"] == "in_flight") and new["state"] in states
and (new["state"] != "accepted" or bool(new.get("provider_ref"))))
# Accepted deliveries and suppression decisions have no authorized mutation
# in this configuration-only operation. New rows remain possible at runtime.
return False
def continuity(before, after):
old_schema, old = ledger(before)
new_schema, new = ledger(after)
require(old_schema == new_schema, "ledger_schema_changed")
changes = {}
for table in sorted(TABLES):
require(old[table].keys() <= new[table].keys(), "ledger_rows_removed")
require(all(row_preserved(table, value, new[table][key])
for key, value in old[table].items()), "ledger_history_changed")
changes[table] = {
"preserved_rows": len(old[table]),
"new_rows": len(new[table]) - len(old[table]),
"permitted_updates": sum(value != new[table][key]
for key, value in old[table].items()),
}
return {"all_six_tables_checked": True, "schema_unchanged": True, "tables": changes,
"ledger_restored": False, "snapshot_bytes_equal":
Path(before).read_bytes() == Path(after).read_bytes()}
class Journal:
"""Private append-only intents; incomplete lines refuse automatic recovery."""
def __init__(self, path, *, proposal=None):
self.path = Path(path).absolute()
require(not any(p.is_symlink() for p in [self.path, *self.path.parents]),
"real_journal_path_required")
parent = self.path.parent.stat()
require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == os.getuid()
and not parent.st_mode & 0o077, "private_journal_parent_required")
flags = os.O_RDWR | os.O_APPEND | os.O_NOFOLLOW
if proposal is not None:
flags |= os.O_CREAT | os.O_EXCL
self.fd = os.open(self.path, flags, 0o600)
try:
info = os.fstat(self.fd)
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
and not info.st_mode & 0o077 and info.st_nlink == 1,
"private_owned_journal_required")
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self.entries = []
if proposal is not None:
self.record("prepared", schema=SCHEMA, proposal=proposal,
proposal_digest=digest(proposal))
directory = os.open(self.path.parent, os.O_DIRECTORY | os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
else:
require(info.st_size <= 16 * 1024**2, "journal_too_large")
data = os.read(self.fd, info.st_size)
require(data.endswith(b"\n"), "incomplete_journal_requires_inspection")
self.entries = [json.loads(line) for line in data.splitlines()]
first = self.entries[0]
require(first["schema"] == SCHEMA
and first["proposal_digest"] == digest(first["proposal"]),
"invalid_proposal_binding")
require(all(e["sequence"] == i for i, e in enumerate(self.entries)),
"invalid_journal_sequence")
except BaseException:
os.close(self.fd)
raise
def record(self, phase, **data):
record = dict(data, phase=phase, sequence=len(self.entries))
body = json.dumps(record, sort_keys=True).encode() + b"\n"
# Write all bytes before returning; short writes never authorize effects.
offset = 0
while offset < len(body):
written = os.write(self.fd, body[offset:])
require(written > 0, "journal_write_failed")
offset += written
os.fsync(self.fd)
self.entries.append(record)
def close(self):
os.close(self.fd)
def run(adapter, journal, *, recover=False):
"""One forward attempt, or explicit recovery to the original configuration.
Adapter contract: observe/patch use complete UID/spec CAS; fence proves no
other PVC writers; snapshot creates a fresh private consistent copy and binds
helper identity through journal intents; cleanup removes only owned helpers;
verify proves actual process configuration, health and no-send denials. The
native adapter, not this protocol, proves credential admission and fencing.
"""
first = journal.entries[0]
proposal = first["proposal"]
before = proposal["before"]
after = desired(before)
require(proposal["after"] == after, "unreviewed_configuration_delta")
require(recover or len(journal.entries) == 1, "forward_replay_refused")
if recover:
require(journal.entries[-1]["phase"] != "recovered", "recovery_already_completed")
target = before if recover else after
mode = "recovery" if recover else "forward"
def observe():
current = adapter.observe()
known(current, before, after)
return current
try:
live = observe()
if not recover:
require(live == before, "preflight_changed")
adapter.admission(proposal)
journal.record(mode + "_pause_intent")
if live["deployment_spec"]["replicas"] != 0:
adapter.patch_deployment(live["deployment_spec"],
with_replicas(live["deployment_spec"], 0))
adapter.fence(journal)
live = observe()
require(live["deployment_spec"]["replicas"] == 0, "receiver_not_paused")
journal.record(mode + "_capture_intent")
baseline, outcomes = adapter.snapshot(journal, quiesced=True)
require(Path(baseline).resolve().is_relative_to(journal.path.parent),
"snapshot_outside_private_attempt")
ledger(baseline) # Refuse a broken or non-private baseline before switching.
baseline_sha256 = hashlib.sha256(Path(baseline).read_bytes()).hexdigest()
# Recovery may need to preserve an unknown outcome caused by an earlier
# interruption. It does not repair or clear that reservation.
if not recover:
require(not outcomes["needs_outcome_reconciliation"], "unknown_outcome_before_switch")
journal.record(mode + "_captured", outcomes=outcomes,
snapshot_name=Path(baseline).relative_to(journal.path.parent).as_posix(),
snapshot_sha256=baseline_sha256)
live = observe()
require(live["deployment_spec"]["replicas"] == 0, "receiver_resumed_before_config")
journal.record(mode + "_config_intent")
adapter.patch_config(live["config_data"], target["config_data"])
live = observe()
require(live["deployment_spec"]["replicas"] == 0, "receiver_resumed_before_deployment")
journal.record(mode + "_deployment_intent")
adapter.patch_deployment(live["deployment_spec"],
with_replicas(target["deployment_spec"], 0))
live = observe()
require(live["config_data"] == target["config_data"], "sender_config_not_verified")
require(live["deployment_spec"] == with_replicas(target["deployment_spec"], 0),
"paused_deployment_not_verified")
journal.record(mode + "_resume_intent")
adapter.patch_deployment(live["deployment_spec"], target["deployment_spec"])
native = adapter.verify(target, journal)
require(observe() == target, "runtime_not_verified")
journal.record(mode + "_postflight_intent", native=native)
returned, _ = adapter.snapshot(journal, quiesced=False)
require(Path(returned).resolve().is_relative_to(journal.path.parent),
"snapshot_outside_private_attempt")
require(hashlib.sha256(Path(baseline).read_bytes()).hexdigest() == baseline_sha256,
"baseline_snapshot_changed")
result = continuity(baseline, returned)
journal.record(mode + "_cleanup_intent", continuity=result)
adapter.cleanup(journal)
journal.record("recovered" if recover else "completed", continuity=result,
ledger_restored=False, helper_cleanup_verified=True,
sender=target["config_data"]["EMAIL_CONNECT_SENDER"])
return result
except BaseException:
# No guessed rollback or resume in finally. Explicit recovery observes
# actual state, captures the CURRENT ledger and retains prior attempts.
journal.record("requires_inspection", operation=mode)
raise