Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
158 lines
7.3 KiB
Python
158 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare, inspect or attend the fixed email-connect sender transition."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import sender_release as release
|
|
from sender_transition import Refused, require
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
FOUNDATION = Path("/home/worsch/railiance-platform/openbao/email-connect-platform-smtp/foundation.json")
|
|
|
|
# This bootstrap is itself bound by the launcher's source hash. No archive
|
|
# extraction, arbitrary filenames, shell interpolation of data or persistent code.
|
|
BOOTSTRAP = r'''
|
|
import hashlib,json,os,sys,tempfile
|
|
from pathlib import Path
|
|
sys.dont_write_bytecode=True
|
|
request=json.load(sys.stdin)
|
|
sources=request['sources']
|
|
expected=request['source_hashes']
|
|
assert __debug__ and set(sources)==set(expected)
|
|
assert all('/' not in n and '\\' not in n and n.endswith('.py') for n in sources)
|
|
assert sum(len(v.encode()) for v in sources.values())<=2*1024**2
|
|
assert {n:hashlib.sha256(v.encode()).hexdigest() for n,v in sources.items()}==expected
|
|
with tempfile.TemporaryDirectory(prefix='email-sender-capsule-') as directory:
|
|
for name,source in sources.items():
|
|
fd=os.open(Path(directory)/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
with os.fdopen(fd,'w') as stream:stream.write(source)
|
|
sys.path.insert(0,directory)
|
|
import sender_release as release
|
|
try:
|
|
operation=request['operation']
|
|
if operation=='prepare':
|
|
result=release.prepare(sources,request['foundation'],request['foundation_receipt'])
|
|
else:
|
|
packet=request['packet']
|
|
assert packet['sources']==sources
|
|
release.validate(packet)
|
|
if operation=='inspect':result=release.inspect(packet)
|
|
elif operation in ('execute','recover'):result=release.execute(packet,recover=operation=='recover')
|
|
else:raise ValueError('unknown_operation')
|
|
print(json.dumps(result))
|
|
except Exception as error:
|
|
print(json.dumps({'status':'sender_release_requires_inspection' if request['operation'] in ('execute','recover')
|
|
else 'sender_release_refused','failure_type':type(error).__name__,
|
|
'retry_automatically':False}))
|
|
sys.exit(1)
|
|
'''
|
|
|
|
|
|
def remote(request):
|
|
try:
|
|
response = subprocess.run([
|
|
"ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "railiance01",
|
|
"sudo -n python3 -I -c " + shlex.quote(BOOTSTRAP)],
|
|
input=json.dumps(request).encode(), capture_output=True, timeout=900, check=False)
|
|
result = json.loads(response.stdout)
|
|
except (subprocess.TimeoutExpired, ValueError):
|
|
# A timeout/lost SSH reply says nothing about whether the node stopped.
|
|
return {"status": "remote_outcome_unconfirmed", "retry_automatically": False}, 1
|
|
return result, int(response.returncode != 0)
|
|
|
|
|
|
def load_packet(path):
|
|
release.private(path)
|
|
require(path.stat().st_size <= 4 * 1024**2, "capsule_too_large")
|
|
packet = json.loads(path.read_text())
|
|
release.validate(packet)
|
|
current = {name: (ROOT / name).read_text() for name in release.FILES}
|
|
require(current == packet["sources"], "reviewed_source_changed_restore_capsule_sources")
|
|
return packet
|
|
|
|
|
|
def read_foundation_receipt(path):
|
|
release.private(path)
|
|
require(path.stat().st_size <= 2 * 1024**2, "foundation_journal_too_large")
|
|
data = path.read_bytes()
|
|
require(data.endswith(b"\n"), "incomplete_foundation_journal")
|
|
entries = [json.loads(line) for line in data.splitlines()]
|
|
require(bool(entries), "foundation_receipt_missing")
|
|
release.foundation_receipt(entries[-1])
|
|
return entries[-1]
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
actions = parser.add_mutually_exclusive_group(required=True)
|
|
for action in ("prepare", "show", "inspect", "execute", "recover"):
|
|
actions.add_argument("--" + action, action="store_true")
|
|
parser.add_argument("--packet", type=Path, required=True,
|
|
help="Private new output for preparation; retained sealed packet otherwise")
|
|
parser.add_argument("--foundation-receipt", type=Path)
|
|
parser.add_argument("--expect-digest")
|
|
args = parser.parse_args(argv)
|
|
require(__debug__, "optimized_python_refused")
|
|
if args.prepare:
|
|
require(args.foundation_receipt is not None and not args.packet.exists(), "fresh_packet_and_receipt_required")
|
|
release.private(args.packet.parent, directory=True)
|
|
release.owner_return("http://127.0.0.1:8000")
|
|
receipt = read_foundation_receipt(args.foundation_receipt)
|
|
sources = {name: (ROOT / name).read_text() for name in release.FILES}
|
|
request = {"operation": "prepare", "sources": sources,
|
|
"source_hashes": release.source_hashes(sources),
|
|
"foundation": json.loads(FOUNDATION.read_text()), "foundation_receipt": receipt}
|
|
packet, code = remote(request)
|
|
if code:
|
|
print(json.dumps(packet))
|
|
return code
|
|
release.validate(packet)
|
|
require(packet["sources"] == sources, "returned_sources_changed")
|
|
release.write(args.packet, packet)
|
|
print(json.dumps({"status": "sender_proposal_prepared", "proposal_digest": packet["proposal_digest"],
|
|
"native_server_dry_runs": 5, "executed": False}))
|
|
return 0
|
|
require(args.foundation_receipt is None, "receipt_only_for_preparation")
|
|
packet = load_packet(args.packet)
|
|
value = packet["proposal_digest"]
|
|
if args.expect_digest:
|
|
require(args.expect_digest == value, "reviewed_digest_changed")
|
|
if args.show:
|
|
print(json.dumps({"proposal_digest": value, "source_files": len(packet["sources"]),
|
|
"old_sender": packet["proposal"]["before"]["config_data"]["EMAIL_CONNECT_SENDER"],
|
|
"new_sender": packet["proposal"]["after"]["config_data"]["EMAIL_CONNECT_SENDER"],
|
|
"ledger_restore": False, "mail_send": False, "executed": False}))
|
|
return 0
|
|
if args.execute or args.recover:
|
|
require(args.expect_digest == value and sys.stdin.isatty(), "founder_terminal_and_exact_digest_required")
|
|
if args.execute:
|
|
release.owner_return("http://127.0.0.1:8000")
|
|
verb = "RECOVER" if args.recover else "DEPLOY"
|
|
print("Recover original configuration using the current ledger." if args.recover else
|
|
"Switch email-connect to the admitted platform sender; retain the current ledger.")
|
|
require(input("Type " + verb + " followed by the full digest: ").strip() == verb + " " + value,
|
|
"not_confirmed")
|
|
request = {"operation": "recover" if args.recover else "execute" if args.execute else "inspect",
|
|
"packet": packet, "sources": packet["sources"], "source_hashes": packet["proposal"]["sources"]}
|
|
result, code = remote(request)
|
|
print(json.dumps(result))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Refused as error:
|
|
print(json.dumps({"status": "sender_release_refused", "reason": str(error), "retry_automatically": False}))
|
|
raise SystemExit(1) from None
|
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
print(json.dumps({"status": "sender_release_refused", "failure_type": type(error).__name__,
|
|
"retry_automatically": False}))
|
|
raise SystemExit(1) from None
|