Files
ScadaBridge/docker-env2/sfsink/sink.py
T
Joseph Doherty 8b6d326824 chore(docker-env2): seed-data-path.sh - opc-plc + read-only Galaxy instances and a store-and-forward sink for the #116 soak
Adds an env2-owned HTTP sink container (scadabridge-env2-sfsink) whose 503
mode makes ExternalSystem.CachedCall buffer, and an EnvSoak template whose 2 s
SoakTick sends one tick-numbered cached call, so loss and duplicates are
countable at the sink.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 23:42:26 -05:00

88 lines
3.0 KiB
Python

"""Tiny HTTP sink for the env2 store-and-forward soak (ScadaBridge#116).
POST/PUT <any path> -> 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": <lines>}
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()