tenant_lifecycle.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. """Deterministic lifecycle and fence contract used before tenant activation."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. from dataclasses import dataclass, field
  7. from typing import Any
  8. class TenantLifecycleError(ValueError):
  9. pass
  10. _DIGEST = re.compile(r"^[0-9a-f]{64}$")
  11. _TRANSITIONS = {
  12. ("provisioning", "activate"): "active",
  13. ("active", "freeze"): "frozen",
  14. ("frozen", "begin_recovery"): "recovering",
  15. ("recovering", "activate"): "active",
  16. ("active", "mark_deletion_candidate"): "deletion_candidate",
  17. ("frozen", "mark_deletion_candidate"): "deletion_candidate",
  18. ("deletion_candidate", "delete"): "deleted",
  19. ("deletion_candidate", "rollback"): "frozen",
  20. }
  21. @dataclass(frozen=True)
  22. class TenantLifecycleRecord:
  23. tenant_id: str
  24. state: str
  25. fence: int
  26. @dataclass
  27. class TenantLifecycle:
  28. tenant_id: str
  29. state: str = "provisioning"
  30. fence: int = 0
  31. _replays: dict[str, tuple[str, TenantLifecycleRecord]] = field(default_factory=dict)
  32. @classmethod
  33. def provision(cls, tenant_id: str) -> TenantLifecycle:
  34. return cls(tenant_id=tenant_id)
  35. def _digest(self, action: str, expected_fence: int, values: dict[str, Any]) -> str:
  36. payload = json.dumps(
  37. {"action": action, "expected_fence": expected_fence, **values},
  38. sort_keys=True,
  39. separators=(",", ":"),
  40. )
  41. return hashlib.sha256(payload.encode("utf-8")).hexdigest()
  42. def transition(
  43. self,
  44. action: str,
  45. *,
  46. expected_fence: int,
  47. idempotency_key: str,
  48. approval_ref: str | None = None,
  49. backup_digest: str | None = None,
  50. retention_seconds: int | None = None,
  51. hold_active: bool = False,
  52. ) -> TenantLifecycleRecord:
  53. if not isinstance(expected_fence, int) or isinstance(expected_fence, bool):
  54. raise TenantLifecycleError("tenant_fence_conflict")
  55. if not isinstance(idempotency_key, str) or not idempotency_key:
  56. raise TenantLifecycleError("tenant_replay_conflict")
  57. values = {
  58. "approval_ref": approval_ref,
  59. "backup_digest": backup_digest,
  60. "retention_seconds": retention_seconds,
  61. "hold_active": hold_active,
  62. }
  63. request_digest = self._digest(action, expected_fence, values)
  64. replay = self._replays.get(idempotency_key)
  65. if replay is not None:
  66. if replay[0] != request_digest:
  67. raise TenantLifecycleError("tenant_replay_conflict")
  68. return replay[1]
  69. if expected_fence != self.fence:
  70. raise TenantLifecycleError("tenant_fence_conflict")
  71. target = _TRANSITIONS.get((self.state, action))
  72. if target is None:
  73. raise TenantLifecycleError("tenant_transition_denied")
  74. if action in {"begin_recovery", "activate"} and self.state == "recovering" and not approval_ref:
  75. raise TenantLifecycleError("tenant_recovery_approval_required")
  76. if action in {"mark_deletion_candidate", "delete"}:
  77. if not approval_ref:
  78. raise TenantLifecycleError("tenant_deletion_approval_required")
  79. if hold_active:
  80. raise TenantLifecycleError("tenant_hold_active")
  81. if not isinstance(backup_digest, str) or not _DIGEST.fullmatch(backup_digest):
  82. raise TenantLifecycleError("tenant_backup_required")
  83. if (
  84. not isinstance(retention_seconds, int)
  85. or isinstance(retention_seconds, bool)
  86. or retention_seconds < 1
  87. ):
  88. raise TenantLifecycleError("tenant_retention_required")
  89. self.state = target
  90. self.fence += 1
  91. result = TenantLifecycleRecord(self.tenant_id, self.state, self.fence)
  92. self._replays[idempotency_key] = (request_digest, result)
  93. return result
  94. def require_active_task_scope(self, *, expected_fence: int) -> TenantLifecycleRecord:
  95. if expected_fence != self.fence:
  96. raise TenantLifecycleError("tenant_fence_conflict")
  97. if self.state != "active":
  98. raise TenantLifecycleError("tenant_not_active")
  99. return TenantLifecycleRecord(self.tenant_id, self.state, self.fence)