Phase1 skeleton: replace demo main.py with src.server contract

- Overlay ModelHub XC Phase1 (health/status, STRATEGY_ID gate, dry-run)
- Dockerfile CMD -> python -m src.server; FROM stays platform TCR base
- Remove demo main.py and requirements.txt (stdlib only)
- Add tests/, scripts/, docs/, Makefile, config/examples (no secrets)
This commit is contained in:
VC_noSugar
2026-10-16 15:47:10 +08:00
parent 26351cefee
commit 0d0f3b611a
31 changed files with 1436 additions and 106 deletions

0
tests/__init__.py Normal file
View File

138
tests/test_gate.py Normal file
View File

@@ -0,0 +1,138 @@
"""Gate defaults to dry-run / blocked; live only with dual confirm + STRATEGY_ID."""
from __future__ import annotations
import os
import unittest
from unittest import mock
from src.gate import LIVE_CONFIRM_VALUE, attempt_live_submit, evaluate_gate
class FakeClient:
def __init__(self) -> None:
self.calls = []
def submit(self, payload):
self.calls.append(payload)
return {"task_id": "fake-1"}
class TestGate(unittest.TestCase):
def _clear_gate_env(self):
return mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "sid-1",
"ALLOW_LIVE_SUBMIT": "0",
"LIVE_CONFIRM": "",
},
clear=False,
)
def test_default_dry_run(self) -> None:
with self._clear_gate_env():
d = evaluate_gate()
self.assertFalse(d.allowed)
self.assertEqual(d.mode, "dry-run")
def test_missing_strategy_blocked(self) -> None:
env = {
"ALLOW_LIVE_SUBMIT": "1",
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
}
# Remove STRATEGY_ID
base = {k: v for k, v in os.environ.items() if k != "STRATEGY_ID"}
base.update(env)
with mock.patch.dict(os.environ, base, clear=True):
d = evaluate_gate()
self.assertFalse(d.allowed)
self.assertEqual(d.mode, "blocked")
def test_allow_without_confirm_blocked(self) -> None:
with mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "sid-1",
"ALLOW_LIVE_SUBMIT": "1",
"LIVE_CONFIRM": "YES",
},
clear=False,
):
d = evaluate_gate()
self.assertFalse(d.allowed)
self.assertEqual(d.mode, "blocked")
def test_dual_gate_opens(self) -> None:
with mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "sid-1",
"ALLOW_LIVE_SUBMIT": "1",
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
},
clear=False,
):
d = evaluate_gate()
self.assertTrue(d.allowed)
self.assertEqual(d.mode, "live")
def test_open_gate_without_client_still_blocked(self) -> None:
"""Phase1: no real HTTP client even when gate is open."""
with mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "sid-1",
"ALLOW_LIVE_SUBMIT": "1",
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
},
clear=False,
):
result = attempt_live_submit({"modelAddress": "x/y"})
self.assertFalse(result["submitted"])
self.assertIn("no real", result["reason"].lower())
self.assertEqual(result["mode"], "blocked")
self.assertFalse(result["ok"])
def test_prd_example_and_whitespace_id_never_submit(self) -> None:
cases = [
{"ALLOW_LIVE_SUBMIT": "true", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE},
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": "", "CONFIRM_LIVE": "YES"},
{"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, "STRATEGY_ID": " "},
]
for overrides in cases:
with self.subTest(overrides=overrides):
client = FakeClient()
env = {"STRATEGY_ID": "sid-test", **overrides}
with mock.patch.dict(os.environ, env, clear=True):
result = attempt_live_submit({}, client=client)
self.assertFalse(result["submitted"])
self.assertFalse(result["ok"])
self.assertEqual(client.calls, [])
self.assertTrue(result["reason"])
def test_injected_fake_client(self) -> None:
client = FakeClient()
with mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "sid-1",
"ALLOW_LIVE_SUBMIT": "1",
"LIVE_CONFIRM": LIVE_CONFIRM_VALUE,
},
clear=False,
):
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
self.assertTrue(result["submitted"])
self.assertEqual(len(client.calls), 1)
def test_default_attempt_rejects(self) -> None:
client = FakeClient()
with self._clear_gate_env():
result = attempt_live_submit({"modelAddress": "x/y"}, client=client)
self.assertFalse(result["submitted"])
self.assertEqual(len(client.calls), 0)
if __name__ == "__main__":
unittest.main()

60
tests/test_health.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for /health and /status handlers."""
from __future__ import annotations
import json
import os
import unittest
from unittest import mock
from src.health import handle_get, health_payload, status_payload
class TestHealth(unittest.TestCase):
def test_health_payload(self) -> None:
self.assertEqual(health_payload(), {"status": "ok"})
def test_handle_get_health(self) -> None:
code, headers, body = handle_get("/health")
self.assertEqual(code, 200)
self.assertIn("application/json", headers["Content-Type"])
data = json.loads(body.decode("utf-8"))
self.assertEqual(data, {"status": "ok"})
def test_handle_get_unknown(self) -> None:
code, _, body = handle_get("/nope")
self.assertEqual(code, 404)
self.assertIn("not_found", body.decode("utf-8"))
def test_status_booleans_only_no_secret_values(self) -> None:
with mock.patch.dict(
os.environ,
{
"STRATEGY_ID": "secret-strategy-id-xyz",
"EXTERNAL_SERVICE_TOKEN": "secret-token-abc",
},
clear=False,
):
payload = status_payload()
self.assertTrue(payload["strategy_id_present"])
self.assertTrue(payload["external_service_token_present"])
blob = json.dumps(payload)
self.assertNotIn("secret-strategy-id-xyz", blob)
self.assertNotIn("secret-token-abc", blob)
def test_status_absent(self) -> None:
env = {k: v for k, v in os.environ.items() if k not in (
"STRATEGY_ID",
"EXTERNAL_SERVICE_TOKEN",
"XC_TOKEN",
"XC_TOKEN_FILE",
"MODELHUB_XC_TOKEN",
)}
with mock.patch.dict(os.environ, env, clear=True):
payload = status_payload()
self.assertFalse(payload["strategy_id_present"])
self.assertFalse(payload["external_service_token_present"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,32 @@
"""Local report and HTTP log regression checks; no network requests."""
import contextlib
import io
import os
import unittest
from pathlib import Path
from unittest import mock
from scripts.dry_run import _render
from src.server import AgentHandler
class TestReportSafety(unittest.TestCase):
def test_report_marks_pending_and_does_not_echo_gate_values(self):
sentinel = "synthetic-sensitive-value"
with mock.patch.dict(os.environ, {
"ALLOW_LIVE_SUBMIT": sentinel,
"LIVE_CONFIRM": sentinel,
"STRATEGY_ID": sentinel,
"EXTERNAL_SERVICE_TOKEN": sentinel,
}, clear=True):
report = _render([], "test-time", Path("example.json"))
self.assertNotIn(sentinel, report)
for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"):
self.assertIn(required, report)
def test_http_log_omits_request_details(self):
output = io.StringIO()
with contextlib.redirect_stderr(output):
AgentHandler.log_message(None, '"%s" %s', '/health?token=synthetic-sensitive-value', 200)
self.assertNotIn("synthetic-sensitive-value", output.getvalue())
self.assertIn("request handled", output.getvalue())

69
tests/test_sigterm.py Normal file
View File

@@ -0,0 +1,69 @@
"""SIGTERM should stop the server subprocess promptly (<< 30s platform window)."""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import time
import unittest
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class TestSigterm(unittest.TestCase):
def test_sigterm_exits_quickly(self) -> None:
port = "19090"
env = os.environ.copy()
env["STRATEGY_ID"] = "sigterm-test"
env["PORT"] = port
env["HOST"] = "127.0.0.1"
proc = subprocess.Popen(
[sys.executable, "-m", "src.server"],
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
try:
# wait until health is up
url = f"http://127.0.0.1:{port}/health"
deadline = time.time() + 5
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=0.5) as resp:
data = json.loads(resp.read().decode("utf-8"))
if resp.status == 200 and data.get("status") == "ok":
break
except Exception:
time.sleep(0.1)
else:
if proc.poll() is not None:
self.fail(f"server exited before health check: {proc.stderr.read().decode(errors='replace')}")
self.fail("server did not become healthy in time")
t0 = time.time()
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
self.fail("server did not exit within 5s after SIGTERM (platform allows ≤30s)")
elapsed = time.time() - t0
self.assertLess(elapsed, 5.0, f"exit took {elapsed:.2f}s")
self.assertEqual(proc.returncode, 0, "SIGTERM must exit cleanly, not kill the process")
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=2)
if proc.stderr:
proc.stderr.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,57 @@
"""Missing STRATEGY_ID must cause startup failure (exit non-zero)."""
from __future__ import annotations
import os
import subprocess
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
class TestStrategyStartup(unittest.TestCase):
def test_missing_strategy_id_exits_nonzero(self) -> None:
env = os.environ.copy()
env["STRATEGY_ID"] = ""
env["PORT"] = "19081"
env["HOST"] = "127.0.0.1"
proc = subprocess.run(
[sys.executable, "-m", "src.server"],
cwd=str(ROOT),
env=env,
capture_output=True,
text=True,
timeout=10,
)
self.assertNotEqual(proc.returncode, 0)
self.assertIn("STRATEGY_ID", proc.stderr)
def test_whitespace_strategy_id_exits_nonzero(self) -> None:
env = os.environ.copy()
env["STRATEGY_ID"] = " "
env["PORT"] = "19082"
env["HOST"] = "127.0.0.1"
proc = subprocess.run(
[sys.executable, "-m", "src.server"],
cwd=str(ROOT),
env=env,
capture_output=True,
text=True,
timeout=10,
)
self.assertNotEqual(proc.returncode, 0)
def test_require_strategy_id_helper(self) -> None:
from src.strategy import require_strategy_id
with self.assertRaises(SystemExit) as ctx:
with mock.patch.dict(os.environ, {"STRATEGY_ID": ""}, clear=False):
require_strategy_id()
self.assertNotEqual(ctx.exception.code, 0)
if __name__ == "__main__":
unittest.main()