"""Tiny HTTP sink for the env2 store-and-forward soak (ScadaBridge#116). POST/PUT -> 200 and the body is appended to /data/received.jsonl (one line: receive time, client IP, path, body), or 503 without recording while /data/mode contains "fail". GET /stats -> {"mode": ..., "received": } GET /received -> the raw received.jsonl The mode switch is how the soak makes ScadaBridge buffer: a 503 is a transient failure, so ExternalSystem.CachedCall parks the call in store-and-forward and retries it; setting the mode back to "ok" lets the active node drain the buffer. docker exec scadabridge-env2-sfsink sh -c 'echo fail > /data/mode' docker exec scadabridge-env2-sfsink sh -c 'echo ok > /data/mode' """ import json import os import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DATA = "/data" LOG = os.path.join(DATA, "received.jsonl") MODE = os.path.join(DATA, "mode") LOCK = threading.Lock() def mode(): try: with open(MODE) as f: return f.read().strip() or "ok" except OSError: return "ok" class Handler(BaseHTTPRequestHandler): def _reply(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _record(self): length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length).decode("utf-8", "replace") if length else "" if mode() == "fail": self._reply(503, {"error": "sink in fail mode"}) return try: body = json.loads(raw) if raw else None except ValueError: body = raw line = json.dumps({"at": time.time(), "client": self.client_address[0], "path": self.path, "body": body}) with LOCK, open(LOG, "a") as f: f.write(line + "\n") self._reply(200, {"ok": True}) do_POST = _record do_PUT = _record def do_GET(self): if self.path == "/stats": n = 0 if os.path.exists(LOG): with open(LOG) as f: n = sum(1 for _ in f) self._reply(200, {"mode": mode(), "received": n}) elif self.path == "/received": data = open(LOG).read() if os.path.exists(LOG) else "" self.send_response(200) self.send_header("Content-Type", "application/x-ndjson") self.send_header("Content-Length", str(len(data.encode()))) self.end_headers() self.wfile.write(data.encode()) else: self._reply(404, {"error": "not found"}) def log_message(self, fmt, *args): # keep the container log quiet pass if __name__ == "__main__": os.makedirs(DATA, exist_ok=True) ThreadingHTTPServer(("0.0.0.0", 5300), Handler).serve_forever()