test_v55_real_cutover_rollback_l3.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. """Opt-in V55 PostgreSQL + n8n + Kestra cutover and rollback drill."""
  2. import os
  3. import uuid
  4. import pytest
  5. import requests
  6. from sqlalchemy import create_engine
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_factory.n8n_client import N8nClient
  9. from app.core.orchestration.engines.kestra import KestraAdapter
  10. from app.core.orchestration.engines.n8n import N8nAdapter
  11. from app.core.orchestration.migration.cutover import (
  12. CutoverGates,
  13. EngineRoleController,
  14. WorkflowCutoverService,
  15. )
  16. from app.core.orchestration.migration.dual_run import DualRunMode
  17. from app.core.orchestration.migration.repository import PostgresMigrationStore
  18. pytestmark = pytest.mark.skipif(
  19. os.getenv("RUN_V55_CUTOVER_L3") != "1",
  20. reason="set RUN_V55_CUTOVER_L3=1 for the local cutover drill",
  21. )
  22. DATABASE_URL = os.getenv("DATAOPS_MCP_TEST_DATABASE_URL", "")
  23. KESTRA_URL = os.getenv(
  24. "KESTRA_BASE_URL", "http://127.0.0.1:18080/api/v1"
  25. )
  26. KESTRA_AUTH = (
  27. os.getenv("KESTRA_USERNAME", ""),
  28. os.getenv("KESTRA_PASSWORD", ""),
  29. )
  30. KESTRA_NAMESPACE = "dataops.v55"
  31. def passing_gates():
  32. return CutoverGates(
  33. inventory_complete=True,
  34. workflow_spec_valid=True,
  35. rollback_target_present=True,
  36. shadow_runs=3,
  37. required_shadow_runs=3,
  38. failure_recovery_observed=True,
  39. reconciliation_passed=True,
  40. connection_budget_passed=True,
  41. sla_passed=True,
  42. n8n_baseline_unchanged=True,
  43. )
  44. def test_real_n8n_and_kestra_cutover_failure_restore_and_explicit_rollback():
  45. engine = create_engine(DATABASE_URL, pool_pre_ping=True)
  46. store = PostgresMigrationStore(engine)
  47. n8n_client = N8nClient(
  48. api_url="http://127.0.0.1:15678",
  49. api_key=os.environ["N8N_API_KEY"],
  50. timeout=20,
  51. )
  52. n8n = N8nAdapter(n8n_client)
  53. kestra = KestraAdapter(
  54. base_url=KESTRA_URL,
  55. username=KESTRA_AUTH[0],
  56. password=KESTRA_AUTH[1],
  57. )
  58. controller = EngineRoleController(
  59. n8n=n8n,
  60. kestra=kestra,
  61. kestra_namespace=KESTRA_NAMESPACE,
  62. )
  63. service = WorkflowCutoverService(store=store, engines=controller)
  64. suffix = uuid.uuid4().hex[:10]
  65. flow_id = f"v55_cutover_{suffix}"
  66. flow_url = f"{KESTRA_URL}/main/flows/{KESTRA_NAMESPACE}/{flow_id}"
  67. workflow_id = None
  68. state_ids = []
  69. n8n_definition = {
  70. "name": f"V55 rollback drill {suffix}",
  71. "nodes": [
  72. {
  73. "parameters": {
  74. "rule": {
  75. "interval": [
  76. {"field": "hours", "hoursInterval": 24}
  77. ]
  78. }
  79. },
  80. "id": f"schedule-{suffix}",
  81. "name": "Schedule Trigger",
  82. "type": "n8n-nodes-base.scheduleTrigger",
  83. "typeVersion": 1.2,
  84. "position": [0, 0],
  85. }
  86. ],
  87. "connections": {},
  88. "settings": {"executionOrder": "v1"},
  89. }
  90. kestra_definition = f"""
  91. id: {flow_id}
  92. namespace: {KESTRA_NAMESPACE}
  93. description: V55 controlled cutover and rollback drill
  94. disabled: true
  95. tasks:
  96. - id: evidence
  97. type: io.kestra.plugin.core.log.Log
  98. message: V55 cutover evidence
  99. """.strip()
  100. try:
  101. workflow = n8n.deploy_disabled(n8n_definition)
  102. workflow_id = str(workflow["id"])
  103. n8n.activate("dataops", workflow_id)
  104. kestra.deploy_disabled(kestra_definition)
  105. failure_state = store.create_state(
  106. {
  107. "dataflow_uid": new_governance_uid(),
  108. "environment": "test",
  109. "mode": DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value,
  110. "n8n_definition_id": workflow_id,
  111. "kestra_definition_id": f"missing_{suffix}",
  112. "migration_batch": "read_only_low_frequency",
  113. }
  114. )
  115. state_ids.append(failure_state["state_id"])
  116. failed_operation = service.request_cutover(
  117. dataflow_uid=failure_state["dataflow_uid"],
  118. environment="test",
  119. target_mode=DualRunMode.KESTRA_PRIMARY_N8N_STANDBY,
  120. gates=passing_gates(),
  121. idempotency_key=f"v55-failure-{suffix}",
  122. correlation_id=new_governance_uid(),
  123. )
  124. failure_result = service.apply_requested_cutover(failed_operation)
  125. assert failure_result["status"] == "rolled_back"
  126. assert n8n_client.get_workflow(workflow_id)["active"] is True
  127. store.delete_test_state(failure_state["state_id"])
  128. state_ids.remove(failure_state["state_id"])
  129. success_state = store.create_state(
  130. {
  131. "dataflow_uid": new_governance_uid(),
  132. "environment": "test",
  133. "mode": DualRunMode.N8N_PRIMARY_KESTRA_SHADOW.value,
  134. "n8n_definition_id": workflow_id,
  135. "kestra_definition_id": flow_id,
  136. "migration_batch": "read_only_low_frequency",
  137. }
  138. )
  139. state_ids.append(success_state["state_id"])
  140. operation = service.request_cutover(
  141. dataflow_uid=success_state["dataflow_uid"],
  142. environment="test",
  143. target_mode=DualRunMode.KESTRA_PRIMARY_N8N_STANDBY,
  144. gates=passing_gates(),
  145. idempotency_key=f"v55-success-{suffix}",
  146. correlation_id=new_governance_uid(),
  147. )
  148. cutover_result = service.apply_requested_cutover(operation)
  149. assert cutover_result["status"] == "succeeded"
  150. assert n8n_client.get_workflow(workflow_id)["active"] is False
  151. response = requests.get(
  152. flow_url, auth=KESTRA_AUTH, timeout=20
  153. )
  154. response.raise_for_status()
  155. assert response.json()["disabled"] is False
  156. rollback_result = service.rollback_to_n8n(
  157. dataflow_uid=success_state["dataflow_uid"],
  158. environment="test",
  159. idempotency_key=f"v55-rollback-{suffix}",
  160. correlation_id=new_governance_uid(),
  161. )
  162. assert rollback_result["status"] == "succeeded"
  163. assert n8n_client.get_workflow(workflow_id)["active"] is True
  164. response = requests.get(
  165. flow_url, auth=KESTRA_AUTH, timeout=20
  166. )
  167. response.raise_for_status()
  168. assert response.json()["disabled"] is True
  169. finally:
  170. for state_id in state_ids:
  171. store.delete_test_state(state_id)
  172. if workflow_id is not None:
  173. try:
  174. n8n.deactivate("dataops", workflow_id)
  175. finally:
  176. n8n_client.delete_workflow(workflow_id)
  177. requests.delete(flow_url, auth=KESTRA_AUTH, timeout=20)
  178. engine.dispose()