Deploy caller-control receiver and align ledger recovery pins
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a119cf-56d0-7fa3-840b-eb8732e1db0b
This commit is contained in:
parent
c9756b093f
commit
3e8996fe94
10 changed files with 454 additions and 9 deletions
253
deploy/k8s/railiance/deploy-callers-2026-10-08.py
Normal file
253
deploy/k8s/railiance/deploy-callers-2026-10-08.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
48
docs/caller-deployment-2026-10-08.json
Normal file
48
docs/caller-deployment-2026-10-08.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
56
docs/caller-deployment-2026-10-08.md
Normal file
56
docs/caller-deployment-2026-10-08.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from ledger_archive import (
|
|||
|
||||
IMAGE = (
|
||||
"forgejo.coulomb.social/coulomb/email-connect@sha256:"
|
||||
"1e613d43734c44c5cd32c6083fda8b4da5d01ef43d10c6ca9c29a33c788cf75c"
|
||||
"eb3e7b68d21209e9eb71b928a5b0bc987e8e3164f21dca4ff712f946d0c103f2"
|
||||
)
|
||||
|
||||
CODE = """import io,json,os,sys
|
||||
|
|
|
|||
57
tests/test_deploy_callers.py
Normal file
57
tests/test_deploy_callers.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue