replica-omnisciente/scripts/data_sharing/transports.py
Raphael Cautus (Maestro) 2f26f2d836 feat(scripts): onboarding, GWS, lifestream, muscriptor, music, data-sharing
- onboard-client.py: client replica scaffolding CLI
- gws/: Google Workspace sync (Gmail, Calendar, Drive)
- lifestream/: life event stream collector
- muscriptor-mcp/: audio → MIDI MCP server
- music-mcp/: music library MCP server
- data_sharing/: consent-gated data sharing (Python + TS)
- sync-mirrors.py: GitHub → Forgejo mirror engine
- brain-to-gbrain.py, vault-sync.py, test-all.sh
- shared/: TS data-sharing library + index
- dirac: provider registry update
- .gitignore: exclude Rust build artifacts

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
2026-07-31 14:58:02 +01:00

402 lines
16 KiB
Python

"""
Transport backends — OS-agnostic data delivery to Portugal Futurista.
Each transport is a strategy with a single method:
transmit(payload: dict, consent: ConsentRecord) -> TransmissionResult
The transport is selected from config.toml [data_sharing].transport:
"http" — POST JSON to an HTTP endpoint (works everywhere)
"ssh" — scp/tar over SSH to a remote host (no Proxmox needed)
"local" — write to a local directory (testing / air-gapped)
"s3" — upload to S3-compatible storage (MinIO, AWS, etc.)
"proxmox" — legacy pct push/pull (Proxmox VE only)
All transports are auto-detected from environment + config — no hard-coded IPs,
container IDs, or OS-specific commands. The same config works on macOS, Linux,
WSL, Docker, bare metal, or VMs.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
import urllib.error
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
from .consent import ConsentRecord, redact
@dataclass
class TransmissionResult:
"""Result of a transmission attempt."""
success: bool
transport: str
message: str
bytes_sent: int = 0
timestamp: str = ""
detail: str = ""
class Transport(Protocol):
"""Interface every transport backend must implement."""
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
...
def name(self) -> str:
...
# ═══════════════════════════════════════════════════════════════════════
# HTTP Transport — universal, works on any OS with Python stdlib
# ═══════════════════════════════════════════════════════════════════════
class HttpTransport:
"""
POST JSON payload to an HTTP endpoint.
Config keys (in [data_sharing]):
endpoint: URL to POST to (required)
auth_token_env: env var name for bearer token (optional)
timeout: seconds (default 30)
Env overrides:
DATA_SHARING_ENDPOINT: overrides endpoint
DATA_SHARING_TOKEN: bearer token (if auth_token_env not set)
"""
def name(self) -> str:
return "http"
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
endpoint = (
os.environ.get("DATA_SHARING_ENDPOINT")
or consent.endpoint
)
if not endpoint:
return TransmissionResult(
False, "http", "No endpoint configured (set [data_sharing].endpoint or DATA_SHARING_ENDPOINT)"
)
timeout = 30
if consent._raw and "timeout" in (consent._raw or {}):
timeout = int(consent._raw["timeout"])
# Resolve auth token
token = os.environ.get("DATA_SHARING_TOKEN", "")
auth_env = ""
if consent._raw and "auth_token_env" in (consent._raw or {}):
auth_env = consent._raw["auth_token_env"]
token = os.environ.get(auth_env, token)
# Serialize + redact
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
if consent.redact_secrets:
body_str = body.decode("utf-8")
body_str = redact(body_str)
body = body_str.encode("utf-8")
headers = {
"Content-Type": "application/json",
"X-Aurelio-Source": consent._raw.get("client_id", "replica") if consent._raw else "replica",
"X-Aurelio-Transport": "http",
}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
req = urllib.request.Request(endpoint, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return TransmissionResult(
success=200 <= resp.status < 300,
transport="http",
message=f"HTTP {resp.status}",
bytes_sent=len(body),
timestamp=datetime.now(timezone.utc).isoformat(),
)
except urllib.error.HTTPError as e:
return TransmissionResult(False, "http", f"HTTP {e.code}: {e.reason}", len(body))
except Exception as e:
return TransmissionResult(False, "http", f"Request failed: {e}")
# ═══════════════════════════════════════════════════════════════════════
# SSH Transport — works on any OS with ssh+scp in PATH
# ═══════════════════════════════════════════════════════════════════════
class SshTransport:
"""
Upload a tarball of the payload via SSH + scp.
Config keys:
ssh_host: user@host (required)
ssh_path: remote directory (default /opt/aurelio-ingest)
ssh_port: port (default 22)
Env overrides:
DATA_SHARING_SSH_HOST
DATA_SHARING_SSH_PATH
DATA_SHARING_SSH_PORT
"""
def name(self) -> str:
return "ssh"
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
host = os.environ.get("DATA_SHARING_SSH_HOST", "")
remote_path = os.environ.get("DATA_SHARING_SSH_PATH", "/opt/aurelio-ingest")
port = os.environ.get("DATA_SHARING_SSH_PORT", "22")
raw = consent._raw or {}
host = raw.get("ssh_host", host)
remote_path = raw.get("ssh_path", remote_path)
port = str(raw.get("ssh_port", port))
if not host:
return TransmissionResult(False, "ssh", "No ssh_host configured")
if not shutil.which("ssh") or not shutil.which("scp"):
return TransmissionResult(False, "ssh", "ssh/scp not in PATH")
try:
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = tmp.name
with tarfile.open(tmp_path, "w:gz") as tar:
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
if consent.redact_secrets:
data = redact(data.decode("utf-8")).encode("utf-8")
import io
info = tarfile.TarInfo(name="payload.json")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
# scp
scp_cmd = ["scp", "-P", port, tmp_path, f"{host}:{remote_path}/"]
r = subprocess.run(scp_cmd, capture_output=True, timeout=60)
os.unlink(tmp_path)
if r.returncode != 0:
return TransmissionResult(
False, "ssh", f"scp failed: {r.stderr.decode()[:200]}"
)
return TransmissionResult(
True, "ssh", f"Uploaded to {host}:{remote_path}",
bytes_sent=os.path.getsize(tmp_path) if os.path.exists(tmp_path) else 0,
timestamp=datetime.now(timezone.utc).isoformat(),
)
except Exception as e:
return TransmissionResult(False, "ssh", f"SSH transmit failed: {e}")
# ═══════════════════════════════════════════════════════════════════════
# Local Transport — testing / air-gapped environments
# ═══════════════════════════════════════════════════════════════════════
class LocalTransport:
"""
Write payload as JSON to a local directory.
Config keys:
local_path: directory to write to (required)
"""
def name(self) -> str:
return "local"
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
raw = consent._raw or {}
dest = raw.get("local_path", os.environ.get("DATA_SHARING_LOCAL_PATH", ""))
if not dest:
return TransmissionResult(False, "local", "No local_path configured")
dest_path = Path(dest)
dest_path.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_file = dest_path / f"ingest-{ts}.json"
data = json.dumps(payload, ensure_ascii=False, indent=2)
if consent.redact_secrets:
data = redact(data)
out_file.write_text(data)
size = out_file.stat().st_size
return TransmissionResult(
True, "local", f"Written to {out_file}",
bytes_sent=size,
timestamp=datetime.now(timezone.utc).isoformat(),
)
# ╁══════════════════════════════════════════════════════════════════════
# S3 Transport — MinIO, AWS S3, Cloudflare R2, etc.
# ═══════════════════════════════════════════════════════════════════════
class S3Transport:
"""
Upload payload to S3-compatible storage using the REST API (no SDK dep).
Config keys:
s3_endpoint: e.g. https://192.168.0.40:9000 or https://s3.amazonaws.com
s3_bucket: bucket name
s3_access_key_env: env var for access key
s3_secret_key_env: env var for secret key
"""
def name(self) -> str:
return "s3"
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
import hashlib
import hmac
import base64
raw = consent._raw or {}
endpoint = raw.get("s3_endpoint", os.environ.get("DATA_SHARING_S3_ENDPOINT", ""))
bucket = raw.get("s3_bucket", os.environ.get("DATA_SHARING_S3_BUCKET", ""))
if not endpoint or not bucket:
return TransmissionResult(False, "s3", "Missing s3_endpoint or s3_bucket")
access_key = os.environ.get(raw.get("s3_access_key_env", "S3_ACCESS_KEY"), "")
secret_key = os.environ.get(raw.get("s3_secret_key_env", "S3_SECRET_KEY"), "")
if not access_key or not secret_key:
return TransmissionResult(False, "s3", "Missing S3 credentials")
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
if consent.redact_secrets:
data = redact(data.decode("utf-8")).encode("utf-8")
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
object_key = f"ingest/ingest-{ts}.json"
# Simple PUT — no SigV4 (works with MinIO public-write buckets or
# pre-signed URLs; for AWS S3 use the http transport with a lambda)
url = f"{endpoint.rstrip('/')}/{bucket}/{object_key}"
req = urllib.request.Request(url, data=data, method="PUT")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return TransmissionResult(
200 <= resp.status < 300, "s3", f"S3 PUT {resp.status}",
bytes_sent=len(data),
timestamp=datetime.now(timezone.utc).isoformat(),
)
except urllib.error.HTTPError as e:
return TransmissionResult(False, "s3", f"S3 PUT {e.code}: {e.reason}", len(data))
except Exception as e:
return TransmissionResult(False, "s3", f"S3 upload failed: {e}")
# ═══════════════════════════════════════════════════════════════════════
# Proxmox Transport — legacy pct push/pull (Proxmox VE only)
# ═══════════════════════════════════════════════════════════════════════
class ProxmoxTransport:
"""
Legacy transport using pct push/pull via a Proxmox host.
Only works when running ON or WITH ACCESS TO a Proxmox VE node.
Config keys:
proxmox_host: IP/hostname of the PVE node
proxmox_ct: container ID
proxmox_path: target path inside the CT (default /opt/aurelio/brain)
"""
def name(self) -> str:
return "proxmox"
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
raw = consent._raw or {}
host = raw.get("proxmox_host", os.environ.get("PROXMOX_HOST", ""))
ct_id = raw.get("proxmox_ct", 0)
ct_path = raw.get("proxmox_path", "/opt/aurelio/brain")
if not host or not ct_id:
return TransmissionResult(False, "proxmox", "Missing proxmox_host or proxmox_ct")
if not shutil.which("ssh"):
return TransmissionResult(False, "proxmox", "ssh not in PATH")
try:
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
tmp_path = tmp.name
with tarfile.open(tmp_path, "w:gz") as tar:
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
if consent.redact_secrets:
data = redact(data.decode("utf-8")).encode("utf-8")
import io
info = tarfile.TarInfo(name="payload.json")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
# scp to proxmox host
r = subprocess.run(
["scp", tmp_path, f"root@{host}:/tmp/"],
capture_output=True, timeout=60
)
if r.returncode != 0:
os.unlink(tmp_path)
return TransmissionResult(False, "proxmox", f"scp failed: {r.stderr.decode()[:200]}")
# pct push into CT
r = subprocess.run(
["ssh", f"root@{host}", "pct", "push", str(ct_id),
f"/tmp/{Path(tmp_path).name}", f"/tmp/{Path(tmp_path).name}"],
capture_output=True, timeout=60
)
os.unlink(tmp_path)
if r.returncode != 0:
return TransmissionResult(False, "proxmox", f"pct push failed: {r.stderr.decode()[:200]}")
size = os.path.getsize(tmp_path) if os.path.exists(tmp_path) else 0
return TransmissionResult(
True, "proxmox", f"Pushed to CT {ct_id} on {host}",
bytes_sent=size,
timestamp=datetime.now(timezone.utc).isoformat(),
)
except Exception as e:
return TransmissionResult(False, "proxmox", f"Proxmox transmit failed: {e}")
# ═══════════════════════════════════════════════════════════════════════
# Registry
# ═══════════════════════════════════════════════════════════════════════
TRANSPORTS: dict[str, Transport] = {
"http": HttpTransport(),
"ssh": SshTransport(),
"local": LocalTransport(),
"s3": S3Transport(),
"proxmox": ProxmoxTransport(),
}
def get_transport(name: str) -> Transport:
"""Get a transport backend by name. Raises ValueError if unknown."""
if name not in TRANSPORTS:
available = ", ".join(TRANSPORTS)
raise ValueError(f"Unknown transport '{name}'. Available: {available}")
return TRANSPORTS[name]
def available_transports() -> list[str]:
"""Return list of registered transport names."""
return list(TRANSPORTS.keys())