test_phase3_wp04_edge_runtime.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. """Real image/container smoke for the pull-only P3-WP04 edge runtime."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import shutil
  7. import sqlite3
  8. import ssl
  9. import subprocess
  10. import threading
  11. import time
  12. from datetime import UTC, datetime, timedelta
  13. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  14. from pathlib import Path
  15. import pytest
  16. from cryptography import x509
  17. from cryptography.hazmat.primitives import hashes, serialization
  18. from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
  19. from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
  20. import app.edge_gateway.runtime as edge_runtime
  21. from app.core.edge_gateway.contracts import (
  22. EdgeTaskContract,
  23. SignedTaskEnvelope,
  24. canonical_sha256,
  25. canonical_timestamp,
  26. )
  27. from app.core.edge_gateway.policy import EdgePolicyError
  28. from app.edge_gateway.bootstrap import (
  29. build_server_crl_ssl_context,
  30. validate_server_crl,
  31. )
  32. ROOT = Path(__file__).resolve().parents[2]
  33. IMAGE = "dataops-edge:wp04-runtime-smoke"
  34. def test_runtime_runner_executes_closed_engineering_self_check_only():
  35. deadline = canonical_timestamp(
  36. (datetime.now(UTC) + timedelta(minutes=2)).isoformat().replace("+00:00", "Z")
  37. )
  38. task = EdgeTaskContract.from_mapping({
  39. "task_id": "runtime-self-check-1",
  40. "gateway_id": "11111111-1111-4111-8111-111111111111",
  41. "environment": "production",
  42. "network_zone": "zone-a",
  43. "purpose": "quality-evaluation",
  44. "classification": "evidence",
  45. "task_type": "quality",
  46. "contract_version": 1,
  47. "deadline_at": deadline,
  48. "attempt": 1,
  49. "idempotency_key": "runtime-self-check-1",
  50. "policy_digest": "b" * 64,
  51. })
  52. result = edge_runtime.build_runtime_runner().execute(task, lambda: False)
  53. assert result == {
  54. "classification": "evidence",
  55. "payload": {
  56. "check_count": 1,
  57. "passed_count": 1,
  58. "scope": "edge_runtime",
  59. "status": "passed",
  60. },
  61. "local_artifact_digest": None,
  62. "local_artifact_ref": None,
  63. }
  64. with pytest.raises(EdgePolicyError):
  65. edge_runtime.build_runtime_runner().execute(
  66. EdgeTaskContract.from_mapping({
  67. **task.to_mapping(),
  68. "task_type": "collect",
  69. "purpose": "governed-inventory",
  70. "classification": "desensitized_metadata",
  71. }),
  72. lambda: False,
  73. )
  74. def test_runtime_session_rebuilds_tls_context_when_server_crl_changes(tmp_path):
  75. _fingerprint, _context, ca_key, ca, _server = _materials(tmp_path)
  76. ca_path = tmp_path / "certificates" / "ca.pem"
  77. crl_path = tmp_path / "certificates" / "control-plane-server.crl.pem"
  78. session = edge_runtime.ServerCrlSession(str(ca_path), str(crl_path))
  79. initial = session.server_crl_digest
  80. now = datetime.now(UTC)
  81. replacement = (
  82. x509.CertificateRevocationListBuilder().issuer_name(ca.subject)
  83. .last_update(now - timedelta(seconds=1))
  84. .next_update(now + timedelta(hours=1))
  85. .sign(ca_key, hashes.SHA256())
  86. )
  87. _write(crl_path, replacement.public_bytes(serialization.Encoding.PEM), 0o644)
  88. assert session.refresh_server_crl() is True
  89. assert session.server_crl_digest != initial
  90. assert session.refresh_server_crl() is False
  91. def _write(path: Path, value: bytes, mode: int = 0o600) -> None:
  92. path.write_bytes(value)
  93. path.chmod(mode)
  94. def _materials(root: Path):
  95. now = datetime.now(UTC)
  96. ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  97. ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "wp04-runtime-ca")])
  98. ca = (
  99. x509.CertificateBuilder()
  100. .subject_name(ca_name).issuer_name(ca_name).public_key(ca_key.public_key())
  101. .serial_number(x509.random_serial_number())
  102. .not_valid_before(now - timedelta(minutes=1))
  103. .not_valid_after(now + timedelta(days=7))
  104. .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
  105. .sign(ca_key, hashes.SHA256())
  106. )
  107. def issue(name: str, *, server: bool):
  108. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  109. builder = (
  110. x509.CertificateBuilder()
  111. .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)]))
  112. .issuer_name(ca.subject).public_key(key.public_key())
  113. .serial_number(x509.random_serial_number())
  114. .not_valid_before(now - timedelta(minutes=1))
  115. .not_valid_after(now + timedelta(days=1))
  116. .add_extension(
  117. x509.ExtendedKeyUsage([
  118. ExtendedKeyUsageOID.SERVER_AUTH if server
  119. else ExtendedKeyUsageOID.CLIENT_AUTH
  120. ]), critical=True,
  121. )
  122. )
  123. if server:
  124. builder = builder.add_extension(
  125. x509.SubjectAlternativeName([
  126. x509.DNSName("host.docker.internal"), x509.DNSName("localhost")
  127. ]), critical=False,
  128. )
  129. return key, builder.sign(ca_key, hashes.SHA256())
  130. server_key, server = issue("host.docker.internal", server=True)
  131. client_key, client = issue("wp04-edge-client", server=False)
  132. crl = (
  133. x509.CertificateRevocationListBuilder().issuer_name(ca.subject)
  134. .last_update(now - timedelta(minutes=1)).next_update(now + timedelta(hours=2))
  135. .sign(ca_key, hashes.SHA256())
  136. )
  137. certificates = root / "certificates"
  138. secrets = root / "secrets"
  139. certificates.mkdir()
  140. secrets.mkdir()
  141. _write(certificates / "ca.pem", ca.public_bytes(serialization.Encoding.PEM), 0o644)
  142. _write(certificates / "client.pem", client.public_bytes(serialization.Encoding.PEM), 0o644)
  143. _write(
  144. certificates / "control-plane-server.crl.pem",
  145. crl.public_bytes(serialization.Encoding.PEM), 0o644,
  146. )
  147. _write(
  148. secrets / "client.key",
  149. client_key.private_bytes(
  150. serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
  151. serialization.NoEncryption(),
  152. ),
  153. )
  154. _write(
  155. root / "server.pem", server.public_bytes(serialization.Encoding.PEM), 0o644
  156. )
  157. _write(
  158. root / "server.key",
  159. server_key.private_bytes(
  160. serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
  161. serialization.NoEncryption(),
  162. ),
  163. )
  164. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  165. context.load_cert_chain(str(root / "server.pem"), str(root / "server.key"))
  166. context.load_verify_locations(str(certificates / "ca.pem"))
  167. context.verify_mode = ssl.CERT_REQUIRED
  168. return client.fingerprint(hashes.SHA256()).hex(), context, ca_key, ca, server
  169. class _ControlHandler(BaseHTTPRequestHandler):
  170. paths: list[str] = []
  171. accepted_events: list[dict[str, object]] = []
  172. task_private_key: ed25519.Ed25519PrivateKey | None = None
  173. task_sent = False
  174. @classmethod
  175. def reset(cls, private_key: ed25519.Ed25519PrivateKey) -> None:
  176. cls.paths = []
  177. cls.accepted_events = []
  178. cls.task_private_key = private_key
  179. cls.task_sent = False
  180. def do_POST(self): # noqa: N802
  181. length = int(self.headers.get("Content-Length", "0"))
  182. self.paths.append(self.path)
  183. request_body = json.loads(self.rfile.read(length) or b"{}")
  184. if self.path.endswith("/reconcile"):
  185. data = {
  186. "cancelled_task_ids": [], "cancel_next_cursor": None,
  187. "release_offers": [], "release_next_cursor": None,
  188. "release_baseline": None,
  189. }
  190. elif self.path.endswith("/tasks/pull"):
  191. if self.task_sent:
  192. data = {"task": None}
  193. else:
  194. now = datetime.now(UTC)
  195. task = {
  196. "task_id": "runtime-self-check-1",
  197. "gateway_id": "11111111-1111-4111-8111-111111111111",
  198. "environment": "production",
  199. "network_zone": "zone-a",
  200. "purpose": "quality-evaluation",
  201. "classification": "evidence",
  202. "task_type": "quality",
  203. "contract_version": 1,
  204. "deadline_at": canonical_timestamp(
  205. (now + timedelta(minutes=2)).isoformat().replace("+00:00", "Z")
  206. ),
  207. "attempt": 1,
  208. "idempotency_key": "runtime-self-check-1",
  209. "policy_digest": "b" * 64,
  210. }
  211. unsigned = {
  212. "task": task,
  213. "authority_key_id": "task-key-1",
  214. "signature_algorithm": "Ed25519",
  215. "contract_digest": canonical_sha256(task),
  216. "gateway_id": task["gateway_id"],
  217. "environment": task["environment"],
  218. "network_zone": task["network_zone"],
  219. "policy_digest": task["policy_digest"],
  220. "purpose": task["purpose"],
  221. "issued_at": canonical_timestamp(
  222. (now - timedelta(seconds=1)).isoformat().replace("+00:00", "Z")
  223. ),
  224. "expires_at": task["deadline_at"],
  225. }
  226. assert self.task_private_key is not None
  227. envelope = {
  228. **unsigned,
  229. "signature": self.task_private_key.sign(
  230. SignedTaskEnvelope.canonical_unsigned_bytes(unsigned)
  231. ).hex(),
  232. }
  233. data = {
  234. "task": task,
  235. "signed_task_envelope": envelope,
  236. "lease_token": "dopl_runtime-smoke-lease",
  237. "lease_expires_at": canonical_timestamp(
  238. (now + timedelta(seconds=90)).isoformat().replace("+00:00", "Z")
  239. ),
  240. }
  241. self.task_sent = True
  242. elif self.path.endswith("/events"):
  243. event = request_body["event"]
  244. lease_token = request_body["lease_token"]
  245. self.accepted_events.append(event)
  246. data = {
  247. "event_id": event["event_id"],
  248. "event_digest": canonical_sha256(event),
  249. "remote_lease_digest": hashlib.sha256(
  250. lease_token.encode("utf-8")
  251. ).hexdigest(),
  252. "received_at": canonical_timestamp(
  253. datetime.now(UTC).isoformat().replace("+00:00", "Z")
  254. ),
  255. "status": "accepted",
  256. }
  257. else:
  258. data = {"status": "active"}
  259. encoded = json.dumps({"code": 200, "message": "ok", "data": data}).encode()
  260. self.send_response(200)
  261. self.send_header("Content-Type", "application/json")
  262. self.send_header("Content-Length", str(len(encoded)))
  263. self.end_headers()
  264. self.wfile.write(encoded)
  265. def log_message(self, _format, *_args):
  266. return
  267. def _wait_for(path: Path, status: str, timeout: float = 30) -> dict:
  268. deadline = time.monotonic() + timeout
  269. while time.monotonic() < deadline:
  270. try:
  271. value = json.loads(path.read_text())
  272. if value.get("status") == status:
  273. return value
  274. except (OSError, json.JSONDecodeError):
  275. pass
  276. time.sleep(0.25)
  277. raise AssertionError(f"health did not reach {status}")
  278. @pytest.mark.skipif(shutil.which("docker") is None, reason="Docker is required")
  279. def test_real_edge_image_mtls_pull_degraded_and_clean_sigterm(tmp_path):
  280. fingerprint, ssl_context, ca_key, ca, server_certificate = _materials(tmp_path)
  281. task_private_key = ed25519.Ed25519PrivateKey.generate()
  282. _ControlHandler.reset(task_private_key)
  283. server = ThreadingHTTPServer(("0.0.0.0", 0), _ControlHandler)
  284. server.socket = ssl_context.wrap_socket(server.socket, server_side=True)
  285. thread = threading.Thread(target=server.serve_forever, daemon=True)
  286. thread.start()
  287. port = server.server_address[1]
  288. for name in ("config", "queue", "artifacts", "releases", "signing", "health"):
  289. (tmp_path / name).mkdir()
  290. credential = "dopg_runtime_smoke_credential"
  291. _write(tmp_path / "secrets" / "control-credential", credential.encode())
  292. signing_key = task_private_key.public_key().public_bytes(
  293. serialization.Encoding.Raw, serialization.PublicFormat.Raw
  294. ).hex()
  295. _write(
  296. tmp_path / "signing" / "task-public-keys.json",
  297. json.dumps({"task-key-1": signing_key}).encode(), 0o644,
  298. )
  299. _write(
  300. tmp_path / "signing" / "release-public-keys.json",
  301. json.dumps({"release-key-1": signing_key}).encode(), 0o644,
  302. )
  303. config = {
  304. "allowed_control_hosts": ["host.docker.internal"],
  305. "allowed_control_origins": [f"https://host.docker.internal:{port}"],
  306. "allowed_proxy_hosts": [],
  307. "allowed_proxy_origins": [],
  308. "artifact_root": "/var/lib/dataops-edge/artifacts",
  309. "ca_bundle_path": "/run/edge/certificates/ca.pem",
  310. "certificate_sha256": fingerprint,
  311. "client_certificate_path": "/run/edge/certificates/client.pem",
  312. "client_private_key_path": "/run/edge/secrets/client.key",
  313. "control_url": f"https://host.docker.internal:{port}",
  314. "environment": "production",
  315. "gateway_id": "11111111-1111-4111-8111-111111111111",
  316. "generation": 1, "network_zone": "zone-a",
  317. "policy_digest": "b" * 64, "proxy_url": None,
  318. "queue_path": "/var/lib/dataops-edge/queue/edge-queue-v2.sqlite3",
  319. "task_authority_clock_skew_seconds": 30, "version": "1.0.0",
  320. }
  321. _write(tmp_path / "config" / "edge.json", json.dumps(config).encode(), 0o644)
  322. build = subprocess.run(
  323. ["docker", "build", "-f", "deploy/edge/Dockerfile.edge", "-t", IMAGE, "."],
  324. cwd=ROOT, capture_output=True, text=True, check=False,
  325. )
  326. assert build.returncode == 0, build.stderr[-4000:]
  327. command = [
  328. "docker", "run", "-d", "--add-host", "host.docker.internal:host-gateway",
  329. "--user", f"{os.getuid()}:{os.getgid()}",
  330. ]
  331. for host, container, mode in (
  332. ("config", "/run/edge/config", "ro"), ("queue", "/var/lib/dataops-edge/queue", "rw"),
  333. ("artifacts", "/var/lib/dataops-edge/artifacts", "rw"),
  334. ("releases", "/var/lib/dataops-edge/releases", "rw"),
  335. ("secrets", "/run/edge/secrets", "ro"),
  336. ("certificates", "/run/edge/certificates", "ro"),
  337. ("signing", "/run/edge/signing", "ro"), ("health", "/run/edge/tmp", "rw"),
  338. ):
  339. command.extend(["-v", f"{tmp_path / host}:{container}:{mode}"])
  340. command.extend([
  341. "-e", "EDGE_CONFIG_PATH=/run/edge/config/edge.json",
  342. "-e", "EDGE_CREDENTIAL_FILE=/run/edge/secrets/control-credential",
  343. "-e", "EDGE_CLIENT_CERTIFICATE_PATH=/run/edge/certificates/client.pem",
  344. "-e", "EDGE_CLIENT_PRIVATE_KEY_PATH=/run/edge/secrets/client.key",
  345. "-e", "EDGE_CA_BUNDLE_PATH=/run/edge/certificates/ca.pem",
  346. "-e", "EDGE_SERVER_CRL_PATH=/run/edge/certificates/control-plane-server.crl.pem",
  347. "-e", "EDGE_TASK_SIGNING_KEYS_PATH=/run/edge/signing/task-public-keys.json",
  348. "-e", "EDGE_RELEASE_SIGNING_KEYS_PATH=/run/edge/signing/release-public-keys.json",
  349. "-e", "EDGE_HEALTH_PATH=/run/edge/tmp/health.json",
  350. "-e", "EDGE_POLL_INTERVAL_SECONDS=1", IMAGE,
  351. ])
  352. started = subprocess.run(command, capture_output=True, text=True, check=False)
  353. assert started.returncode == 0, started.stderr
  354. container_id = started.stdout.strip()
  355. health_path = tmp_path / "health" / "health.json"
  356. try:
  357. healthy = _wait_for(health_path, "healthy", 40)
  358. assert credential not in json.dumps(healthy)
  359. assert any(path.endswith("/heartbeat") for path in _ControlHandler.paths)
  360. assert any(path.endswith("/reconcile") for path in _ControlHandler.paths)
  361. assert any(path.endswith("/tasks/pull") for path in _ControlHandler.paths)
  362. deadline = time.monotonic() + 15
  363. while not _ControlHandler.accepted_events and time.monotonic() < deadline:
  364. time.sleep(0.25)
  365. if len(_ControlHandler.accepted_events) != 1:
  366. queue_path = tmp_path / "queue" / "edge-queue-v2.sqlite3"
  367. with sqlite3.connect(queue_path) as connection:
  368. event_rows = connection.execute(
  369. "SELECT status,attempt_count,error_code,available_at,lease_expires_at,event_json "
  370. "FROM edge_outbound_events"
  371. ).fetchall()
  372. diagnostic = subprocess.run(
  373. ["docker", "logs", container_id],
  374. capture_output=True,
  375. text=True,
  376. check=False,
  377. )
  378. probe = subprocess.run(
  379. [
  380. "docker", "exec", container_id, "python", "-c",
  381. "import json,sqlite3;"
  382. "from app.edge_gateway.runtime import build_runtime_from_environment;"
  383. "a,_,_=build_runtime_from_environment();"
  384. "c=sqlite3.connect(a.queue.db_path);"
  385. "e=json.loads(c.execute('select event_json from edge_outbound_events limit 1').fetchone()[0]);"
  386. "t=a.queue.recover_remote_lease('runtime-self-check-1');"
  387. "print('lease',len(t),__import__('hashlib').sha256(t.encode()).hexdigest() == __import__('hashlib').sha256(b'dopl_runtime-smoke-lease').hexdigest());"
  388. "\ntry: a.transport.send_event(e,t)"
  389. "\nexcept Exception as x: print(type(x).__name__,str(x),type(x.__cause__).__name__ if x.__cause__ else '',repr(x.__cause__))",
  390. ],
  391. capture_output=True,
  392. text=True,
  393. check=False,
  394. )
  395. raise AssertionError(
  396. f"event chain incomplete paths={_ControlHandler.paths!r} "
  397. f"health={health_path.read_text()!r} events={event_rows!r} logs="
  398. f"{(diagnostic.stdout + diagnostic.stderr)[-4000:]!r} "
  399. f"probe={(probe.stdout + probe.stderr)[-4000:]!r}"
  400. )
  401. assert _ControlHandler.accepted_events[0]["classification"] == "evidence"
  402. assert _ControlHandler.accepted_events[0]["payload"] == {
  403. "check_count": 1,
  404. "passed_count": 1,
  405. "scope": "edge_runtime",
  406. "status": "passed",
  407. }
  408. inspect = subprocess.run(
  409. ["docker", "inspect", container_id], capture_output=True, text=True, check=True
  410. )
  411. record = json.loads(inspect.stdout)[0]
  412. assert not record["HostConfig"]["PortBindings"]
  413. now = datetime.now(UTC)
  414. revoked = (
  415. x509.RevokedCertificateBuilder()
  416. .serial_number(server_certificate.serial_number)
  417. .revocation_date(now - timedelta(seconds=1))
  418. .build()
  419. )
  420. replacement_crl = (
  421. x509.CertificateRevocationListBuilder().issuer_name(ca.subject)
  422. .last_update(now - timedelta(seconds=1))
  423. .next_update(now + timedelta(hours=1))
  424. .add_revoked_certificate(revoked)
  425. .sign(ca_key, hashes.SHA256())
  426. )
  427. _write(
  428. tmp_path / "certificates" / "control-plane-server.crl.pem",
  429. replacement_crl.public_bytes(serialization.Encoding.PEM),
  430. 0o644,
  431. )
  432. degraded = _wait_for(health_path, "degraded", 15)
  433. assert credential not in json.dumps(degraded)
  434. running = subprocess.run(
  435. ["docker", "inspect", "-f", "{{.State.Running}}", container_id],
  436. capture_output=True, text=True, check=True,
  437. )
  438. assert running.stdout.strip() == "true"
  439. logs = subprocess.run(
  440. ["docker", "logs", container_id], capture_output=True, text=True, check=True
  441. )
  442. assert credential not in logs.stdout + logs.stderr
  443. stopped = subprocess.run(
  444. ["docker", "stop", "--time", "5", container_id],
  445. capture_output=True, text=True, check=False,
  446. )
  447. assert stopped.returncode == 0, stopped.stderr
  448. exit_code = subprocess.run(
  449. ["docker", "inspect", "-f", "{{.State.ExitCode}}", container_id],
  450. capture_output=True, text=True, check=True,
  451. )
  452. assert exit_code.stdout.strip() == "0"
  453. finally:
  454. server.shutdown()
  455. server.server_close()
  456. subprocess.run(["docker", "rm", "-f", container_id], capture_output=True)
  457. def test_server_crl_context_checks_leaf_and_rejects_stale_crl(tmp_path):
  458. _fingerprint, _context, ca_key, ca, _server = _materials(tmp_path)
  459. ca_path = tmp_path / "certificates" / "ca.pem"
  460. crl_path = tmp_path / "certificates" / "control-plane-server.crl.pem"
  461. context = build_server_crl_ssl_context(str(ca_path), str(crl_path))
  462. assert context.verify_flags & ssl.VERIFY_CRL_CHECK_LEAF
  463. now = datetime.now(UTC)
  464. stale = (
  465. x509.CertificateRevocationListBuilder().issuer_name(ca.subject)
  466. .last_update(now - timedelta(days=2)).next_update(now - timedelta(days=1))
  467. .sign(ca_key, hashes.SHA256())
  468. )
  469. _write(crl_path, stale.public_bytes(serialization.Encoding.PEM), 0o644)
  470. with pytest.raises(ValueError, match="not fresh"):
  471. validate_server_crl(str(ca_path), str(crl_path))