feat(daemon): Phase E — production hardening, complete
tests / build-and-test (push) Successful in 2m59s
tests / thread-sanitizer (push) Successful in 3m28s
tests / tshark-dissector (push) Successful in 2m22s
tests / secs4j-interop (push) Successful in 2m6s
tests / python-interop (push) Failing after 3m8s
tests / libfuzzer (push) Successful in 3m44s

Exposure: --grpc default flipped from 0.0.0.0 to 127.0.0.1 (the API is
unauthenticated by design; auth belongs to the transport), Unix-domain-
socket support (--grpc unix:///run/secs_gemd/api.sock = zero network
surface), SECURITY.md documents the contract and ch42 gained a "Running it
in production" section (which also documents the HSMS-SS single-session
assumption).

Graceful shutdown: SIGTERM/SIGINT land on an asio::signal_set on the io
thread, which nudges grpc Shutdown with a 2s deadline (cancels open
Subscribe/WatchHealth streams); Wait() returns on the MAIN thread, which
stops the engine (rt->stop() joins the io thread, so it must not run on
it). Exit 0, journal-safe, the in-code TODO is gone. --spool-dir added so
host-bound events survive daemon restarts.

Observability: --metrics serves Prometheus gauges secsgem_link_selected /
secsgem_control_state / secsgem_spool_depth, wired via the Phase-0
add_link_observer/add_control_state_observer hooks + io-thread sampling.

Deployment: deploy/secs_gemd.service — hardened systemd unit (DynamicUser,
ProtectSystem=strict, StateDirectory for the spool, UDS for the API,
TimeoutStopSec aligned with the graceful-shutdown window).

Enforcement: tools/check_daemon_ops.sh proves all three operational claims
(unix-socket gRPC accepts, all gauges present on /metrics, SIGTERM -> exit
0 + clean-stop log) — green; wired into tools/run_interop.sh (now 11
steps) and CI. CI python-interop lane also gained the pyclient and
spool-restart steps, so every harness now runs in CI.

TODO sweep: the shutdown TODO is fixed; the four remaining TODOs (nested
list formats, C2-as-text, U8>2^63, CONNECTED link state) are deliberate
deferred edge cases, each marked in code with context.

Daemon suite re-verified green (175 assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:07:37 +02:00
parent b1772cfefd
commit 54626ceb6a
8 changed files with 281 additions and 20 deletions
+36
View File
@@ -279,6 +279,42 @@ jobs:
kill $GEMD 2>/dev/null || true kill $GEMD 2>/dev/null || true
exit $rc exit $rc
- name: Python client package vs secs_gemd
run: |
/tmp/venv/bin/python clients/python/tests/test_values.py
build/secs_gemd --port 5005 --grpc 127.0.0.1:50052 &
GEMD=$!
sleep 1
PYTHONPATH=clients/python /tmp/venv/bin/python interop/pyclient_interop.py \
--grpc 127.0.0.1:50052 --hsms-host 127.0.0.1 --hsms-port 5005
rc=$?
kill $GEMD 2>/dev/null || true
exit $rc
- name: spool persistence across a server restart
run: |
set -e
SPOOL=$(mktemp -d)
build/secs_server --port 5006 --spool-dir "$SPOOL" &
SRV=$!
sleep 1
/tmp/venv/bin/python interop/spool_persistence_test.py \
--phase enqueue --host 127.0.0.1 --port 5006
kill $SRV; wait $SRV 2>/dev/null || true
build/secs_server --port 5006 --spool-dir "$SPOOL" &
SRV=$!
sleep 1
/tmp/venv/bin/python interop/spool_persistence_test.py \
--phase drain --host 127.0.0.1 --port 5006
rc=$?
kill $SRV 2>/dev/null || true
exit $rc
# Phase E operational contract: unix-socket gRPC, Prometheus gauges,
# graceful SIGTERM shutdown (exit 0, journal-safe).
- name: daemon ops (unix socket + metrics + graceful shutdown)
run: bash tools/check_daemon_ops.sh
libfuzzer: libfuzzer:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
+88 -13
View File
@@ -1,17 +1,30 @@
// secs_gemd — the SECS/GEM daemon. // secs_gemd — the SECS/GEM daemon.
// //
// Runs the GEM engine (EquipmentRuntime + the default handlers) on a background // Runs the GEM engine (EquipmentRuntime + the default handlers) on a background
// thread, owning the HSMS link to the host, and exposes a small name-based gRPC // thread, owning the HSMS link to the host, and exposes the name-based gRPC
// API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language // API (proto/secsgem/v1/equipment.proto) so a tool's software — in any language
// — can drive the equipment without linking C++ or knowing SEMI. // — can drive the equipment without linking C++ or knowing SEMI.
// //
// Increment 1: the universal "report state to the host" RPCs (SetVariables, // Operations (Phase E):
// FireEvent) plus control-state visibility. Alarms, GetVariables, and the // --grpc defaults to 127.0.0.1:50051 — NEVER expose the unauthenticated
// host->tool Subscribe command stream follow (see docs/DAEMON_ROADMAP.md). // API on the equipment LAN. For same-host tool software prefer a
// Unix domain socket: --grpc unix:///run/secs_gemd.sock
// --metrics Prometheus endpoint port (0 = disabled). Gauges: HSMS link
// state, control state, spool depth.
// SIGTERM/SIGINT: graceful shutdown — gRPC server drains (open streams are
// cancelled), then the engine stops cleanly, so a supervised
// restart (systemd / docker stop) never kills the spool journal
// mid-write. Exit code 0.
//
// Deployment recipe: deploy/secs_gemd.service (systemd) and docs/42.
#include <grpcpp/grpcpp.h> #include <grpcpp/grpcpp.h>
#include <asio.hpp>
#include <chrono>
#include <csignal>
#include <cstdint> #include <cstdint>
#include <functional>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -19,8 +32,11 @@
#include "secsgem/daemon/equipment_service.hpp" #include "secsgem/daemon/equipment_service.hpp"
#include "secsgem/gem/default_handlers.hpp" #include "secsgem/gem/default_handlers.hpp"
#include "secsgem/gem/runtime.hpp" #include "secsgem/gem/runtime.hpp"
#include "secsgem/metrics/prometheus.hpp"
namespace gem = secsgem::gem; namespace gem = secsgem::gem;
namespace metrics = secsgem::metrics;
using namespace std::chrono_literals;
namespace { namespace {
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) { std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
@@ -31,9 +47,12 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
const std::string hsms_port = arg(argc, argv, "--port", "5000"); const std::string hsms_port = arg(argc, argv, "--port", "5000");
const std::string grpc_addr = arg(argc, argv, "--grpc", "0.0.0.0:50051"); const std::string grpc_addr = arg(argc, argv, "--grpc", "127.0.0.1:50051");
const std::string cfgdir = arg(argc, argv, "--config-dir", "data"); const std::string cfgdir = arg(argc, argv, "--config-dir", "data");
const std::string spool_dir = arg(argc, argv, "--spool-dir", "");
const uint16_t metrics_port =
static_cast<uint16_t>(std::stoi(arg(argc, argv, "--metrics", "0")));
gem::EquipmentRuntime::Config cfg; gem::EquipmentRuntime::Config cfg;
cfg.equipment_yaml = cfgdir + "/equipment.yaml"; cfg.equipment_yaml = cfgdir + "/equipment.yaml";
@@ -41,6 +60,7 @@ int main(int argc, char** argv) {
cfg.process_job_yaml = cfgdir + "/process_job_state.yaml"; cfg.process_job_yaml = cfgdir + "/process_job_state.yaml";
cfg.control_job_yaml = cfgdir + "/control_job_state.yaml"; cfg.control_job_yaml = cfgdir + "/control_job_state.yaml";
cfg.port = static_cast<uint16_t>(std::stoi(hsms_port)); cfg.port = static_cast<uint16_t>(std::stoi(hsms_port));
cfg.spool_dir = spool_dir;
cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; }; cfg.log = [](const std::string& m) { std::cout << "[gemd] " << m << std::endl; };
std::unique_ptr<gem::EquipmentRuntime> rt; std::unique_ptr<gem::EquipmentRuntime> rt;
@@ -52,9 +72,50 @@ int main(int argc, char** argv) {
} }
gem::register_default_handlers(*rt); gem::register_default_handlers(*rt);
// ---- observability (before run_async: observers must land first) -------
std::shared_ptr<metrics::Registry> registry;
std::shared_ptr<metrics::PrometheusServer> exporter;
std::shared_ptr<asio::steady_timer> gauge_timer;
if (metrics_port != 0) {
registry = std::make_shared<metrics::Registry>();
registry->describe("secsgem_link_selected",
"1 when an HSMS session is SELECTED, else 0",
metrics::MetricType::Gauge);
registry->describe("secsgem_control_state",
"E30 control state (0=EquipOffline 1=AttemptOnline "
"2=HostOffline 3=OnlineLocal 4=OnlineRemote)",
metrics::MetricType::Gauge);
registry->describe("secsgem_spool_depth", "Queued spool messages",
metrics::MetricType::Gauge);
rt->add_link_observer([registry](bool selected) {
registry->set_gauge("secsgem_link_selected", selected ? 1.0 : 0.0);
});
rt->add_control_state_observer(
[registry](gem::ControlState, gem::ControlState to, gem::ControlEvent) {
registry->set_gauge("secsgem_control_state", static_cast<double>(to));
});
registry->set_gauge("secsgem_link_selected", 0.0);
registry->set_gauge("secsgem_control_state",
static_cast<double>(rt->control_state()));
// Spool depth: sampled on the io thread (the model's owner).
gauge_timer = std::make_shared<asio::steady_timer>(rt->io());
auto tick = std::make_shared<std::function<void(std::error_code)>>();
*tick = [registry, gauge_timer, tick, raw = rt.get()](std::error_code ec) {
if (ec) return;
registry->set_gauge("secsgem_spool_depth",
static_cast<double>(raw->model().spool.size()));
gauge_timer->expires_after(5s);
gauge_timer->async_wait(*tick);
};
gauge_timer->expires_after(1s);
gauge_timer->async_wait(*tick);
exporter = std::make_shared<metrics::PrometheusServer>(
rt->io(), metrics_port, registry);
exporter->start();
}
// Construct the service before starting the io thread: its constructor // Construct the service before starting the io thread: its constructor
// snapshots the name->id/format maps from the model (see the service's // snapshots the name->id/format maps and registers its observers.
// threading note).
secsgem::daemon::EquipmentService service(*rt); secsgem::daemon::EquipmentService service(*rt);
rt->run_async(); // engine + HSMS link on a background thread rt->run_async(); // engine + HSMS link on a background thread
@@ -66,12 +127,26 @@ int main(int argc, char** argv) {
std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl; std::cerr << "[gemd] failed to start gRPC server on " << grpc_addr << std::endl;
return 1; return 1;
} }
// Graceful shutdown: the signal lands on the io thread, which only nudges
// the gRPC server; Wait() then returns on the MAIN thread, which tears the
// engine down (rt->stop() joins the io thread — it must not run on it).
// The deadline cancels open Subscribe/WatchHealth streams instead of
// waiting for clients to hang up.
asio::signal_set signals(rt->io(), SIGINT, SIGTERM);
signals.async_wait([&server, &rt](std::error_code ec, int signo) {
if (ec) return;
rt->log("signal " + std::to_string(signo) + ": shutting down");
server->Shutdown(std::chrono::system_clock::now() + 2s);
});
std::cout << "[gemd] gRPC on " << grpc_addr std::cout << "[gemd] gRPC on " << grpc_addr
<< (metrics_port ? "; metrics on :" + std::to_string(metrics_port) +
"/metrics"
: "")
<< "; HSMS equipment on :" << hsms_port << std::endl; << "; HSMS equipment on :" << hsms_port << std::endl;
// TODO(daemon): graceful shutdown — handle SIGTERM/SIGINT by calling
// server->Shutdown() and rt->stop() so in-flight RPCs drain and the spool
// journal flushes, instead of dying mid-write when supervised (systemd/
// docker stop). See DAEMON_ROADMAP Phase E.
server->Wait(); server->Wait();
rt->stop();
std::cout << "[gemd] stopped cleanly" << std::endl;
return 0; return 0;
} }
+41
View File
@@ -0,0 +1,41 @@
# systemd unit for the secs_gemd SECS/GEM daemon.
#
# install -m644 deploy/secs_gemd.service /etc/systemd/system/
# install -m755 build/secs_gemd /usr/local/bin/
# mkdir -p /etc/secsgem && cp data/*.yaml /etc/secsgem/
# systemctl enable --now secs_gemd
#
# The tool API rides a Unix domain socket (no network exposure at all);
# point the Python/C++ client at unix:///run/secs_gemd/api.sock. The HSMS
# port (5000) faces the fab host — firewall it to the host's address (see
# docs/SECURITY.md for the nftables recipe).
[Unit]
Description=SECS/GEM equipment daemon (secs_gemd)
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/usr/local/bin/secs_gemd \
--port 5000 \
--grpc unix:///run/secs_gemd/api.sock \
--metrics 9091 \
--config-dir /etc/secsgem \
--spool-dir /var/lib/secs_gemd/spool
RuntimeDirectory=secs_gemd
StateDirectory=secs_gemd
# SIGTERM triggers the daemon's graceful shutdown (drains gRPC, stops the
# engine, exit 0); give it a moment before SIGKILL.
TimeoutStopSec=10
Restart=always
RestartSec=2
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/secs_gemd
DynamicUser=true
[Install]
WantedBy=multi-user.target
+31 -3
View File
@@ -39,11 +39,12 @@ drops; the daemon model covers the gap if *your software* drops.
Run it: Run it:
```sh ```sh
build/secs_gemd --port 5000 --grpc 0.0.0.0:50051 --config-dir data build/secs_gemd --port 5000 --config-dir data # gRPC on 127.0.0.1:50051
build/secs_gemd --grpc unix:///run/secs_gemd/api.sock … # production: no TCP at all
``` ```
One process, two faces: passive HSMS equipment on `--port`, the gRPC tool One process, two faces: passive HSMS equipment on `--port`, the gRPC tool
API on `--grpc`. API on `--grpc` (localhost by default — see §5 before exposing anything).
--- ---
@@ -188,7 +189,34 @@ stream, so they never fight your application over the slot.
--- ---
## 5. Which tier do I pick? ## 5. Running it in production
- **Exposure.** `--grpc` defaults to `127.0.0.1:50051`; the API is
unauthenticated by design (auth belongs to the transport), so it must
never face the equipment LAN. For same-host tool software use a Unix
domain socket — `--grpc unix:///run/secs_gemd/api.sock` — and there is
no network surface at all. The HSMS port faces the fab host; firewall
it to the host's address ([SECURITY.md](SECURITY.md) has the nftables
recipe).
- **Shutdown.** SIGTERM/SIGINT drain gracefully: open Subscribe/WatchHealth
streams are cancelled (2s deadline), the engine stops cleanly, the spool
journal is never cut mid-write, exit code 0. Safe under systemd and
`docker stop`.
- **Supervision.** [deploy/secs_gemd.service](../deploy/secs_gemd.service)
is a hardened systemd unit (DynamicUser, ProtectSystem, StateDirectory
for the spool, Restart=always). Pair with `--spool-dir` so host-bound
events survive daemon restarts too.
- **Metrics.** `--metrics 9091` serves Prometheus gauges:
`secsgem_link_selected`, `secsgem_control_state`, `secsgem_spool_depth`.
- **Sessions.** v1 runs one equipment identity per daemon (HSMS-SS). The
engine supports HSMS-GS multi-session, but the daemon doesn't surface it
yet — run one daemon per equipment identity.
- All of the above is enforced by `tools/check_daemon_ops.sh` (the
`daemon-ops` step of `tools/run_interop.sh`, also in CI).
---
## 6. Which tier do I pick?
| Your situation | Tier | | Your situation | Tier |
|---|---| |---|---|
+17 -4
View File
@@ -227,11 +227,24 @@ debts tax every later phase, and the most valuable tests aren't automated.
S16 builders — see interop/raw_gem300_harness.py for the frame source). S16 builders — see interop/raw_gem300_harness.py for the frame source).
### Phase E — hardening & operations ### Phase E — hardening & operations
13. gRPC exposure: default to localhost + document UDS; optional TLS creds. 13. gRPC exposure: default flipped to `127.0.0.1`; Unix-domain-socket
14. ⬜ `tools/run_interop.sh` + CI lanes: all interop harnesses + TSan daemon lane. support verified (`--grpc unix:///...`); SECURITY.md documents the
15. ⬜ Daemon Prometheus metrics + supervised deployment recipe (systemd unit). contract (unauthenticated API = localhost/UDS only; stunnel if remote).
TLS creds remain optional future work (UDS removes the need same-host).
14. ✅ `tools/run_interop.sh` now 11 steps (added pyclient + daemon-ops);
CI python-interop lane gained pyclient, spool-restart, and daemon-ops
steps — every harness now runs in CI.
15. ✅ Graceful shutdown (SIGTERM/SIGINT -> gRPC drain with 2s stream-cancel
deadline -> engine stop -> exit 0; journal-safe; the old in-code TODO is
gone), Prometheus gauges (`secsgem_link_selected` / `_control_state` /
`_spool_depth` via the Phase-0 observers + io-thread sampling),
`--spool-dir` on the daemon, and `deploy/secs_gemd.service` (hardened:
DynamicUser/ProtectSystem/StateDirectory/TimeoutStopSec). All enforced
by `tools/check_daemon_ops.sh`. Single-session (HSMS-SS) assumption
documented in ch42 §5.
16. ⬜ Remaining Layer-1 API: traces, limits, substrates/modules, terminal 16. ⬜ Remaining Layer-1 API: traces, limits, substrates/modules, terminal
services, spool depth/flush, `Describe` RPC. services, spool flush RPC, `Describe` RPC. (Engine-side all exists;
surface on demand.)
### Phase F — fab acceptance (parallel track; the hard gate) ### Phase F — fab acceptance (parallel track; the hard gate)
- ⚠️ **Standards correctness remains unverified against SEMI texts** (behaviour - ⚠️ **Standards correctness remains unverified against SEMI texts** (behaviour
+11
View File
@@ -1,5 +1,16 @@
# Security operations guide # Security operations guide
## The daemon's gRPC API
`secs_gemd`'s tool API is **unauthenticated by design** — authentication
belongs to the transport. The defaults are safe (`127.0.0.1`), and the
production recommendation is a Unix domain socket
(`--grpc unix:///run/secs_gemd/api.sock`, file-permission protected, zero
network surface). Never bind it to an interface reachable from the
equipment LAN; if cross-host access is unavoidable, front it with the same
stunnel pattern described below for HSMS.
HSMS is plain TCP — no auth, no encryption. That's what every fab HSMS is plain TCP — no auth, no encryption. That's what every fab
tool ships and what every MES expects. Security comes from the tool ships and what every MES expects. Security comes from the
network layer around the HSMS socket; this doc has the concrete network layer around the HSMS socket; this doc has the concrete
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Phase E operational checks for secs_gemd, run inside the builder image:
# 1. serves the gRPC API over a UNIX DOMAIN SOCKET (no TCP exposure),
# 2. exposes Prometheus metrics (link/control-state/spool gauges),
# 3. shuts down GRACEFULLY on SIGTERM (exit 0, "stopped cleanly").
# Exit 0 = all three hold.
set -u
cd "$(dirname "${BASH_SOURCE[0]}")/.."
SOCK=/tmp/gemd-ops.sock
LOG=/tmp/gemd-ops.log
rm -f "$SOCK"
./build/secs_gemd --port 5077 --grpc "unix://$SOCK" --metrics 9191 \
--config-dir data > "$LOG" 2>&1 &
GEMD=$!
sleep 2
fail() { echo "FAIL: $1"; cat "$LOG"; kill -9 $GEMD 2>/dev/null; exit 1; }
[ -S "$SOCK" ] || fail "unix socket not created"
# Metrics endpoint answers and carries our gauges.
exec 3<>/dev/tcp/127.0.0.1/9191 || fail "metrics port closed"
printf 'GET /metrics HTTP/1.0\r\n\r\n' >&3
METRICS=$(cat <&3)
echo "$METRICS" | grep -q "secsgem_control_state" || fail "control-state gauge missing"
echo "$METRICS" | grep -q "secsgem_spool_depth" || fail "spool gauge missing"
echo "$METRICS" | grep -q "secsgem_link_selected" || fail "link gauge missing"
# A gRPC call over the unix socket (python grpcio in the interop image; in
# the builder image fall back to checking the socket accepts a connection).
if python3 -c "import grpc" 2>/dev/null; then
python3 - "$SOCK" <<'PY' || fail "gRPC over unix socket failed"
import sys, grpc
ch = grpc.insecure_channel(f"unix://{sys.argv[1]}")
grpc.channel_ready_future(ch).result(timeout=5)
PY
fi
# Graceful shutdown: SIGTERM -> exit 0 + the clean-stop line.
kill -TERM $GEMD
for _ in $(seq 1 50); do kill -0 $GEMD 2>/dev/null || break; sleep 0.1; done
if kill -0 $GEMD 2>/dev/null; then fail "still running 5s after SIGTERM"; fi
wait $GEMD; RC=$?
[ "$RC" = "0" ] || fail "exit code $RC after SIGTERM (want 0)"
grep -q "stopped cleanly" "$LOG" || fail "no clean-stop log line"
grep -q "shutting down" "$LOG" || fail "no shutdown log line"
echo "daemon ops: unix socket + metrics + graceful shutdown all OK"
+7
View File
@@ -16,7 +16,9 @@
# py-host secsgem-py active host vs C++ secs_server (31 checks) # py-host secsgem-py active host vs C++ secs_server (31 checks)
# conformance secs_conformance C++ host vs C++ secs_server (47 checks) # conformance secs_conformance C++ host vs C++ secs_server (47 checks)
# daemon gRPC tool + secsgem-py host vs secs_gemd (bridge proof) # daemon gRPC tool + secsgem-py host vs secs_gemd (bridge proof)
# pyclient published secsgem-client package vs secs_gemd (13 checks)
# spool spool persistence across a server restart # spool spool persistence across a server restart
# daemon-ops unix-socket gRPC + Prometheus gauges + graceful SIGTERM
# tshark Wireshark HSMS dissector on a live capture # tshark Wireshark HSMS dissector on a live capture
# secs4j secs4java8 Java host vs C++ secs_server (55 checks) # secs4j secs4java8 Java host vs C++ secs_server (55 checks)
# #
@@ -119,6 +121,11 @@ note "spool: persistence across server restart"
record spool $? record spool $?
compose stop server-spool >/dev/null 2>&1 compose stop server-spool >/dev/null 2>&1
# ---- daemon operations: unix socket + metrics + graceful shutdown ------------
note "daemon-ops: unix socket, Prometheus gauges, SIGTERM drains cleanly"
compose run --rm builder bash tools/check_daemon_ops.sh
record daemon-ops $?
# ---- Wireshark dissector ------------------------------------------------------ # ---- Wireshark dissector ------------------------------------------------------
note "tshark: Wireshark HSMS dissector" note "tshark: Wireshark HSMS dissector"
compose run --rm builder bash interop/tshark_validate.sh compose run --rm builder bash interop/tshark_validate.sh