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/migrate.py ADDED
@@ -0,0 +1,730 @@
1
+ """Resumable migration orchestrator chaining the platform milestones.
2
+
3
+ One plan drives a migration end to end: server baseline, infrastructure
4
+ services, artifact builds, health-gated deployments, HTTP routes, a DNS
5
+ verification pause, TLS issuance, and a final evidence pass that proves
6
+ the result with inspection, audit compliance, and healthy routes. Step
7
+ progress persists to a plan file, so failed or deliberately paused runs
8
+ resume at the first incomplete step.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import socket
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime, timedelta
17
+ from enum import StrEnum
18
+ from ipaddress import ip_address
19
+ from typing import TYPE_CHECKING, cast
20
+
21
+ from cloudfall.audit import AuditStatus, audit_inventory
22
+ from cloudfall.cutover import (
23
+ SystemDnsProbe,
24
+ check_ttl,
25
+ parallel_run,
26
+ rollback_instructions,
27
+ )
28
+ from cloudfall.domain import ReleaseId, ResourceId, TcpPort
29
+ from cloudfall.inventory import PlatformInventory
30
+ from cloudfall.lifecycle import (
31
+ DeployOptions,
32
+ LifecycleError,
33
+ build_release_artifact,
34
+ deploy,
35
+ migrate_data,
36
+ run_engine_playbook,
37
+ )
38
+ from cloudfall.observation import load_observations
39
+ from cloudfall.operations import (
40
+ OperationsHealth,
41
+ UtcTimestamp,
42
+ build_operations_view,
43
+ )
44
+ from cloudfall.service_evidence import (
45
+ DeploymentReceiptSet,
46
+ DomainObservationSet,
47
+ EvidenceTimestamp,
48
+ SocketDomainNetworkClient,
49
+ inspect_domains,
50
+ load_deployment_receipts,
51
+ load_domain_observations,
52
+ )
53
+ from cloudfall.validation import validate_config
54
+
55
+ if TYPE_CHECKING:
56
+ from collections.abc import Callable, Mapping
57
+ from pathlib import Path
58
+
59
+ from cloudfall.agent_tools import AgentConfig
60
+ from cloudfall.inventory import DomainInventory, ServerInventory
61
+
62
+ Runner = Callable[[], dict[str, object]]
63
+
64
+ _PLAN_VERSION = 1
65
+ _DNS_PROBE_PORT = 443
66
+ _ERROR_COMPONENT_UNKNOWN = "migrate_component_unknown"
67
+ _ERROR_COMPONENT_UNBUILT = "migrate_component_unbuilt"
68
+ _ERROR_PLAN_STALE = "migrate_plan_stale"
69
+ _ERROR_PLAN_INVALID = "migrate_plan_invalid"
70
+ _ERROR_RELEASE_MISSING = "migrate_release_missing"
71
+ _ERROR_DNS_UNVERIFIED = "migrate_dns_unverified"
72
+ _ERROR_TTL_HIGH = "migrate_ttl_high"
73
+ _ERROR_PARALLEL_RUN = "migrate_parallel_run_failed"
74
+ _MAX_CUTOVER_TTL_SECONDS = 300
75
+ _ROLLBACK_WINDOW_HOURS = 24
76
+ _PAUSE_CODES = frozenset({_ERROR_DNS_UNVERIFIED, _ERROR_TTL_HIGH})
77
+ _ERROR_AUDIT_DRIFT = "migrate_audit_drift"
78
+ _ERROR_ROUTES_UNHEALTHY = "migrate_routes_unhealthy"
79
+ _ERROR_DATABASE_UNKNOWN = "migrate_database_unknown"
80
+
81
+
82
+ class MigrateError(RuntimeError):
83
+ """Fail-fast migration error with a stable machine-readable code."""
84
+
85
+ def __init__(self, code: str, message: str) -> None:
86
+ """Record the failure code and human-readable detail."""
87
+ self.code = code
88
+ self.detail = message
89
+ super().__init__(f"{code}: {message}")
90
+
91
+ def as_dict(self) -> dict[str, object]:
92
+ """Serialize the error envelope for system boundaries."""
93
+ return {
94
+ "status": "error",
95
+ "error": {"code": self.code, "message": self.detail},
96
+ }
97
+
98
+
99
+ class StepStatus(StrEnum):
100
+ """Persisted status of one migration step."""
101
+
102
+ PENDING = "pending"
103
+ COMPLETED = "completed"
104
+
105
+
106
+ @dataclass(slots=True)
107
+ class PlanStep:
108
+ """One resumable migration step."""
109
+
110
+ step_id: str
111
+ description: str
112
+ status: StepStatus = StepStatus.PENDING
113
+ completed_at: str | None = None
114
+ detail: dict[str, object] | None = None
115
+
116
+ def as_dict(self) -> dict[str, object]:
117
+ """Serialize the step for the plan file and result envelopes."""
118
+ result: dict[str, object] = {
119
+ "id": self.step_id,
120
+ "description": self.description,
121
+ "status": self.status.value,
122
+ }
123
+ if self.completed_at is not None:
124
+ result["completedAt"] = self.completed_at
125
+ if self.detail is not None:
126
+ result["detail"] = self.detail
127
+ return result
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class MigrateOptions:
132
+ """Inputs selecting what one migration run builds and deploys."""
133
+
134
+ plan_file: Path
135
+ builds: Mapping[str, str]
136
+ releases: Mapping[str, str]
137
+ environment_files: Mapping[str, Path]
138
+ data_migrations: Mapping[str, Path] = field(default_factory=dict)
139
+ execute: bool = False
140
+ restart: bool = False
141
+
142
+
143
+ def execute_migration(
144
+ config: AgentConfig,
145
+ options: MigrateOptions,
146
+ runners: Mapping[str, Runner] | None = None,
147
+ ) -> dict[str, object]:
148
+ """Run or preview the migration plan, resuming persisted progress."""
149
+ state = validate_config(config.project_directory, config.schema_directory)
150
+ inventory = PlatformInventory.from_state(state)
151
+ _require_known_components(inventory, options)
152
+ steps = _load_plan(options, _computed_steps(inventory, options))
153
+ if not options.execute:
154
+ return _envelope("plan", steps)
155
+
156
+ releases = _recover_releases(steps, options)
157
+ resolved_runners = (
158
+ runners
159
+ if runners is not None
160
+ else _build_runners(config, options, inventory, releases, steps)
161
+ )
162
+ for step in steps:
163
+ if step.status is StepStatus.COMPLETED:
164
+ continue
165
+ try:
166
+ step.detail = resolved_runners[step.step_id]()
167
+ except (MigrateError, LifecycleError) as error:
168
+ _save_plan(options.plan_file, steps)
169
+ envelope = _envelope(
170
+ "paused"
171
+ if isinstance(error, MigrateError)
172
+ and error.code in _PAUSE_CODES
173
+ else "error",
174
+ steps,
175
+ )
176
+ envelope["step"] = step.step_id
177
+ envelope["error"] = error.as_dict()["error"]
178
+ return envelope
179
+ step.status = StepStatus.COMPLETED
180
+ step.completed_at = datetime.now(tz=UTC).strftime(
181
+ "%Y-%m-%dT%H:%M:%SZ"
182
+ )
183
+ _save_plan(options.plan_file, steps)
184
+ return _envelope("ok", steps)
185
+
186
+
187
+ def _envelope(status: str, steps: list[PlanStep]) -> dict[str, object]:
188
+ pending = [
189
+ step.step_id for step in steps if step.status is StepStatus.PENDING
190
+ ]
191
+ envelope: dict[str, object] = {
192
+ "status": status,
193
+ "steps": [step.as_dict() for step in steps],
194
+ "completed": sum(
195
+ 1 for step in steps if step.status is StepStatus.COMPLETED
196
+ ),
197
+ }
198
+ if pending:
199
+ envelope["next"] = pending[0]
200
+ return envelope
201
+
202
+
203
+ def _computed_steps(
204
+ inventory: PlatformInventory, options: MigrateOptions
205
+ ) -> list[PlanStep]:
206
+ steps = [
207
+ PlanStep("baseline", "converge every server to the managed baseline"),
208
+ PlanStep(
209
+ "services", "converge every declared infrastructure service"
210
+ ),
211
+ ]
212
+ if inventory.domains:
213
+ steps.append(
214
+ PlanStep(
215
+ "ttl-lower",
216
+ "measure authoritative DNS TTLs and require them at or "
217
+ f"below {_MAX_CUTOVER_TTL_SECONDS}s before the cutover",
218
+ )
219
+ )
220
+ steps.extend(
221
+ PlanStep(
222
+ f"data:{database}",
223
+ f"migrate external data into database {database} with "
224
+ "row-count verification",
225
+ )
226
+ for database in sorted(options.data_migrations)
227
+ )
228
+ for component in inventory.components:
229
+ component_id = component.resource_id.value
230
+ if component_id not in options.releases:
231
+ steps.append(
232
+ PlanStep(
233
+ f"build:{component_id}",
234
+ f"build a release artifact for {component_id}",
235
+ )
236
+ )
237
+ steps.append(
238
+ PlanStep(
239
+ f"deploy:{component_id}",
240
+ f"deploy {component_id} behind its health gate",
241
+ )
242
+ )
243
+ if inventory.domains:
244
+ steps.extend(
245
+ (
246
+ PlanStep(
247
+ "domains-http",
248
+ "render every declared domain route over HTTP",
249
+ ),
250
+ PlanStep(
251
+ "parallel-run",
252
+ "prove the new origin serves every declared domain "
253
+ "before any DNS record changes",
254
+ ),
255
+ PlanStep(
256
+ "dns-verify",
257
+ "verify public DNS points at the declared proxy servers",
258
+ ),
259
+ PlanStep(
260
+ "domains-tls",
261
+ "issue certificates and enable HTTPS routes",
262
+ ),
263
+ )
264
+ )
265
+ steps.extend(
266
+ (
267
+ PlanStep("inspect", "collect read-only server evidence"),
268
+ PlanStep("audit", "require a compliant desired-state audit"),
269
+ )
270
+ )
271
+ if inventory.domains:
272
+ steps.extend(
273
+ (
274
+ PlanStep(
275
+ "verify-routes",
276
+ "require every declared route to be healthy",
277
+ ),
278
+ PlanStep(
279
+ "rollback-window",
280
+ "record the pre-switch DNS answers and the explicit "
281
+ f"{_ROLLBACK_WINDOW_HOURS}h rollback recipe",
282
+ ),
283
+ )
284
+ )
285
+ return steps
286
+
287
+
288
+ def _require_known_components(
289
+ inventory: PlatformInventory, options: MigrateOptions
290
+ ) -> None:
291
+ declared = {component.resource_id.value for component in inventory.components}
292
+ for source_name, mapping in (
293
+ ("--build", options.builds),
294
+ ("--release", options.releases),
295
+ ("--env-file", options.environment_files),
296
+ ):
297
+ unknown = sorted(set(mapping) - declared)
298
+ if unknown:
299
+ detail = (
300
+ f"{source_name} references undeclared components: "
301
+ f"{', '.join(unknown)}"
302
+ )
303
+ raise MigrateError(_ERROR_COMPONENT_UNKNOWN, detail)
304
+ unbuilt = sorted(
305
+ declared - set(options.builds) - set(options.releases)
306
+ )
307
+ if unbuilt:
308
+ detail = (
309
+ "declare a git ref (--build component=ref) or a pinned release "
310
+ f"(--release component=id) for: {', '.join(unbuilt)}"
311
+ )
312
+ raise MigrateError(_ERROR_COMPONENT_UNBUILT, detail)
313
+ _require_known_databases(inventory, options)
314
+
315
+
316
+ def _require_known_databases(
317
+ inventory: PlatformInventory, options: MigrateOptions
318
+ ) -> None:
319
+ declared_databases = {
320
+ database.name.value
321
+ for service in inventory.services
322
+ if service.postgresql is not None
323
+ for database in service.postgresql.databases
324
+ }
325
+ unknown = sorted(set(options.data_migrations) - declared_databases)
326
+ if unknown:
327
+ detail = (
328
+ "--data references databases not declared on any PostgreSQL "
329
+ f"service: {', '.join(unknown)}"
330
+ )
331
+ raise MigrateError(_ERROR_DATABASE_UNKNOWN, detail)
332
+
333
+
334
+ def _load_plan(
335
+ options: MigrateOptions, computed: list[PlanStep]
336
+ ) -> list[PlanStep]:
337
+ plan_file = options.plan_file
338
+ if options.restart or not plan_file.is_file():
339
+ return computed
340
+ raw = cast("object", json.loads(plan_file.read_text(encoding="utf-8")))
341
+ if not isinstance(raw, dict) or not isinstance(raw.get("steps"), list):
342
+ detail = f"plan file is not a valid migration plan: {plan_file}"
343
+ raise MigrateError(_ERROR_PLAN_INVALID, detail)
344
+ persisted: list[PlanStep] = []
345
+ for raw_step in cast("list[object]", raw["steps"]):
346
+ if not isinstance(raw_step, dict):
347
+ detail = f"plan file contains a malformed step: {plan_file}"
348
+ raise MigrateError(_ERROR_PLAN_INVALID, detail)
349
+ step = cast("dict[str, object]", raw_step)
350
+ raw_detail = step.get("detail")
351
+ persisted.append(
352
+ PlanStep(
353
+ step_id=str(step.get("id")),
354
+ description=str(step.get("description")),
355
+ status=StepStatus(str(step.get("status"))),
356
+ completed_at=(
357
+ str(step["completedAt"])
358
+ if step.get("completedAt") is not None
359
+ else None
360
+ ),
361
+ detail=(
362
+ cast("dict[str, object]", raw_detail)
363
+ if isinstance(raw_detail, dict)
364
+ else None
365
+ ),
366
+ )
367
+ )
368
+ if [step.step_id for step in persisted] != [
369
+ step.step_id for step in computed
370
+ ]:
371
+ detail = (
372
+ "the persisted plan no longer matches declared state; rerun "
373
+ "with --restart to discard it"
374
+ )
375
+ raise MigrateError(_ERROR_PLAN_STALE, detail)
376
+ return persisted
377
+
378
+
379
+ def _save_plan(plan_file: Path, steps: list[PlanStep]) -> None:
380
+ plan_file.parent.mkdir(parents=True, exist_ok=True)
381
+ payload = {
382
+ "version": _PLAN_VERSION,
383
+ "steps": [step.as_dict() for step in steps],
384
+ }
385
+ plan_file.write_text(
386
+ f"{json.dumps(payload, indent=2, sort_keys=True)}\n",
387
+ encoding="utf-8",
388
+ )
389
+
390
+
391
+ def _recover_releases(
392
+ steps: list[PlanStep], options: MigrateOptions
393
+ ) -> dict[str, str]:
394
+ releases = dict(options.releases)
395
+ for step in steps:
396
+ if (
397
+ step.step_id.startswith("build:")
398
+ and step.status is StepStatus.COMPLETED
399
+ and step.detail is not None
400
+ and isinstance(step.detail.get("release"), str)
401
+ ):
402
+ releases[step.step_id.removeprefix("build:")] = str(
403
+ step.detail["release"]
404
+ )
405
+ return releases
406
+
407
+
408
+ def _build_runners(
409
+ config: AgentConfig,
410
+ options: MigrateOptions,
411
+ inventory: PlatformInventory,
412
+ releases: dict[str, str],
413
+ steps: list[PlanStep],
414
+ ) -> dict[str, Runner]:
415
+ context = config.context()
416
+ runners: dict[str, Runner] = {
417
+ "baseline": lambda: _playbook(config, "baseline.yml", {}),
418
+ "services": lambda: _playbook(config, "services.yml", {}),
419
+ "ttl-lower": lambda: _lower_ttl(inventory),
420
+ "parallel-run": lambda: _parallel_run(inventory),
421
+ "rollback-window": lambda: _rollback_window(steps),
422
+ "domains-http": lambda: _playbook(
423
+ config, "domains.yml", _domain_vars(config)
424
+ ),
425
+ "dns-verify": lambda: _verify_dns(inventory),
426
+ "domains-tls": lambda: _playbook(
427
+ config,
428
+ "domains.yml",
429
+ {
430
+ **_domain_vars(config),
431
+ "cloudfall_domains_issue_certificates": True,
432
+ },
433
+ ),
434
+ "inspect": lambda: _playbook(
435
+ config,
436
+ "inspect.yml",
437
+ {
438
+ "cloudfall_inspect_output_directory": str(
439
+ config.observed_directory.resolve()
440
+ )
441
+ },
442
+ ),
443
+ "audit": lambda: _verify_audit(config),
444
+ "verify-routes": lambda: _verify_routes(config),
445
+ }
446
+
447
+ def _build_runner(component_id: str, git_ref: str) -> Runner:
448
+ def _run() -> dict[str, object]:
449
+ built = build_release_artifact(
450
+ context,
451
+ ResourceId(component_id),
452
+ git_ref,
453
+ config.artifacts_directory,
454
+ )
455
+ release = built.get("release")
456
+ if not isinstance(release, str):
457
+ detail = "artifact builder returned no release id"
458
+ raise MigrateError(_ERROR_RELEASE_MISSING, detail)
459
+ releases[component_id] = release
460
+ return {"release": release, "gitRef": git_ref}
461
+
462
+ return _run
463
+
464
+ def _deploy_runner(component_id: str) -> Runner:
465
+ def _run() -> dict[str, object]:
466
+ release = releases.get(component_id)
467
+ if release is None:
468
+ detail = (
469
+ f"no built or pinned release recorded for {component_id}"
470
+ )
471
+ raise MigrateError(_ERROR_RELEASE_MISSING, detail)
472
+ result = deploy(
473
+ context,
474
+ ResourceId(component_id),
475
+ ReleaseId(release),
476
+ config.artifacts_directory,
477
+ DeployOptions(
478
+ environment_file=options.environment_files.get(
479
+ component_id
480
+ ),
481
+ receipt_directory=config.releases_directory,
482
+ ),
483
+ )
484
+ return result.as_dict()
485
+
486
+ return _run
487
+
488
+ for component in inventory.components:
489
+ component_id = component.resource_id.value
490
+ git_ref = options.builds.get(component_id)
491
+ if git_ref is not None:
492
+ runners[f"build:{component_id}"] = _build_runner(
493
+ component_id, git_ref
494
+ )
495
+ runners[f"deploy:{component_id}"] = _deploy_runner(component_id)
496
+ for database, source_url_file in options.data_migrations.items():
497
+ runners[f"data:{database}"] = _data_runner(
498
+ config, inventory, database, source_url_file
499
+ )
500
+ return runners
501
+
502
+
503
+ def _data_runner(
504
+ config: AgentConfig,
505
+ inventory: PlatformInventory,
506
+ database: str,
507
+ source_url_file: Path,
508
+ ) -> Runner:
509
+ def _run() -> dict[str, object]:
510
+ service = next(
511
+ service
512
+ for service in inventory.services
513
+ if service.postgresql is not None
514
+ and any(
515
+ entry.name.value == database
516
+ for entry in service.postgresql.databases
517
+ )
518
+ )
519
+ return migrate_data(
520
+ config.context(),
521
+ service.resource_id,
522
+ database,
523
+ source_url_file,
524
+ config.data_migrations_directory,
525
+ )
526
+
527
+ return _run
528
+
529
+
530
+ def _playbook(
531
+ config: AgentConfig,
532
+ playbook_name: str,
533
+ extra_vars: dict[str, object],
534
+ ) -> dict[str, object]:
535
+ run_engine_playbook(config.context(), playbook_name, extra_vars)
536
+ return {"playbook": playbook_name}
537
+
538
+
539
+ def _domain_vars(config: AgentConfig) -> dict[str, object]:
540
+ return {
541
+ "cloudfall_domains_receipt_directory": str(
542
+ config.deployments_directory.resolve()
543
+ )
544
+ }
545
+
546
+
547
+ def _lower_ttl(inventory: PlatformInventory) -> dict[str, object]:
548
+ detail, offenders = check_ttl(
549
+ inventory, SystemDnsProbe(), _MAX_CUTOVER_TTL_SECONDS
550
+ )
551
+ if offenders:
552
+ message = (
553
+ "lower these DNS TTLs at your provider, wait one old TTL for "
554
+ "propagation, and rerun the migration: " + "; ".join(offenders)
555
+ )
556
+ raise MigrateError(_ERROR_TTL_HIGH, message)
557
+ return detail
558
+
559
+
560
+ def _parallel_run(inventory: PlatformInventory) -> dict[str, object]:
561
+ detail, failures = parallel_run(inventory, SocketDomainNetworkClient())
562
+ if failures:
563
+ message = (
564
+ "the new origin does not serve every declared domain yet; fix "
565
+ "these before touching DNS: " + "; ".join(failures)
566
+ )
567
+ raise MigrateError(_ERROR_PARALLEL_RUN, message)
568
+ return detail
569
+
570
+
571
+ def _rollback_window(steps: list[PlanStep]) -> dict[str, object]:
572
+ previous = next(
573
+ (
574
+ step.detail
575
+ for step in steps
576
+ if step.step_id == "ttl-lower" and step.detail is not None
577
+ ),
578
+ None,
579
+ )
580
+ switched_at = next(
581
+ (
582
+ step.completed_at
583
+ for step in steps
584
+ if step.step_id == "dns-verify" and step.completed_at is not None
585
+ ),
586
+ None,
587
+ )
588
+ now = datetime.now(tz=UTC)
589
+ switched = switched_at if switched_at is not None else _timestamp(now)
590
+ window_ends = _timestamp(
591
+ now + timedelta(hours=_ROLLBACK_WINDOW_HOURS)
592
+ )
593
+ return rollback_instructions(
594
+ previous if previous is not None else {},
595
+ _ROLLBACK_WINDOW_HOURS,
596
+ switched,
597
+ window_ends,
598
+ )
599
+
600
+
601
+ def _timestamp(moment: datetime) -> str:
602
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
603
+
604
+
605
+ def _verify_dns(inventory: PlatformInventory) -> dict[str, object]:
606
+ servers = {server.resource_id: server for server in inventory.servers}
607
+ client = SocketDomainNetworkClient()
608
+ verified: list[str] = []
609
+ skipped: list[str] = []
610
+ mismatched: list[str] = []
611
+ for domain in inventory.domains:
612
+ if domain.edge.mode.value != "dns-only":
613
+ skipped.append(domain.primary_name.value)
614
+ continue
615
+ expected = _expected_addresses(servers[domain.proxy.server_id])
616
+ observed = _resolved_addresses(client, domain)
617
+ if expected & observed:
618
+ verified.append(domain.primary_name.value)
619
+ else:
620
+ mismatched.append(
621
+ f"{domain.primary_name.value} resolves to "
622
+ f"{sorted(observed) or 'nothing'} instead of "
623
+ f"{sorted(expected)}"
624
+ )
625
+ if mismatched:
626
+ detail = (
627
+ "lower the DNS TTL, point these records at their declared proxy "
628
+ "servers, wait for propagation, and rerun the migration: "
629
+ + "; ".join(mismatched)
630
+ )
631
+ raise MigrateError(_ERROR_DNS_UNVERIFIED, detail)
632
+ return {"verified": verified, "skippedProxied": skipped}
633
+
634
+
635
+ def _expected_addresses(server: ServerInventory) -> set[str]:
636
+ addresses: set[str] = set()
637
+ try:
638
+ ip_address(server.address.value)
639
+ except ValueError:
640
+ addresses.update(_resolve_names(server.address.value))
641
+ else:
642
+ addresses.add(server.address.value)
643
+ if server.network is not None:
644
+ addresses.add(server.network.ipv4.value)
645
+ return addresses
646
+
647
+
648
+ def _resolved_addresses(
649
+ client: SocketDomainNetworkClient, domain: DomainInventory
650
+ ) -> set[str]:
651
+ try:
652
+ resolved = client.resolve(
653
+ domain.primary_name, TcpPort(_DNS_PROBE_PORT)
654
+ )
655
+ except OSError:
656
+ return set()
657
+ return {address.value for address in resolved}
658
+
659
+
660
+ def _resolve_names(hostname: str) -> set[str]:
661
+ try:
662
+ records = socket.getaddrinfo(
663
+ hostname, _DNS_PROBE_PORT, type=socket.SOCK_STREAM
664
+ )
665
+ except OSError:
666
+ return set()
667
+ return {str(record[4][0]) for record in records}
668
+
669
+
670
+ def _verify_audit(config: AgentConfig) -> dict[str, object]:
671
+ state = validate_config(config.project_directory, config.schema_directory)
672
+ observations = load_observations(
673
+ config.observed_directory, config.schema_directory
674
+ )
675
+ report = audit_inventory(PlatformInventory.from_state(state), observations)
676
+ if report.status is not AuditStatus.COMPLIANT:
677
+ detail = (
678
+ f"the audit reported {report.status.value}; resolve the drift "
679
+ "and rerun the migration"
680
+ )
681
+ raise MigrateError(_ERROR_AUDIT_DRIFT, detail)
682
+ summary = report.as_dict()["summary"]
683
+ return {"summary": cast("dict[str, object]", summary)}
684
+
685
+
686
+ def _verify_routes(config: AgentConfig) -> dict[str, object]:
687
+ state = validate_config(config.project_directory, config.schema_directory)
688
+ inventory = PlatformInventory.from_state(state)
689
+ inspect_domains(
690
+ inventory,
691
+ config.service_observed_directory,
692
+ SocketDomainNetworkClient(),
693
+ observed_at=EvidenceTimestamp.now(),
694
+ )
695
+ observations = load_observations(
696
+ config.observed_directory, config.schema_directory
697
+ )
698
+ receipts = (
699
+ load_deployment_receipts(
700
+ config.deployments_directory, config.schema_directory
701
+ )
702
+ if config.deployments_directory.is_dir()
703
+ else DeploymentReceiptSet.empty()
704
+ )
705
+ domain_observations = (
706
+ load_domain_observations(
707
+ config.service_observed_directory, config.schema_directory
708
+ )
709
+ if config.service_observed_directory.is_dir()
710
+ else DomainObservationSet.empty()
711
+ )
712
+ view = build_operations_view(
713
+ inventory,
714
+ observations,
715
+ receipts,
716
+ domain_observations,
717
+ generated_at=UtcTimestamp.now(),
718
+ )
719
+ unhealthy = [
720
+ f"{domain.primary_name}: {domain.health.value} "
721
+ f"({domain.public_route.detail})"
722
+ for domain in view.domains
723
+ if domain.health is not OperationsHealth.HEALTHY
724
+ ]
725
+ if unhealthy:
726
+ detail = "routes are not healthy yet: " + "; ".join(unhealthy)
727
+ raise MigrateError(_ERROR_ROUTES_UNHEALTHY, detail)
728
+ return {
729
+ "routes": [domain.primary_name for domain in view.domains],
730
+ }