deployment.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  1. """Governed Data Factory deployment lifecycle.
  2. This service is the boundary between immutable production-line semantics and
  3. environment-specific infrastructure. It never accepts executable source,
  4. recompiles a rule, or mutates a released package.
  5. """
  6. from __future__ import annotations
  7. import copy
  8. import hashlib
  9. import json
  10. import re
  11. import time
  12. from collections.abc import Callable, Mapping
  13. from datetime import UTC, datetime, timedelta
  14. from typing import Any
  15. import requests
  16. from app.core.common.identifiers import ensure_governance_uid, new_governance_uid
  17. from app.core.orchestration.compilers.kestra import compile_kestra_flow
  18. from app.core.orchestration.spec import validate_schedule_plan
  19. ENVIRONMENTS = frozenset({"development", "test", "production"})
  20. TRANSITIONS = {
  21. "draft": frozenset({"disabled", "failed"}),
  22. "disabled": frozenset({"canary", "failed"}),
  23. "canary": frozenset({"active", "failed", "rolled_back"}),
  24. "active": frozenset({"superseded", "rolled_back", "failed"}),
  25. "failed": frozenset({"disabled", "rolled_back"}),
  26. "superseded": frozenset({"rolled_back", "active"}),
  27. "rolled_back": frozenset(),
  28. }
  29. _FORBIDDEN_INPUT_KEYS = frozenset(
  30. {
  31. "sql",
  32. "python",
  33. "script",
  34. "code",
  35. "yaml",
  36. "workflow_spec",
  37. "execution_plan",
  38. "artifact",
  39. "artifact_ref",
  40. "password",
  41. "secret",
  42. "credential",
  43. "credentials",
  44. "token",
  45. "api_key",
  46. "connection_string",
  47. }
  48. )
  49. _BINDING_SIDES = frozenset({"input", "output"})
  50. _BINDING_FIELDS = frozenset(
  51. {
  52. "data_source_uid",
  53. "object_kind",
  54. "object_ref",
  55. "schema_snapshot_id",
  56. "schema_hash",
  57. "binding_hash",
  58. "access_mode",
  59. "write_mode",
  60. "dialect",
  61. "binding_id",
  62. "logical_ref",
  63. }
  64. )
  65. def _uid(value: Any, label: str) -> str:
  66. try:
  67. return ensure_governance_uid({"uid": str(value)})
  68. except ValueError as exc:
  69. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  70. def _text(value: Any, label: str, limit: int) -> str:
  71. if not isinstance(value, str) or not value.strip():
  72. raise ValueError(f"{label} is required")
  73. normalized = value.strip()
  74. if len(normalized) > limit:
  75. raise ValueError(f"{label} exceeds {limit} characters")
  76. return normalized
  77. def _digest(value: Any, label: str) -> str:
  78. normalized = str(value or "").strip().lower()
  79. if not re.fullmatch(r"[0-9a-f]{64}", normalized):
  80. raise ValueError(f"{label} must be a sha256 hex digest")
  81. return normalized
  82. def _canonical_hash(value: Any) -> str:
  83. encoded = json.dumps(
  84. value,
  85. sort_keys=True,
  86. separators=(",", ":"),
  87. ensure_ascii=False,
  88. ).encode("utf-8")
  89. return hashlib.sha256(encoded).hexdigest()
  90. def _timestamp(value: Any, label: str) -> datetime:
  91. if isinstance(value, datetime):
  92. parsed = value
  93. elif isinstance(value, str):
  94. source = value[:-1] + "+00:00" if value.endswith("Z") else value
  95. try:
  96. parsed = datetime.fromisoformat(source)
  97. except ValueError as exc:
  98. raise ValueError(f"{label} is invalid") from exc
  99. else:
  100. raise ValueError(f"{label} is invalid")
  101. if parsed.tzinfo is None:
  102. raise ValueError(f"{label} must include a timezone")
  103. return parsed.astimezone(UTC)
  104. def _closed_tree(value: Any) -> None:
  105. if isinstance(value, Mapping):
  106. for key, item in value.items():
  107. normalized = str(key).strip().lower()
  108. if normalized in _FORBIDDEN_INPUT_KEYS:
  109. if normalized in {
  110. "password",
  111. "secret",
  112. "credential",
  113. "credentials",
  114. "token",
  115. "api_key",
  116. "connection_string",
  117. }:
  118. raise ValueError("deployment credentials are not accepted")
  119. raise ValueError("inline executable semantics are not accepted")
  120. _closed_tree(item)
  121. elif isinstance(value, list):
  122. for item in value:
  123. _closed_tree(item)
  124. def _binding_snapshot(value: Any) -> dict[str, Any]:
  125. if not isinstance(value, Mapping) or set(value) != _BINDING_SIDES:
  126. raise ValueError("binding_snapshot must contain input and output")
  127. result: dict[str, Any] = {}
  128. for side in ("input", "output"):
  129. raw = value[side]
  130. if not isinstance(raw, Mapping):
  131. raise ValueError(f"{side} binding must be an object")
  132. unknown = set(raw) - _BINDING_FIELDS
  133. if unknown:
  134. raise ValueError(
  135. f"{side} binding contains unsupported fields: "
  136. + ", ".join(sorted(unknown))
  137. )
  138. _closed_tree(raw)
  139. required = {
  140. "data_source_uid",
  141. "object_kind",
  142. "object_ref",
  143. "schema_snapshot_id",
  144. "schema_hash",
  145. "binding_hash",
  146. "access_mode",
  147. "dialect",
  148. }
  149. if not required.issubset(raw):
  150. raise ValueError(f"{side} binding is incomplete")
  151. item = {
  152. "data_source_uid": _uid(
  153. raw["data_source_uid"], f"{side} data_source_uid"
  154. ),
  155. "object_kind": _text(
  156. raw["object_kind"], f"{side} object_kind", 40
  157. ),
  158. "object_ref": _text(raw["object_ref"], f"{side} object_ref", 500),
  159. "schema_snapshot_id": _uid(
  160. raw["schema_snapshot_id"], f"{side} schema_snapshot_id"
  161. ),
  162. "schema_hash": _digest(
  163. raw["schema_hash"], f"{side} schema_hash"
  164. ),
  165. "binding_hash": _digest(
  166. raw["binding_hash"], f"{side} binding_hash"
  167. ),
  168. "access_mode": _text(
  169. raw["access_mode"], f"{side} access_mode", 20
  170. ),
  171. "dialect": _text(raw["dialect"], f"{side} dialect", 40).lower(),
  172. }
  173. if raw.get("binding_id") is not None:
  174. item["binding_id"] = _uid(
  175. raw["binding_id"], f"{side} binding_id"
  176. )
  177. if raw.get("logical_ref") is not None:
  178. item["logical_ref"] = _text(
  179. raw["logical_ref"], f"{side} logical_ref", 500
  180. )
  181. if "write_mode" in raw and raw["write_mode"] is not None:
  182. item["write_mode"] = _text(
  183. raw["write_mode"], f"{side} write_mode", 40
  184. )
  185. result[side] = item
  186. if result["input"]["access_mode"] != "read":
  187. raise ValueError("input binding access_mode must be read")
  188. if result["output"]["access_mode"] != "write":
  189. raise ValueError("output binding access_mode must be write")
  190. if result["input"].get("write_mode"):
  191. raise ValueError("input binding cannot declare write_mode")
  192. if not result["output"].get("write_mode"):
  193. raise ValueError("output binding requires write_mode")
  194. return result
  195. def _safe_receipt(value: Any) -> dict[str, Any]:
  196. if not isinstance(value, Mapping):
  197. return {}
  198. allowed = {"id", "revision", "status", "uid", "namespace", "flowId"}
  199. return {
  200. str(key): copy.deepcopy(item)
  201. for key, item in value.items()
  202. if key in allowed and isinstance(item, (str, int, float, bool, type(None)))
  203. }
  204. class DataFlowDeploymentService:
  205. """Deploy one released production line through fail-closed transitions."""
  206. def __init__(
  207. self,
  208. repository,
  209. *,
  210. engine,
  211. compiler: Callable[..., Any] | None = None,
  212. token_issuer=None,
  213. clock: Callable[[], datetime] | None = None,
  214. canary_ttl_seconds: int = 900,
  215. canary_timeout_seconds: int = 60,
  216. poll_interval_seconds: float = 1.0,
  217. monotonic: Callable[[], float] | None = None,
  218. sleeper: Callable[[float], None] | None = None,
  219. ):
  220. if engine is None:
  221. raise RuntimeError("deployment engine is not configured")
  222. self.repository = repository
  223. self.engine = engine
  224. self.compiler = compiler or compile_kestra_flow
  225. self.token_issuer = token_issuer
  226. self.clock = clock or (lambda: datetime.now(UTC))
  227. self.monotonic = monotonic or time.monotonic
  228. self.sleeper = sleeper or time.sleep
  229. if canary_ttl_seconds < 60 or canary_ttl_seconds > 3600:
  230. raise ValueError("canary evidence ttl is outside policy")
  231. self.canary_ttl_seconds = canary_ttl_seconds
  232. if canary_timeout_seconds < 1 or canary_timeout_seconds > 900:
  233. raise ValueError("canary timeout is outside policy")
  234. if poll_interval_seconds <= 0 or poll_interval_seconds > 10:
  235. raise ValueError("canary poll interval is outside policy")
  236. self.canary_timeout_seconds = canary_timeout_seconds
  237. self.poll_interval_seconds = poll_interval_seconds
  238. @staticmethod
  239. def _operation_key(value: Any) -> str:
  240. return _text(value, "idempotency_key", 200)
  241. @staticmethod
  242. def _correlation(value: Any) -> str:
  243. return _uid(value or new_governance_uid(), "correlation_id")
  244. def create(
  245. self,
  246. dataflow_version_id: str,
  247. *,
  248. binding_snapshot: dict[str, Any],
  249. environment: str,
  250. schedule_plan: dict[str, Any],
  251. actor_uid: str,
  252. reason: str,
  253. idempotency_key: str,
  254. correlation_id: str | None = None,
  255. ) -> dict[str, Any]:
  256. version_id = _uid(dataflow_version_id, "dataflow_version_id")
  257. actor = _uid(actor_uid, "actor_uid")
  258. if environment not in ENVIRONMENTS:
  259. raise ValueError("deployment environment is invalid")
  260. key = self._operation_key(idempotency_key)
  261. why = _text(reason, "reason", 1000)
  262. correlation = self._correlation(correlation_id)
  263. requested_binding = binding_snapshot
  264. schedule = validate_schedule_plan(schedule_plan)
  265. release = self.repository.load_deployable_release(
  266. version_id, environment=environment
  267. )
  268. if (
  269. not isinstance(release, dict)
  270. or release.get("status") != "released"
  271. or str(release.get("id")) != version_id
  272. ):
  273. raise ValueError("only a released dataflow version may be deployed")
  274. package = release.get("package")
  275. if not isinstance(package, dict):
  276. raise ValueError("released production-line package is missing")
  277. package_hash = _digest(release.get("package_hash"), "package_hash")
  278. if package.get("package_hash") != package_hash:
  279. raise ValueError("released production-line package has drifted")
  280. workflow_spec = package.get("workflow_spec")
  281. if not isinstance(workflow_spec, dict):
  282. raise ValueError("released workflow spec is missing")
  283. if workflow_spec.get("dataflow_uid") != package.get("dataflow_uid"):
  284. raise ValueError("released workflow identity has drifted")
  285. physical_hashes = sorted(
  286. {
  287. _digest(item, "physical_plan_hash")
  288. for item in release.get("physical_plan_hashes", [])
  289. }
  290. )
  291. if not physical_hashes:
  292. raise ValueError("published physical execution plans are required")
  293. schema_snapshots = release.get("schema_snapshots")
  294. if not isinstance(schema_snapshots, dict):
  295. raise ValueError("server-owned schema snapshots are required")
  296. canonical_binding = _binding_snapshot(release.get("binding_snapshot"))
  297. if (
  298. isinstance(requested_binding, Mapping)
  299. and set(requested_binding) == {"input", "output"}
  300. and all(
  301. isinstance(requested_binding[side], Mapping)
  302. and set(requested_binding[side]) == {"binding_id"}
  303. for side in ("input", "output")
  304. )
  305. ):
  306. if any(
  307. _uid(
  308. requested_binding[side]["binding_id"],
  309. f"{side} binding_id",
  310. )
  311. != canonical_binding[side].get("binding_id")
  312. for side in ("input", "output")
  313. ):
  314. raise ValueError("deployment binding selection has drifted")
  315. else:
  316. requested = _binding_snapshot(requested_binding)
  317. if requested != canonical_binding:
  318. raise ValueError("deployment binding selection has drifted")
  319. binding = canonical_binding
  320. for side in ("input", "output"):
  321. canonical = schema_snapshots.get(side)
  322. if (
  323. not isinstance(canonical, dict)
  324. or binding[side]["schema_snapshot_id"] != canonical.get("id")
  325. or binding[side]["schema_hash"] != canonical.get("schema_hash")
  326. ):
  327. raise ValueError("deployment binding schema has drifted")
  328. snapshot = {
  329. "dataflow_version_id": version_id,
  330. "version_no": int(release.get("version_no") or 0),
  331. "dataflow_uid": _uid(package.get("dataflow_uid"), "dataflow_uid"),
  332. "environment": environment,
  333. "package": copy.deepcopy(package),
  334. "package_hash": package_hash,
  335. "standard_version_ids": [
  336. _uid(item, "standard_version_id")
  337. for item in package.get("standard_version_ids", [])
  338. ],
  339. "rule_version_ids": [
  340. _uid(item, "rule_version_id")
  341. for item in package.get("rule_version_ids", [])
  342. ],
  343. "binding_snapshot": binding,
  344. "binding_hash": _canonical_hash(binding),
  345. "schema_snapshots": copy.deepcopy(schema_snapshots),
  346. "schema_snapshot_hash": _canonical_hash(schema_snapshots),
  347. "physical_plan_hashes": physical_hashes,
  348. "workflow_spec": copy.deepcopy(workflow_spec),
  349. "workflow_spec_hash": _canonical_hash(workflow_spec),
  350. "schedule_snapshot": schedule,
  351. "schedule_hash": _canonical_hash(schedule),
  352. "actor_uid": actor,
  353. "reason": why,
  354. "idempotency_key": key,
  355. "correlation_id": correlation,
  356. "preparatory_deployment_id": release.get(
  357. "preparatory_deployment_id"
  358. ),
  359. }
  360. return self.repository.create_deployment(snapshot)
  361. def _claim(
  362. self,
  363. deployment_id: str,
  364. action: str,
  365. actor_uid: str,
  366. idempotency_key: str,
  367. correlation_id: str | None,
  368. reason: str,
  369. request_payload: dict[str, Any],
  370. ):
  371. deployment = self.repository.get_deployment(
  372. _uid(deployment_id, "deployment_id")
  373. )
  374. actor = _uid(actor_uid, "actor_uid")
  375. key = self._operation_key(idempotency_key)
  376. correlation = self._correlation(correlation_id)
  377. claimed, operation = self.repository.claim_operation(
  378. deployment["id"],
  379. action,
  380. key,
  381. actor,
  382. correlation,
  383. reason,
  384. request_payload,
  385. )
  386. if claimed:
  387. self._commit()
  388. if not claimed:
  389. if operation.get("status") == "completed":
  390. return deployment, actor, operation, operation.get("result")
  391. if operation.get("status") == "claimed":
  392. raise ValueError("deployment operation is already in progress")
  393. if operation.get("status") == "unknown":
  394. raise ValueError(
  395. "deployment operation outcome requires reconciliation"
  396. )
  397. raise ValueError(
  398. "failed deployment operation requires a new idempotency key"
  399. )
  400. return deployment, actor, operation, None
  401. def _commit(self) -> None:
  402. commit = getattr(self.repository, "commit", None)
  403. if commit is not None:
  404. commit()
  405. @staticmethod
  406. def _unknown_outcome(exc: Exception) -> bool:
  407. if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
  408. return True
  409. if isinstance(exc, requests.HTTPError):
  410. response = getattr(exc, "response", None)
  411. return response is None or int(response.status_code) >= 500
  412. return False
  413. def _engine_failure(self, operation: Mapping[str, Any], exc: Exception):
  414. if self._unknown_outcome(exc):
  415. mark_unknown = getattr(
  416. self.repository, "mark_operation_unknown", None
  417. )
  418. if mark_unknown is not None:
  419. mark_unknown(
  420. operation["id"], "deployment_engine_outcome_unknown"
  421. )
  422. else:
  423. self.repository.fail_operation(
  424. operation["id"], "deployment_engine_outcome_unknown"
  425. )
  426. self._commit()
  427. raise RuntimeError(
  428. "deployment engine outcome is unknown; reconciliation required"
  429. )
  430. self.repository.fail_operation(
  431. operation["id"], "deployment_engine_failure"
  432. )
  433. self._commit()
  434. raise RuntimeError("deployment engine operation failed")
  435. def _local_operation_failure(
  436. self, operation: Mapping[str, Any], error_code: str
  437. ) -> None:
  438. self.repository.fail_operation(operation["id"], error_code)
  439. self._commit()
  440. def deploy_disabled(
  441. self,
  442. deployment_id: str,
  443. actor_uid: str,
  444. *,
  445. idempotency_key: str,
  446. reason: str = "deploy disabled",
  447. correlation_id: str | None = None,
  448. ) -> dict[str, Any]:
  449. _text(reason, "reason", 1000)
  450. deployment, _actor, operation, replay = self._claim(
  451. deployment_id,
  452. "deploy_disabled",
  453. actor_uid,
  454. idempotency_key,
  455. correlation_id,
  456. reason,
  457. {},
  458. )
  459. if replay is not None:
  460. return replay
  461. try:
  462. if deployment["status"] not in {"draft", "failed"}:
  463. raise ValueError(
  464. "deployment is not eligible for disabled deploy"
  465. )
  466. compiled = self.compiler(
  467. deployment["workflow_spec"],
  468. deployment["schedule_snapshot"],
  469. deployment["environment"],
  470. int(deployment.get("version_no") or 1),
  471. )
  472. except Exception:
  473. self._local_operation_failure(
  474. operation, "deployment_preflight_failed"
  475. )
  476. raise
  477. if compiled.definition_hash != _canonical_hash(
  478. # The compiler owns canonical serialized output; this comparison is
  479. # deliberately against its own immutable payload.
  480. compiled.yaml
  481. ):
  482. # Existing compiler hashes bytes, while canonical JSON hashing a
  483. # string includes quotes. Preserve its hash but still validate it.
  484. _digest(compiled.definition_hash, "engine_definition_hash")
  485. try:
  486. response = self.engine.deploy_disabled(compiled.yaml)
  487. except Exception as exc:
  488. self._engine_failure(operation, exc)
  489. engine = {
  490. "namespace": compiled.namespace,
  491. "definition_id": compiled.flow_id,
  492. "revision": (
  493. response.get("revision")
  494. if isinstance(response, Mapping)
  495. else None
  496. ),
  497. "definition_hash": _digest(
  498. compiled.definition_hash, "engine_definition_hash"
  499. ),
  500. "receipt": _safe_receipt(response),
  501. }
  502. try:
  503. result = self.repository.mark_disabled(
  504. deployment["id"],
  505. deployment["lock_version"],
  506. engine,
  507. operation["id"],
  508. )
  509. except Exception:
  510. self._local_operation_failure(
  511. operation, "disabled_finalize_failed"
  512. )
  513. raise
  514. self._commit()
  515. return result
  516. def run_canary(
  517. self,
  518. deployment_id: str,
  519. inputs: dict[str, Any],
  520. actor_uid: str,
  521. *,
  522. idempotency_key: str,
  523. reason: str = "trial production",
  524. correlation_id: str | None = None,
  525. ) -> dict[str, Any]:
  526. _text(reason, "reason", 1000)
  527. if not isinstance(inputs, dict) or len(inputs) > 100:
  528. raise ValueError("canary inputs must be a bounded object")
  529. _closed_tree(inputs)
  530. if len(json.dumps(inputs, ensure_ascii=False).encode("utf-8")) > 65536:
  531. raise ValueError("canary inputs exceed size limit")
  532. deployment, actor, operation, replay = self._claim(
  533. deployment_id,
  534. "run_canary",
  535. actor_uid,
  536. idempotency_key,
  537. correlation_id,
  538. reason,
  539. {"inputs_hash": _canonical_hash(inputs)},
  540. )
  541. if replay is not None:
  542. return replay
  543. try:
  544. if deployment["status"] not in {"disabled", "canary"}:
  545. raise ValueError("disabled deployment is required for canary")
  546. allowed_inputs = set(
  547. (deployment["workflow_spec"].get("parameters") or {}).keys()
  548. )
  549. unknown = sorted(set(inputs) - allowed_inputs)
  550. if unknown:
  551. raise ValueError(
  552. "canary inputs contain unknown parameters: "
  553. + ", ".join(unknown)
  554. )
  555. engine_inputs = copy.deepcopy(inputs)
  556. if self.token_issuer is not None:
  557. task_tokens = {}
  558. for node in deployment["workflow_spec"].get("nodes", []):
  559. task_tokens[node["id"]] = self.token_issuer.issue(
  560. task_uid=new_governance_uid(),
  561. dataflow_uid=deployment["dataflow_uid"],
  562. deployment_id=deployment["id"],
  563. environment=deployment["environment"],
  564. workflow_version=int(
  565. deployment.get("version_no") or 1
  566. ),
  567. correlation_id=operation["correlation_id"],
  568. node=node,
  569. write_authorized=True,
  570. )
  571. engine_inputs["dataops_task_tokens"] = task_tokens
  572. except Exception:
  573. self._local_operation_failure(
  574. operation, "canary_preflight_failed"
  575. )
  576. raise
  577. try:
  578. self.engine.activate(
  579. deployment["engine_namespace"],
  580. deployment["engine_definition_id"],
  581. )
  582. try:
  583. response = self.engine.execute(
  584. deployment["engine_namespace"],
  585. deployment["engine_definition_id"],
  586. inputs=engine_inputs,
  587. )
  588. finally:
  589. self.engine.deactivate(
  590. deployment["engine_namespace"],
  591. deployment["engine_definition_id"],
  592. )
  593. execution_id = (
  594. response.get("id") if isinstance(response, Mapping) else None
  595. )
  596. if not execution_id:
  597. raise RuntimeError("canary execution id is missing")
  598. deadline = self.monotonic() + self.canary_timeout_seconds
  599. execution = self.engine.get_execution(str(execution_id))
  600. while True:
  601. state = (
  602. execution.get("state")
  603. if isinstance(execution, Mapping)
  604. else None
  605. )
  606. if isinstance(state, Mapping):
  607. state = state.get("current")
  608. normalized_state = str(state or "").upper()
  609. if normalized_state in {
  610. "SUCCESS",
  611. "SUCCEEDED",
  612. "FAILED",
  613. "KILLED",
  614. "CANCELLED",
  615. "WARNING",
  616. }:
  617. break
  618. if self.monotonic() >= deadline:
  619. normalized_state = "UNKNOWN"
  620. break
  621. self.sleeper(self.poll_interval_seconds)
  622. execution = self.engine.get_execution(str(execution_id))
  623. except Exception as exc:
  624. self._engine_failure(operation, exc)
  625. passed = normalized_state in {"SUCCESS", "SUCCEEDED"}
  626. evidence_status = (
  627. "passed"
  628. if passed
  629. else (
  630. "failed"
  631. if normalized_state
  632. in {"FAILED", "KILLED", "CANCELLED", "WARNING"}
  633. else "unknown"
  634. )
  635. )
  636. now = self.clock().astimezone(UTC)
  637. evidence = {
  638. "id": new_governance_uid(),
  639. "deployment_id": deployment["id"],
  640. "execution_id": str(execution_id),
  641. "status": evidence_status,
  642. "package_hash": deployment["package_hash"],
  643. "binding_hash": deployment["binding_hash"],
  644. "schema_snapshot_hash": deployment["schema_snapshot_hash"],
  645. "physical_plan_hashes": deployment["physical_plan_hashes"],
  646. "workflow_spec_hash": deployment["workflow_spec_hash"],
  647. "schedule_hash": deployment["schedule_hash"],
  648. "engine_definition_hash": deployment["engine_definition_hash"],
  649. "verified_by": actor,
  650. "created_at": now.isoformat(),
  651. "expires_at": (
  652. now + timedelta(seconds=self.canary_ttl_seconds)
  653. ).isoformat(),
  654. "engine_response": _safe_receipt(execution),
  655. }
  656. try:
  657. result = self.repository.record_canary(
  658. deployment["id"],
  659. deployment["lock_version"],
  660. evidence,
  661. operation["id"],
  662. )
  663. except Exception:
  664. self._local_operation_failure(
  665. operation, "canary_finalize_failed"
  666. )
  667. raise
  668. self._commit()
  669. return result
  670. def _validate_evidence(
  671. self, deployment: Mapping[str, Any], evidence_id: Any
  672. ) -> dict[str, Any]:
  673. if not evidence_id:
  674. raise ValueError("passed canary evidence is required")
  675. evidence = self.repository.get_canary_evidence(
  676. _uid(evidence_id, "evidence_id")
  677. )
  678. if evidence.get("status") != "passed" or not evidence.get("verified_by"):
  679. raise ValueError("passed canary evidence is required")
  680. expires_at = _timestamp(evidence.get("expires_at"), "canary expiry")
  681. if expires_at <= self.clock().astimezone(UTC):
  682. raise ValueError("passed canary evidence has expired")
  683. expected = {
  684. "deployment_id": deployment["id"],
  685. "package_hash": deployment["package_hash"],
  686. "binding_hash": deployment["binding_hash"],
  687. "schema_snapshot_hash": deployment["schema_snapshot_hash"],
  688. "physical_plan_hashes": deployment["physical_plan_hashes"],
  689. "workflow_spec_hash": deployment["workflow_spec_hash"],
  690. "schedule_hash": deployment["schedule_hash"],
  691. "engine_definition_hash": deployment["engine_definition_hash"],
  692. }
  693. if any(evidence.get(key) != value for key, value in expected.items()):
  694. raise ValueError("canary evidence attestation has drifted")
  695. return evidence
  696. def activate(
  697. self,
  698. deployment_id: str,
  699. evidence_id: str | None,
  700. actor_uid: str,
  701. *,
  702. idempotency_key: str,
  703. reason: str = "mass production activation",
  704. correlation_id: str | None = None,
  705. ) -> dict[str, Any]:
  706. _text(reason, "reason", 1000)
  707. deployment, actor, operation, replay = self._claim(
  708. deployment_id,
  709. "activate",
  710. actor_uid,
  711. idempotency_key,
  712. correlation_id,
  713. reason,
  714. {"evidence_id": evidence_id},
  715. )
  716. if replay is not None:
  717. return replay
  718. try:
  719. if deployment["status"] != "canary":
  720. raise ValueError("passed canary deployment is required")
  721. evidence = self._validate_evidence(deployment, evidence_id)
  722. except Exception:
  723. self._local_operation_failure(
  724. operation, "activation_preflight_failed"
  725. )
  726. raise
  727. try:
  728. self.engine.activate(
  729. deployment["engine_namespace"],
  730. deployment["engine_definition_id"],
  731. )
  732. except Exception as exc:
  733. self._engine_failure(operation, exc)
  734. try:
  735. result = self.repository.activate_atomic(
  736. deployment["id"],
  737. deployment["lock_version"],
  738. evidence["id"],
  739. actor,
  740. operation["id"],
  741. )
  742. self._commit()
  743. return result
  744. except Exception:
  745. try:
  746. self.engine.deactivate(
  747. deployment["engine_namespace"],
  748. deployment["engine_definition_id"],
  749. )
  750. finally:
  751. self.repository.fail_operation(
  752. operation["id"], "activation_commit_failure"
  753. )
  754. self._commit()
  755. raise
  756. def rollback(
  757. self,
  758. deployment_id: str,
  759. actor_uid: str,
  760. *,
  761. idempotency_key: str,
  762. reason: str = "restore previous production line",
  763. correlation_id: str | None = None,
  764. ) -> dict[str, Any]:
  765. _text(reason, "reason", 1000)
  766. deployment, actor, operation, replay = self._claim(
  767. deployment_id,
  768. "rollback",
  769. actor_uid,
  770. idempotency_key,
  771. correlation_id,
  772. reason,
  773. {
  774. "candidate_deployment_id": deployment_id,
  775. },
  776. )
  777. if replay is not None:
  778. return replay
  779. try:
  780. if deployment["status"] not in {"active", "failed"}:
  781. raise ValueError("active deployment is required for rollback")
  782. previous_id = deployment.get("previous_active_deployment_id")
  783. if not previous_id:
  784. raise ValueError("previous deployment is not available")
  785. previous = self.repository.get_deployment(previous_id)
  786. if (
  787. previous.get("status") != "superseded"
  788. or previous.get("dataflow_uid")
  789. != deployment.get("dataflow_uid")
  790. or previous.get("environment")
  791. != deployment.get("environment")
  792. ):
  793. raise ValueError(
  794. "previous deployment snapshot is not restorable"
  795. )
  796. except Exception:
  797. self._local_operation_failure(
  798. operation, "rollback_preflight_failed"
  799. )
  800. raise
  801. try:
  802. # Fail-safe ordering: stop the candidate before restoring the exact
  803. # prior engine definition and immutable package.
  804. self.engine.deactivate(
  805. deployment["engine_namespace"],
  806. deployment["engine_definition_id"],
  807. )
  808. self.engine.activate(
  809. previous["engine_namespace"],
  810. previous["engine_definition_id"],
  811. )
  812. except Exception as exc:
  813. try:
  814. self.engine.activate(
  815. deployment["engine_namespace"],
  816. deployment["engine_definition_id"],
  817. )
  818. finally:
  819. self._engine_failure(operation, exc)
  820. try:
  821. result = self.repository.rollback_atomic(
  822. deployment["id"],
  823. deployment["lock_version"],
  824. actor,
  825. operation["id"],
  826. )
  827. self._commit()
  828. return result
  829. except Exception:
  830. # External state is reconciled back to the pre-operation state.
  831. self.engine.deactivate(
  832. previous["engine_namespace"], previous["engine_definition_id"]
  833. )
  834. self.engine.activate(
  835. deployment["engine_namespace"],
  836. deployment["engine_definition_id"],
  837. )
  838. self.repository.fail_operation(
  839. operation["id"], "rollback_commit_failure"
  840. )
  841. self._commit()
  842. raise
  843. @staticmethod
  844. def _flow_labels(flow: Mapping[str, Any]) -> dict[str, str]:
  845. labels = flow.get("labels")
  846. if isinstance(labels, Mapping):
  847. return {str(key): str(value) for key, value in labels.items()}
  848. if isinstance(labels, list):
  849. return {
  850. str(item.get("key")): str(item.get("value"))
  851. for item in labels
  852. if isinstance(item, Mapping)
  853. and item.get("key") is not None
  854. and item.get("value") is not None
  855. }
  856. return {}
  857. def reconcile(
  858. self,
  859. deployment_id: str,
  860. action: str,
  861. actor_uid: str,
  862. *,
  863. idempotency_key: str,
  864. ) -> dict[str, Any]:
  865. """Resolve a response-loss outcome without replaying the side effect."""
  866. deployment = self.repository.get_deployment(
  867. _uid(deployment_id, "deployment_id")
  868. )
  869. actor = _uid(actor_uid, "actor_uid")
  870. operation = self.repository.get_operation(
  871. deployment["id"], action, self._operation_key(idempotency_key)
  872. )
  873. if operation.get("status") == "completed":
  874. return operation["result"]
  875. if operation.get("status") != "unknown":
  876. raise ValueError("only an unknown operation may be reconciled")
  877. if operation.get("actor_uid") != actor:
  878. raise ValueError("operation is bound to another actor")
  879. if action == "deploy_disabled":
  880. compiled = self.compiler(
  881. deployment["workflow_spec"],
  882. deployment["schedule_snapshot"],
  883. deployment["environment"],
  884. int(deployment.get("version_no") or 1),
  885. )
  886. flow = self.engine.get_flow(compiled.namespace, compiled.flow_id)
  887. labels = self._flow_labels(flow)
  888. expected_labels = {
  889. "dataflow_uid": deployment["dataflow_uid"],
  890. "environment": deployment["environment"],
  891. "workflow_version": str(
  892. int(deployment.get("version_no") or 1)
  893. ),
  894. }
  895. if (
  896. not isinstance(flow, Mapping)
  897. or flow.get("disabled") is not True
  898. or any(
  899. labels.get(key) != value
  900. for key, value in expected_labels.items()
  901. )
  902. ):
  903. raise ValueError(
  904. "engine flow does not attest the unknown deployment"
  905. )
  906. self.repository.reclaim_unknown_operation(operation["id"])
  907. self._commit()
  908. result = self.repository.mark_disabled(
  909. deployment["id"],
  910. deployment["lock_version"],
  911. {
  912. "namespace": compiled.namespace,
  913. "definition_id": compiled.flow_id,
  914. "revision": flow.get("revision"),
  915. "definition_hash": compiled.definition_hash,
  916. "receipt": _safe_receipt(flow),
  917. },
  918. operation["id"],
  919. )
  920. self._commit()
  921. return result
  922. if action == "activate":
  923. evidence_id = operation.get("request", {}).get("evidence_id")
  924. evidence = self._validate_evidence(deployment, evidence_id)
  925. flow = self.engine.get_flow(
  926. deployment["engine_namespace"],
  927. deployment["engine_definition_id"],
  928. )
  929. if not isinstance(flow, Mapping) or flow.get("disabled") is not False:
  930. raise ValueError("engine activation is not observable")
  931. self.repository.reclaim_unknown_operation(operation["id"])
  932. self._commit()
  933. result = self.repository.activate_atomic(
  934. deployment["id"],
  935. deployment["lock_version"],
  936. evidence["id"],
  937. actor,
  938. operation["id"],
  939. )
  940. self._commit()
  941. return result
  942. if action == "rollback":
  943. if (
  944. deployment.get("status") not in {"active", "failed"}
  945. or not deployment.get("previous_active_deployment_id")
  946. ):
  947. raise ValueError("rollback deployment state is not recoverable")
  948. previous = self.repository.get_deployment(
  949. deployment["previous_active_deployment_id"]
  950. )
  951. if (
  952. previous.get("status") != "superseded"
  953. or previous.get("dataflow_uid")
  954. != deployment.get("dataflow_uid")
  955. or previous.get("environment")
  956. != deployment.get("environment")
  957. ):
  958. raise ValueError("previous deployment is not restorable")
  959. candidate_flow = self.engine.get_flow(
  960. deployment["engine_namespace"],
  961. deployment["engine_definition_id"],
  962. )
  963. previous_flow = self.engine.get_flow(
  964. previous["engine_namespace"],
  965. previous["engine_definition_id"],
  966. )
  967. if candidate_flow.get("disabled") is not True:
  968. self.engine.deactivate(
  969. deployment["engine_namespace"],
  970. deployment["engine_definition_id"],
  971. )
  972. if previous_flow.get("disabled") is not False:
  973. self.engine.activate(
  974. previous["engine_namespace"],
  975. previous["engine_definition_id"],
  976. )
  977. candidate_flow = self.engine.get_flow(
  978. deployment["engine_namespace"],
  979. deployment["engine_definition_id"],
  980. )
  981. previous_flow = self.engine.get_flow(
  982. previous["engine_namespace"],
  983. previous["engine_definition_id"],
  984. )
  985. if (
  986. candidate_flow.get("disabled") is not True
  987. or previous_flow.get("disabled") is not False
  988. ):
  989. raise ValueError("engine rollback state is not observable")
  990. self.repository.reclaim_unknown_operation(operation["id"])
  991. self._commit()
  992. result = self.repository.rollback_atomic(
  993. deployment["id"],
  994. deployment["lock_version"],
  995. actor,
  996. operation["id"],
  997. )
  998. self._commit()
  999. return result
  1000. raise ValueError(
  1001. "unknown operation requires execution-level manual reconciliation"
  1002. )
  1003. def list(self, *, environment: str | None = None):
  1004. if environment is not None and environment not in ENVIRONMENTS:
  1005. raise ValueError("deployment environment is invalid")
  1006. return self.repository.list_deployments(environment=environment)
  1007. def candidates(self, *, environment: str):
  1008. if environment not in ENVIRONMENTS:
  1009. raise ValueError("deployment environment is invalid")
  1010. return self.repository.list_deployable_releases(
  1011. environment=environment
  1012. )