8b6d326824
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
88 lines
3.0 KiB
Python
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()
|