deployment.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389
  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.engines.kestra import (
  19. canonical_flow_definition_hash,
  20. )
  21. from app.core.orchestration.spec import validate_schedule_plan
  22. ENVIRONMENTS = frozenset({"development", "test", "production"})
  23. KESTRA_HASH_VERSION = "kestra_canonical_v2"
  24. TRANSITIONS = {
  25. "draft": frozenset({"disabled", "failed"}),
  26. "disabled": frozenset({"canary", "failed"}),
  27. "canary": frozenset({"active", "failed", "rolled_back"}),
  28. "active": frozenset({"superseded", "rolled_back", "failed"}),
  29. "failed": frozenset({"disabled", "rolled_back"}),
  30. "superseded": frozenset({"rolled_back", "active"}),
  31. "rolled_back": frozenset(),
  32. }
  33. _FORBIDDEN_INPUT_KEYS = frozenset(
  34. {
  35. "sql",
  36. "python",
  37. "script",
  38. "code",
  39. "yaml",
  40. "workflow_spec",
  41. "execution_plan",
  42. "artifact",
  43. "artifact_ref",
  44. "password",
  45. "secret",
  46. "credential",
  47. "credentials",
  48. "token",
  49. "api_key",
  50. "connection_string",
  51. }
  52. )
  53. _BINDING_SIDES = frozenset({"input", "output"})
  54. _BINDING_FIELDS = frozenset(
  55. {
  56. "data_source_uid",
  57. "object_kind",
  58. "object_ref",
  59. "schema_snapshot_id",
  60. "schema_hash",
  61. "binding_hash",
  62. "access_mode",
  63. "write_mode",
  64. "dialect",
  65. "binding_id",
  66. "logical_ref",
  67. }
  68. )
  69. class DeploymentOperationError(ValueError, RuntimeError):
  70. code = "terminal_conflict"
  71. idempotency_key_disposition = "rotate"
  72. class OperationInProgress(DeploymentOperationError):
  73. code = "operation_in_progress"
  74. idempotency_key_disposition = "retain"
  75. class OperationUnknown(DeploymentOperationError):
  76. code = "operation_unknown"
  77. idempotency_key_disposition = "retain"
  78. def __init__(
  79. self,
  80. message: str,
  81. *,
  82. operation: Mapping[str, Any] | None = None,
  83. ) -> None:
  84. super().__init__(message)
  85. self.blocking_operation = (
  86. {
  87. "id": operation.get("id"),
  88. "deployment_id": operation.get("deployment_id"),
  89. "action": operation.get("action"),
  90. "idempotency_key": operation.get("idempotency_key"),
  91. "scope_unknown_count": int(operation.get("scope_unknown_count") or 1),
  92. }
  93. if operation is not None
  94. else None
  95. )
  96. class TerminalConflict(DeploymentOperationError):
  97. code = "terminal_conflict"
  98. def _uid(value: Any, label: str) -> str:
  99. try:
  100. return ensure_governance_uid({"uid": str(value)})
  101. except ValueError as exc:
  102. raise ValueError(f"{label} must be a valid UUIDv7") from exc
  103. def _text(value: Any, label: str, limit: int) -> str:
  104. if not isinstance(value, str) or not value.strip():
  105. raise ValueError(f"{label} is required")
  106. normalized = value.strip()
  107. if len(normalized) > limit:
  108. raise ValueError(f"{label} exceeds {limit} characters")
  109. return normalized
  110. def _digest(value: Any, label: str) -> str:
  111. normalized = str(value or "").strip().lower()
  112. if not re.fullmatch(r"[0-9a-f]{64}", normalized):
  113. raise ValueError(f"{label} must be a sha256 hex digest")
  114. return normalized
  115. def _canonical_hash(value: Any) -> str:
  116. encoded = json.dumps(
  117. value,
  118. sort_keys=True,
  119. separators=(",", ":"),
  120. ensure_ascii=False,
  121. ).encode("utf-8")
  122. return hashlib.sha256(encoded).hexdigest()
  123. def _timestamp(value: Any, label: str) -> datetime:
  124. if isinstance(value, datetime):
  125. parsed = value
  126. elif isinstance(value, str):
  127. source = value[:-1] + "+00:00" if value.endswith("Z") else value
  128. try:
  129. parsed = datetime.fromisoformat(source)
  130. except ValueError as exc:
  131. raise ValueError(f"{label} is invalid") from exc
  132. else:
  133. raise ValueError(f"{label} is invalid")
  134. if parsed.tzinfo is None:
  135. raise ValueError(f"{label} must include a timezone")
  136. return parsed.astimezone(UTC)
  137. def _closed_tree(value: Any) -> None:
  138. if isinstance(value, Mapping):
  139. for key, item in value.items():
  140. normalized = str(key).strip().lower()
  141. if normalized in _FORBIDDEN_INPUT_KEYS:
  142. if normalized in {
  143. "password",
  144. "secret",
  145. "credential",
  146. "credentials",
  147. "token",
  148. "api_key",
  149. "connection_string",
  150. }:
  151. raise ValueError("deployment credentials are not accepted")
  152. raise ValueError("inline executable semantics are not accepted")
  153. _closed_tree(item)
  154. elif isinstance(value, list):
  155. for item in value:
  156. _closed_tree(item)
  157. def _binding_snapshot(value: Any) -> dict[str, Any]:
  158. if not isinstance(value, Mapping) or set(value) != _BINDING_SIDES:
  159. raise ValueError("binding_snapshot must contain input and output")
  160. result: dict[str, Any] = {}
  161. for side in ("input", "output"):
  162. raw = value[side]
  163. if not isinstance(raw, Mapping):
  164. raise ValueError(f"{side} binding must be an object")
  165. unknown = set(raw) - _BINDING_FIELDS
  166. if unknown:
  167. raise ValueError(
  168. f"{side} binding contains unsupported fields: "
  169. + ", ".join(sorted(unknown))
  170. )
  171. _closed_tree(raw)
  172. required = {
  173. "data_source_uid",
  174. "object_kind",
  175. "object_ref",
  176. "schema_snapshot_id",
  177. "schema_hash",
  178. "binding_hash",
  179. "access_mode",
  180. "dialect",
  181. }
  182. if not required.issubset(raw):
  183. raise ValueError(f"{side} binding is incomplete")
  184. item = {
  185. "data_source_uid": _uid(raw["data_source_uid"], f"{side} data_source_uid"),
  186. "object_kind": _text(raw["object_kind"], f"{side} object_kind", 40),
  187. "object_ref": _text(raw["object_ref"], f"{side} object_ref", 500),
  188. "schema_snapshot_id": _uid(
  189. raw["schema_snapshot_id"], f"{side} schema_snapshot_id"
  190. ),
  191. "schema_hash": _digest(raw["schema_hash"], f"{side} schema_hash"),
  192. "binding_hash": _digest(raw["binding_hash"], f"{side} binding_hash"),
  193. "access_mode": _text(raw["access_mode"], f"{side} access_mode", 20),
  194. "dialect": _text(raw["dialect"], f"{side} dialect", 40).lower(),
  195. }
  196. if raw.get("binding_id") is not None:
  197. item["binding_id"] = _uid(raw["binding_id"], f"{side} binding_id")
  198. if raw.get("logical_ref") is not None:
  199. item["logical_ref"] = _text(raw["logical_ref"], f"{side} logical_ref", 500)
  200. if "write_mode" in raw and raw["write_mode"] is not None:
  201. item["write_mode"] = _text(raw["write_mode"], f"{side} write_mode", 40)
  202. result[side] = item
  203. if result["input"]["access_mode"] != "read":
  204. raise ValueError("input binding access_mode must be read")
  205. if result["output"]["access_mode"] not in {"write", "read_write"}:
  206. raise ValueError("output binding access_mode must be write or read_write")
  207. if result["input"].get("write_mode"):
  208. raise ValueError("input binding cannot declare write_mode")
  209. if not result["output"].get("write_mode"):
  210. raise ValueError("output binding requires write_mode")
  211. return result
  212. def _safe_receipt(value: Any) -> dict[str, Any]:
  213. if not isinstance(value, Mapping):
  214. return {}
  215. allowed = {"id", "revision", "status", "uid", "namespace", "flowId"}
  216. return {
  217. str(key): copy.deepcopy(item)
  218. for key, item in value.items()
  219. if key in allowed and isinstance(item, (str, int, float, bool, type(None)))
  220. }
  221. class DataFlowDeploymentService:
  222. """Deploy one released production line through fail-closed transitions."""
  223. def __init__(
  224. self,
  225. repository,
  226. *,
  227. engine,
  228. compiler: Callable[..., Any] | None = None,
  229. token_issuer=None,
  230. clock: Callable[[], datetime] | None = None,
  231. canary_ttl_seconds: int = 900,
  232. canary_timeout_seconds: int = 60,
  233. poll_interval_seconds: float = 1.0,
  234. monotonic: Callable[[], float] | None = None,
  235. sleeper: Callable[[float], None] | None = None,
  236. operation_lease_seconds: int = 1200,
  237. ):
  238. if engine is None:
  239. raise RuntimeError("deployment engine is not configured")
  240. self.repository = repository
  241. self.engine = engine
  242. self.compiler = compiler or compile_kestra_flow
  243. self.token_issuer = token_issuer
  244. self.clock = clock or (lambda: datetime.now(UTC))
  245. self.monotonic = monotonic or time.monotonic
  246. self.sleeper = sleeper or time.sleep
  247. if operation_lease_seconds < 60 or operation_lease_seconds > 3600:
  248. raise ValueError("operation lease is outside policy")
  249. self.operation_lease_seconds = operation_lease_seconds
  250. if canary_ttl_seconds < 60 or canary_ttl_seconds > 3600:
  251. raise ValueError("canary evidence ttl is outside policy")
  252. self.canary_ttl_seconds = canary_ttl_seconds
  253. if canary_timeout_seconds < 1 or canary_timeout_seconds > 900:
  254. raise ValueError("canary timeout is outside policy")
  255. if poll_interval_seconds <= 0 or poll_interval_seconds > 10:
  256. raise ValueError("canary poll interval is outside policy")
  257. self.canary_timeout_seconds = canary_timeout_seconds
  258. self.poll_interval_seconds = poll_interval_seconds
  259. @staticmethod
  260. def _operation_key(value: Any) -> str:
  261. return _text(value, "idempotency_key", 200)
  262. @staticmethod
  263. def _correlation(value: Any) -> str:
  264. return _uid(value or new_governance_uid(), "correlation_id")
  265. def create(
  266. self,
  267. dataflow_version_id: str,
  268. *,
  269. binding_snapshot: dict[str, Any],
  270. environment: str,
  271. schedule_plan: dict[str, Any],
  272. actor_uid: str,
  273. reason: str,
  274. idempotency_key: str,
  275. correlation_id: str | None = None,
  276. ) -> dict[str, Any]:
  277. version_id = _uid(dataflow_version_id, "dataflow_version_id")
  278. actor = _uid(actor_uid, "actor_uid")
  279. if environment not in ENVIRONMENTS:
  280. raise ValueError("deployment environment is invalid")
  281. key = self._operation_key(idempotency_key)
  282. why = _text(reason, "reason", 1000)
  283. correlation = self._correlation(correlation_id)
  284. requested_binding = binding_snapshot
  285. schedule = validate_schedule_plan(schedule_plan)
  286. release = self.repository.load_deployable_release(
  287. version_id, environment=environment
  288. )
  289. if (
  290. not isinstance(release, dict)
  291. or release.get("status") != "released"
  292. or str(release.get("id")) != version_id
  293. ):
  294. raise ValueError("only a released dataflow version may be deployed")
  295. package = release.get("package")
  296. if not isinstance(package, dict):
  297. raise ValueError("released production-line package is missing")
  298. package_hash = _digest(release.get("package_hash"), "package_hash")
  299. if package.get("package_hash") != package_hash:
  300. raise ValueError("released production-line package has drifted")
  301. released_workflow_spec = package.get("workflow_spec")
  302. if not isinstance(released_workflow_spec, dict):
  303. raise ValueError("released workflow spec is missing")
  304. if released_workflow_spec.get("dataflow_uid") != package.get("dataflow_uid"):
  305. raise ValueError("released workflow identity has drifted")
  306. workflow_spec = release.get("physical_workflow_spec")
  307. if not isinstance(workflow_spec, dict):
  308. raise ValueError("server-owned physical workflow spec is required")
  309. if workflow_spec.get("dataflow_uid") != package.get("dataflow_uid"):
  310. raise ValueError("physical workflow identity has drifted")
  311. physical_hashes = sorted(
  312. {
  313. _digest(item, "physical_plan_hash")
  314. for item in release.get("physical_plan_hashes", [])
  315. }
  316. )
  317. if not physical_hashes:
  318. raise ValueError("published physical execution plans are required")
  319. schema_snapshots = release.get("schema_snapshots")
  320. if not isinstance(schema_snapshots, dict):
  321. raise ValueError("server-owned schema snapshots are required")
  322. canonical_binding = _binding_snapshot(release.get("binding_snapshot"))
  323. if (
  324. isinstance(requested_binding, Mapping)
  325. and set(requested_binding) == {"input", "output"}
  326. and all(
  327. isinstance(requested_binding[side], Mapping)
  328. and set(requested_binding[side]) == {"binding_id"}
  329. for side in ("input", "output")
  330. )
  331. ):
  332. if any(
  333. _uid(
  334. requested_binding[side]["binding_id"],
  335. f"{side} binding_id",
  336. )
  337. != canonical_binding[side].get("binding_id")
  338. for side in ("input", "output")
  339. ):
  340. raise ValueError("deployment binding selection has drifted")
  341. else:
  342. requested = _binding_snapshot(requested_binding)
  343. if requested != canonical_binding:
  344. raise ValueError("deployment binding selection has drifted")
  345. binding = canonical_binding
  346. for side in ("input", "output"):
  347. canonical = schema_snapshots.get(side)
  348. if (
  349. not isinstance(canonical, dict)
  350. or binding[side]["schema_snapshot_id"] != canonical.get("id")
  351. or binding[side]["schema_hash"] != canonical.get("schema_hash")
  352. ):
  353. raise ValueError("deployment binding schema has drifted")
  354. snapshot = {
  355. "dataflow_version_id": version_id,
  356. "version_no": int(release.get("version_no") or 0),
  357. "dataflow_uid": _uid(package.get("dataflow_uid"), "dataflow_uid"),
  358. "environment": environment,
  359. "package": copy.deepcopy(package),
  360. "package_hash": package_hash,
  361. "standard_version_ids": [
  362. _uid(item, "standard_version_id")
  363. for item in package.get("standard_version_ids", [])
  364. ],
  365. "rule_version_ids": [
  366. _uid(item, "rule_version_id")
  367. for item in package.get("rule_version_ids", [])
  368. ],
  369. "binding_snapshot": binding,
  370. "binding_hash": _canonical_hash(binding),
  371. "schema_snapshots": copy.deepcopy(schema_snapshots),
  372. "schema_snapshot_hash": _canonical_hash(schema_snapshots),
  373. "physical_plan_hashes": physical_hashes,
  374. "workflow_spec": copy.deepcopy(workflow_spec),
  375. "workflow_spec_hash": _canonical_hash(workflow_spec),
  376. "schedule_snapshot": schedule,
  377. "schedule_hash": _canonical_hash(schedule),
  378. "actor_uid": actor,
  379. "reason": why,
  380. "idempotency_key": key,
  381. "correlation_id": correlation,
  382. "preparatory_deployment_id": release.get("preparatory_deployment_id"),
  383. }
  384. return self.repository.create_deployment(snapshot)
  385. def _claim(
  386. self,
  387. deployment_id: str,
  388. action: str,
  389. actor_uid: str,
  390. idempotency_key: str,
  391. correlation_id: str | None,
  392. reason: str,
  393. request_payload: dict[str, Any],
  394. ):
  395. deployment = self.repository.get_deployment(
  396. _uid(deployment_id, "deployment_id")
  397. )
  398. actor = _uid(actor_uid, "actor_uid")
  399. key = self._operation_key(idempotency_key)
  400. correlation = self._correlation(correlation_id)
  401. claimed, operation = self.repository.claim_operation(
  402. deployment["id"],
  403. action,
  404. key,
  405. actor,
  406. correlation,
  407. reason,
  408. request_payload,
  409. )
  410. # Claim may also expire and classify an abandoned owner. Commit both
  411. # successful claims and those durable state observations.
  412. self._commit()
  413. if not claimed:
  414. if operation.get("status") == "completed":
  415. return deployment, actor, operation, operation.get("result")
  416. if operation.get("status") == "claimed":
  417. raise OperationInProgress("deployment operation is already in progress")
  418. if operation.get("status") == "unknown":
  419. raise OperationUnknown(
  420. "deployment operation outcome requires reconciliation",
  421. operation=operation,
  422. )
  423. raise TerminalConflict(
  424. "failed deployment operation requires a new idempotency key"
  425. )
  426. return deployment, actor, operation, None
  427. def _commit(self) -> None:
  428. commit = getattr(self.repository, "commit", None)
  429. if commit is not None:
  430. commit()
  431. @staticmethod
  432. def _unknown_outcome(exc: Exception) -> bool:
  433. if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
  434. return True
  435. if isinstance(exc, requests.HTTPError):
  436. response = getattr(exc, "response", None)
  437. return response is None or int(response.status_code) >= 500
  438. return False
  439. def _engine_failure(self, operation: Mapping[str, Any], exc: Exception):
  440. if self._unknown_outcome(exc):
  441. mark_unknown = getattr(self.repository, "mark_operation_unknown", None)
  442. if mark_unknown is not None:
  443. mark_unknown(operation, "deployment_engine_outcome_unknown")
  444. else:
  445. self.repository.fail_operation(
  446. operation, "deployment_engine_outcome_unknown"
  447. )
  448. self._commit()
  449. raise OperationUnknown(
  450. "deployment engine outcome is unknown; reconciliation required"
  451. )
  452. self.repository.fail_operation(operation, "deployment_engine_failure")
  453. self._commit()
  454. raise RuntimeError("deployment engine operation failed")
  455. def _local_operation_failure(
  456. self, operation: Mapping[str, Any], error_code: str
  457. ) -> None:
  458. self.repository.fail_operation(operation, error_code)
  459. self._commit()
  460. def _owns_fence(self, operation: Mapping[str, Any]) -> bool:
  461. locker = getattr(self.repository, "lock_operation_lease", None)
  462. if locker is not None:
  463. return bool(locker(operation))
  464. checker = getattr(self.repository, "owns_operation_lease", None)
  465. return checker is None or bool(checker(operation))
  466. def _renew_fence(self, operation: Mapping[str, Any]) -> None:
  467. renew = getattr(self.repository, "renew_operation_lease", None)
  468. if renew is not None and not renew(operation, self.operation_lease_seconds):
  469. raise ValueError("deployment fencing lease lost")
  470. def _compiled_definition(self, deployment: Mapping[str, Any]):
  471. return self.compiler(
  472. deployment["workflow_spec"],
  473. deployment["schedule_snapshot"],
  474. deployment["environment"],
  475. int(deployment.get("version_no") or 1),
  476. )
  477. def _attest_compiled_flow(
  478. self,
  479. deployment: Mapping[str, Any],
  480. flow: Mapping[str, Any],
  481. compiled,
  482. ) -> str:
  483. labels = self._flow_labels(flow)
  484. expected_labels = {
  485. "dataflow_uid": deployment["dataflow_uid"],
  486. "environment": deployment["environment"],
  487. "workflow_version": str(int(deployment.get("version_no") or 1)),
  488. }
  489. if (
  490. flow.get("id") != compiled.flow_id
  491. or flow.get("namespace") != compiled.namespace
  492. or any(labels.get(key) != value for key, value in expected_labels.items())
  493. ):
  494. raise ValueError("engine flow identity attestation failed")
  495. actual_hash = canonical_flow_definition_hash(flow)
  496. expected = getattr(compiled, "definition", None)
  497. if (
  498. isinstance(expected, Mapping)
  499. and canonical_flow_definition_hash(expected) != actual_hash
  500. ):
  501. raise ValueError("engine definition drift detected")
  502. return actual_hash
  503. def _ensure_definition_attestation(
  504. self, deployment: Mapping[str, Any]
  505. ) -> dict[str, Any]:
  506. flow = self.engine.get_flow(
  507. deployment["engine_namespace"],
  508. deployment["engine_definition_id"],
  509. )
  510. if not isinstance(flow, Mapping):
  511. raise ValueError("engine definition is not observable")
  512. actual_hash = canonical_flow_definition_hash(flow)
  513. if deployment.get("engine_definition_hash_version") == KESTRA_HASH_VERSION:
  514. if actual_hash != deployment.get("engine_definition_hash"):
  515. raise ValueError("engine definition drift detected")
  516. return dict(deployment)
  517. compiled = self._compiled_definition(deployment)
  518. self._attest_compiled_flow(deployment, flow, compiled)
  519. re_attest = getattr(self.repository, "re_attest_engine_definition", None)
  520. if re_attest is None:
  521. raise ValueError("legacy engine definition requires re-attestation")
  522. updated = re_attest(
  523. deployment["id"],
  524. deployment["lock_version"],
  525. definition_hash=actual_hash,
  526. revision=flow.get("revision"),
  527. )
  528. if updated.get("engine_definition_hash_version") != KESTRA_HASH_VERSION:
  529. raise ValueError("engine definition re-attestation failed")
  530. return updated
  531. def _assert_definition(self, deployment: Mapping[str, Any]) -> Mapping[str, Any]:
  532. if deployment.get("engine_definition_hash_version") != KESTRA_HASH_VERSION:
  533. raise ValueError("legacy engine definition requires re-attestation")
  534. flow = self.engine.get_flow(
  535. deployment["engine_namespace"],
  536. deployment["engine_definition_id"],
  537. )
  538. if not isinstance(flow, Mapping):
  539. raise ValueError("engine definition is not observable")
  540. actual_hash = canonical_flow_definition_hash(flow)
  541. if actual_hash != deployment.get("engine_definition_hash"):
  542. raise ValueError("engine definition drift detected")
  543. return flow
  544. def _get_flow_optional(self, namespace: str, definition_id: str):
  545. try:
  546. return self.engine.get_flow(namespace, definition_id)
  547. except (KeyError, LookupError):
  548. return None
  549. except requests.HTTPError as exc:
  550. response = getattr(exc, "response", None)
  551. if response is not None and int(response.status_code) == 404:
  552. return None
  553. raise
  554. def deploy_disabled(
  555. self,
  556. deployment_id: str,
  557. actor_uid: str,
  558. *,
  559. idempotency_key: str,
  560. reason: str = "deploy disabled",
  561. correlation_id: str | None = None,
  562. ) -> dict[str, Any]:
  563. _text(reason, "reason", 1000)
  564. deployment, _actor, operation, replay = self._claim(
  565. deployment_id,
  566. "deploy_disabled",
  567. actor_uid,
  568. idempotency_key,
  569. correlation_id,
  570. reason,
  571. {},
  572. )
  573. if replay is not None:
  574. return replay
  575. try:
  576. if deployment["status"] not in {"draft", "failed"}:
  577. raise ValueError("deployment is not eligible for disabled deploy")
  578. compiled = self.compiler(
  579. deployment["workflow_spec"],
  580. deployment["schedule_snapshot"],
  581. deployment["environment"],
  582. int(deployment.get("version_no") or 1),
  583. )
  584. except Exception:
  585. self._local_operation_failure(operation, "deployment_preflight_failed")
  586. raise
  587. if compiled.definition_hash != _canonical_hash(
  588. # The compiler owns canonical serialized output; this comparison is
  589. # deliberately against its own immutable payload.
  590. compiled.yaml
  591. ):
  592. # Existing compiler hashes bytes, while canonical JSON hashing a
  593. # string includes quotes. Preserve its hash but still validate it.
  594. _digest(compiled.definition_hash, "engine_definition_hash")
  595. try:
  596. self._renew_fence(operation)
  597. response = self.engine.deploy_disabled(compiled.yaml)
  598. flow = self.engine.get_flow(compiled.namespace, compiled.flow_id)
  599. if not isinstance(flow, Mapping) or flow.get("disabled") is not True:
  600. raise RuntimeError("disabled engine definition is not observable")
  601. actual_hash = self._attest_compiled_flow(deployment, flow, compiled)
  602. except Exception as exc:
  603. self._engine_failure(operation, exc)
  604. engine = {
  605. "namespace": compiled.namespace,
  606. "definition_id": compiled.flow_id,
  607. "revision": flow.get("revision"),
  608. "definition_hash": actual_hash,
  609. "definition_hash_version": KESTRA_HASH_VERSION,
  610. "receipt": _safe_receipt(flow) or _safe_receipt(response),
  611. }
  612. try:
  613. result = self.repository.mark_disabled(
  614. deployment["id"],
  615. deployment["lock_version"],
  616. engine,
  617. operation,
  618. )
  619. except Exception:
  620. self._local_operation_failure(operation, "disabled_finalize_failed")
  621. raise
  622. self._commit()
  623. return result
  624. def run_canary(
  625. self,
  626. deployment_id: str,
  627. inputs: dict[str, Any],
  628. actor_uid: str,
  629. *,
  630. idempotency_key: str,
  631. reason: str = "trial production",
  632. correlation_id: str | None = None,
  633. ) -> dict[str, Any]:
  634. _text(reason, "reason", 1000)
  635. if not isinstance(inputs, dict) or len(inputs) > 100:
  636. raise ValueError("canary inputs must be a bounded object")
  637. _closed_tree(inputs)
  638. if len(json.dumps(inputs, ensure_ascii=False).encode("utf-8")) > 65536:
  639. raise ValueError("canary inputs exceed size limit")
  640. deployment, actor, operation, replay = self._claim(
  641. deployment_id,
  642. "run_canary",
  643. actor_uid,
  644. idempotency_key,
  645. correlation_id,
  646. reason,
  647. {"inputs_hash": _canonical_hash(inputs)},
  648. )
  649. if replay is not None:
  650. return replay
  651. try:
  652. if deployment["status"] not in {"disabled", "canary"}:
  653. raise ValueError("disabled deployment is required for canary")
  654. deployment = self._ensure_definition_attestation(deployment)
  655. allowed_inputs = set(
  656. (deployment["workflow_spec"].get("parameters") or {}).keys()
  657. )
  658. unknown = sorted(set(inputs) - allowed_inputs)
  659. if unknown:
  660. raise ValueError(
  661. "canary inputs contain unknown parameters: " + ", ".join(unknown)
  662. )
  663. engine_inputs = copy.deepcopy(inputs)
  664. if self.token_issuer is not None:
  665. task_tokens = {}
  666. for node in deployment["workflow_spec"].get("nodes", []):
  667. task_tokens[node["id"]] = self.token_issuer.issue(
  668. task_uid=new_governance_uid(),
  669. dataflow_uid=deployment["dataflow_uid"],
  670. deployment_id=deployment["id"],
  671. environment=deployment["environment"],
  672. workflow_version=int(deployment.get("version_no") or 1),
  673. correlation_id=operation["correlation_id"],
  674. node=node,
  675. write_authorized=node.get("purpose") == "write",
  676. )
  677. engine_inputs["dataops_task_tokens"] = task_tokens
  678. except Exception:
  679. self._local_operation_failure(operation, "canary_preflight_failed")
  680. raise
  681. try:
  682. self._renew_fence(operation)
  683. self.engine.activate(
  684. deployment["engine_namespace"],
  685. deployment["engine_definition_id"],
  686. )
  687. try:
  688. response = self.engine.execute(
  689. deployment["engine_namespace"],
  690. deployment["engine_definition_id"],
  691. inputs=engine_inputs,
  692. )
  693. finally:
  694. self._renew_fence(operation)
  695. self.engine.deactivate(
  696. deployment["engine_namespace"],
  697. deployment["engine_definition_id"],
  698. )
  699. execution_id = response.get("id") if isinstance(response, Mapping) else None
  700. if not execution_id:
  701. raise RuntimeError("canary execution id is missing")
  702. deadline = self.monotonic() + self.canary_timeout_seconds
  703. execution = self.engine.get_execution(str(execution_id))
  704. while True:
  705. state = (
  706. execution.get("state") if isinstance(execution, Mapping) else None
  707. )
  708. if isinstance(state, Mapping):
  709. state = state.get("current")
  710. normalized_state = str(state or "").upper()
  711. if normalized_state in {
  712. "SUCCESS",
  713. "SUCCEEDED",
  714. "FAILED",
  715. "KILLED",
  716. "CANCELLED",
  717. "WARNING",
  718. }:
  719. break
  720. if self.monotonic() >= deadline:
  721. normalized_state = "UNKNOWN"
  722. break
  723. self.sleeper(self.poll_interval_seconds)
  724. self._renew_fence(operation)
  725. execution = self.engine.get_execution(str(execution_id))
  726. except Exception as exc:
  727. self._engine_failure(operation, exc)
  728. passed = normalized_state in {"SUCCESS", "SUCCEEDED"}
  729. evidence_status = (
  730. "passed"
  731. if passed
  732. else (
  733. "failed"
  734. if normalized_state in {"FAILED", "KILLED", "CANCELLED", "WARNING"}
  735. else "unknown"
  736. )
  737. )
  738. now = self.clock().astimezone(UTC)
  739. evidence = {
  740. "id": new_governance_uid(),
  741. "deployment_id": deployment["id"],
  742. "execution_id": str(execution_id),
  743. "status": evidence_status,
  744. "package_hash": deployment["package_hash"],
  745. "binding_hash": deployment["binding_hash"],
  746. "schema_snapshot_hash": deployment["schema_snapshot_hash"],
  747. "physical_plan_hashes": deployment["physical_plan_hashes"],
  748. "workflow_spec_hash": deployment["workflow_spec_hash"],
  749. "schedule_hash": deployment["schedule_hash"],
  750. "engine_definition_hash": deployment["engine_definition_hash"],
  751. "engine_definition_hash_version": KESTRA_HASH_VERSION,
  752. "verified_by": actor,
  753. "created_at": now.isoformat(),
  754. "expires_at": (
  755. now + timedelta(seconds=self.canary_ttl_seconds)
  756. ).isoformat(),
  757. "engine_response": _safe_receipt(execution),
  758. }
  759. try:
  760. result = self.repository.record_canary(
  761. deployment["id"],
  762. deployment["lock_version"],
  763. evidence,
  764. operation,
  765. )
  766. except Exception:
  767. self._local_operation_failure(operation, "canary_finalize_failed")
  768. raise
  769. self._commit()
  770. return result
  771. def _validate_evidence(
  772. self, deployment: Mapping[str, Any], evidence_id: Any
  773. ) -> dict[str, Any]:
  774. if not evidence_id:
  775. raise ValueError("passed canary evidence is required")
  776. evidence = self.repository.get_canary_evidence(_uid(evidence_id, "evidence_id"))
  777. if evidence.get("status") != "passed" or not evidence.get("verified_by"):
  778. raise ValueError("passed canary evidence is required")
  779. expires_at = _timestamp(evidence.get("expires_at"), "canary expiry")
  780. if expires_at <= self.clock().astimezone(UTC):
  781. raise ValueError("passed canary evidence has expired")
  782. expected = {
  783. "deployment_id": deployment["id"],
  784. "package_hash": deployment["package_hash"],
  785. "binding_hash": deployment["binding_hash"],
  786. "schema_snapshot_hash": deployment["schema_snapshot_hash"],
  787. "physical_plan_hashes": deployment["physical_plan_hashes"],
  788. "workflow_spec_hash": deployment["workflow_spec_hash"],
  789. "schedule_hash": deployment["schedule_hash"],
  790. "engine_definition_hash": deployment["engine_definition_hash"],
  791. "engine_definition_hash_version": KESTRA_HASH_VERSION,
  792. }
  793. if any(evidence.get(key) != value for key, value in expected.items()):
  794. raise ValueError("canary evidence attestation has drifted")
  795. return evidence
  796. def execute_active(
  797. self,
  798. deployment_id: str,
  799. inputs: dict[str, Any],
  800. actor_uid: str,
  801. *,
  802. idempotency_key: str,
  803. reason: str = "manual production execution",
  804. correlation_id: str | None = None,
  805. ) -> dict[str, Any]:
  806. """Launch one active production line with server-issued task tokens."""
  807. _text(reason, "reason", 1000)
  808. if not isinstance(inputs, dict) or len(inputs) > 100:
  809. raise ValueError("production inputs must be a bounded object")
  810. _closed_tree(inputs)
  811. if len(json.dumps(inputs, ensure_ascii=False).encode("utf-8")) > 65536:
  812. raise ValueError("production inputs exceed size limit")
  813. deployment, _actor, operation, replay = self._claim(
  814. deployment_id,
  815. "execute_active",
  816. actor_uid,
  817. idempotency_key,
  818. correlation_id,
  819. reason,
  820. {"inputs_hash": _canonical_hash(inputs)},
  821. )
  822. if replay is not None:
  823. return replay
  824. try:
  825. if deployment["status"] != "active":
  826. raise ValueError("active deployment is required for execution")
  827. deployment = self._ensure_definition_attestation(deployment)
  828. allowed_inputs = set(
  829. (deployment["workflow_spec"].get("parameters") or {}).keys()
  830. )
  831. unknown = sorted(set(inputs) - allowed_inputs)
  832. if unknown:
  833. raise ValueError(
  834. "production inputs contain unknown parameters: "
  835. + ", ".join(unknown)
  836. )
  837. if self.token_issuer is None:
  838. raise RuntimeError("production task token issuer is not configured")
  839. task_tokens = {}
  840. for node in deployment["workflow_spec"].get("nodes", []):
  841. task_tokens[node["id"]] = self.token_issuer.issue(
  842. task_uid=new_governance_uid(),
  843. dataflow_uid=deployment["dataflow_uid"],
  844. deployment_id=deployment["id"],
  845. environment=deployment["environment"],
  846. workflow_version=int(deployment.get("version_no") or 1),
  847. correlation_id=operation["correlation_id"],
  848. node=node,
  849. write_authorized=node.get("purpose") == "write",
  850. )
  851. engine_inputs = {
  852. **copy.deepcopy(inputs),
  853. "dataops_task_tokens": task_tokens,
  854. }
  855. except Exception:
  856. self._local_operation_failure(
  857. operation, "active_execution_preflight_failed"
  858. )
  859. raise
  860. try:
  861. self._renew_fence(operation)
  862. response = self.engine.execute(
  863. deployment["engine_namespace"],
  864. deployment["engine_definition_id"],
  865. inputs=engine_inputs,
  866. )
  867. execution_id = response.get("id") if isinstance(response, Mapping) else None
  868. if not execution_id:
  869. raise RuntimeError("production execution id is missing")
  870. receipt = {
  871. "deployment_id": deployment["id"],
  872. "dataflow_uid": deployment["dataflow_uid"],
  873. "dataflow_version_id": deployment["dataflow_version_id"],
  874. "workflow_version": int(deployment.get("version_no") or 1),
  875. "execution_id": str(execution_id),
  876. "correlation_id": operation["correlation_id"],
  877. "status": "submitted",
  878. "engine_response": _safe_receipt(response),
  879. }
  880. self.repository.record_active_execution_receipt(operation, receipt)
  881. # Recovery boundary: after this commit, reconciliation observes the
  882. # same execution id and must never resubmit the workflow.
  883. self._commit()
  884. except Exception as exc:
  885. self._engine_failure(operation, exc)
  886. try:
  887. deadline = self.monotonic() + self.canary_timeout_seconds
  888. execution = self.engine.get_execution(str(execution_id))
  889. while True:
  890. state = (
  891. execution.get("state") if isinstance(execution, Mapping) else None
  892. )
  893. if isinstance(state, Mapping):
  894. state = state.get("current")
  895. normalized_state = str(state or "").upper()
  896. if normalized_state in {
  897. "SUCCESS",
  898. "SUCCEEDED",
  899. "FAILED",
  900. "KILLED",
  901. "CANCELLED",
  902. "WARNING",
  903. }:
  904. break
  905. if self.monotonic() >= deadline:
  906. normalized_state = "UNKNOWN"
  907. break
  908. self.sleeper(self.poll_interval_seconds)
  909. self._renew_fence(operation)
  910. execution = self.engine.get_execution(str(execution_id))
  911. except Exception as exc:
  912. self._engine_failure(operation, exc)
  913. if normalized_state not in {"SUCCESS", "SUCCEEDED"}:
  914. failure = (
  915. "active_execution_unknown"
  916. if normalized_state == "UNKNOWN"
  917. else "active_execution_failed"
  918. )
  919. if failure == "active_execution_unknown":
  920. self.repository.mark_operation_unknown(operation, failure)
  921. self._commit()
  922. raise OperationUnknown(
  923. "production execution outcome requires reconciliation",
  924. operation=operation,
  925. )
  926. self.repository.fail_operation(operation, failure)
  927. self._commit()
  928. raise RuntimeError("production execution failed")
  929. result = {
  930. "deployment_id": deployment["id"],
  931. "dataflow_uid": deployment["dataflow_uid"],
  932. "dataflow_version_id": deployment["dataflow_version_id"],
  933. "workflow_version": int(deployment.get("version_no") or 1),
  934. "execution_id": str(execution_id),
  935. "correlation_id": operation["correlation_id"],
  936. "status": "success",
  937. "engine_response": _safe_receipt(execution),
  938. }
  939. try:
  940. self.repository.complete_active_execution(operation, result)
  941. except Exception:
  942. self._local_operation_failure(operation, "active_execution_finalize_failed")
  943. raise
  944. self._commit()
  945. return result
  946. def activate(
  947. self,
  948. deployment_id: str,
  949. evidence_id: str | None,
  950. actor_uid: str,
  951. *,
  952. idempotency_key: str,
  953. reason: str = "mass production activation",
  954. correlation_id: str | None = None,
  955. ) -> dict[str, Any]:
  956. _text(reason, "reason", 1000)
  957. deployment, actor, operation, replay = self._claim(
  958. deployment_id,
  959. "activate",
  960. actor_uid,
  961. idempotency_key,
  962. correlation_id,
  963. reason,
  964. {"evidence_id": evidence_id},
  965. )
  966. if replay is not None:
  967. return replay
  968. try:
  969. if deployment["status"] != "canary":
  970. raise ValueError("passed canary deployment is required")
  971. deployment = self._ensure_definition_attestation(deployment)
  972. evidence = self._validate_evidence(deployment, evidence_id)
  973. except Exception:
  974. self._local_operation_failure(operation, "activation_preflight_failed")
  975. raise
  976. try:
  977. self._renew_fence(operation)
  978. self.engine.activate(
  979. deployment["engine_namespace"],
  980. deployment["engine_definition_id"],
  981. )
  982. except Exception as exc:
  983. self._engine_failure(operation, exc)
  984. try:
  985. result = self.repository.activate_atomic(
  986. deployment["id"],
  987. deployment["lock_version"],
  988. evidence["id"],
  989. actor,
  990. operation,
  991. )
  992. self._commit()
  993. return result
  994. except Exception:
  995. if self._owns_fence(operation):
  996. try:
  997. self.engine.deactivate(
  998. deployment["engine_namespace"],
  999. deployment["engine_definition_id"],
  1000. )
  1001. finally:
  1002. self.repository.fail_operation(
  1003. operation, "activation_commit_failure"
  1004. )
  1005. self._commit()
  1006. raise
  1007. def rollback(
  1008. self,
  1009. deployment_id: str,
  1010. actor_uid: str,
  1011. *,
  1012. idempotency_key: str,
  1013. reason: str = "restore previous production line",
  1014. correlation_id: str | None = None,
  1015. ) -> dict[str, Any]:
  1016. _text(reason, "reason", 1000)
  1017. deployment, actor, operation, replay = self._claim(
  1018. deployment_id,
  1019. "rollback",
  1020. actor_uid,
  1021. idempotency_key,
  1022. correlation_id,
  1023. reason,
  1024. {
  1025. "candidate_deployment_id": deployment_id,
  1026. },
  1027. )
  1028. if replay is not None:
  1029. return replay
  1030. try:
  1031. if deployment["status"] not in {"active", "failed"}:
  1032. raise ValueError("active deployment is required for rollback")
  1033. previous_id = deployment.get("previous_active_deployment_id")
  1034. if not previous_id:
  1035. raise ValueError("previous deployment is not available")
  1036. previous = self.repository.get_deployment(previous_id)
  1037. if (
  1038. previous.get("status") != "superseded"
  1039. or previous.get("dataflow_uid") != deployment.get("dataflow_uid")
  1040. or previous.get("environment") != deployment.get("environment")
  1041. ):
  1042. raise ValueError("previous deployment snapshot is not restorable")
  1043. deployment = self._ensure_definition_attestation(deployment)
  1044. previous = self._ensure_definition_attestation(previous)
  1045. except Exception:
  1046. self._local_operation_failure(operation, "rollback_preflight_failed")
  1047. raise
  1048. try:
  1049. # Fail-safe ordering: stop the candidate before restoring the exact
  1050. # prior engine definition and immutable package.
  1051. self._renew_fence(operation)
  1052. self.engine.deactivate(
  1053. deployment["engine_namespace"],
  1054. deployment["engine_definition_id"],
  1055. )
  1056. self._renew_fence(operation)
  1057. self.engine.activate(
  1058. previous["engine_namespace"],
  1059. previous["engine_definition_id"],
  1060. )
  1061. except Exception as exc:
  1062. if self._unknown_outcome(exc):
  1063. self._engine_failure(operation, exc)
  1064. if self._owns_fence(operation):
  1065. try:
  1066. self.engine.activate(
  1067. deployment["engine_namespace"],
  1068. deployment["engine_definition_id"],
  1069. )
  1070. finally:
  1071. self._engine_failure(operation, exc)
  1072. raise
  1073. try:
  1074. result = self.repository.rollback_atomic(
  1075. deployment["id"],
  1076. deployment["lock_version"],
  1077. actor,
  1078. operation,
  1079. )
  1080. self._commit()
  1081. return result
  1082. except Exception:
  1083. # External state is reconciled back to the pre-operation state.
  1084. if self._owns_fence(operation):
  1085. self.engine.deactivate(
  1086. previous["engine_namespace"], previous["engine_definition_id"]
  1087. )
  1088. self.engine.activate(
  1089. deployment["engine_namespace"],
  1090. deployment["engine_definition_id"],
  1091. )
  1092. self.repository.fail_operation(operation, "rollback_commit_failure")
  1093. self._commit()
  1094. raise
  1095. @staticmethod
  1096. def _flow_labels(flow: Mapping[str, Any]) -> dict[str, str]:
  1097. labels = flow.get("labels")
  1098. if isinstance(labels, Mapping):
  1099. return {str(key): str(value) for key, value in labels.items()}
  1100. if isinstance(labels, list):
  1101. return {
  1102. str(item.get("key")): str(item.get("value"))
  1103. for item in labels
  1104. if isinstance(item, Mapping)
  1105. and item.get("key") is not None
  1106. and item.get("value") is not None
  1107. }
  1108. return {}
  1109. def reconcile(
  1110. self,
  1111. deployment_id: str,
  1112. action: str,
  1113. actor_uid: str,
  1114. *,
  1115. idempotency_key: str,
  1116. ) -> dict[str, Any]:
  1117. """Resolve a response-loss outcome without replaying the side effect."""
  1118. deployment = self.repository.get_deployment(
  1119. _uid(deployment_id, "deployment_id")
  1120. )
  1121. actor = _uid(actor_uid, "actor_uid")
  1122. operation = self.repository.get_operation(
  1123. deployment["id"], action, self._operation_key(idempotency_key)
  1124. )
  1125. if operation.get("status") == "completed":
  1126. return operation["result"]
  1127. if operation.get("actor_uid") != actor:
  1128. raise ValueError("operation is bound to another actor")
  1129. if operation.get("status") == "claimed":
  1130. expires_at = operation.get("lease_expires_at")
  1131. if expires_at and _timestamp(expires_at, "operation lease") > self.clock():
  1132. raise OperationInProgress("deployment operation is already in progress")
  1133. if operation.get("status") not in {"claimed", "unknown"}:
  1134. raise TerminalConflict("terminal deployment operation cannot reconcile")
  1135. recover = getattr(self.repository, "recover_operation", None)
  1136. if recover is not None:
  1137. try:
  1138. operation = recover(
  1139. deployment["id"],
  1140. action,
  1141. self._operation_key(idempotency_key),
  1142. actor,
  1143. )
  1144. except ValueError as exc:
  1145. if "already in progress" in str(exc):
  1146. raise OperationInProgress(str(exc)) from exc
  1147. raise
  1148. else:
  1149. self.repository.reclaim_unknown_operation(operation["id"])
  1150. operation = self.repository.get_operation(
  1151. deployment["id"], action, self._operation_key(idempotency_key)
  1152. )
  1153. self._commit()
  1154. if action == "execute_active":
  1155. receipt = operation.get("result")
  1156. if not isinstance(receipt, Mapping) or not receipt.get("execution_id"):
  1157. self.repository.mark_operation_unknown(
  1158. operation, "active_execution_receipt_missing"
  1159. )
  1160. self._commit()
  1161. raise OperationUnknown(
  1162. "production execution receipt is missing; "
  1163. "the execution will not be replayed",
  1164. operation=operation,
  1165. )
  1166. try:
  1167. execution = self.engine.get_execution(
  1168. str(receipt["execution_id"])
  1169. )
  1170. except Exception as exc:
  1171. self.repository.mark_operation_unknown(
  1172. operation, "active_execution_reconcile_unavailable"
  1173. )
  1174. self._commit()
  1175. raise OperationUnknown(
  1176. "production execution state is unavailable",
  1177. operation=operation,
  1178. ) from exc
  1179. state = (
  1180. execution.get("state")
  1181. if isinstance(execution, Mapping)
  1182. else None
  1183. )
  1184. if isinstance(state, Mapping):
  1185. state = state.get("current")
  1186. normalized_state = str(state or "").upper()
  1187. if normalized_state in {"SUCCESS", "SUCCEEDED"}:
  1188. result = {
  1189. **dict(receipt),
  1190. "status": "success",
  1191. "engine_response": _safe_receipt(execution),
  1192. }
  1193. self.repository.complete_active_execution(operation, result)
  1194. self._commit()
  1195. return result
  1196. if normalized_state in {
  1197. "FAILED",
  1198. "KILLED",
  1199. "CANCELLED",
  1200. "WARNING",
  1201. }:
  1202. self.repository.fail_operation(
  1203. operation, "active_execution_failed"
  1204. )
  1205. self._commit()
  1206. raise RuntimeError("production execution failed")
  1207. self.repository.mark_operation_unknown(
  1208. operation, "active_execution_not_terminal"
  1209. )
  1210. self._commit()
  1211. raise OperationUnknown(
  1212. "production execution is not terminal",
  1213. operation=operation,
  1214. )
  1215. if action == "deploy_disabled":
  1216. compiled = self._compiled_definition(deployment)
  1217. flow = self._get_flow_optional(compiled.namespace, compiled.flow_id)
  1218. receipt = {}
  1219. if flow is None:
  1220. self._renew_fence(operation)
  1221. receipt = self.engine.deploy_disabled(compiled.yaml)
  1222. flow = self.engine.get_flow(compiled.namespace, compiled.flow_id)
  1223. if not isinstance(flow, Mapping) or flow.get("disabled") is not True:
  1224. raise ValueError("engine flow does not attest the unknown deployment")
  1225. actual_hash = self._attest_compiled_flow(deployment, flow, compiled)
  1226. result = self.repository.mark_disabled(
  1227. deployment["id"],
  1228. deployment["lock_version"],
  1229. {
  1230. "namespace": compiled.namespace,
  1231. "definition_id": compiled.flow_id,
  1232. "revision": flow.get("revision"),
  1233. "definition_hash": actual_hash,
  1234. "definition_hash_version": KESTRA_HASH_VERSION,
  1235. "receipt": _safe_receipt(flow) or _safe_receipt(receipt),
  1236. },
  1237. operation,
  1238. )
  1239. self._commit()
  1240. return result
  1241. if action == "activate":
  1242. evidence_id = operation.get("request", {}).get("evidence_id")
  1243. deployment = self._ensure_definition_attestation(deployment)
  1244. evidence = self._validate_evidence(deployment, evidence_id)
  1245. flow = self._assert_definition(deployment)
  1246. if flow.get("disabled") is True:
  1247. self._renew_fence(operation)
  1248. self.engine.activate(
  1249. deployment["engine_namespace"],
  1250. deployment["engine_definition_id"],
  1251. )
  1252. flow = self._assert_definition(deployment)
  1253. if flow.get("disabled") is not False:
  1254. raise ValueError("engine activation is not observable")
  1255. result = self.repository.activate_atomic(
  1256. deployment["id"],
  1257. deployment["lock_version"],
  1258. evidence["id"],
  1259. actor,
  1260. operation,
  1261. )
  1262. self._commit()
  1263. return result
  1264. if action == "rollback":
  1265. if deployment.get("status") not in {
  1266. "active",
  1267. "failed",
  1268. } or not deployment.get("previous_active_deployment_id"):
  1269. raise ValueError("rollback deployment state is not recoverable")
  1270. previous = self.repository.get_deployment(
  1271. deployment["previous_active_deployment_id"]
  1272. )
  1273. if (
  1274. previous.get("status") != "superseded"
  1275. or previous.get("dataflow_uid") != deployment.get("dataflow_uid")
  1276. or previous.get("environment") != deployment.get("environment")
  1277. ):
  1278. raise ValueError("previous deployment is not restorable")
  1279. deployment = self._ensure_definition_attestation(deployment)
  1280. previous = self._ensure_definition_attestation(previous)
  1281. candidate_flow = self._assert_definition(deployment)
  1282. previous_flow = self._assert_definition(previous)
  1283. state = (
  1284. candidate_flow.get("disabled"),
  1285. previous_flow.get("disabled"),
  1286. )
  1287. if state == (False, True):
  1288. self._renew_fence(operation)
  1289. self.engine.deactivate(
  1290. deployment["engine_namespace"],
  1291. deployment["engine_definition_id"],
  1292. )
  1293. state = (True, True)
  1294. if state == (True, True):
  1295. self._renew_fence(operation)
  1296. self.engine.activate(
  1297. previous["engine_namespace"],
  1298. previous["engine_definition_id"],
  1299. )
  1300. state = (True, False)
  1301. if state != (True, False):
  1302. raise ValueError("engine rollback state is not safely recoverable")
  1303. candidate_flow = self._assert_definition(deployment)
  1304. previous_flow = self._assert_definition(previous)
  1305. if (
  1306. candidate_flow.get("disabled") is not True
  1307. or previous_flow.get("disabled") is not False
  1308. ):
  1309. raise ValueError("engine rollback state is not observable")
  1310. result = self.repository.rollback_atomic(
  1311. deployment["id"],
  1312. deployment["lock_version"],
  1313. actor,
  1314. operation,
  1315. )
  1316. self._commit()
  1317. return result
  1318. raise ValueError(
  1319. "unknown operation requires execution-level manual reconciliation"
  1320. )
  1321. def list(self, *, environment: str | None = None):
  1322. if environment is not None and environment not in ENVIRONMENTS:
  1323. raise ValueError("deployment environment is invalid")
  1324. return self.repository.list_deployments(environment=environment)
  1325. def candidates(self, *, environment: str):
  1326. if environment not in ENVIRONMENTS:
  1327. raise ValueError("deployment environment is invalid")
  1328. return self.repository.list_deployable_releases(environment=environment)