|
@@ -1,5 +1,7 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
+import hashlib
|
|
|
|
|
+import json
|
|
|
from copy import deepcopy
|
|
from copy import deepcopy
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
|
@@ -7,6 +9,10 @@ import pytest
|
|
|
import requests
|
|
import requests
|
|
|
|
|
|
|
|
from app.core.common.identifiers import new_governance_uid
|
|
from app.core.common.identifiers import new_governance_uid
|
|
|
|
|
+from app.core.data_rules.deployment import OperationUnknown
|
|
|
|
|
+from app.core.orchestration.engines.kestra import (
|
|
|
|
|
+ canonical_flow_definition_hash,
|
|
|
|
|
+)
|
|
|
|
|
|
|
|
ACTOR = new_governance_uid()
|
|
ACTOR = new_governance_uid()
|
|
|
FLOW_UID = new_governance_uid()
|
|
FLOW_UID = new_governance_uid()
|
|
@@ -46,6 +52,7 @@ def _release():
|
|
|
"status": "released",
|
|
"status": "released",
|
|
|
"package": package,
|
|
"package": package,
|
|
|
"package_hash": package["package_hash"],
|
|
"package_hash": package["package_hash"],
|
|
|
|
|
+ "physical_workflow_spec": deepcopy(package["workflow_spec"]),
|
|
|
"schema_snapshots": {
|
|
"schema_snapshots": {
|
|
|
"input": {"id": new_governance_uid(), "schema_hash": "c" * 64},
|
|
"input": {"id": new_governance_uid(), "schema_hash": "c" * 64},
|
|
|
"output": {"id": new_governance_uid(), "schema_hash": "d" * 64},
|
|
"output": {"id": new_governance_uid(), "schema_hash": "d" * 64},
|
|
@@ -100,18 +107,21 @@ class FakeRepository:
|
|
|
for side in ("input", "output"):
|
|
for side in ("input", "output"):
|
|
|
binding[side]["binding_id"] = new_governance_uid()
|
|
binding[side]["binding_id"] = new_governance_uid()
|
|
|
binding[side]["logical_ref"] = side
|
|
binding[side]["logical_ref"] = side
|
|
|
- binding[side]["schema_snapshot_id"] = self.release[
|
|
|
|
|
- "schema_snapshots"
|
|
|
|
|
- ][side]["id"]
|
|
|
|
|
- binding[side]["schema_hash"] = self.release["schema_snapshots"][
|
|
|
|
|
|
|
+ binding[side]["schema_snapshot_id"] = self.release["schema_snapshots"][
|
|
|
side
|
|
side
|
|
|
- ]["schema_hash"]
|
|
|
|
|
|
|
+ ]["id"]
|
|
|
|
|
+ binding[side]["schema_hash"] = self.release["schema_snapshots"][side][
|
|
|
|
|
+ "schema_hash"
|
|
|
|
|
+ ]
|
|
|
self.release["binding_snapshot"] = binding
|
|
self.release["binding_snapshot"] = binding
|
|
|
self.deployments = {}
|
|
self.deployments = {}
|
|
|
self.by_key = {}
|
|
self.by_key = {}
|
|
|
self.evidence = {}
|
|
self.evidence = {}
|
|
|
self.operations = {}
|
|
self.operations = {}
|
|
|
self.transitions = []
|
|
self.transitions = []
|
|
|
|
|
+ self.lease = None
|
|
|
|
|
+ self.fencing_epoch = 0
|
|
|
|
|
+ self.steal_during = None
|
|
|
|
|
|
|
|
def load_deployable_release(self, version_id, environment=None):
|
|
def load_deployable_release(self, version_id, environment=None):
|
|
|
assert version_id == VERSION_ID
|
|
assert version_id == VERSION_ID
|
|
@@ -119,7 +129,11 @@ class FakeRepository:
|
|
|
return deepcopy(self.release)
|
|
return deepcopy(self.release)
|
|
|
|
|
|
|
|
def create_deployment(self, snapshot):
|
|
def create_deployment(self, snapshot):
|
|
|
- key = (snapshot["actor_uid"], snapshot["environment"], snapshot["idempotency_key"])
|
|
|
|
|
|
|
+ key = (
|
|
|
|
|
+ snapshot["actor_uid"],
|
|
|
|
|
+ snapshot["environment"],
|
|
|
|
|
+ snapshot["idempotency_key"],
|
|
|
|
|
+ )
|
|
|
if key in self.by_key:
|
|
if key in self.by_key:
|
|
|
return deepcopy(self.deployments[self.by_key[key]])
|
|
return deepcopy(self.deployments[self.by_key[key]])
|
|
|
deployment = {
|
|
deployment = {
|
|
@@ -159,36 +173,80 @@ class FakeRepository:
|
|
|
"deployment idempotency key is bound to another actor or request"
|
|
"deployment idempotency key is bound to another actor or request"
|
|
|
)
|
|
)
|
|
|
return False, deepcopy(existing)
|
|
return False, deepcopy(existing)
|
|
|
|
|
+ deployment = self.deployments[deployment_id]
|
|
|
|
|
+ unknowns = sorted(
|
|
|
|
|
+ (
|
|
|
|
|
+ value
|
|
|
|
|
+ for value in self.operations.values()
|
|
|
|
|
+ if value["status"] == "unknown"
|
|
|
|
|
+ and self.deployments[value["deployment_id"]]["dataflow_uid"]
|
|
|
|
|
+ == deployment["dataflow_uid"]
|
|
|
|
|
+ and self.deployments[value["deployment_id"]]["environment"]
|
|
|
|
|
+ == deployment["environment"]
|
|
|
|
|
+ ),
|
|
|
|
|
+ key=lambda value: (
|
|
|
|
|
+ int(value.get("fencing_epoch") or 0),
|
|
|
|
|
+ value["id"],
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ if unknowns:
|
|
|
|
|
+ blocker = deepcopy(unknowns[0])
|
|
|
|
|
+ blocker["scope_unknown_count"] = len(unknowns)
|
|
|
|
|
+ return False, blocker
|
|
|
|
|
+ if self.lease is not None:
|
|
|
|
|
+ return False, deepcopy(self.operations[self.lease["operation_key"]])
|
|
|
|
|
+ self.fencing_epoch += 1
|
|
|
operation = {
|
|
operation = {
|
|
|
"id": new_governance_uid(),
|
|
"id": new_governance_uid(),
|
|
|
"deployment_id": deployment_id,
|
|
"deployment_id": deployment_id,
|
|
|
"action": action,
|
|
"action": action,
|
|
|
|
|
+ "idempotency_key": key,
|
|
|
"status": "claimed",
|
|
"status": "claimed",
|
|
|
"actor_uid": actor_uid,
|
|
"actor_uid": actor_uid,
|
|
|
"correlation_id": correlation_id,
|
|
"correlation_id": correlation_id,
|
|
|
"reason": reason,
|
|
"reason": reason,
|
|
|
"request": deepcopy(request_payload),
|
|
"request": deepcopy(request_payload),
|
|
|
|
|
+ "owner_token": new_governance_uid(),
|
|
|
|
|
+ "attempt_epoch": 1,
|
|
|
|
|
+ "fencing_epoch": self.fencing_epoch,
|
|
|
|
|
+ "lease_expires_at": (datetime.now(UTC) + timedelta(minutes=5)).isoformat(),
|
|
|
}
|
|
}
|
|
|
self.operations[op_key] = operation
|
|
self.operations[op_key] = operation
|
|
|
|
|
+ self.lease = {
|
|
|
|
|
+ "operation_key": op_key,
|
|
|
|
|
+ "owner_token": operation["owner_token"],
|
|
|
|
|
+ "fencing_epoch": operation["fencing_epoch"],
|
|
|
|
|
+ }
|
|
|
return True, deepcopy(operation)
|
|
return True, deepcopy(operation)
|
|
|
|
|
|
|
|
- def fail_operation(self, operation_id, error_code):
|
|
|
|
|
|
|
+ def fail_operation(self, operation, error_code):
|
|
|
|
|
+ operation_id = operation["id"] if isinstance(operation, dict) else operation
|
|
|
for value in self.operations.values():
|
|
for value in self.operations.values():
|
|
|
if value["id"] == operation_id:
|
|
if value["id"] == operation_id:
|
|
|
value.update(status="failed", error_code=error_code)
|
|
value.update(status="failed", error_code=error_code)
|
|
|
-
|
|
|
|
|
- def mark_operation_unknown(self, operation_id, error_code):
|
|
|
|
|
|
|
+ if self.lease is not None and self.lease["operation_key"] == (
|
|
|
|
|
+ value["deployment_id"],
|
|
|
|
|
+ value["action"],
|
|
|
|
|
+ next(
|
|
|
|
|
+ key[2]
|
|
|
|
|
+ for key, operation in self.operations.items()
|
|
|
|
|
+ if operation["id"] == operation_id
|
|
|
|
|
+ ),
|
|
|
|
|
+ ):
|
|
|
|
|
+ self.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ def mark_operation_unknown(self, operation, error_code):
|
|
|
|
|
+ operation_id = operation["id"] if isinstance(operation, dict) else operation
|
|
|
for value in self.operations.values():
|
|
for value in self.operations.values():
|
|
|
if value["id"] == operation_id:
|
|
if value["id"] == operation_id:
|
|
|
value.update(status="unknown", error_code=error_code)
|
|
value.update(status="unknown", error_code=error_code)
|
|
|
|
|
+ self.lease = None
|
|
|
|
|
|
|
|
def commit(self):
|
|
def commit(self):
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
def get_operation(self, deployment_id, action, idempotency_key):
|
|
def get_operation(self, deployment_id, action, idempotency_key):
|
|
|
- return deepcopy(
|
|
|
|
|
- self.operations[(deployment_id, action, idempotency_key)]
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ return deepcopy(self.operations[(deployment_id, action, idempotency_key)])
|
|
|
|
|
|
|
|
def reclaim_unknown_operation(self, operation_id):
|
|
def reclaim_unknown_operation(self, operation_id):
|
|
|
for value in self.operations.values():
|
|
for value in self.operations.values():
|
|
@@ -197,10 +255,82 @@ class FakeRepository:
|
|
|
raise ValueError("unknown operation changed concurrently")
|
|
raise ValueError("unknown operation changed concurrently")
|
|
|
value["status"] = "claimed"
|
|
value["status"] = "claimed"
|
|
|
value["error_code"] = None
|
|
value["error_code"] = None
|
|
|
|
|
+ self.fencing_epoch += 1
|
|
|
|
|
+ value["attempt_epoch"] += 1
|
|
|
|
|
+ value["owner_token"] = new_governance_uid()
|
|
|
|
|
+ value["fencing_epoch"] = self.fencing_epoch
|
|
|
|
|
+ key = next(
|
|
|
|
|
+ key
|
|
|
|
|
+ for key, operation in self.operations.items()
|
|
|
|
|
+ if operation["id"] == operation_id
|
|
|
|
|
+ )
|
|
|
|
|
+ self.lease = {
|
|
|
|
|
+ "operation_key": key,
|
|
|
|
|
+ "owner_token": value["owner_token"],
|
|
|
|
|
+ "fencing_epoch": value["fencing_epoch"],
|
|
|
|
|
+ }
|
|
|
return
|
|
return
|
|
|
raise ValueError("operation missing")
|
|
raise ValueError("operation missing")
|
|
|
|
|
|
|
|
- def mark_disabled(self, deployment_id, expected_version, engine, operation_id):
|
|
|
|
|
|
|
+ def recover_operation(self, deployment_id, action, idempotency_key, actor_uid):
|
|
|
|
|
+ operation = self.operations[(deployment_id, action, idempotency_key)]
|
|
|
|
|
+ if operation["actor_uid"] != actor_uid:
|
|
|
|
|
+ raise ValueError("operation is bound to another actor")
|
|
|
|
|
+ if operation["status"] not in {"claimed", "unknown"}:
|
|
|
|
|
+ return deepcopy(operation)
|
|
|
|
|
+ operation["status"] = "claimed"
|
|
|
|
|
+ operation["error_code"] = None
|
|
|
|
|
+ operation["attempt_epoch"] += 1
|
|
|
|
|
+ self.fencing_epoch += 1
|
|
|
|
|
+ operation["fencing_epoch"] = self.fencing_epoch
|
|
|
|
|
+ operation["owner_token"] = new_governance_uid()
|
|
|
|
|
+ self.lease = {
|
|
|
|
|
+ "operation_key": (deployment_id, action, idempotency_key),
|
|
|
|
|
+ "owner_token": operation["owner_token"],
|
|
|
|
|
+ "fencing_epoch": operation["fencing_epoch"],
|
|
|
|
|
+ }
|
|
|
|
|
+ return deepcopy(operation)
|
|
|
|
|
+
|
|
|
|
|
+ def owns_operation_lease(self, operation):
|
|
|
|
|
+ return (
|
|
|
|
|
+ self.lease is not None
|
|
|
|
|
+ and self.lease["owner_token"] == operation.get("owner_token")
|
|
|
|
|
+ and self.lease["fencing_epoch"] == operation.get("fencing_epoch")
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def renew_operation_lease(self, operation, lease_seconds):
|
|
|
|
|
+ if not self.owns_operation_lease(operation):
|
|
|
|
|
+ return False
|
|
|
|
|
+ operation["lease_expires_at"] = (
|
|
|
|
|
+ datetime.now(UTC) + timedelta(seconds=lease_seconds)
|
|
|
|
|
+ ).isoformat()
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ def _steal_lease(self, deployment_id, action):
|
|
|
|
|
+ self.fencing_epoch += 1
|
|
|
|
|
+ key = (deployment_id, action, f"concurrent-{self.fencing_epoch}")
|
|
|
|
|
+ self.operations[key] = {
|
|
|
|
|
+ "id": new_governance_uid(),
|
|
|
|
|
+ "deployment_id": deployment_id,
|
|
|
|
|
+ "action": action,
|
|
|
|
|
+ "status": "claimed",
|
|
|
|
|
+ "actor_uid": ACTOR,
|
|
|
|
|
+ "correlation_id": new_governance_uid(),
|
|
|
|
|
+ "reason": "concurrent operator",
|
|
|
|
|
+ "request": {},
|
|
|
|
|
+ "owner_token": new_governance_uid(),
|
|
|
|
|
+ "attempt_epoch": 1,
|
|
|
|
|
+ "fencing_epoch": self.fencing_epoch,
|
|
|
|
|
+ }
|
|
|
|
|
+ self.lease = {
|
|
|
|
|
+ "operation_key": key,
|
|
|
|
|
+ "owner_token": self.operations[key]["owner_token"],
|
|
|
|
|
+ "fencing_epoch": self.fencing_epoch,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ def mark_disabled(self, deployment_id, expected_version, engine, operation):
|
|
|
|
|
+ if not self.owns_operation_lease(operation):
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
current = self.deployments[deployment_id]
|
|
current = self.deployments[deployment_id]
|
|
|
if current["lock_version"] != expected_version:
|
|
if current["lock_version"] != expected_version:
|
|
|
raise ValueError("deployment state changed concurrently")
|
|
raise ValueError("deployment state changed concurrently")
|
|
@@ -211,24 +341,58 @@ class FakeRepository:
|
|
|
engine_definition_id=engine["definition_id"],
|
|
engine_definition_id=engine["definition_id"],
|
|
|
engine_revision=engine["revision"],
|
|
engine_revision=engine["revision"],
|
|
|
engine_definition_hash=engine["definition_hash"],
|
|
engine_definition_hash=engine["definition_hash"],
|
|
|
|
|
+ engine_definition_hash_version=engine["definition_hash_version"],
|
|
|
deploy_receipt=engine["receipt"],
|
|
deploy_receipt=engine["receipt"],
|
|
|
)
|
|
)
|
|
|
- self._complete(operation_id, current)
|
|
|
|
|
|
|
+ self._complete(operation["id"], current)
|
|
|
|
|
+ return deepcopy(current)
|
|
|
|
|
+
|
|
|
|
|
+ def re_attest_engine_definition(
|
|
|
|
|
+ self,
|
|
|
|
|
+ deployment_id,
|
|
|
|
|
+ expected_version,
|
|
|
|
|
+ *,
|
|
|
|
|
+ definition_hash,
|
|
|
|
|
+ revision,
|
|
|
|
|
+ ):
|
|
|
|
|
+ current = self.deployments[deployment_id]
|
|
|
|
|
+ if current["lock_version"] != expected_version:
|
|
|
|
|
+ raise ValueError("deployment state changed concurrently")
|
|
|
|
|
+ old_evidence = current.get("canary_evidence_id")
|
|
|
|
|
+ if old_evidence and old_evidence in self.evidence:
|
|
|
|
|
+ self.evidence[old_evidence]["status"] = "failed"
|
|
|
|
|
+ current.update(
|
|
|
|
|
+ engine_definition_hash=definition_hash,
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision=str(revision) if revision is not None else None,
|
|
|
|
|
+ canary_evidence_id=None,
|
|
|
|
|
+ )
|
|
|
return deepcopy(current)
|
|
return deepcopy(current)
|
|
|
|
|
|
|
|
- def record_canary(self, deployment_id, expected_version, evidence, operation_id):
|
|
|
|
|
|
|
+ def record_canary(self, deployment_id, expected_version, evidence, operation):
|
|
|
|
|
+ if not self.owns_operation_lease(operation):
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
current = self.deployments[deployment_id]
|
|
current = self.deployments[deployment_id]
|
|
|
if current["lock_version"] != expected_version:
|
|
if current["lock_version"] != expected_version:
|
|
|
raise ValueError("deployment state changed concurrently")
|
|
raise ValueError("deployment state changed concurrently")
|
|
|
- current.update(status="canary", lock_version=expected_version + 1)
|
|
|
|
|
|
|
+ current.update(
|
|
|
|
|
+ status="canary",
|
|
|
|
|
+ lock_version=expected_version + 1,
|
|
|
|
|
+ canary_evidence_id=evidence["id"],
|
|
|
|
|
+ )
|
|
|
self.evidence[evidence["id"]] = deepcopy(evidence)
|
|
self.evidence[evidence["id"]] = deepcopy(evidence)
|
|
|
- self._complete(operation_id, evidence)
|
|
|
|
|
|
|
+ self._complete(operation["id"], evidence)
|
|
|
return deepcopy(evidence)
|
|
return deepcopy(evidence)
|
|
|
|
|
|
|
|
def get_canary_evidence(self, evidence_id):
|
|
def get_canary_evidence(self, evidence_id):
|
|
|
return deepcopy(self.evidence[evidence_id])
|
|
return deepcopy(self.evidence[evidence_id])
|
|
|
|
|
|
|
|
- def activate_atomic(self, deployment_id, expected_version, evidence_id, actor_uid, operation_id):
|
|
|
|
|
|
|
+ def activate_atomic(
|
|
|
|
|
+ self, deployment_id, expected_version, evidence_id, actor_uid, operation
|
|
|
|
|
+ ):
|
|
|
|
|
+ if self.steal_during == "activate":
|
|
|
|
|
+ self._steal_lease(deployment_id, "activate")
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
current = self.deployments[deployment_id]
|
|
current = self.deployments[deployment_id]
|
|
|
if current["lock_version"] != expected_version:
|
|
if current["lock_version"] != expected_version:
|
|
|
raise ValueError("deployment state changed concurrently")
|
|
raise ValueError("deployment state changed concurrently")
|
|
@@ -244,7 +408,9 @@ class FakeRepository:
|
|
|
None,
|
|
None,
|
|
|
)
|
|
)
|
|
|
if previous:
|
|
if previous:
|
|
|
- previous.update(status="superseded", lock_version=previous["lock_version"] + 1)
|
|
|
|
|
|
|
+ previous.update(
|
|
|
|
|
+ status="superseded", lock_version=previous["lock_version"] + 1
|
|
|
|
|
+ )
|
|
|
current["previous_active_deployment_id"] = previous["id"]
|
|
current["previous_active_deployment_id"] = previous["id"]
|
|
|
current.update(
|
|
current.update(
|
|
|
status="active",
|
|
status="active",
|
|
@@ -252,19 +418,39 @@ class FakeRepository:
|
|
|
activated_by=actor_uid,
|
|
activated_by=actor_uid,
|
|
|
canary_evidence_id=evidence_id,
|
|
canary_evidence_id=evidence_id,
|
|
|
)
|
|
)
|
|
|
- self._complete(operation_id, current)
|
|
|
|
|
|
|
+ if not self.owns_operation_lease(operation):
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
|
|
+ self._complete(operation["id"], current)
|
|
|
return deepcopy(current)
|
|
return deepcopy(current)
|
|
|
|
|
|
|
|
- def rollback_atomic(self, deployment_id, expected_version, actor_uid, operation_id):
|
|
|
|
|
|
|
+ def rollback_atomic(self, deployment_id, expected_version, actor_uid, operation):
|
|
|
|
|
+ if self.steal_during == "rollback":
|
|
|
|
|
+ self._steal_lease(deployment_id, "rollback")
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
current = self.deployments[deployment_id]
|
|
current = self.deployments[deployment_id]
|
|
|
if current["lock_version"] != expected_version:
|
|
if current["lock_version"] != expected_version:
|
|
|
raise ValueError("deployment state changed concurrently")
|
|
raise ValueError("deployment state changed concurrently")
|
|
|
previous = self.deployments[current["previous_active_deployment_id"]]
|
|
previous = self.deployments[current["previous_active_deployment_id"]]
|
|
|
current.update(status="rolled_back", lock_version=expected_version + 1)
|
|
current.update(status="rolled_back", lock_version=expected_version + 1)
|
|
|
previous.update(status="active", lock_version=previous["lock_version"] + 1)
|
|
previous.update(status="active", lock_version=previous["lock_version"] + 1)
|
|
|
- self._complete(operation_id, previous)
|
|
|
|
|
|
|
+ if not self.owns_operation_lease(operation):
|
|
|
|
|
+ raise ValueError("deployment fencing lease lost")
|
|
|
|
|
+ self._complete(operation["id"], previous)
|
|
|
return {"rolled_back": deepcopy(current), "active": deepcopy(previous)}
|
|
return {"rolled_back": deepcopy(current), "active": deepcopy(previous)}
|
|
|
|
|
|
|
|
|
|
+ def complete_active_execution(self, operation, result):
|
|
|
|
|
+ assert operation["action"] == "execute_active"
|
|
|
|
|
+ self._complete(operation["id"], result)
|
|
|
|
|
+
|
|
|
|
|
+ def record_active_execution_receipt(self, operation, receipt):
|
|
|
|
|
+ assert operation["action"] == "execute_active"
|
|
|
|
|
+ assert self.owns_operation_lease(operation)
|
|
|
|
|
+ for value in self.operations.values():
|
|
|
|
|
+ if value["id"] == operation["id"]:
|
|
|
|
|
+ value["result"] = deepcopy(receipt)
|
|
|
|
|
+ return
|
|
|
|
|
+ raise AssertionError("operation was not found")
|
|
|
|
|
+
|
|
|
def list_deployments(self, environment=None):
|
|
def list_deployments(self, environment=None):
|
|
|
values = self.deployments.values()
|
|
values = self.deployments.values()
|
|
|
return [
|
|
return [
|
|
@@ -277,17 +463,32 @@ class FakeRepository:
|
|
|
for value in self.operations.values():
|
|
for value in self.operations.values():
|
|
|
if value["id"] == operation_id:
|
|
if value["id"] == operation_id:
|
|
|
value.update(status="completed", result=deepcopy(result))
|
|
value.update(status="completed", result=deepcopy(result))
|
|
|
|
|
+ if self.lease is not None:
|
|
|
|
|
+ leased = self.operations[self.lease["operation_key"]]
|
|
|
|
|
+ if leased["id"] == operation_id:
|
|
|
|
|
+ self.lease = None
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeCompiler:
|
|
class FakeCompiler:
|
|
|
def __call__(self, workflow_spec, schedule_plan, environment, version_no):
|
|
def __call__(self, workflow_spec, schedule_plan, environment, version_no):
|
|
|
- del workflow_spec, schedule_plan, version_no
|
|
|
|
|
|
|
+ del workflow_spec, schedule_plan
|
|
|
|
|
|
|
|
class Compiled:
|
|
class Compiled:
|
|
|
namespace = f"dataops.{environment}"
|
|
namespace = f"dataops.{environment}"
|
|
|
- flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
|
|
|
|
|
|
|
+ flow_id = f"df_{FLOW_UID.replace('-', '')}_v{version_no}"
|
|
|
definition_hash = "9" * 64
|
|
definition_hash = "9" * 64
|
|
|
yaml = "id: flow\ndisabled: true\n"
|
|
yaml = "id: flow\ndisabled: true\n"
|
|
|
|
|
+ definition = {
|
|
|
|
|
+ "id": flow_id,
|
|
|
|
|
+ "namespace": namespace,
|
|
|
|
|
+ "disabled": True,
|
|
|
|
|
+ "labels": [
|
|
|
|
|
+ {"key": "dataflow_uid", "value": FLOW_UID},
|
|
|
|
|
+ {"key": "environment", "value": environment},
|
|
|
|
|
+ {"key": "workflow_version", "value": str(version_no)},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tasks": [{"id": "dataops_dag", "type": "test.Dag"}],
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
return Compiled()
|
|
return Compiled()
|
|
|
|
|
|
|
@@ -297,6 +498,7 @@ class FakeEngine:
|
|
|
self.calls = []
|
|
self.calls = []
|
|
|
self.fail = None
|
|
self.fail = None
|
|
|
self.flows = {}
|
|
self.flows = {}
|
|
|
|
|
+ self.hard_exit_on_get_execution = False
|
|
|
|
|
|
|
|
def deploy_disabled(self, definition):
|
|
def deploy_disabled(self, definition):
|
|
|
self.calls.append(("deploy_disabled", definition))
|
|
self.calls.append(("deploy_disabled", definition))
|
|
@@ -314,6 +516,7 @@ class FakeEngine:
|
|
|
{"key": "environment", "value": "production"},
|
|
{"key": "environment", "value": "production"},
|
|
|
{"key": "workflow_version", "value": "7"},
|
|
{"key": "workflow_version", "value": "7"},
|
|
|
],
|
|
],
|
|
|
|
|
+ "tasks": [{"id": "dataops_dag", "type": "test.Dag"}],
|
|
|
}
|
|
}
|
|
|
raise requests.Timeout("response lost")
|
|
raise requests.Timeout("response lost")
|
|
|
flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
|
|
flow_id = f"df_{FLOW_UID.replace('-', '')}_v7"
|
|
@@ -321,6 +524,13 @@ class FakeEngine:
|
|
|
"id": flow_id,
|
|
"id": flow_id,
|
|
|
"namespace": "dataops.production",
|
|
"namespace": "dataops.production",
|
|
|
"disabled": True,
|
|
"disabled": True,
|
|
|
|
|
+ "revision": 3,
|
|
|
|
|
+ "labels": [
|
|
|
|
|
+ {"key": "dataflow_uid", "value": FLOW_UID},
|
|
|
|
|
+ {"key": "environment", "value": "production"},
|
|
|
|
|
+ {"key": "workflow_version", "value": "7"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tasks": [{"id": "dataops_dag", "type": "test.Dag"}],
|
|
|
}
|
|
}
|
|
|
return {"revision": 3, "id": "server-receipt"}
|
|
return {"revision": 3, "id": "server-receipt"}
|
|
|
|
|
|
|
@@ -349,6 +559,9 @@ class FakeEngine:
|
|
|
|
|
|
|
|
def get_execution(self, execution_id):
|
|
def get_execution(self, execution_id):
|
|
|
self.calls.append(("get_execution", execution_id))
|
|
self.calls.append(("get_execution", execution_id))
|
|
|
|
|
+ if self.hard_exit_on_get_execution:
|
|
|
|
|
+ self.hard_exit_on_get_execution = False
|
|
|
|
|
+ raise SystemExit("simulated hard exit after execute response")
|
|
|
return {"id": execution_id, "state": {"current": "SUCCESS"}}
|
|
return {"id": execution_id, "state": {"current": "SUCCESS"}}
|
|
|
|
|
|
|
|
def get_flow(self, namespace, definition_id):
|
|
def get_flow(self, namespace, definition_id):
|
|
@@ -356,13 +569,14 @@ class FakeEngine:
|
|
|
return deepcopy(self.flows[(namespace, definition_id)])
|
|
return deepcopy(self.flows[(namespace, definition_id)])
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _service(repository=None, engine=None, now=None):
|
|
|
|
|
|
|
+def _service(repository=None, engine=None, now=None, token_issuer=None):
|
|
|
from app.core.data_rules.deployment import DataFlowDeploymentService
|
|
from app.core.data_rules.deployment import DataFlowDeploymentService
|
|
|
|
|
|
|
|
return DataFlowDeploymentService(
|
|
return DataFlowDeploymentService(
|
|
|
repository or FakeRepository(),
|
|
repository or FakeRepository(),
|
|
|
engine=engine or FakeEngine(),
|
|
engine=engine or FakeEngine(),
|
|
|
compiler=FakeCompiler(),
|
|
compiler=FakeCompiler(),
|
|
|
|
|
+ token_issuer=token_issuer,
|
|
|
clock=(lambda: now) if now else None,
|
|
clock=(lambda: now) if now else None,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -391,9 +605,7 @@ def test_production_deployment_requires_disabled_deploy_and_passed_canary():
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="passed canary"):
|
|
with pytest.raises(ValueError, match="passed canary"):
|
|
|
- service.activate(
|
|
|
|
|
- disabled["id"], None, ACTOR, idempotency_key="activate-1"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ service.activate(disabled["id"], None, ACTOR, idempotency_key="activate-1")
|
|
|
|
|
|
|
|
evidence = service.run_canary(
|
|
evidence = service.run_canary(
|
|
|
disabled["id"],
|
|
disabled["id"],
|
|
@@ -412,6 +624,168 @@ def test_production_deployment_requires_disabled_deploy_and_passed_canary():
|
|
|
assert [call[0] for call in engine.calls].count("activate") == 2
|
|
assert [call[0] for call in engine.calls].count("activate") == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def test_active_production_execution_issues_server_tokens_and_is_idempotent():
|
|
|
|
|
+ from app.runner.auth import TaskTokenIssuer
|
|
|
|
|
+
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(
|
|
|
|
|
+ repository,
|
|
|
|
|
+ engine,
|
|
|
|
|
+ token_issuer=TaskTokenIssuer("x" * 32),
|
|
|
|
|
+ )
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="deploy-execute"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="canary-execute",
|
|
|
|
|
+ )
|
|
|
|
|
+ active = service.activate(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ evidence["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="activate-execute",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ correlation_id = new_governance_uid()
|
|
|
|
|
+ result = service.execute_active(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="execute-active",
|
|
|
|
|
+ correlation_id=correlation_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ replay = service.execute_active(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="execute-active",
|
|
|
|
|
+ correlation_id=correlation_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert result == replay
|
|
|
|
|
+ assert result["status"] == "success"
|
|
|
|
|
+ assert result["correlation_id"] == correlation_id
|
|
|
|
|
+ assert result["workflow_version"] == 7
|
|
|
|
|
+ execute_call = next(call for call in engine.calls if call[0] == "execute")
|
|
|
|
|
+ assert set(execute_call[3]) == {
|
|
|
|
|
+ "biz_date",
|
|
|
|
|
+ "dataops_task_tokens",
|
|
|
|
|
+ }
|
|
|
|
|
+ operation = repository.operations[
|
|
|
|
|
+ (active["id"], "execute_active", "execute-active")
|
|
|
|
|
+ ]
|
|
|
|
|
+ assert operation["status"] == "completed"
|
|
|
|
|
+ assert operation["result"] == result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_active_execution_hard_exit_reconciles_receipt_without_resubmission():
|
|
|
|
|
+ from app.runner.auth import TaskTokenIssuer
|
|
|
|
|
+
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(
|
|
|
|
|
+ repository,
|
|
|
|
|
+ engine,
|
|
|
|
|
+ token_issuer=TaskTokenIssuer("x" * 32),
|
|
|
|
|
+ )
|
|
|
|
|
+ deployment = _create(service, key="hard-exit-execute-create")
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="hard-exit-execute-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="hard-exit-execute-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+ active = service.activate(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ evidence["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="hard-exit-execute-activate",
|
|
|
|
|
+ )
|
|
|
|
|
+ engine.hard_exit_on_get_execution = True
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(SystemExit, match="simulated hard exit"):
|
|
|
|
|
+ service.execute_active(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="hard-exit-execute",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ operation = repository.operations[
|
|
|
|
|
+ (active["id"], "execute_active", "hard-exit-execute")
|
|
|
|
|
+ ]
|
|
|
|
|
+ assert operation["status"] == "claimed"
|
|
|
|
|
+ assert operation["result"]["execution_id"] == "execution-1"
|
|
|
|
|
+ operation["lease_expires_at"] = (
|
|
|
|
|
+ datetime.now(UTC) - timedelta(seconds=1)
|
|
|
|
|
+ ).isoformat()
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ reconciled = service.reconcile(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ "execute_active",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="hard-exit-execute",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert reconciled["status"] == "success"
|
|
|
|
|
+ assert sum(call[0] == "execute" for call in engine.calls) == 2
|
|
|
|
|
+ # The other execute call is the canary; production was submitted exactly once.
|
|
|
|
|
+ assert [
|
|
|
|
|
+ call for call in engine.calls if call[0] == "execute"
|
|
|
|
|
+ ][-1][3]["biz_date"] == "2026-07-24"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_active_execution_reconcile_without_receipt_stays_unknown_and_never_replays():
|
|
|
|
|
+ from app.runner.auth import TaskTokenIssuer
|
|
|
|
|
+
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(
|
|
|
|
|
+ repository,
|
|
|
|
|
+ engine,
|
|
|
|
|
+ token_issuer=TaskTokenIssuer("x" * 32),
|
|
|
|
|
+ )
|
|
|
|
|
+ active = _create(service, key="missing-receipt-create")
|
|
|
|
|
+ active.update(status="active")
|
|
|
|
|
+ repository.deployments[active["id"]] = deepcopy(active)
|
|
|
|
|
+ claimed, operation = repository.claim_operation(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ "execute_active",
|
|
|
|
|
+ "missing-receipt",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "manual production execution",
|
|
|
|
|
+ {"inputs_hash": hashlib.sha256(b"{}").hexdigest()},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ repository.operations[
|
|
|
|
|
+ (active["id"], "execute_active", "missing-receipt")
|
|
|
|
|
+ ]["lease_expires_at"] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(OperationUnknown, match="receipt is missing"):
|
|
|
|
|
+ service.reconcile(
|
|
|
|
|
+ active["id"],
|
|
|
|
|
+ "execute_active",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="missing-receipt",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert repository.operations[
|
|
|
|
|
+ (active["id"], "execute_active", "missing-receipt")
|
|
|
|
|
+ ]["status"] == "unknown"
|
|
|
|
|
+ assert not [call for call in engine.calls if call[0] == "execute"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def test_create_is_idempotent_and_rejects_inline_semantics_or_credentials():
|
|
def test_create_is_idempotent_and_rejects_inline_semantics_or_credentials():
|
|
|
service = _service()
|
|
service = _service()
|
|
|
first = _create(service, key="same")
|
|
first = _create(service, key="same")
|
|
@@ -449,6 +823,140 @@ def test_create_is_idempotent_and_rejects_inline_semantics_or_credentials():
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def test_create_snapshots_server_owned_physical_workflow_without_mutating_package():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ rule_binding = new_governance_uid()
|
|
|
|
|
+ quality_binding = new_governance_uid()
|
|
|
|
|
+ semantic_rule_hash = "2" * 64
|
|
|
|
|
+ semantic_quality_hash = "3" * 64
|
|
|
|
|
+ physical_rule_hash = "4" * 64
|
|
|
|
|
+ physical_quality_hash = "5" * 64
|
|
|
|
|
+ semantic = {
|
|
|
|
|
+ **deepcopy(repository.release["package"]["workflow_spec"]),
|
|
|
|
|
+ "nodes": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "apply",
|
|
|
|
|
+ "type": "rule.apply",
|
|
|
|
|
+ "config": {
|
|
|
|
|
+ "component_binding_id": rule_binding,
|
|
|
|
|
+ "execution_plan_hash": semantic_rule_hash,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "quality",
|
|
|
|
|
+ "type": "quality.check",
|
|
|
|
|
+ "config": {
|
|
|
|
|
+ "component_binding_id": quality_binding,
|
|
|
|
|
+ "execution_plan_hash": semantic_quality_hash,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ physical = deepcopy(semantic)
|
|
|
|
|
+ physical["nodes"][0]["config"]["execution_plan_hash"] = physical_rule_hash
|
|
|
|
|
+ physical["nodes"][1]["config"]["execution_plan_hash"] = physical_quality_hash
|
|
|
|
|
+ repository.release["package"]["workflow_spec"] = semantic
|
|
|
|
|
+ repository.release["physical_workflow_spec"] = physical
|
|
|
|
|
+ repository.release["physical_plan_hashes"] = [
|
|
|
|
|
+ physical_rule_hash,
|
|
|
|
|
+ physical_quality_hash,
|
|
|
|
|
+ ]
|
|
|
|
|
+ package_before = deepcopy(repository.release["package"])
|
|
|
|
|
+
|
|
|
|
|
+ deployment = _create(_service(repository))
|
|
|
|
|
+
|
|
|
|
|
+ assert deployment["workflow_spec"] == physical
|
|
|
|
|
+ assert (
|
|
|
|
|
+ deployment["workflow_spec_hash"]
|
|
|
|
|
+ == hashlib.sha256(
|
|
|
|
|
+ json.dumps(
|
|
|
|
|
+ physical,
|
|
|
|
|
+ sort_keys=True,
|
|
|
|
|
+ separators=(",", ":"),
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ ).encode("utf-8")
|
|
|
|
|
+ ).hexdigest()
|
|
|
|
|
+ )
|
|
|
|
|
+ assert deployment["package"] == package_before
|
|
|
|
|
+ assert (
|
|
|
|
|
+ deployment["package"]["workflow_spec"]["nodes"][0]["config"][
|
|
|
|
|
+ "execution_plan_hash"
|
|
|
|
|
+ ]
|
|
|
|
|
+ == semantic_rule_hash
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_output_binding_strictly_allows_write_or_read_write_with_write_mode():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ service = _service(repository)
|
|
|
|
|
+ read_write = deepcopy(repository.release["binding_snapshot"])
|
|
|
|
|
+ read_write["output"]["access_mode"] = "read_write"
|
|
|
|
|
+ repository.release["binding_snapshot"] = deepcopy(read_write)
|
|
|
|
|
+
|
|
|
|
|
+ deployment = _create(service, read_write, key="read-write-output")
|
|
|
|
|
+ assert deployment["binding_snapshot"]["output"]["access_mode"] == "read_write"
|
|
|
|
|
+ assert deployment["binding_snapshot"]["output"]["write_mode"]
|
|
|
|
|
+
|
|
|
|
|
+ for invalid_mode in ("read", "admin", "write_read"):
|
|
|
|
|
+ invalid = deepcopy(read_write)
|
|
|
|
|
+ invalid["output"]["access_mode"] = invalid_mode
|
|
|
|
|
+ repository.release["binding_snapshot"] = deepcopy(invalid)
|
|
|
|
|
+ with pytest.raises(ValueError, match="write or read_write"):
|
|
|
|
|
+ _create(service, invalid, key=f"invalid-{invalid_mode}")
|
|
|
|
|
+
|
|
|
|
|
+ missing_write_mode = deepcopy(read_write)
|
|
|
|
|
+ missing_write_mode["output"].pop("write_mode")
|
|
|
|
|
+ repository.release["binding_snapshot"] = deepcopy(missing_write_mode)
|
|
|
|
|
+ with pytest.raises(ValueError, match="requires write_mode"):
|
|
|
|
|
+ _create(service, missing_write_mode, key="read-write-no-write-mode")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_canary_tokens_authorize_rule_write_but_keep_quality_read_only():
|
|
|
|
|
+ class RecordingIssuer:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.calls = []
|
|
|
|
|
+
|
|
|
|
|
+ def issue(self, **kwargs):
|
|
|
|
|
+ self.calls.append(deepcopy(kwargs))
|
|
|
|
|
+ return f"token-{kwargs['node']['id']}"
|
|
|
|
|
+
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ nodes = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "apply",
|
|
|
|
|
+ "type": "rule.apply",
|
|
|
|
|
+ "purpose": "write",
|
|
|
|
|
+ "config": {},
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "quality",
|
|
|
|
|
+ "type": "quality.check",
|
|
|
|
|
+ "purpose": "read",
|
|
|
|
|
+ "config": {},
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+ repository.release["package"]["workflow_spec"]["nodes"] = deepcopy(nodes)
|
|
|
|
|
+ repository.release["physical_workflow_spec"]["nodes"] = deepcopy(nodes)
|
|
|
|
|
+ issuer = RecordingIssuer()
|
|
|
|
|
+ service = _service(repository, token_issuer=issuer)
|
|
|
|
|
+ deployment = _create(service, key="token-purpose")
|
|
|
|
|
+ deployment = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="token-purpose-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ service.run_canary(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="token-purpose-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert {call["node"]["id"]: call["write_authorized"] for call in issuer.calls} == {
|
|
|
|
|
+ "apply": True,
|
|
|
|
|
+ "quality": False,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def test_activation_rejects_expired_or_drifted_server_owned_evidence():
|
|
def test_activation_rejects_expired_or_drifted_server_owned_evidence():
|
|
|
now = datetime(2026, 7, 24, 8, tzinfo=UTC)
|
|
now = datetime(2026, 7, 24, 8, tzinfo=UTC)
|
|
|
repository = FakeRepository()
|
|
repository = FakeRepository()
|
|
@@ -476,9 +984,7 @@ def test_activation_rejects_expired_or_drifted_server_owned_evidence():
|
|
|
).isoformat()
|
|
).isoformat()
|
|
|
repository.evidence[evidence["id"]]["package_hash"] = "0" * 64
|
|
repository.evidence[evidence["id"]]["package_hash"] = "0" * 64
|
|
|
with pytest.raises(ValueError, match="drift"):
|
|
with pytest.raises(ValueError, match="drift"):
|
|
|
- service.activate(
|
|
|
|
|
- disabled["id"], evidence["id"], ACTOR, idempotency_key="drift"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ service.activate(disabled["id"], evidence["id"], ACTOR, idempotency_key="drift")
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_activation_supersedes_previous_and_rollback_restores_exact_snapshot():
|
|
def test_activation_supersedes_previous_and_rollback_restores_exact_snapshot():
|
|
@@ -541,9 +1047,7 @@ def test_engine_failure_is_redacted_and_operation_can_be_retried():
|
|
|
engine.fail = "deploy"
|
|
engine.fail = "deploy"
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="deployment engine operation failed"):
|
|
with pytest.raises(RuntimeError, match="deployment engine operation failed"):
|
|
|
- service.deploy_disabled(
|
|
|
|
|
- deployment["id"], ACTOR, idempotency_key="deploy-fail"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ service.deploy_disabled(deployment["id"], ACTOR, idempotency_key="deploy-fail")
|
|
|
assert "secret" not in str(repository.operations)
|
|
assert "secret" not in str(repository.operations)
|
|
|
|
|
|
|
|
engine.fail = None
|
|
engine.fail = None
|
|
@@ -562,9 +1066,7 @@ def test_optimistic_lock_rejects_stale_transition():
|
|
|
repository.get_deployment = lambda deployment_id, for_update=False: stale
|
|
repository.get_deployment = lambda deployment_id, for_update=False: stale
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="concurrently"):
|
|
with pytest.raises(ValueError, match="concurrently"):
|
|
|
- service.deploy_disabled(
|
|
|
|
|
- deployment["id"], ACTOR, idempotency_key="stale"
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ service.deploy_disabled(deployment["id"], ACTOR, idempotency_key="stale")
|
|
|
operation = next(iter(repository.operations.values()))
|
|
operation = next(iter(repository.operations.values()))
|
|
|
assert operation["status"] == "failed"
|
|
assert operation["status"] == "failed"
|
|
|
|
|
|
|
@@ -597,6 +1099,128 @@ def test_response_loss_is_persisted_unknown_and_reconciled_without_redeploy():
|
|
|
assert operation["status"] == "completed"
|
|
assert operation["status"] == "completed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def test_unknown_scope_blocks_new_key_action_and_deployment_before_external_call():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ first = _create(service, key="unknown-first")
|
|
|
|
|
+ second = _create(service, key="unknown-second")
|
|
|
|
|
+ engine.fail = "deploy_response_lost"
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(OperationUnknown):
|
|
|
|
|
+ service.deploy_disabled(first["id"], ACTOR, idempotency_key="unknown-original")
|
|
|
|
|
+ operation = repository.operations[
|
|
|
|
|
+ (first["id"], "deploy_disabled", "unknown-original")
|
|
|
|
|
+ ]
|
|
|
|
|
+ external_calls = list(engine.calls)
|
|
|
|
|
+ engine.fail = None
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(OperationUnknown):
|
|
|
|
|
+ service.deploy_disabled(
|
|
|
|
|
+ first["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="unknown-different-key",
|
|
|
|
|
+ )
|
|
|
|
|
+ with pytest.raises(OperationUnknown):
|
|
|
|
|
+ service.run_canary(
|
|
|
|
|
+ first["id"],
|
|
|
|
|
+ {},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="unknown-different-action",
|
|
|
|
|
+ )
|
|
|
|
|
+ with pytest.raises(OperationUnknown) as blocked_by_original:
|
|
|
|
|
+ service.deploy_disabled(
|
|
|
|
|
+ second["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="unknown-different-deployment",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert blocked_by_original.value.blocking_operation == {
|
|
|
|
|
+ "id": operation["id"],
|
|
|
|
|
+ "deployment_id": first["id"],
|
|
|
|
|
+ "action": "deploy_disabled",
|
|
|
|
|
+ "idempotency_key": "unknown-original",
|
|
|
|
|
+ "scope_unknown_count": 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ assert engine.calls == external_calls
|
|
|
|
|
+ assert len(repository.operations) == 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_multiple_unknown_operations_block_deterministically_oldest_first():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ service = _service(repository)
|
|
|
|
|
+ first = _create(service, key="multiple-unknown-first")
|
|
|
|
|
+ second = _create(service, key="multiple-unknown-second")
|
|
|
|
|
+ first_key = (first["id"], "deploy_disabled", "unknown-oldest")
|
|
|
|
|
+ second_key = (second["id"], "activate", "unknown-newer")
|
|
|
|
|
+ repository.operations[first_key] = {
|
|
|
|
|
+ "id": new_governance_uid(),
|
|
|
|
|
+ "deployment_id": first["id"],
|
|
|
|
|
+ "action": "deploy_disabled",
|
|
|
|
|
+ "idempotency_key": first_key[2],
|
|
|
|
|
+ "status": "unknown",
|
|
|
|
|
+ "actor_uid": ACTOR,
|
|
|
|
|
+ "correlation_id": new_governance_uid(),
|
|
|
|
|
+ "reason": "oldest unresolved outcome",
|
|
|
|
|
+ "request": {},
|
|
|
|
|
+ "owner_token": new_governance_uid(),
|
|
|
|
|
+ "attempt_epoch": 1,
|
|
|
|
|
+ "fencing_epoch": 1,
|
|
|
|
|
+ }
|
|
|
|
|
+ repository.operations[second_key] = {
|
|
|
|
|
+ **deepcopy(repository.operations[first_key]),
|
|
|
|
|
+ "id": new_governance_uid(),
|
|
|
|
|
+ "deployment_id": second["id"],
|
|
|
|
|
+ "action": "activate",
|
|
|
|
|
+ "idempotency_key": second_key[2],
|
|
|
|
|
+ "reason": "newer unresolved outcome",
|
|
|
|
|
+ "fencing_epoch": 2,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ claimed, blocker = repository.claim_operation(
|
|
|
|
|
+ second["id"],
|
|
|
|
|
+ "rollback",
|
|
|
|
|
+ "must-not-claim",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "blocked until reconciliation",
|
|
|
|
|
+ {},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert claimed is False
|
|
|
|
|
+ assert blocker["id"] == repository.operations[first_key]["id"]
|
|
|
|
|
+ assert blocker["scope_unknown_count"] == 2
|
|
|
|
|
+ assert len(repository.operations) == 2
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_deploy_fails_closed_if_post_create_flow_definition_is_replaced():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ original_get_flow = engine.get_flow
|
|
|
|
|
+
|
|
|
|
|
+ def replaced_flow(namespace, definition_id):
|
|
|
|
|
+ flow = original_get_flow(namespace, definition_id)
|
|
|
|
|
+ flow["tasks"] = [{"id": "replaced", "type": "attacker.Flow"}]
|
|
|
|
|
+ return flow
|
|
|
|
|
+
|
|
|
|
|
+ engine.get_flow = replaced_flow
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(RuntimeError, match="deployment engine operation failed"):
|
|
|
|
|
+ service.deploy_disabled(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="post-create-replacement",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert repository.get_deployment(deployment["id"])["status"] == "draft"
|
|
|
|
|
+ operation = repository.operations[
|
|
|
|
|
+ (deployment["id"], "deploy_disabled", "post-create-replacement")
|
|
|
|
|
+ ]
|
|
|
|
|
+ assert operation["status"] == "failed"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def test_idempotency_key_is_bound_to_actor_reason_and_canary_inputs():
|
|
def test_idempotency_key_is_bound_to_actor_reason_and_canary_inputs():
|
|
|
repository = FakeRepository()
|
|
repository = FakeRepository()
|
|
|
service = _service(repository)
|
|
service = _service(repository)
|
|
@@ -636,6 +1260,7 @@ def test_rollback_response_loss_reconciles_observed_partial_engine_state():
|
|
|
lock_version=1,
|
|
lock_version=1,
|
|
|
engine_namespace="dataops.production",
|
|
engine_namespace="dataops.production",
|
|
|
engine_definition_id="previous-flow",
|
|
engine_definition_id="previous-flow",
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
)
|
|
)
|
|
|
candidate.update(
|
|
candidate.update(
|
|
|
status="active",
|
|
status="active",
|
|
@@ -643,15 +1268,23 @@ def test_rollback_response_loss_reconciles_observed_partial_engine_state():
|
|
|
previous_active_deployment_id=previous["id"],
|
|
previous_active_deployment_id=previous["id"],
|
|
|
engine_namespace="dataops.production",
|
|
engine_namespace="dataops.production",
|
|
|
engine_definition_id="candidate-flow",
|
|
engine_definition_id="candidate-flow",
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
)
|
|
)
|
|
|
repository.deployments[previous["id"]] = deepcopy(previous)
|
|
repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
- engine.flows[("dataops.production", "previous-flow")] = {
|
|
|
|
|
- "disabled": True
|
|
|
|
|
- }
|
|
|
|
|
- engine.flows[("dataops.production", "candidate-flow")] = {
|
|
|
|
|
- "disabled": False
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-flow")] = {"disabled": True}
|
|
|
|
|
+ engine.flows[("dataops.production", "candidate-flow")] = {"disabled": False}
|
|
|
|
|
+ for flow_id, deployment_value in (
|
|
|
|
|
+ ("previous-flow", previous),
|
|
|
|
|
+ ("candidate-flow", candidate),
|
|
|
|
|
+ ):
|
|
|
|
|
+ flow = engine.flows[("dataops.production", flow_id)]
|
|
|
|
|
+ flow.update(id=flow_id, namespace="dataops.production", revision=1)
|
|
|
|
|
+ deployment_value["engine_definition_hash"] = canonical_flow_definition_hash(
|
|
|
|
|
+ flow
|
|
|
|
|
+ )
|
|
|
|
|
+ deployment_value["engine_revision"] = "1"
|
|
|
|
|
+ repository.deployments[deployment_value["id"]] = deepcopy(deployment_value)
|
|
|
engine.fail = "rollback_response_lost"
|
|
engine.fail = "rollback_response_lost"
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="unknown"):
|
|
with pytest.raises(RuntimeError, match="unknown"):
|
|
@@ -673,9 +1306,590 @@ def test_rollback_response_loss_reconciles_observed_partial_engine_state():
|
|
|
)
|
|
)
|
|
|
assert result["rolled_back"]["status"] == "rolled_back"
|
|
assert result["rolled_back"]["status"] == "rolled_back"
|
|
|
assert result["active"]["id"] == previous["id"]
|
|
assert result["active"]["id"] == previous["id"]
|
|
|
- assert engine.flows[("dataops.production", "candidate-flow")][
|
|
|
|
|
- "disabled"
|
|
|
|
|
- ] is True
|
|
|
|
|
- assert engine.flows[("dataops.production", "previous-flow")][
|
|
|
|
|
- "disabled"
|
|
|
|
|
- ] is False
|
|
|
|
|
|
|
+ assert engine.flows[("dataops.production", "candidate-flow")]["disabled"] is True
|
|
|
|
|
+ assert engine.flows[("dataops.production", "previous-flow")]["disabled"] is False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_lost_activation_fence_never_compensates_a_new_owner_flow():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="fence-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="fence-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.steal_during = "activate"
|
|
|
|
|
+ deactivations_before = sum(call[0] == "deactivate" for call in engine.calls)
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(ValueError, match="fencing lease lost"):
|
|
|
|
|
+ service.activate(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ evidence["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="activation-owner-one",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert any(
|
|
|
|
|
+ key[2].startswith("concurrent-")
|
|
|
|
|
+ for key in repository.operations
|
|
|
|
|
+ if key[1] == "activate"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert sum(call[0] == "deactivate" for call in engine.calls) == (
|
|
|
|
|
+ deactivations_before
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_lost_rollback_fence_never_compensates_a_new_owner_flow():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ previous = _create(service, key="fence-previous")
|
|
|
|
|
+ previous.update(
|
|
|
|
|
+ status="superseded",
|
|
|
|
|
+ lock_version=1,
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id="previous-flow",
|
|
|
|
|
+ engine_definition_hash="8" * 64,
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="1",
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate = _create(service, key="fence-candidate")
|
|
|
|
|
+ candidate.update(
|
|
|
|
|
+ status="active",
|
|
|
|
|
+ lock_version=3,
|
|
|
|
|
+ previous_active_deployment_id=previous["id"],
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id="candidate-flow",
|
|
|
|
|
+ engine_definition_hash="7" * 64,
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="2",
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
|
|
+ repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-flow")] = {
|
|
|
|
|
+ "id": "previous-flow",
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": True,
|
|
|
|
|
+ "revision": 1,
|
|
|
|
|
+ "labels": [
|
|
|
|
|
+ {"key": "dataflow_uid", "value": FLOW_UID},
|
|
|
|
|
+ {"key": "environment", "value": "production"},
|
|
|
|
|
+ {"key": "workflow_version", "value": "7"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tasks": [{"id": "previous"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ engine.flows[("dataops.production", "candidate-flow")] = {
|
|
|
|
|
+ "id": "candidate-flow",
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": False,
|
|
|
|
|
+ "revision": 2,
|
|
|
|
|
+ "labels": [
|
|
|
|
|
+ {"key": "dataflow_uid", "value": FLOW_UID},
|
|
|
|
|
+ {"key": "environment", "value": "production"},
|
|
|
|
|
+ {"key": "workflow_version", "value": "7"},
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tasks": [{"id": "candidate"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ previous["engine_definition_hash"] = canonical_flow_definition_hash(
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-flow")]
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate["engine_definition_hash"] = canonical_flow_definition_hash(
|
|
|
|
|
+ engine.flows[("dataops.production", "candidate-flow")]
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
|
|
+ repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
|
|
+ repository.steal_during = "rollback"
|
|
|
|
|
+ calls_before = len(engine.calls)
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(ValueError, match="fencing lease lost"):
|
|
|
|
|
+ service.rollback(
|
|
|
|
|
+ candidate["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="rollback-owner-one",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert [
|
|
|
|
|
+ call[0]
|
|
|
|
|
+ for call in engine.calls[calls_before:]
|
|
|
|
|
+ if call[0] in {"activate", "deactivate"}
|
|
|
|
|
+ ] == [
|
|
|
|
|
+ "deactivate",
|
|
|
|
|
+ "activate",
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_expired_claimed_deploy_is_recovered_by_reconciliation():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ claimed, operation = repository.claim_operation(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ "deploy_disabled",
|
|
|
|
|
+ "crashed-deploy",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "crashed deploy",
|
|
|
|
|
+ {},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ compiled = FakeCompiler()(
|
|
|
|
|
+ deployment["workflow_spec"],
|
|
|
|
|
+ deployment["schedule_snapshot"],
|
|
|
|
|
+ deployment["environment"],
|
|
|
|
|
+ deployment["version_no"],
|
|
|
|
|
+ )
|
|
|
|
|
+ engine.deploy_disabled(compiled.yaml)
|
|
|
|
|
+ repository.operations[(deployment["id"], "deploy_disabled", "crashed-deploy")][
|
|
|
|
|
+ "lease_expires_at"
|
|
|
|
|
+ ] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ recovered = service.reconcile(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ "deploy_disabled",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crashed-deploy",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert recovered["status"] == "disabled"
|
|
|
|
|
+ assert operation["attempt_epoch"] == 1
|
|
|
|
|
+ assert (
|
|
|
|
|
+ repository.operations[(deployment["id"], "deploy_disabled", "crashed-deploy")][
|
|
|
|
|
+ "attempt_epoch"
|
|
|
|
|
+ ]
|
|
|
|
|
+ == 2
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_expired_claimed_activation_is_recovered_without_reactivation():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="crash-activate-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crash-activate-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+ claimed, _operation = repository.claim_operation(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ "activate",
|
|
|
|
|
+ "crashed-activate",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "crashed activation",
|
|
|
|
|
+ {"evidence_id": evidence["id"]},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ engine.activate(disabled["engine_namespace"], disabled["engine_definition_id"])
|
|
|
|
|
+ repository.operations[(disabled["id"], "activate", "crashed-activate")][
|
|
|
|
|
+ "lease_expires_at"
|
|
|
|
|
+ ] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+ activations_before = sum(call[0] == "activate" for call in engine.calls)
|
|
|
|
|
+
|
|
|
|
|
+ recovered = service.reconcile(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ "activate",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crashed-activate",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert recovered["status"] == "active"
|
|
|
|
|
+ assert sum(call[0] == "activate" for call in engine.calls) == (activations_before)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_expired_claimed_rollback_recovers_between_two_engine_calls():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ previous = _create(service, key="hard-exit-previous")
|
|
|
|
|
+ candidate = _create(service, key="hard-exit-candidate")
|
|
|
|
|
+ previous.update(
|
|
|
|
|
+ status="superseded",
|
|
|
|
|
+ lock_version=1,
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id="previous-hard-exit",
|
|
|
|
|
+ engine_definition_hash="8" * 64,
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="1",
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate.update(
|
|
|
|
|
+ status="active",
|
|
|
|
|
+ lock_version=3,
|
|
|
|
|
+ previous_active_deployment_id=previous["id"],
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id="candidate-hard-exit",
|
|
|
|
|
+ engine_definition_hash="7" * 64,
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="2",
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
|
|
+ repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-hard-exit")] = {
|
|
|
|
|
+ "id": "previous-hard-exit",
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": True,
|
|
|
|
|
+ "revision": 1,
|
|
|
|
|
+ "labels": [],
|
|
|
|
|
+ "tasks": [{"id": "previous"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ engine.flows[("dataops.production", "candidate-hard-exit")] = {
|
|
|
|
|
+ "id": "candidate-hard-exit",
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": True,
|
|
|
|
|
+ "revision": 2,
|
|
|
|
|
+ "labels": [],
|
|
|
|
|
+ "tasks": [{"id": "candidate"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ previous["engine_definition_hash"] = canonical_flow_definition_hash(
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-hard-exit")]
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate["engine_definition_hash"] = canonical_flow_definition_hash(
|
|
|
|
|
+ engine.flows[("dataops.production", "candidate-hard-exit")]
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
|
|
+ repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
|
|
+ claimed, _operation = repository.claim_operation(
|
|
|
|
|
+ candidate["id"],
|
|
|
|
|
+ "rollback",
|
|
|
|
|
+ "crashed-rollback",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "hard exit after candidate disable",
|
|
|
|
|
+ {"candidate_deployment_id": candidate["id"]},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ repository.operations[(candidate["id"], "rollback", "crashed-rollback")][
|
|
|
|
|
+ "lease_expires_at"
|
|
|
|
|
+ ] = (datetime.now(UTC) - timedelta(seconds=1)).isoformat()
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ recovered = service.reconcile(
|
|
|
|
|
+ candidate["id"],
|
|
|
|
|
+ "rollback",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crashed-rollback",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert recovered["rolled_back"]["status"] == "rolled_back"
|
|
|
|
|
+ assert (
|
|
|
|
|
+ engine.flows[("dataops.production", "previous-hard-exit")]["disabled"] is False
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_engine_definition_drift_blocks_canary_and_activation():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="definition-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ flow_key = (
|
|
|
|
|
+ disabled["engine_namespace"],
|
|
|
|
|
+ disabled["engine_definition_id"],
|
|
|
|
|
+ )
|
|
|
|
|
+ engine.flows[flow_key]["tasks"].append({"id": "tampered"})
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(ValueError, match="definition.*drift"):
|
|
|
|
|
+ service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="definition-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # Obtain valid evidence from the original immutable definition, then prove
|
|
|
|
|
+ # activation re-attests instead of trusting the earlier canary.
|
|
|
|
|
+ engine.flows[flow_key]["tasks"].pop()
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="definition-canary-valid",
|
|
|
|
|
+ )
|
|
|
|
|
+ engine.flows[flow_key]["tasks"].append({"id": "tampered-again"})
|
|
|
|
|
+ with pytest.raises(ValueError, match="definition.*drift"):
|
|
|
|
|
+ service.activate(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ evidence["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="definition-activate",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_expired_claim_before_deploy_executes_missing_idempotent_step():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ claimed, operation = repository.claim_operation(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ "deploy_disabled",
|
|
|
|
|
+ "crashed-before-deploy",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "crash before external deploy",
|
|
|
|
|
+ {},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ operation["lease_expires_at"] = (
|
|
|
|
|
+ datetime.now(UTC) - timedelta(seconds=1)
|
|
|
|
|
+ ).isoformat()
|
|
|
|
|
+ repository.operations[
|
|
|
|
|
+ (deployment["id"], "deploy_disabled", "crashed-before-deploy")
|
|
|
|
|
+ ].update(operation)
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+
|
|
|
|
|
+ recovered = service.reconcile(
|
|
|
|
|
+ deployment["id"],
|
|
|
|
|
+ "deploy_disabled",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crashed-before-deploy",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert recovered["status"] == "disabled"
|
|
|
|
|
+ assert sum(call[0] == "deploy_disabled" for call in engine.calls) == 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_expired_claim_before_activation_executes_missing_enable_step():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="pre-activate-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="pre-activate-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+ claimed, operation = repository.claim_operation(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ "activate",
|
|
|
|
|
+ "crashed-before-activate",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "crash before external activate",
|
|
|
|
|
+ {"evidence_id": evidence["id"]},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ operation["lease_expires_at"] = (
|
|
|
|
|
+ datetime.now(UTC) - timedelta(seconds=1)
|
|
|
|
|
+ ).isoformat()
|
|
|
|
|
+ repository.operations[
|
|
|
|
|
+ (disabled["id"], "activate", "crashed-before-activate")
|
|
|
|
|
+ ].update(operation)
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+ before = sum(call[0] == "activate" for call in engine.calls)
|
|
|
|
|
+
|
|
|
|
|
+ recovered = service.reconcile(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ "activate",
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="crashed-before-activate",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert recovered["status"] == "active"
|
|
|
|
|
+ assert sum(call[0] == "activate" for call in engine.calls) == before + 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.parametrize(
|
|
|
|
|
+ ("candidate_disabled", "previous_disabled", "expected_mutations"),
|
|
|
|
|
+ [
|
|
|
|
|
+ (False, True, ["deactivate", "activate"]),
|
|
|
|
|
+ (True, True, ["activate"]),
|
|
|
|
|
+ (True, False, []),
|
|
|
|
|
+ ],
|
|
|
|
|
+)
|
|
|
|
|
+def test_expired_rollback_claim_recovers_all_safe_external_windows(
|
|
|
|
|
+ candidate_disabled, previous_disabled, expected_mutations
|
|
|
|
|
+):
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ previous = _create(
|
|
|
|
|
+ service, key=f"window-prev-{candidate_disabled}-{previous_disabled}"
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate = _create(
|
|
|
|
|
+ service, key=f"window-candidate-{candidate_disabled}-{previous_disabled}"
|
|
|
|
|
+ )
|
|
|
|
|
+ previous.update(
|
|
|
|
|
+ status="superseded",
|
|
|
|
|
+ lock_version=1,
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id=f"window-previous-{candidate_disabled}-{previous_disabled}",
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="1",
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate.update(
|
|
|
|
|
+ status="active",
|
|
|
|
|
+ lock_version=3,
|
|
|
|
|
+ previous_active_deployment_id=previous["id"],
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id=f"window-candidate-{candidate_disabled}-{previous_disabled}",
|
|
|
|
|
+ engine_definition_hash_version="kestra_canonical_v2",
|
|
|
|
|
+ engine_revision="2",
|
|
|
|
|
+ )
|
|
|
|
|
+ previous_flow = {
|
|
|
|
|
+ "id": previous["engine_definition_id"],
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": previous_disabled,
|
|
|
|
|
+ "revision": 1,
|
|
|
|
|
+ "tasks": [{"id": "previous"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ candidate_flow = {
|
|
|
|
|
+ "id": candidate["engine_definition_id"],
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": candidate_disabled,
|
|
|
|
|
+ "revision": 2,
|
|
|
|
|
+ "tasks": [{"id": "candidate"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ previous["engine_definition_hash"] = canonical_flow_definition_hash(previous_flow)
|
|
|
|
|
+ candidate["engine_definition_hash"] = canonical_flow_definition_hash(candidate_flow)
|
|
|
|
|
+ repository.deployments[previous["id"]] = deepcopy(previous)
|
|
|
|
|
+ repository.deployments[candidate["id"]] = deepcopy(candidate)
|
|
|
|
|
+ engine.flows[("dataops.production", previous["engine_definition_id"])] = (
|
|
|
|
|
+ previous_flow
|
|
|
|
|
+ )
|
|
|
|
|
+ engine.flows[("dataops.production", candidate["engine_definition_id"])] = (
|
|
|
|
|
+ candidate_flow
|
|
|
|
|
+ )
|
|
|
|
|
+ key = f"window-{candidate_disabled}-{previous_disabled}"
|
|
|
|
|
+ claimed, operation = repository.claim_operation(
|
|
|
|
|
+ candidate["id"],
|
|
|
|
|
+ "rollback",
|
|
|
|
|
+ key,
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ new_governance_uid(),
|
|
|
|
|
+ "recover rollback window",
|
|
|
|
|
+ {"candidate_deployment_id": candidate["id"]},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert claimed is True
|
|
|
|
|
+ operation["lease_expires_at"] = (
|
|
|
|
|
+ datetime.now(UTC) - timedelta(seconds=1)
|
|
|
|
|
+ ).isoformat()
|
|
|
|
|
+ repository.operations[(candidate["id"], "rollback", key)].update(operation)
|
|
|
|
|
+ repository.lease = None
|
|
|
|
|
+ before = len(engine.calls)
|
|
|
|
|
+
|
|
|
|
|
+ result = service.reconcile(candidate["id"], "rollback", ACTOR, idempotency_key=key)
|
|
|
|
|
+
|
|
|
|
|
+ mutations = [
|
|
|
|
|
+ call[0]
|
|
|
|
|
+ for call in engine.calls[before:]
|
|
|
|
|
+ if call[0] in {"activate", "deactivate"}
|
|
|
|
|
+ ]
|
|
|
|
|
+ assert mutations == expected_mutations
|
|
|
|
|
+ assert result["active"]["id"] == previous["id"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_legacy_hash_is_re_attested_and_old_canary_evidence_is_invalidated():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ deployment = _create(service)
|
|
|
|
|
+ disabled = service.deploy_disabled(
|
|
|
|
|
+ deployment["id"], ACTOR, idempotency_key="legacy-deploy"
|
|
|
|
|
+ )
|
|
|
|
|
+ evidence = service.run_canary(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ {"biz_date": "2026-07-24"},
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="legacy-canary",
|
|
|
|
|
+ )
|
|
|
|
|
+ repository.deployments[disabled["id"]].update(
|
|
|
|
|
+ engine_definition_hash="9" * 64,
|
|
|
|
|
+ engine_definition_hash_version="yaml_v1",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ with pytest.raises(ValueError, match="passed canary evidence"):
|
|
|
|
|
+ service.activate(
|
|
|
|
|
+ disabled["id"],
|
|
|
|
|
+ evidence["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="legacy-activate",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ upgraded = repository.deployments[disabled["id"]]
|
|
|
|
|
+ assert upgraded["engine_definition_hash_version"] == "kestra_canonical_v2"
|
|
|
|
|
+ assert upgraded["canary_evidence_id"] is None
|
|
|
|
|
+ assert repository.evidence[evidence["id"]]["status"] == "failed"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_yaml_v1_active_and_previous_are_re_attested_before_rollback():
|
|
|
|
|
+ repository = FakeRepository()
|
|
|
|
|
+ engine = FakeEngine()
|
|
|
|
|
+ service = _service(repository, engine)
|
|
|
|
|
+ previous = _create(service, key="legacy-rollback-previous")
|
|
|
|
|
+ candidate = _create(service, key="legacy-rollback-candidate")
|
|
|
|
|
+ previous_id = f"df_{FLOW_UID.replace('-', '')}_v6"
|
|
|
|
|
+ candidate_id = f"df_{FLOW_UID.replace('-', '')}_v7"
|
|
|
|
|
+ previous.update(
|
|
|
|
|
+ version_no=6,
|
|
|
|
|
+ status="superseded",
|
|
|
|
|
+ lock_version=1,
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id=previous_id,
|
|
|
|
|
+ engine_definition_hash="8" * 64,
|
|
|
|
|
+ engine_definition_hash_version="yaml_v1",
|
|
|
|
|
+ )
|
|
|
|
|
+ candidate.update(
|
|
|
|
|
+ status="active",
|
|
|
|
|
+ lock_version=3,
|
|
|
|
|
+ previous_active_deployment_id=previous["id"],
|
|
|
|
|
+ engine_namespace="dataops.production",
|
|
|
|
|
+ engine_definition_id=candidate_id,
|
|
|
|
|
+ engine_definition_hash="9" * 64,
|
|
|
|
|
+ engine_definition_hash_version="yaml_v1",
|
|
|
|
|
+ )
|
|
|
|
|
+ for deployment, flow_id, disabled in (
|
|
|
|
|
+ (previous, previous_id, True),
|
|
|
|
|
+ (candidate, candidate_id, False),
|
|
|
|
|
+ ):
|
|
|
|
|
+ engine.flows[("dataops.production", flow_id)] = {
|
|
|
|
|
+ "id": flow_id,
|
|
|
|
|
+ "namespace": "dataops.production",
|
|
|
|
|
+ "disabled": disabled,
|
|
|
|
|
+ "revision": 1,
|
|
|
|
|
+ "labels": [
|
|
|
|
|
+ {"key": "dataflow_uid", "value": FLOW_UID},
|
|
|
|
|
+ {"key": "environment", "value": "production"},
|
|
|
|
|
+ {
|
|
|
|
|
+ "key": "workflow_version",
|
|
|
|
|
+ "value": str(deployment["version_no"]),
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tasks": [{"id": "dataops_dag", "type": "test.Dag"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ repository.deployments[deployment["id"]] = deepcopy(deployment)
|
|
|
|
|
+
|
|
|
|
|
+ result = service.rollback(
|
|
|
|
|
+ candidate["id"],
|
|
|
|
|
+ ACTOR,
|
|
|
|
|
+ idempotency_key="legacy-rollback",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ assert result["active"]["id"] == previous["id"]
|
|
|
|
|
+ assert (
|
|
|
|
|
+ repository.deployments[previous["id"]]["engine_definition_hash_version"]
|
|
|
|
|
+ == "kestra_canonical_v2"
|
|
|
|
|
+ )
|
|
|
|
|
+ assert (
|
|
|
|
|
+ repository.deployments[candidate["id"]]["engine_definition_hash_version"]
|
|
|
|
|
+ == "kestra_canonical_v2"
|
|
|
|
|
+ )
|