From 3e8996fe94f79e3f778f1869fae4509863f0c001 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 14:10:26 +0200 Subject: [PATCH] Deploy caller-control receiver and align ledger recovery pins Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b --- .../railiance/deploy-callers-2026-10-08.py | 253 ++++++++++++++++++ deploy/k8s/railiance/deployment.yaml | 4 +- docs/caller-admission-2026-10-08.md | 11 +- docs/caller-deployment-2026-10-08.json | 48 ++++ docs/caller-deployment-2026-10-08.md | 56 ++++ scripts/capture-ledger.py | 2 +- scripts/rehearse-ledger.py | 2 +- tests/test_deploy_callers.py | 57 ++++ .../EMAIL-WP-0007-ledger-backup-recovery.md | 11 + ...IL-WP-0008-scoped-transactional-callers.md | 19 ++ 10 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 deploy/k8s/railiance/deploy-callers-2026-10-08.py create mode 100644 docs/caller-deployment-2026-10-08.json create mode 100644 docs/caller-deployment-2026-10-08.md create mode 100644 tests/test_deploy_callers.py diff --git a/deploy/k8s/railiance/deploy-callers-2026-10-08.py b/deploy/k8s/railiance/deploy-callers-2026-10-08.py new file mode 100644 index 0000000..90400c9 --- /dev/null +++ b/deploy/k8s/railiance/deploy-callers-2026-10-08.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Exact founder-approved email-connect caller-source release, 2026-10-08. + +Default is server dry-run. Never prints SQLite bytes, credentials or child errors. +""" +import argparse +import copy +import hashlib +import json +import os +import signal +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path + +NS = 'email-connect' +UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +OLD = 'forgejo.coulomb.social/coulomb/email-connect@sha256:1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c' +NEW = 'forgejo.coulomb.social/coulomb/email-connect@sha256:eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2' +DECISION = '0ef49fde-5e92-4656-ac1e-8f945b4bba08' + + +class Refused(RuntimeError): + pass + + +def k(*args, body=None, timeout=180): + result = subprocess.run(['kubectl', *args], input=body, capture_output=True, timeout=timeout) + if result.returncode: + raise Refused('command_failed') + return result.stdout + + +def document(*args): + return json.loads(k(*args, '-o', 'json')) + + +def image(dep): + return dep['spec']['template']['spec']['containers'][0]['image'] + + +def private_write(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + + +def backup_pod(name): + return {'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'email-connect-recovery': name}}, + 'spec': {'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, 'seccompProfile': {'type': 'RuntimeDefault'}}, + 'containers': [{'name': 'backup', 'image': NEW, + 'command': ['python', '-c', 'import time;time.sleep(600)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '32Mi'}, + 'limits': {'cpu': '100m', 'memory': '128Mi'}}, + '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 patch(): + return json.dumps([ + {'op': 'test', 'path': '/spec/template/spec/containers/0/image', 'value': OLD}, + {'op': 'replace', 'path': '/spec/template/spec/containers/0/image', 'value': NEW}, + ]) + + +def capture(name, destination): + code = '''import os,sqlite3,sys +src=sqlite3.connect('file:/data/email-connect.db?mode=ro',uri=True) +dst=sqlite3.connect('/tmp/verification-pre-release.db') +src.backup(dst) +assert dst.execute('PRAGMA integrity_check').fetchone()[0]=='ok' +dst.close();src.close() +os.chmod('/tmp/verification-pre-release.db',0o600) +with open('/tmp/verification-pre-release.db','rb') as stream:sys.stdout.buffer.write(stream.read()) +''' + data = k('exec', '-n', NS, name, '--', 'python', '-c', code) + if not data.startswith(b'SQLite format 3\x00'): + raise Refused('invalid_sqlite_backup') + path = destination / 'database.sqlite3' + private_write(path, data) + with sqlite3.connect('file:' + str(path) + '?mode=ro', uri=True) as database: + if database.execute('PRAGMA integrity_check').fetchone()[0] != 'ok': + raise Refused('invalid_sqlite_backup') + return {'database_sha256': hashlib.sha256(data).hexdigest(), + 'database_bytes': len(data), 'sqlite_integrity': 'ok', + 'source_pvc_read_only': True, 'writers_quiesced': True} + + +def rehearse(name): + code = 'import json,sqlite3,hashlib,os\nfrom email_connect.transactional import SQLiteDeliveryStore,ProviderError\nsrc=sqlite3.connect(\'file:/tmp/verification-pre-release.db?mode=ro\',uri=True)\ntarget=\'/tmp/candidate-migration.db\'\ndst=sqlite3.connect(target);src.backup(dst);dst.close()\ntables=[r[0] for r in src.execute("SELECT name FROM sqlite_master WHERE type=\'table\'")]\nassert set(tables)=={\'deliveries\',\'verifications\',\'suppressions\',\'send_attempts\',\'verification_attempts\'}\ncols={t:[r[1] for r in src.execute(\'PRAGMA table_info(\'+t+\')\')] for t in tables}\ndef rows(db,t):return sorted(json.dumps(r) for r in db.execute(\'SELECT \'+\',\'.join(cols[t])+\' FROM \'+t))\nbefore={t:rows(src,t) for t in tables};src.close()\nstore=SQLiteDeliveryStore(target)\nassert all(rows(store.db,t)==before[t] for t in tables)\nassert store.db.execute(\'SELECT COUNT(*) FROM caller_buckets\').fetchone()[0]==0\nassert store.db.execute("SELECT COUNT(*) FROM verifications WHERE caller_id<>\'\'").fetchone()[0]==0\naccepted=unknown=terminal=0\nfor event,fp in store.db.execute(\'SELECT d.event_id,a.fingerprint FROM deliveries d LEFT JOIN send_attempts a USING(event_id)\').fetchall():\n assert store.reserve_send(event,fp or \'synthetic-legacy-probe\') is False;accepted+=1\nfor event,fp,state in store.db.execute("SELECT event_id,fingerprint,state FROM send_attempts WHERE event_id NOT IN (SELECT event_id FROM deliveries)").fetchall():\n if state in (\'in_flight\',\'failed\'):\n try:store.reserve_send(event,fp);raise AssertionError(\'unsafe_retry\')\n except ProviderError as e:assert e.retryable is False\n if state==\'in_flight\':unknown+=1\n else:terminal+=1\nassert all(rows(store.db,t)==before[t] for t in tables)\nassert os.getuid()==10001\nprint(json.dumps({\'uid\':os.getuid(),\'all_five_table_rows_preserved\':True,\'six_table_schema\':True,\'accepted_invitations\':accepted,\'unknown_invitations\':unknown,\'terminal_historical_failures\':terminal,\'mail_sent\':False}))\n' + return json.loads(k('exec', '-n', NS, name, '--', 'python', '-c', code)) + + +def verify(): + code = '''import json,os,sqlite3,urllib.request,urllib.error +base='http://127.0.0.1:8080' +results={} +for path in ['/healthz','/readyz']: + with urllib.request.urlopen(base+path,timeout=5) as response:results[path]=response.status +for label,path,payload,token,key,expected,reason in [ + ('unauthenticated','/v1/send',{},'synthetic-invalid',None,401,'unauthorized'), + ('template_denial','/v1/send',{'id':'verification-release-denial','source':'user-engine','type':'arbitrary.send'},os.environ['EMAIL_CONNECT_INGEST_TOKEN'].strip(),'verification-release-denial',400,'template_not_allowed'), + ('invalid_verification','/v1/registration-verifications',{},os.environ['EMAIL_CONNECT_INGEST_TOKEN'].strip(),None,400,'invalid_verification_request')]: + headers={'Content-Type':'application/json','Authorization':'Bearer '+token} + if key:headers['Idempotency-Key']=key + request=urllib.request.Request(base+path,data=json.dumps(payload).encode(),headers=headers,method='POST') + try:urllib.request.urlopen(request,timeout=5);raise RuntimeError('expected_denial') + except urllib.error.HTTPError as error: + body=json.load(error) + assert error.code==expected and body.get('error')==reason + results[label]=error.code +with sqlite3.connect('file:/data/email-connect.db?mode=ro',uri=True) as database: + assert database.execute('PRAGMA integrity_check').fetchone()[0]=='ok' + assert database.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='caller_buckets'").fetchone() + assert database.execute('SELECT COUNT(*) FROM caller_buckets').fetchone()[0]==0 + assert 'caller_id' in {r[1] for r in database.execute('PRAGMA table_info(verifications)')} + assert {'error','retryable'} <= {r[1] for r in database.execute('PRAGMA table_info(send_attempts)')} +assert 'EMAIL_CONNECT_CALLERS_FILE' not in os.environ +results.update(sqlite_integrity='ok',caller_schema_present=True,scoped_policy_active=False,mail_sent=False) +print(json.dumps(results)) +''' + return json.loads(k('exec', '-n', NS, 'deployment/email-connect', '--', 'python', '-c', code)) + + +def deploy(before, body, destination): + destination.mkdir(mode=0o700) + receipt = {'status': 'incomplete', 'decision': DECISION, 'old_image': OLD, + 'new_image': NEW, 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + started = time.monotonic() + paused = False + helper = False + succeeded = False + name = json.loads(body)['metadata']['name'] + try: + paused = True + receipt['phase'] = 'pause' + k('patch', 'deployment/email-connect', '-n', NS, '--type=json', '-p', json.dumps([ + {'op':'test','path':'/spec/template','value':before['spec']['template']}, + {'op':'test','path':'/spec/replicas','value':1}, + {'op':'replace','path':'/spec/replicas','value':0}])) + k('wait', '--for=delete', 'pod', '-n', NS, + '-l', 'app.kubernetes.io/name=email-connect', '--timeout=120s') + if document('get', 'deployment', 'email-connect', '-n', NS)['spec']['replicas'] != 0: + raise Refused('writers_not_quiesced') + helper = True + k('create', '-f', '-', body=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=180s', timeout=240) + receipt['phase'] = 'capture' + receipt.update(capture(name, destination)) + receipt['phase'] = 'isolated_migration' + receipt['migration_checks'] = rehearse(name) + k('delete', 'pod', name, '-n', NS, '--wait=true', '--timeout=120s') + helper = False + receipt['phase'] = 'image_patch' + k('patch', 'deployment', 'email-connect', '-n', NS, '--type=json', '-p', patch()) + k('scale', 'deployment/email-connect', '-n', NS, '--replicas=1') + k('rollout', 'status', 'deployment/email-connect', '-n', NS, '--timeout=180s', timeout=240) + actual = document('get', 'deployment', 'email-connect', '-n', NS) + expected = copy.deepcopy(before['spec']) + expected['template']['spec']['containers'][0]['image'] = NEW + if actual['spec'] != expected or actual['status'].get('readyReplicas') != 1: + raise Refused('unexpected_release_spec') + receipt['phase'] = 'verification' + receipt['checks'] = verify() + receipt.update(status='released_verified', image_only_spec_change=True, ready_replicas=1) + succeeded = True + except BaseException as exc: + receipt.update(status='failed', failure_type=type(exc).__name__) + raise + finally: + try: + if helper: + k('delete', 'pod', name, '-n', NS, '--ignore-not-found=true', '--wait=true', '--timeout=120s') + except Exception: + receipt['helper_cleanup_failed'] = True + if paused and not succeeded: + try: + current = image(document('get', 'deployment', 'email-connect', '-n', NS)) + replicas = '1' if current == OLD else '0' + k('scale', 'deployment/email-connect', '-n', NS, '--replicas=' + replicas) + receipt['failure_replicas'] = int(replicas) + if replicas == '1': + k('rollout', 'status', 'deployment/email-connect', '-n', NS, '--timeout=180s', timeout=240) + receipt['old_receiver_resumed'] = True + except Exception: + receipt['receiver_state_requires_check'] = True + receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(destination / 'release-receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + 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' + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != UID: + raise Refused('wrong_cluster') + before = document('get', 'deployment', 'email-connect', '-n', NS) + if image(before) != OLD or before['spec']['replicas'] != 1 or before['status'].get('readyReplicas') != 1: + 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 'EMAIL_CONNECT_CALLERS_FILE' in config: + raise Refused('scoped_policy_not_in_release') + flags = json.loads(k('exec', '-n', NS, 'deployment/email-connect', '--', 'python', '-c', + "import os,json;print(json.dumps({'scoped': 'EMAIL_CONNECT_CALLERS_FILE' in os.environ}))")) + if flags['scoped']: + raise Refused('unexpected_live_policy') + if config.get('EMAIL_CONNECT_DATABASE_PATH') != '/data/email-connect.db': + raise Refused('unexpected_database_path') + name = 'email-connect-callers-backup-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + body = json.dumps(backup_pod(name)).encode() + k('create', '--dry-run=server', '-f', '-', body=body) + projected = document('patch', 'deployment', 'email-connect', '-n', NS, + '--type=json', '-p', patch(), '--dry-run=server') + expected = copy.deepcopy(before['spec']) + expected['template']['spec']['containers'][0]['image'] = NEW + if projected['spec'] != expected: + raise Refused('unexpected_dry_run_spec') + if not args.execute: + print(json.dumps({'status': 'reviewed', 'live_changes': False, + 'image_only_spec_diff': True, 'readonly_backup_helper': True})) + return + if not args.destination.is_absolute() or args.destination.exists(): + raise Refused('destination_must_be_new') + def terminate(signum, frame): + raise Refused('release_interrupted') + signal.signal(signal.SIGTERM, terminate) + deploy(before, body, args.destination) + + +if __name__ == '__main__': + try: + main() + except Exception as exc: + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/deploy/k8s/railiance/deployment.yaml b/deploy/k8s/railiance/deployment.yaml index 0e56150..5070336 100644 --- a/deploy/k8s/railiance/deployment.yaml +++ b/deploy/k8s/railiance/deployment.yaml @@ -42,8 +42,8 @@ spec: terminationGracePeriodSeconds: 15 containers: - name: email-connect - # Verification retry/restart correction, source 7258505; candidate pending founder go. - image: forgejo.coulomb.social/coulomb/email-connect@sha256:1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c + # Caller controls and terminal invitation retry fix, source c9756b0; approved image-only rollout. + image: forgejo.coulomb.social/coulomb/email-connect@sha256:eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2 imagePullPolicy: IfNotPresent ports: - name: http diff --git a/docs/caller-admission-2026-10-08.md b/docs/caller-admission-2026-10-08.md index aff7899..0b818ff 100644 --- a/docs/caller-admission-2026-10-08.md +++ b/docs/caller-admission-2026-10-08.md @@ -1,8 +1,9 @@ # Scoped transactional callers — source review, 2026-10-08 The receiver can isolate explicitly admitted callers while retaining the existing -single-caller startup path until deployment admission. No live credentials, -mailboxes, sender, chart, image, ledger or timer changed in this source increment. +single-caller startup path until deployment admission. The initial source increment changed no runtime. The tested image is now +deployed in the existing single-caller mode; see caller-deployment-2026-10-08.md. +Scoped policy admission and new caller activation remain separate. Set EMAIL_CONNECT_CALLERS_FILE to an owner-controlled read-only JSON policy file. If present, invalid policy fails startup before ledger migration; there is no @@ -53,9 +54,9 @@ denies its operations without deleting transport/idempotency evidence. Startup adds caller_id to verification rows, error/retryable to send_attempts, and caller_buckets. Capture fingerprints include all six tables; old five-table captures remain readable. Rehearsal source preserves scoped accepted/uncertain -verification reservations and budgets without provider calls. Helpers remain -pinned to the current admitted image: repin capture and rehearsal together to -the candidate's immutable image before new-schema capture or scoped activation. +verification reservations and budgets without provider calls. Capture/rehearsal helper source pins are repinned together to the deployed +eb3e7b68 immutable image. Their current guarded capture still requires explicit +archive admission; scoped policy activation remains a separate operation. Do not downgrade after new-schema rows exist without an admitted compatible recovery plan; old receiver code can lose retry restrictions or fail inserts. diff --git a/docs/caller-deployment-2026-10-08.json b/docs/caller-deployment-2026-10-08.json new file mode 100644 index 0000000..6818dea --- /dev/null +++ b/docs/caller-deployment-2026-10-08.json @@ -0,0 +1,48 @@ +{ + "status": "released_verified", + "decision": "0ef49fde-5e92-4656-ac1e-8f945b4bba08", + "old_image": "forgejo.coulomb.social/coulomb/email-connect@sha256:1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c", + "new_image": "forgejo.coulomb.social/coulomb/email-connect@sha256:eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2", + "started_at": "2026-10-08T12:05:17Z", + "phase": "verification", + "database_sha256": "1c943f28517cb75f324f1b11ed2e24f9aaa9779992a8534ef85906c1ff09dcae", + "database_bytes": 49152, + "sqlite_integrity": "ok", + "source_pvc_read_only": true, + "writers_quiesced": true, + "migration_checks": { + "uid": 10001, + "all_five_table_rows_preserved": true, + "six_table_schema": true, + "accepted_invitations": 2, + "unknown_invitations": 0, + "terminal_historical_failures": 0, + "mail_sent": false + }, + "checks": { + "/healthz": 200, + "/readyz": 200, + "unauthenticated": 401, + "template_denial": 400, + "invalid_verification": 400, + "sqlite_integrity": "ok", + "caller_schema_present": true, + "scoped_policy_active": false, + "mail_sent": false + }, + "image_only_spec_change": true, + "ready_replicas": 1, + "elapsed_seconds": 62.79, + "source_commit": "c9756b0", + "local_packaged_rehearsal": { + "uid": 10001, + "all_five_table_rows_preserved": true, + "six_table_schema": true, + "accepted_invitations": 2, + "unknown_invitations": 0, + "terminal_historical_failures": 0, + "mail_sent": false + }, + "synthetic_release_failure_tests": 7, + "backup_custody": "private temporary copies; durable custody admission pending" +} diff --git a/docs/caller-deployment-2026-10-08.md b/docs/caller-deployment-2026-10-08.md new file mode 100644 index 0000000..f96e9a8 --- /dev/null +++ b/docs/caller-deployment-2026-10-08.md @@ -0,0 +1,56 @@ +# Email receiver deployment — 2026-10-08 + +Released under the user's explicit “If not please do so” instruction and +State Hub decision 0ef49fde-5e92-4656-ac1e-8f945b4bba08. Source c9756b0 (runtime code f703bf6), +image forgejo.coulomb.social/coulomb/email-connect@sha256:eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2. Deployment revision7, one Ready replica. +Started 2026-10-08T12:05:17Z; elapsed 62.79s. + +The live Deployment spec changed only its immutable image. Existing caller token, +sender, config, PVC, ServiceAccount and networking are retained. Scoped policy +is not selected: per-caller admission is a separate operation in EMAIL-WP-0008-T03 +and CSOC-WP-0015-T01. Permanent invitation rejection now remains terminal in the +existing startup mode; new caller controls are available for admitted policy. +No new caller, credential, sender, mailbox access, offsite upload or timer is +activated. No mail was sent for release verification. + +Preflight: pinned cluster UID a553c742-0115-43d4-99a4-a5ca56fe0786, +old image1e613d43 and one Ready replica, Bound existing PVC, expected database +path and no selected caller policy. Node Ready/no pressure, CPU requests3455m +of4000m, memory9440Mi, disk120Gi free. Server dry-run proves image-only spec. +Seven offline failure scenarios prove pre-patch old-receiver resumption and +post-patch stopped-receiver fencing. Guard tests full template and replica count +before quiescing; historical helper cannot repeat this completed rollout. + +Pause writer, take SQLite backup through read-only data-PVC helper without runtime +credentials, rehearse new-schema migration in its private copy, remove helper, +patch old-image test→new image, resume and verify. Every row and old column in +all five pre-release tables remains unchanged; caller namespace defaults empty, +caller budget table empty, two accepted invitation reservations remain duplicate +receipts. Isolated local packaged UID10001/read-only-root/network-none migration +of the actual copied backup passes the same proof. Synthetic packaged tests prove +scoped restart/idempotency, scope denial and redacted reference behavior. + +Postflight: healthz/readyz200, unauthorized401, admitted bearer/disallowed +template400, invalid verification400, SQLite integrityok, six-table caller schema, +zero budget records and no selected scoped policy. One Ready replica, only image +spec change and no capture helper left. Public receipt: +caller-deployment-2026-10-08.json. Guarded capture and rehearsal helper source pins +are repinned together to the new digest; focused backup/release gates pass. + +Private pre-release SQLite:49152bytes, +SHA256 1c943f28517cb75f324f1b11ed2e24f9aaa9779992a8534ef85906c1ff09dcae. +Node root0700 /tmp/email-connect-precallers-20261008/database.sqlite3 and +workstation0700 /tmp/csoc-mail-private-j4qnemvw/database.sqlite3 hold matching mode0600 copies. +These are temporary custody, not governed offsite recovery. Their retention/ +expiry stays live in CSOC-WP-0015-T03 and EMAIL-WP-0007-T03/T04. + +Rollback: stop the receiver first and preserve current ledger plus private backup. +Prefer rolling forward. Old code ignores caller ownership/budgets and terminal +failure policy, and old inserts can fail against added columns. Never use +unrestricted rollout undo or overwrite the current ledger with a pre-release +snapshot after traffic: reconcile later provider acceptance and challenge use +before any release of restored traffic. Scope/mode change needs separate return. + +Remaining product work is CSOC-WP-0015 (runtime policy, sender/reply/bounce, +independent ledger custody and isolated owner-browser/flow acceptance), with +EMAIL-WP-0008-T03 and EMAIL-WP-0007-T03/T04 as canonical owner tasks. diff --git a/scripts/capture-ledger.py b/scripts/capture-ledger.py index 7a9f721..784d3cb 100644 --- a/scripts/capture-ledger.py +++ b/scripts/capture-ledger.py @@ -20,7 +20,7 @@ NS = "email-connect" CLUSTER_UID = "a553c742-0115-43d4-99a4-a5ca56fe0786" IMAGE = ( "forgejo.coulomb.social/coulomb/email-connect@sha256:" - "1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c" + "eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2" ) LOCK_PATH = Path("/var/lib/email-connect-backup/capture.lock") SELECTOR = "app.kubernetes.io/name=email-connect" diff --git a/scripts/rehearse-ledger.py b/scripts/rehearse-ledger.py index 5a13769..706eb3b 100644 --- a/scripts/rehearse-ledger.py +++ b/scripts/rehearse-ledger.py @@ -20,7 +20,7 @@ from ledger_archive import ( IMAGE = ( "forgejo.coulomb.social/coulomb/email-connect@sha256:" - "1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c" + "eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2" ) CODE = """import io,json,os,sys diff --git a/tests/test_deploy_callers.py b/tests/test_deploy_callers.py new file mode 100644 index 0000000..ce8b73b --- /dev/null +++ b/tests/test_deploy_callers.py @@ -0,0 +1,57 @@ +"""Exact release failure fences, mocked Kubernetes; no mail or cluster calls.""" +import copy +import importlib.util +import json +from pathlib import Path +import pytest + +@pytest.mark.parametrize('failure', ['capture','migration','patch_before','patch_unknown','rollout','verify',None]) +def test_release_preserves_old_receiver_or_holds_new_receiver_stopped(tmp_path, monkeypatch, failure): + script=Path(__file__).resolve().parents[1]/'deploy/k8s/railiance/deploy-callers-2026-10-08.py' + spec=importlib.util.spec_from_file_location('caller_release',script) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + before={'spec':{'replicas':1,'template':{'spec':{'containers':[{'image':module.OLD}]} }},'status':{'readyReplicas':1}} + state=copy.deepcopy(before) + def document(*args):return copy.deepcopy(state) + def k(*args, **kwargs): + if args[0]=='patch': + operations=json.loads(args[args.index('-p')+1]) + if operations[0]['path']=='/spec/template': + assert state['spec']['template']==operations[0]['value'] + assert state['spec']['replicas']==1 + state['spec']['replicas']=0 + else: + assert module.image(state)==module.OLD + if failure=='patch_before':raise module.Refused('synthetic_failure') + state['spec']['template']['spec']['containers'][0]['image']=module.NEW + if failure=='patch_unknown':raise module.Refused('synthetic_lost_response') + if args[0]=='scale':state['spec']['replicas']=int(args[-1].split('=')[1]) + if args[0]=='rollout' and module.image(state)==module.NEW and failure=='rollout': + raise module.Refused('synthetic_failure') + return b'' + def capture(*args): + if failure=='capture':raise module.Refused('synthetic_failure') + return {'database_sha256':'synthetic','database_bytes':1} + def rehearse(*args): + if failure=='migration':raise module.Refused('synthetic_failure') + return {'all_five_table_rows_preserved':True,'mail_sent':False} + def verify(): + if failure=='verify':raise module.Refused('synthetic_failure') + return {'mail_sent':False,'scoped_policy_active':False} + for name,value in [('k',k),('document',document),('capture',capture),('rehearse',rehearse),('verify',verify)]: + monkeypatch.setattr(module,name,value) + destination=tmp_path/'release' + body=json.dumps({'metadata':{'name':'synthetic-helper'}}).encode() + if failure: + with pytest.raises(module.Refused):module.deploy(before,body,destination) + receipt=json.loads((destination/'release-receipt.json').read_text()) + assert receipt['status']=='failed' + changed=failure in {'patch_unknown','rollout','verify'} + assert module.image(state)==(module.NEW if changed else module.OLD) + assert state['spec']['replicas']==(0 if changed else 1) + else: + receipt=module.deploy(before,body,destination) + assert receipt['status']=='released_verified' + assert state['spec']['replicas']==1 and module.image(state)==module.NEW + assert destination.stat().st_mode & 0o777==0o700 + assert (destination/'release-receipt.json').stat().st_mode & 0o777==0o600 diff --git a/workplans/EMAIL-WP-0007-ledger-backup-recovery.md b/workplans/EMAIL-WP-0007-ledger-backup-recovery.md index c0c660f..2263f04 100644 --- a/workplans/EMAIL-WP-0007-ledger-backup-recovery.md +++ b/workplans/EMAIL-WP-0007-ledger-backup-recovery.md @@ -112,3 +112,14 @@ 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. + +## Receiver image/schema alignment — 2026-10-08 + +EMAIL-WP-0008-T04 deploys receiver eb3e7b68 (sourcec9756b0) in the admitted +single-caller mode. The ledger has caller_id/send-failure fields and caller_buckets. +Capture/rehearsal source helpers are repinned together to this digest; fingerprints +cover all six tables. Actual pre-release five-table backup migrates privately +with every existing row preserved, UID10001/network-none and no send. See +docs/caller-deployment-2026-10-08.md. This is image/schema alignment only: T03/T04 +archive custody, first admitted offsite run/retrieval/application recovery and +monitoring stay waiting. CSOC-WP-0015-T03 is the product acceptance follow-up. diff --git a/workplans/EMAIL-WP-0008-scoped-transactional-callers.md b/workplans/EMAIL-WP-0008-scoped-transactional-callers.md index 454e311..c89fe57 100644 --- a/workplans/EMAIL-WP-0008-scoped-transactional-callers.md +++ b/workplans/EMAIL-WP-0008-scoped-transactional-callers.md @@ -72,3 +72,22 @@ RPF-WP-0053-T05/T06 retain offsite archive/atomic credential pair custody. CSOC-WP-0010-T04/T05/T06 retain platform operations and productive/browser review. Review: docs/caller-admission-2026-10-08.md. No sender, caller, token, image, mailbox, production ledger, network admission, upload or timer is activated. + +## T04 — Deploy the tested receiver source in the admitted startup mode + +```task +id: EMAIL-WP-0008-T04 +status: done +priority: high +``` + +User explicitly instructed deployment. Image eb3e7b68 from sourcec9756b0 is +live, revision7, one Ready replica. Image-only spec guard/server dry-run, +quiesced private backup, five-table row preservation/new-schema migration, +packaged UID10001/no-network rehearsal and no-send denial checks pass. +Elapsed62.79s; decision0ef49fde-5e92-4656-ac1e-8f945b4bba08. No scoped policy, sender/caller +change or timer activated. Capture/rehearsal helper source pins move together to +this digest; old-version recovery and downgrade require their bound receipts. +Review: docs/caller-deployment-2026-10-08.md and JSON receipt. +T03 stays wait for actual scoped-policy/credential admission. Product follow-up +CSOC-WP-0015 and existing EMAIL-WP-0007/RPF-WP-0053 retain operations/recovery.