cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
cloudfall/lifecycle.py ADDED
@@ -0,0 +1,874 @@
1
+ """Agent-facing component lifecycle operations.
2
+
3
+ The SDK orchestrates deployments through the engine's stable process
4
+ boundaries: the ``cloudfall_engine`` command-line contract for inventory
5
+ rendering and the engine's playbook contract for execution. It never
6
+ imports Ansible or engine internals.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from typing import TYPE_CHECKING, cast
20
+
21
+ from jsonschema.exceptions import ValidationError
22
+
23
+ from cloudfall.inventory import PlatformInventory
24
+ from cloudfall.validation import SchemaCatalog, validate_config
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Mapping
28
+ from pathlib import Path
29
+
30
+ from cloudfall.domain import ReleaseId, ResourceId
31
+ from cloudfall.inventory import ComponentInventory, ServiceInventory
32
+
33
+ _ARTIFACT_SCHEMA = "artifact.schema.json"
34
+ _STEP_TIMEOUT_SECONDS = 3600
35
+ _OUTPUT_TAIL_CHARACTERS = 2000
36
+ _ERROR_COMPONENT_MISSING = "lifecycle_component_missing"
37
+ _ERROR_ARTIFACT_MISSING = "lifecycle_artifact_missing"
38
+ _ERROR_ARTIFACT_METADATA = "lifecycle_artifact_metadata_invalid"
39
+ _ERROR_ARTIFACT_IDENTITY = "lifecycle_artifact_identity_mismatch"
40
+ _ERROR_ARTIFACT_DIGEST = "lifecycle_artifact_digest_mismatch"
41
+ _ERROR_ANSIBLE_MISSING = "lifecycle_ansible_missing"
42
+ _ERROR_ENGINE_MISSING = "lifecycle_engine_missing"
43
+ _ERROR_EXECUTION_FAILED = "lifecycle_execution_failed"
44
+ _ERROR_RECEIPT_MISSING = "lifecycle_receipt_missing"
45
+ _ERROR_SERVICE_MISSING = "lifecycle_service_missing"
46
+ _ERROR_DATABASE_MISSING = "lifecycle_database_missing"
47
+ _ERROR_SOURCE_URL_MISSING = "lifecycle_source_url_missing"
48
+
49
+
50
+ class LifecycleError(RuntimeError):
51
+ """Fail-fast lifecycle error with a stable machine-readable code."""
52
+
53
+ def __init__(self, code: str, message: str) -> None:
54
+ """Record the failure code and human-readable detail."""
55
+ self.code = code
56
+ self.detail = message
57
+ super().__init__(f"{code}: {message}")
58
+
59
+ def as_dict(self) -> dict[str, object]:
60
+ """Serialize the error envelope for system boundaries."""
61
+ return {
62
+ "status": "error",
63
+ "error": {"code": self.code, "message": self.detail},
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class ExecutionStep:
69
+ """One subprocess invocation with explicit environment additions."""
70
+
71
+ description: str
72
+ argv: tuple[str, ...]
73
+ environment: Mapping[str, str]
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class ExecutionPlan:
78
+ """Ordered subprocess steps for one lifecycle action."""
79
+
80
+ action: str
81
+ component_id: ResourceId
82
+ release: ReleaseId | None
83
+ steps: tuple[ExecutionStep, ...]
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class DeployOptions:
88
+ """Optional deployment inputs."""
89
+
90
+ environment_file: Path | None = None
91
+ receipt_directory: Path | None = None
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class VerifiedArtifact:
96
+ """A release artifact whose metadata and digest have been verified."""
97
+
98
+ archive_path: Path
99
+ metadata_path: Path
100
+ sha256: str
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class LifecycleResult:
105
+ """Structured outcome of one lifecycle action."""
106
+
107
+ action: str
108
+ component_id: ResourceId
109
+ release: ReleaseId | None
110
+ servers: tuple[ResourceId, ...]
111
+ healthy: bool
112
+ detail: str | None
113
+ receipt: Path | None
114
+
115
+ def as_dict(self) -> dict[str, object]:
116
+ """Serialize the result for agent consumers."""
117
+ result: dict[str, object] = {
118
+ "status": "ok" if self.healthy else "unhealthy",
119
+ "action": self.action,
120
+ "component": self.component_id.value,
121
+ "servers": [server.value for server in self.servers],
122
+ "healthy": self.healthy,
123
+ }
124
+ if self.release is not None:
125
+ result["release"] = self.release.value
126
+ if self.detail is not None:
127
+ result["detail"] = self.detail
128
+ if self.receipt is not None:
129
+ result["receipt"] = str(self.receipt)
130
+ return result
131
+
132
+
133
+ class PreviewSubject(StrEnum):
134
+ """Kind of declared resource a previewed action targets."""
135
+
136
+ COMPONENT = "component"
137
+ SERVICE = "service"
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class LifecyclePreview:
142
+ """A server-changing action validated against the config, not executed.
143
+
144
+ Previews run every controller-side check the action itself runs, so an
145
+ action that previews cleanly fails only on what the servers report.
146
+ """
147
+
148
+ action: str
149
+ subject: PreviewSubject
150
+ subject_id: ResourceId
151
+ release: ReleaseId | None
152
+ servers: tuple[ResourceId, ...]
153
+ effect: str
154
+
155
+ def as_dict(self) -> dict[str, object]:
156
+ """Serialize the preview for agent consumers."""
157
+ result: dict[str, object] = {
158
+ "status": "plan",
159
+ "action": self.action,
160
+ self.subject.value: self.subject_id.value,
161
+ "servers": [server.value for server in self.servers],
162
+ "wouldRun": self.effect,
163
+ }
164
+ if self.release is not None:
165
+ result["release"] = self.release.value
166
+ return result
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class EngineContext:
171
+ """Filesystem contract shared by every lifecycle action."""
172
+
173
+ project_directory: Path
174
+ schema_directory: Path
175
+ engine_directory: Path
176
+ inventory_file: Path
177
+
178
+ def playbook(self, name: str) -> Path:
179
+ """Return the path of one engine playbook, requiring it to exist."""
180
+ path = self.engine_directory / "ansible" / "playbooks" / name
181
+ if not path.is_file():
182
+ detail = f"engine playbook does not exist: {path}"
183
+ raise LifecycleError(_ERROR_ENGINE_MISSING, detail)
184
+ return path
185
+
186
+ def ansible_environment(self) -> dict[str, str]:
187
+ """Return environment additions for engine playbook execution."""
188
+ return {
189
+ "ANSIBLE_CONFIG": str(
190
+ self.engine_directory / "ansible" / "ansible.cfg"
191
+ )
192
+ }
193
+
194
+
195
+ def verify_artifact(
196
+ artifact_directory: Path,
197
+ component_id: ResourceId,
198
+ release: ReleaseId,
199
+ schema_directory: Path,
200
+ ) -> VerifiedArtifact:
201
+ """Verify a built artifact's metadata, identity, and digest."""
202
+ component_directory = artifact_directory / component_id.value
203
+ archive_path = component_directory / f"{release.value}.tar.gz"
204
+ metadata_path = component_directory / f"{release.value}.json"
205
+ if not archive_path.is_file() or not metadata_path.is_file():
206
+ detail = (
207
+ "artifact archive or metadata does not exist for "
208
+ f"{component_id}/{release.value} under {artifact_directory}"
209
+ )
210
+ raise LifecycleError(_ERROR_ARTIFACT_MISSING, detail)
211
+
212
+ raw = cast(
213
+ "object", json.loads(metadata_path.read_text(encoding="utf-8"))
214
+ )
215
+ if not isinstance(raw, dict) or not all(
216
+ isinstance(key, str) for key in raw
217
+ ):
218
+ detail = f"artifact metadata is not an object: {metadata_path}"
219
+ raise LifecycleError(_ERROR_ARTIFACT_METADATA, detail)
220
+ metadata = cast("Mapping[str, object]", raw)
221
+ try:
222
+ SchemaCatalog(schema_directory).validate_named(
223
+ _ARTIFACT_SCHEMA, metadata
224
+ )
225
+ except ValidationError as error:
226
+ detail = f"artifact metadata is invalid: {error.message}"
227
+ raise LifecycleError(_ERROR_ARTIFACT_METADATA, detail) from error
228
+
229
+ spec = cast("Mapping[str, object]", metadata["spec"])
230
+ if (
231
+ spec.get("component") != component_id.value
232
+ or spec.get("release") != release.value
233
+ ):
234
+ detail = (
235
+ "artifact metadata identifies "
236
+ f"{spec.get('component')}/{spec.get('release')}, not "
237
+ f"{component_id}/{release.value}"
238
+ )
239
+ raise LifecycleError(_ERROR_ARTIFACT_IDENTITY, detail)
240
+
241
+ declared_digest = spec.get("archiveSha256")
242
+ actual_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
243
+ if actual_digest != declared_digest:
244
+ detail = (
245
+ f"artifact digest {actual_digest} does not match declared "
246
+ f"metadata digest {declared_digest}"
247
+ )
248
+ raise LifecycleError(_ERROR_ARTIFACT_DIGEST, detail)
249
+ return VerifiedArtifact(
250
+ archive_path=archive_path,
251
+ metadata_path=metadata_path,
252
+ sha256=actual_digest,
253
+ )
254
+
255
+
256
+ def plan_deploy(
257
+ context: EngineContext,
258
+ component_id: ResourceId,
259
+ release: ReleaseId,
260
+ artifact: VerifiedArtifact,
261
+ options: DeployOptions,
262
+ ) -> ExecutionPlan:
263
+ """Compose the subprocess steps for one deployment."""
264
+ extra_vars: dict[str, object] = {
265
+ "cloudfall_deploy_component_id": component_id.value,
266
+ "cloudfall_deploy_release": release.value,
267
+ # The engine resolves relative paths against its own tree, so every
268
+ # controller-side path must cross this boundary absolute.
269
+ "cloudfall_deploy_artifact_archive": str(
270
+ artifact.archive_path.resolve()
271
+ ),
272
+ "cloudfall_deploy_artifact_sha256": artifact.sha256,
273
+ }
274
+ if options.environment_file is not None:
275
+ extra_vars["cloudfall_deploy_environment_file"] = str(
276
+ options.environment_file.resolve()
277
+ )
278
+ if options.receipt_directory is not None:
279
+ extra_vars["cloudfall_deploy_receipt_directory"] = str(
280
+ options.receipt_directory.resolve()
281
+ )
282
+ return ExecutionPlan(
283
+ action="deploy",
284
+ component_id=component_id,
285
+ release=release,
286
+ steps=(
287
+ _render_inventory_step(context),
288
+ _playbook_step(context, "deploy.yml", extra_vars),
289
+ ),
290
+ )
291
+
292
+
293
+ def plan_rollback(
294
+ context: EngineContext,
295
+ component_id: ResourceId,
296
+ release: ReleaseId,
297
+ ) -> ExecutionPlan:
298
+ """Compose the subprocess steps for one explicit rollback."""
299
+ extra_vars: dict[str, object] = {
300
+ "cloudfall_deploy_component_id": component_id.value,
301
+ "cloudfall_deploy_release": release.value,
302
+ }
303
+ return ExecutionPlan(
304
+ action="rollback",
305
+ component_id=component_id,
306
+ release=release,
307
+ steps=(
308
+ _render_inventory_step(context),
309
+ _playbook_step(context, "rollback.yml", extra_vars),
310
+ ),
311
+ )
312
+
313
+
314
+ def plan_restart(
315
+ context: EngineContext, component_id: ResourceId
316
+ ) -> ExecutionPlan:
317
+ """Compose the subprocess steps for one health-gated restart."""
318
+ extra_vars: dict[str, object] = {
319
+ "cloudfall_deploy_component_id": component_id.value,
320
+ }
321
+ return ExecutionPlan(
322
+ action="restart",
323
+ component_id=component_id,
324
+ release=None,
325
+ steps=(
326
+ _render_inventory_step(context),
327
+ _playbook_step(context, "restart.yml", extra_vars),
328
+ ),
329
+ )
330
+
331
+
332
+ def plan_health(
333
+ context: EngineContext, component_id: ResourceId
334
+ ) -> ExecutionPlan:
335
+ """Compose the subprocess steps for one read-only health check."""
336
+ extra_vars: dict[str, object] = {
337
+ "cloudfall_deploy_component_id": component_id.value,
338
+ }
339
+ return ExecutionPlan(
340
+ action="health",
341
+ component_id=component_id,
342
+ release=None,
343
+ steps=(
344
+ _render_inventory_step(context),
345
+ _playbook_step(context, "health.yml", extra_vars),
346
+ ),
347
+ )
348
+
349
+
350
+ def execute_plan(plan: ExecutionPlan) -> None:
351
+ """Run every step of a plan, failing fast on the first error."""
352
+ _execute_steps(plan.steps)
353
+
354
+
355
+ def run_engine_playbook(
356
+ context: EngineContext,
357
+ playbook_name: str,
358
+ extra_vars: Mapping[str, object],
359
+ ) -> None:
360
+ """Render inventory and run one engine playbook contract."""
361
+ _execute_steps(
362
+ (
363
+ _render_inventory_step(context),
364
+ _playbook_step(context, playbook_name, extra_vars),
365
+ )
366
+ )
367
+
368
+
369
+ def build_release_artifact(
370
+ context: EngineContext,
371
+ component_id: ResourceId,
372
+ git_ref: str,
373
+ artifact_directory: Path,
374
+ ) -> dict[str, object]:
375
+ """Build one release artifact through the engine's CLI contract."""
376
+ argv = (
377
+ sys.executable,
378
+ "-m",
379
+ "cloudfall_engine",
380
+ "artifact",
381
+ "build",
382
+ "--project",
383
+ str(context.project_directory),
384
+ component_id.value,
385
+ "--ref",
386
+ git_ref,
387
+ "--schemas",
388
+ str(context.schema_directory),
389
+ "--output-dir",
390
+ str(artifact_directory),
391
+ )
392
+ step = ExecutionStep(
393
+ description="build release artifact",
394
+ argv=argv,
395
+ environment={},
396
+ )
397
+ output = _execute_step_with_output(step)
398
+ parsed = cast("object", json.loads(output))
399
+ if not isinstance(parsed, dict) or not all(
400
+ isinstance(key, str) for key in parsed
401
+ ):
402
+ detail = "artifact builder returned a non-object payload"
403
+ raise LifecycleError(_ERROR_EXECUTION_FAILED, detail)
404
+ return cast("dict[str, object]", parsed)
405
+
406
+
407
+ def _execute_steps(steps: tuple[ExecutionStep, ...]) -> None:
408
+ for step in steps:
409
+ _execute_step_with_output(step)
410
+
411
+
412
+ def _execute_step_with_output(step: ExecutionStep) -> str:
413
+ environment = {**os.environ, **step.environment}
414
+ try:
415
+ completed = subprocess.run( # noqa: S603 - argv from typed values.
416
+ list(step.argv),
417
+ check=True,
418
+ capture_output=True,
419
+ text=True,
420
+ timeout=_STEP_TIMEOUT_SECONDS,
421
+ env=environment,
422
+ )
423
+ except subprocess.CalledProcessError as error:
424
+ tail = f"{error.stdout}\n{error.stderr}"[-_OUTPUT_TAIL_CHARACTERS:]
425
+ detail = f"{step.description} failed: {tail.strip()}"
426
+ raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) from error
427
+ except subprocess.TimeoutExpired as error:
428
+ detail = (
429
+ f"{step.description} exceeded {_STEP_TIMEOUT_SECONDS} seconds"
430
+ )
431
+ raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) from error
432
+ return completed.stdout
433
+
434
+
435
+ def deploy(
436
+ context: EngineContext,
437
+ component_id: ResourceId,
438
+ release: ReleaseId,
439
+ artifact_directory: Path,
440
+ options: DeployOptions | None = None,
441
+ ) -> LifecycleResult:
442
+ """Deploy one verified release behind the engine's health gate."""
443
+ resolved_options = options if options is not None else DeployOptions()
444
+ component = _component(context, component_id)
445
+ artifact = verify_artifact(
446
+ artifact_directory, component_id, release, context.schema_directory
447
+ )
448
+ plan = plan_deploy(
449
+ context,
450
+ component_id,
451
+ release,
452
+ artifact,
453
+ resolved_options,
454
+ )
455
+ execute_plan(plan)
456
+ receipt = None
457
+ if resolved_options.receipt_directory is not None:
458
+ receipt = (
459
+ resolved_options.receipt_directory / f"{component_id.value}.json"
460
+ )
461
+ if not receipt.is_file():
462
+ detail = (
463
+ "deployment reported success but wrote no release receipt: "
464
+ f"{receipt}"
465
+ )
466
+ raise LifecycleError(_ERROR_RECEIPT_MISSING, detail)
467
+ return LifecycleResult(
468
+ action="deploy",
469
+ component_id=component_id,
470
+ release=release,
471
+ servers=component.server_ids,
472
+ healthy=True,
473
+ detail=None,
474
+ receipt=receipt,
475
+ )
476
+
477
+
478
+ def preview_deploy(
479
+ context: EngineContext,
480
+ component_id: ResourceId,
481
+ release: ReleaseId,
482
+ artifact_directory: Path,
483
+ ) -> LifecyclePreview:
484
+ """Validate a deployment without touching any server."""
485
+ component = _component(context, component_id)
486
+ verify_artifact(
487
+ artifact_directory, component_id, release, context.schema_directory
488
+ )
489
+ return LifecyclePreview(
490
+ action="deploy",
491
+ subject=PreviewSubject.COMPONENT,
492
+ subject_id=component_id,
493
+ release=release,
494
+ servers=component.server_ids,
495
+ effect=(
496
+ f"deploy release {release.value} of component {component_id} to "
497
+ f"{_server_list(component.server_ids)} behind its health check, "
498
+ "rolling back automatically if the check fails"
499
+ ),
500
+ )
501
+
502
+
503
+ def preview_rollback(
504
+ context: EngineContext, component_id: ResourceId, release: ReleaseId
505
+ ) -> LifecyclePreview:
506
+ """Validate a rollback without touching any server.
507
+
508
+ Whether the release is still retained is known only to the servers; the
509
+ rollback itself fails if it is not.
510
+ """
511
+ component = _component(context, component_id)
512
+ return LifecyclePreview(
513
+ action="rollback",
514
+ subject=PreviewSubject.COMPONENT,
515
+ subject_id=component_id,
516
+ release=release,
517
+ servers=component.server_ids,
518
+ effect=(
519
+ f"switch component {component_id} on "
520
+ f"{_server_list(component.server_ids)} back to retained release "
521
+ f"{release.value} and restart it behind its health check"
522
+ ),
523
+ )
524
+
525
+
526
+ def preview_restart(
527
+ context: EngineContext, component_id: ResourceId
528
+ ) -> LifecyclePreview:
529
+ """Validate a restart without touching any server."""
530
+ component = _component(context, component_id)
531
+ return LifecyclePreview(
532
+ action="restart",
533
+ subject=PreviewSubject.COMPONENT,
534
+ subject_id=component_id,
535
+ release=None,
536
+ servers=component.server_ids,
537
+ effect=(
538
+ f"restart component {component_id} on "
539
+ f"{_server_list(component.server_ids)} and require its declared "
540
+ "health check"
541
+ ),
542
+ )
543
+
544
+
545
+ def rollback(
546
+ context: EngineContext,
547
+ component_id: ResourceId,
548
+ release: ReleaseId,
549
+ ) -> LifecycleResult:
550
+ """Switch one component back to an existing release, health gated."""
551
+ component = _component(context, component_id)
552
+ plan = plan_rollback(context, component_id, release)
553
+ execute_plan(plan)
554
+ return LifecycleResult(
555
+ action="rollback",
556
+ component_id=component_id,
557
+ release=release,
558
+ servers=component.server_ids,
559
+ healthy=True,
560
+ detail=None,
561
+ receipt=None,
562
+ )
563
+
564
+
565
+ def restart(
566
+ context: EngineContext, component_id: ResourceId
567
+ ) -> LifecycleResult:
568
+ """Restart one component and require its declared health check."""
569
+ component = _component(context, component_id)
570
+ plan = plan_restart(context, component_id)
571
+ execute_plan(plan)
572
+ return LifecycleResult(
573
+ action="restart",
574
+ component_id=component_id,
575
+ release=None,
576
+ servers=component.server_ids,
577
+ healthy=True,
578
+ detail=None,
579
+ receipt=None,
580
+ )
581
+
582
+
583
+ def health(
584
+ context: EngineContext, component_id: ResourceId
585
+ ) -> LifecycleResult:
586
+ """Probe one component's declared health check on every server."""
587
+ component = _component(context, component_id)
588
+ plan = plan_health(context, component_id)
589
+ healthy = True
590
+ detail: str | None = None
591
+ try:
592
+ execute_plan(plan)
593
+ except LifecycleError as error:
594
+ if error.code != _ERROR_EXECUTION_FAILED:
595
+ raise
596
+ healthy = False
597
+ detail = error.detail
598
+ return LifecycleResult(
599
+ action="health",
600
+ component_id=component_id,
601
+ release=None,
602
+ servers=component.server_ids,
603
+ healthy=healthy,
604
+ detail=detail,
605
+ receipt=None,
606
+ )
607
+
608
+
609
+ def plan_data_migration(
610
+ context: EngineContext,
611
+ service_id: ResourceId,
612
+ database: str,
613
+ source_url_file: Path,
614
+ receipt_directory: Path | None,
615
+ ) -> ExecutionPlan:
616
+ """Compose the subprocess steps for one guided data migration.
617
+
618
+ The plan reuses ``ExecutionPlan`` with the service identifier in the
619
+ ``component_id`` slot; the source URL never crosses a process argument,
620
+ only the controller-side file path does.
621
+ """
622
+ extra_vars: dict[str, object] = {
623
+ "cloudfall_data_service_id": service_id.value,
624
+ "cloudfall_data_database": database,
625
+ "cloudfall_data_source_url_file": str(source_url_file.resolve()),
626
+ }
627
+ if receipt_directory is not None:
628
+ extra_vars["cloudfall_data_receipt_directory"] = str(
629
+ receipt_directory.resolve()
630
+ )
631
+ return ExecutionPlan(
632
+ action="data-migration",
633
+ component_id=service_id,
634
+ release=None,
635
+ steps=(
636
+ _render_inventory_step(context),
637
+ _playbook_step(context, "data.yml", extra_vars),
638
+ ),
639
+ )
640
+
641
+
642
+ def migrate_data(
643
+ context: EngineContext,
644
+ service_id: ResourceId,
645
+ database: str,
646
+ source_url_file: Path,
647
+ receipt_directory: Path | None = None,
648
+ ) -> dict[str, object]:
649
+ """Dump one external database and restore it into a declared service."""
650
+ service = _checked_data_migration(context, service_id, database, source_url_file)
651
+ plan = plan_data_migration(
652
+ context, service_id, database, source_url_file, receipt_directory
653
+ )
654
+ execute_plan(plan)
655
+ result: dict[str, object] = {
656
+ "status": "ok",
657
+ "action": "data-migration",
658
+ "service": service_id.value,
659
+ "database": database,
660
+ "servers": [service.server_id.value],
661
+ "verification": "per-table row counts matched",
662
+ }
663
+ if receipt_directory is not None:
664
+ result["receipt"] = str(
665
+ receipt_directory / f"{service_id.value}-{database}.json"
666
+ )
667
+ return result
668
+
669
+
670
+ def preview_data_migration(
671
+ context: EngineContext,
672
+ service_id: ResourceId,
673
+ database: str,
674
+ source_url_file: Path,
675
+ ) -> LifecyclePreview:
676
+ """Validate a data migration without touching any server."""
677
+ service = _checked_data_migration(context, service_id, database, source_url_file)
678
+ return LifecyclePreview(
679
+ action="data-migration",
680
+ subject=PreviewSubject.SERVICE,
681
+ subject_id=service_id,
682
+ release=None,
683
+ servers=(service.server_id,),
684
+ effect=(
685
+ f"dump the database whose URL is in {source_url_file} on "
686
+ f"{service.server_id} and restore it into declared database "
687
+ f"{database} of service {service_id}, refusing a non-empty target "
688
+ "and verifying per-table row counts"
689
+ ),
690
+ )
691
+
692
+
693
+ def _checked_data_migration(
694
+ context: EngineContext,
695
+ service_id: ResourceId,
696
+ database: str,
697
+ source_url_file: Path,
698
+ ) -> ServiceInventory:
699
+ service = _postgresql_service(context, service_id)
700
+ postgresql = service.postgresql
701
+ if postgresql is None:
702
+ detail = f"service {service_id} has no PostgreSQL contract"
703
+ raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
704
+ declared = {entry.name.value for entry in postgresql.databases}
705
+ if database not in declared:
706
+ detail = (
707
+ f"database {database!r} is not declared on service "
708
+ f"{service_id}; declared: {sorted(declared)}"
709
+ )
710
+ raise LifecycleError(_ERROR_DATABASE_MISSING, detail)
711
+ if not source_url_file.is_file() or not source_url_file.read_text(
712
+ encoding="utf-8"
713
+ ).strip():
714
+ detail = (
715
+ "source URL file does not exist or is empty: "
716
+ f"{source_url_file}"
717
+ )
718
+ raise LifecycleError(_ERROR_SOURCE_URL_MISSING, detail)
719
+ return service
720
+
721
+
722
+ def backup_service(
723
+ context: EngineContext,
724
+ service_id: ResourceId,
725
+ receipt_directory: Path,
726
+ ) -> dict[str, object]:
727
+ """Run the declared backup for one service and return its receipt."""
728
+ return _backup_operation(context, service_id, receipt_directory, "backup")
729
+
730
+
731
+ def verify_backup(
732
+ context: EngineContext,
733
+ service_id: ResourceId,
734
+ receipt_directory: Path,
735
+ ) -> dict[str, object]:
736
+ """Run the restore-proof check for one service, receipted."""
737
+ return _backup_operation(
738
+ context, service_id, receipt_directory, "restore-check"
739
+ )
740
+
741
+
742
+ def _backup_operation(
743
+ context: EngineContext,
744
+ service_id: ResourceId,
745
+ receipt_directory: Path,
746
+ action: str,
747
+ ) -> dict[str, object]:
748
+ state = validate_config(context.project_directory, context.schema_directory)
749
+ inventory = PlatformInventory.from_state(state)
750
+ declared = next(
751
+ (
752
+ service
753
+ for service in inventory.services
754
+ if service.resource_id == service_id
755
+ ),
756
+ None,
757
+ )
758
+ if declared is None:
759
+ detail = f"declared service does not exist: {service_id}"
760
+ raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
761
+ run_engine_playbook(
762
+ context,
763
+ "backup.yml",
764
+ {
765
+ "cloudfall_backup_service": service_id.value,
766
+ "cloudfall_backup_action": action,
767
+ "cloudfall_backup_receipt_directory": str(
768
+ receipt_directory.resolve()
769
+ ),
770
+ },
771
+ )
772
+ receipt_path = receipt_directory / f"{service_id.value}-{action}.json"
773
+ if not receipt_path.is_file():
774
+ detail = (
775
+ f"no {action} receipt was written for {service_id}; the "
776
+ "service's server produced no result"
777
+ )
778
+ raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
779
+ receipt = cast(
780
+ "dict[str, object]",
781
+ json.loads(receipt_path.read_text(encoding="utf-8")),
782
+ )
783
+ SchemaCatalog(context.schema_directory).validate_named(
784
+ "backup-receipt.schema.json", receipt
785
+ )
786
+ return {"status": "ok", "receipt": receipt, "path": str(receipt_path)}
787
+
788
+
789
+ def _postgresql_service(
790
+ context: EngineContext, service_id: ResourceId
791
+ ) -> ServiceInventory:
792
+ state = validate_config(context.project_directory, context.schema_directory)
793
+ inventory = PlatformInventory.from_state(state)
794
+ service = next(
795
+ (
796
+ candidate
797
+ for candidate in inventory.services
798
+ if candidate.resource_id == service_id
799
+ ),
800
+ None,
801
+ )
802
+ if service is None or service.postgresql is None:
803
+ detail = (
804
+ f"declared PostgreSQL service does not exist: {service_id}"
805
+ )
806
+ raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
807
+ return service
808
+
809
+
810
+ def _component(
811
+ context: EngineContext, component_id: ResourceId
812
+ ) -> ComponentInventory:
813
+ state = validate_config(context.project_directory, context.schema_directory)
814
+ inventory = PlatformInventory.from_state(state)
815
+ component = next(
816
+ (
817
+ candidate
818
+ for candidate in inventory.components
819
+ if candidate.resource_id == component_id
820
+ ),
821
+ None,
822
+ )
823
+ if component is None:
824
+ detail = f"component does not exist: {component_id}"
825
+ raise LifecycleError(_ERROR_COMPONENT_MISSING, detail)
826
+ return component
827
+
828
+
829
+ def _server_list(servers: tuple[ResourceId, ...]) -> str:
830
+ return ", ".join(server.value for server in servers)
831
+
832
+
833
+ def _render_inventory_step(context: EngineContext) -> ExecutionStep:
834
+ return ExecutionStep(
835
+ description="render Ansible inventory",
836
+ argv=(
837
+ sys.executable,
838
+ "-m",
839
+ "cloudfall_engine",
840
+ "inventory",
841
+ "render",
842
+ "--project",
843
+ str(context.project_directory),
844
+ "--schemas",
845
+ str(context.schema_directory),
846
+ "--output",
847
+ str(context.inventory_file),
848
+ ),
849
+ environment={},
850
+ )
851
+
852
+
853
+ def _playbook_step(
854
+ context: EngineContext,
855
+ playbook_name: str,
856
+ extra_vars: Mapping[str, object],
857
+ ) -> ExecutionStep:
858
+ binary = shutil.which("ansible-playbook")
859
+ if binary is None:
860
+ detail = "ansible-playbook is not installed on this controller"
861
+ raise LifecycleError(_ERROR_ANSIBLE_MISSING, detail)
862
+ playbook = context.playbook(playbook_name)
863
+ return ExecutionStep(
864
+ description=f"run {playbook_name}",
865
+ argv=(
866
+ binary,
867
+ "--inventory",
868
+ str(context.inventory_file),
869
+ "--extra-vars",
870
+ json.dumps(dict(extra_vars), sort_keys=True),
871
+ str(playbook),
872
+ ),
873
+ environment=context.ansible_environment(),
874
+ )