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/commands.py ADDED
@@ -0,0 +1,257 @@
1
+ """Catalog of every command an agent may run in a project, by effect.
2
+
3
+ The catalog is the one place that says which commands change servers and
4
+ which do not. ``cloudfall init`` renders it into the project's ``AGENTS.md``
5
+ so an agent reads the same classification the CLI enforces, and the tests
6
+ check it against the argparse trees, so a command added to a parser
7
+ without a catalog entry fails the suite instead of going unclassified.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+
15
+
16
+ class CommandEffect(Enum):
17
+ """The furthest a command's side effects reach."""
18
+
19
+ READ = "read"
20
+ """Reads the project, evidence under ``tmp/``, or servers; writes nothing
21
+ but evidence and reports under ``tmp/``."""
22
+
23
+ PROJECT = "project"
24
+ """Writes files into the project on the controller; servers untouched."""
25
+
26
+ SERVERS = "servers"
27
+ """Changes servers, always through the engine, always with a receipt."""
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class CommandContract:
32
+ """One leaf command, its effect, and the gate that guards it."""
33
+
34
+ program: str
35
+ """Entry point: ``cloudfall`` or ``cloudfall-engine``."""
36
+
37
+ name: str
38
+ """Space-joined subcommand path, such as ``data migrate``."""
39
+
40
+ effect: CommandEffect
41
+
42
+ summary: str
43
+ """One clause saying what the command does, for the operating contract."""
44
+
45
+ gate: str | None = None
46
+ """What has to happen before the command changes servers."""
47
+
48
+ @property
49
+ def invocation(self) -> str:
50
+ """Return the command as typed from the project directory."""
51
+ return f"uv run {self.program} {self.name}"
52
+
53
+
54
+ _YES = "`--yes`; without it the command validates and prints the plan only"
55
+
56
+ CLI_COMMANDS: tuple[CommandContract, ...] = (
57
+ CommandContract(
58
+ "cloudfall",
59
+ "init",
60
+ CommandEffect.PROJECT,
61
+ "lay out a new project directory",
62
+ ),
63
+ CommandContract(
64
+ "cloudfall",
65
+ "add ssh-key",
66
+ CommandEffect.PROJECT,
67
+ "declare an `SshPublicKey` from a public key file",
68
+ ),
69
+ CommandContract(
70
+ "cloudfall",
71
+ "add server-type",
72
+ CommandEffect.PROJECT,
73
+ "declare a `ServerType` from the bundled Debian 13 baseline",
74
+ ),
75
+ CommandContract(
76
+ "cloudfall",
77
+ "add server",
78
+ CommandEffect.PROJECT,
79
+ "declare a `Server`, creating its type on first use",
80
+ ),
81
+ CommandContract(
82
+ "cloudfall",
83
+ "config validate",
84
+ CommandEffect.READ,
85
+ "validate every resource against the schemas and cross-references",
86
+ ),
87
+ CommandContract(
88
+ "cloudfall",
89
+ "inventory show",
90
+ CommandEffect.READ,
91
+ "show the typed, secret-free platform inventory",
92
+ ),
93
+ CommandContract(
94
+ "cloudfall",
95
+ "audit",
96
+ CommandEffect.READ,
97
+ "compare the declared config with observed server snapshots",
98
+ ),
99
+ CommandContract(
100
+ "cloudfall",
101
+ "services inspect",
102
+ CommandEffect.READ,
103
+ "probe DNS, TLS, origin, and public routes into evidence",
104
+ ),
105
+ CommandContract(
106
+ "cloudfall",
107
+ "services status",
108
+ CommandEffect.READ,
109
+ "derive each public service's lifecycle from current evidence",
110
+ ),
111
+ CommandContract(
112
+ "cloudfall",
113
+ "health",
114
+ CommandEffect.READ,
115
+ "probe one component's declared health check on its servers",
116
+ ),
117
+ CommandContract(
118
+ "cloudfall",
119
+ "dashboard build",
120
+ CommandEffect.READ,
121
+ "build the static read-only operations dashboard",
122
+ ),
123
+ CommandContract(
124
+ "cloudfall",
125
+ "dashboard serve",
126
+ CommandEffect.READ,
127
+ "serve the read-only dashboard over HTTP (long-running)",
128
+ ),
129
+ CommandContract(
130
+ "cloudfall",
131
+ "import render",
132
+ CommandEffect.READ,
133
+ "map a `render.yaml` blueprint onto config fragments under `tmp/`",
134
+ ),
135
+ CommandContract(
136
+ "cloudfall",
137
+ "import render-api",
138
+ CommandEffect.READ,
139
+ "map a live Render workspace onto config fragments under `tmp/`",
140
+ ),
141
+ CommandContract(
142
+ "cloudfall",
143
+ "secrets render",
144
+ CommandEffect.READ,
145
+ "render one component's secret references into a `0600` file under `tmp/`",
146
+ ),
147
+ CommandContract(
148
+ "cloudfall",
149
+ "operator run",
150
+ CommandEffect.READ,
151
+ "watch declared alerts and drift, write proposal receipts",
152
+ ),
153
+ CommandContract(
154
+ "cloudfall",
155
+ "operator list",
156
+ CommandEffect.READ,
157
+ "list proposal receipts",
158
+ ),
159
+ CommandContract(
160
+ "cloudfall",
161
+ "operator show",
162
+ CommandEffect.READ,
163
+ "show one proposal receipt",
164
+ ),
165
+ CommandContract(
166
+ "cloudfall",
167
+ "deploy",
168
+ CommandEffect.SERVERS,
169
+ "activate one built release behind its health check",
170
+ gate=_YES,
171
+ ),
172
+ CommandContract(
173
+ "cloudfall",
174
+ "rollback",
175
+ CommandEffect.SERVERS,
176
+ "switch one component back to an existing release",
177
+ gate=_YES,
178
+ ),
179
+ CommandContract(
180
+ "cloudfall",
181
+ "restart",
182
+ CommandEffect.SERVERS,
183
+ "restart one component behind its health check",
184
+ gate=_YES,
185
+ ),
186
+ CommandContract(
187
+ "cloudfall",
188
+ "data migrate",
189
+ CommandEffect.SERVERS,
190
+ "dump an external database and restore it into a declared service",
191
+ gate=_YES,
192
+ ),
193
+ CommandContract(
194
+ "cloudfall",
195
+ "migrate",
196
+ CommandEffect.SERVERS,
197
+ "run the resumable end-to-end migration plan",
198
+ gate=_YES,
199
+ ),
200
+ CommandContract(
201
+ "cloudfall",
202
+ "backup run",
203
+ CommandEffect.SERVERS,
204
+ "run the declared backup for one service on its server",
205
+ gate="the service must declare the backup; a receipt is written",
206
+ ),
207
+ CommandContract(
208
+ "cloudfall",
209
+ "backup verify",
210
+ CommandEffect.SERVERS,
211
+ "restore the newest backup into a scratch database on the server",
212
+ gate="the service must declare the backup; a receipt is written",
213
+ ),
214
+ CommandContract(
215
+ "cloudfall",
216
+ "operator approve",
217
+ CommandEffect.SERVERS,
218
+ "execute one proposal and verify that its trigger resolves",
219
+ gate="the proposal id names a receipt a human has reviewed",
220
+ ),
221
+ )
222
+
223
+ ENGINE_COMMANDS: tuple[CommandContract, ...] = (
224
+ CommandContract(
225
+ "cloudfall-engine",
226
+ "inventory render",
227
+ CommandEffect.READ,
228
+ "render the Ansible JSON inventory from the validated config",
229
+ ),
230
+ CommandContract(
231
+ "cloudfall-engine",
232
+ "artifact build",
233
+ CommandEffect.READ,
234
+ "clone, package, and hash one component release under `tmp/artifacts`",
235
+ ),
236
+ CommandContract(
237
+ "cloudfall-engine",
238
+ "playbook list",
239
+ CommandEffect.READ,
240
+ "list the playbooks bundled with the engine",
241
+ ),
242
+ CommandContract(
243
+ "cloudfall-engine",
244
+ "playbook run",
245
+ CommandEffect.SERVERS,
246
+ "run one bundled or project playbook against the inventory "
247
+ "(`inspect` only reads; every other playbook converges servers)",
248
+ gate="a human runs it or names the playbook to run",
249
+ ),
250
+ )
251
+
252
+ COMMANDS: tuple[CommandContract, ...] = (*CLI_COMMANDS, *ENGINE_COMMANDS)
253
+
254
+
255
+ def commands_with_effect(effect: CommandEffect) -> tuple[CommandContract, ...]:
256
+ """Return every cataloged command whose side effects reach ``effect``."""
257
+ return tuple(command for command in COMMANDS if command.effect is effect)
cloudfall/cutover.py ADDED
@@ -0,0 +1,370 @@
1
+ """Explicit cutover machinery: TTL evidence, parallel-run, rollback data.
2
+
3
+ The DNS switch is the one irreversible-feeling moment of a migration, so
4
+ everything around it is made explicit and evidence-backed: authoritative
5
+ TTLs are measured (not assumed) with a minimal stdlib DNS client, the new
6
+ origin is proven to serve every declared domain before any record changes,
7
+ and the pre-switch answers are captured so the rollback window is a
8
+ recorded fact with exact revert instructions rather than folklore.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import socket
15
+ import struct
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Protocol
19
+
20
+ from cloudfall.domain import HttpScheme, PositiveCount, TcpPort
21
+ from cloudfall.service_evidence import ProbeTarget
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ from cloudfall.inventory import DomainInventory, PlatformInventory
27
+ from cloudfall.service_evidence import DomainNetworkClient
28
+
29
+ _RESOLV_CONF = Path("/etc/resolv.conf")
30
+ _DNS_PORT = 53
31
+ _DNS_TIMEOUT_SECONDS = 5.0
32
+ _TYPE_A = 1
33
+ _TYPE_NS = 2
34
+ _TYPE_CNAME = 5
35
+ _TYPE_AAAA = 28
36
+ _TYPE_NAMES = {_TYPE_A: "A", _TYPE_NS: "NS", _TYPE_CNAME: "CNAME",
37
+ _TYPE_AAAA: "AAAA"}
38
+ _CLASS_IN = 1
39
+ _POINTER_MASK = 0xC0
40
+ _HTTP_PORT = 80
41
+
42
+
43
+ class CutoverError(RuntimeError):
44
+ """Structured cutover failure suitable for plan envelopes."""
45
+
46
+ def __init__(self, code: str, message: str) -> None:
47
+ """Capture a stable error code alongside the human message."""
48
+ super().__init__(message)
49
+ self.code = code
50
+ self.message = message
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class DnsAnswer:
55
+ """One authoritative DNS answer with its true TTL."""
56
+
57
+ name: str
58
+ record_type: str
59
+ ttl: int
60
+ value: str
61
+
62
+ def as_dict(self) -> dict[str, object]:
63
+ """Serialize for plan details and rollback instructions."""
64
+ return {
65
+ "name": self.name,
66
+ "type": self.record_type,
67
+ "ttl": self.ttl,
68
+ "value": self.value,
69
+ }
70
+
71
+
72
+ class DnsProbe(Protocol):
73
+ """Source of authoritative DNS answers."""
74
+
75
+ def authoritative_answers(self, hostname: str) -> tuple[DnsAnswer, ...]:
76
+ """Return the zone's authoritative answers for one hostname."""
77
+ ...
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class SystemDnsProbe:
82
+ """Authoritative resolver walking the zone from the system resolver."""
83
+
84
+ resolver_address: str | None = None
85
+
86
+ def authoritative_answers(self, hostname: str) -> tuple[DnsAnswer, ...]:
87
+ """Query the zone's own nameserver so TTLs are not cache decay."""
88
+ resolver = (
89
+ self.resolver_address
90
+ if self.resolver_address is not None
91
+ else _system_resolver()
92
+ )
93
+ nameserver = _zone_nameserver(resolver, hostname)
94
+ answers = _query(nameserver, hostname, _TYPE_A)
95
+ answers += _query(nameserver, hostname, _TYPE_AAAA)
96
+ deduplicated = tuple(dict.fromkeys(answers))
97
+ if not deduplicated:
98
+ message = (
99
+ f"the authoritative nameserver returned no records for "
100
+ f"{hostname}"
101
+ )
102
+ code = "cutover_dns_empty"
103
+ raise CutoverError(code, message)
104
+ return deduplicated
105
+
106
+
107
+ def check_ttl(
108
+ inventory: PlatformInventory,
109
+ probe: DnsProbe,
110
+ max_ttl_seconds: int,
111
+ ) -> tuple[dict[str, object], tuple[str, ...]]:
112
+ """Measure authoritative TTLs; report every domain above the bound."""
113
+ domains: list[dict[str, object]] = []
114
+ offenders: list[str] = []
115
+ for domain in _dns_only_domains(inventory):
116
+ name = domain.primary_name.value
117
+ try:
118
+ answers = probe.authoritative_answers(name)
119
+ except CutoverError as error:
120
+ offenders.append(f"{name}: {error.message}")
121
+ domains.append({"domain": name, "error": error.message})
122
+ continue
123
+ highest = max(answer.ttl for answer in answers)
124
+ domains.append(
125
+ {
126
+ "domain": name,
127
+ "answers": [answer.as_dict() for answer in answers],
128
+ "maxTtl": highest,
129
+ }
130
+ )
131
+ if highest > max_ttl_seconds:
132
+ offenders.append(
133
+ f"{name}: authoritative TTL {highest}s exceeds "
134
+ f"{max_ttl_seconds}s"
135
+ )
136
+ detail: dict[str, object] = {
137
+ "maxTtlSeconds": max_ttl_seconds,
138
+ "domains": domains,
139
+ }
140
+ return detail, tuple(offenders)
141
+
142
+
143
+ def parallel_run(
144
+ inventory: PlatformInventory,
145
+ client: DomainNetworkClient,
146
+ ) -> tuple[dict[str, object], tuple[str, ...]]:
147
+ """Prove the new origin serves every domain before DNS changes."""
148
+ servers = {server.resource_id: server for server in inventory.servers}
149
+ probes: list[dict[str, object]] = []
150
+ failures: list[str] = []
151
+ for domain in inventory.domains:
152
+ name = domain.primary_name.value
153
+ proxy = servers[domain.proxy.server_id]
154
+ expected = tuple(
155
+ status.value for status in domain.health_check.expected_statuses
156
+ )
157
+ target = ProbeTarget(
158
+ address=proxy.address,
159
+ port=TcpPort(_HTTP_PORT),
160
+ server_name=domain.primary_name,
161
+ scheme=HttpScheme.HTTP,
162
+ path=domain.health_check.path,
163
+ timeout=PositiveCount(
164
+ domain.health_check.timeout_seconds.value
165
+ ),
166
+ )
167
+ endpoint = client.request(target).endpoint
168
+ observed = (
169
+ endpoint.status.value if endpoint.status is not None else None
170
+ )
171
+ entry: dict[str, object] = {
172
+ "domain": name,
173
+ "origin": proxy.address.value,
174
+ "path": domain.health_check.path,
175
+ "status": observed,
176
+ "expected": list(expected),
177
+ }
178
+ if endpoint.error is not None:
179
+ entry["error"] = endpoint.error
180
+ probes.append(entry)
181
+ if not endpoint.reachable or observed is None:
182
+ failures.append(
183
+ f"{name}: origin unreachable: "
184
+ f"{endpoint.error or 'no response'}"
185
+ )
186
+ elif observed not in expected:
187
+ failures.append(
188
+ f"{name}: origin answered {observed} on "
189
+ f"{domain.health_check.path}, expected one of "
190
+ f"{sorted(expected)}"
191
+ )
192
+ detail: dict[str, object] = {"probes": probes}
193
+ return detail, tuple(failures)
194
+
195
+
196
+ def rollback_instructions(
197
+ previous: Mapping[str, object],
198
+ window_hours: int,
199
+ switched_at: str,
200
+ window_ends_at: str,
201
+ ) -> dict[str, object]:
202
+ """Turn the pre-switch answers into an explicit rollback recipe."""
203
+ return {
204
+ "windowHours": window_hours,
205
+ "switchedAt": switched_at,
206
+ "windowEndsAt": window_ends_at,
207
+ "previous": previous,
208
+ "instruction": (
209
+ "to roll the cutover back within the window, restore the "
210
+ "previous DNS answers exactly as recorded, wait one TTL for "
211
+ "propagation, and keep the imported services running until "
212
+ "traffic confirms the revert"
213
+ ),
214
+ }
215
+
216
+
217
+ def _dns_only_domains(
218
+ inventory: PlatformInventory,
219
+ ) -> tuple[DomainInventory, ...]:
220
+ return tuple(
221
+ domain
222
+ for domain in inventory.domains
223
+ if domain.edge.mode.value == "dns-only"
224
+ )
225
+
226
+
227
+ def _system_resolver() -> str:
228
+ code = "cutover_resolver_missing"
229
+ if not _RESOLV_CONF.is_file():
230
+ message = f"no system resolver configuration at {_RESOLV_CONF}"
231
+ raise CutoverError(code, message)
232
+ for line in _RESOLV_CONF.read_text(encoding="utf-8").splitlines():
233
+ fields = line.split()
234
+ if len(fields) >= 2 and fields[0] == "nameserver": # noqa: PLR2004
235
+ return fields[1]
236
+ message = f"no nameserver entries in {_RESOLV_CONF}"
237
+ raise CutoverError(code, message)
238
+
239
+
240
+ def _zone_nameserver(resolver: str, hostname: str) -> str:
241
+ labels = hostname.rstrip(".").split(".")
242
+ for start in range(len(labels) - 1):
243
+ zone = ".".join(labels[start:])
244
+ answers = _query(resolver, zone, _TYPE_NS)
245
+ nameservers = [
246
+ answer.value for answer in answers if answer.record_type == "NS"
247
+ ]
248
+ if nameservers:
249
+ try:
250
+ records = socket.getaddrinfo(
251
+ nameservers[0], _DNS_PORT, type=socket.SOCK_DGRAM
252
+ )
253
+ except OSError as error:
254
+ message = (
255
+ f"cannot resolve nameserver {nameservers[0]} for "
256
+ f"{zone}: {error}"
257
+ )
258
+ code = "cutover_nameserver_unreachable"
259
+ raise CutoverError(code, message) from error
260
+ return str(records[0][4][0])
261
+ code = "cutover_nameserver_missing"
262
+ message = f"no authoritative nameserver found for {hostname}"
263
+ raise CutoverError(code, message)
264
+
265
+
266
+ def _query(server: str, name: str, record_type: int) -> tuple[DnsAnswer, ...]:
267
+ query = _encode_query(name, record_type)
268
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as connection:
269
+ connection.settimeout(_DNS_TIMEOUT_SECONDS)
270
+ try:
271
+ connection.sendto(query, (server, _DNS_PORT))
272
+ payload, _ = connection.recvfrom(4096)
273
+ except OSError as error:
274
+ code = "cutover_dns_unreachable"
275
+ message = f"DNS query to {server} for {name} failed: {error}"
276
+ raise CutoverError(code, message) from error
277
+ return parse_answers(payload, query[:2])
278
+
279
+
280
+ def _encode_query(name: str, record_type: int) -> bytes:
281
+ header = struct.pack(
282
+ ">HHHHHH", int.from_bytes(os.urandom(2)), 0x0100, 1, 0, 0, 0
283
+ )
284
+ question = b"".join(
285
+ bytes((len(label),)) + label.encode("ascii")
286
+ for label in name.rstrip(".").split(".")
287
+ ) + b"\x00"
288
+ return header + question + struct.pack(">HH", record_type, _CLASS_IN)
289
+
290
+
291
+ def parse_answers(
292
+ payload: bytes, expected_id: bytes
293
+ ) -> tuple[DnsAnswer, ...]:
294
+ """Parse the answer section of one DNS response payload."""
295
+ code = "cutover_dns_invalid"
296
+ if len(payload) < 12 or payload[:2] != expected_id: # noqa: PLR2004
297
+ message = "DNS response header is invalid"
298
+ raise CutoverError(code, message)
299
+ _, _, question_count, answer_count, _, _ = struct.unpack(
300
+ ">HHHHHH", payload[:12]
301
+ )
302
+ offset = 12
303
+ for _ in range(question_count):
304
+ _, offset = _read_name(payload, offset)
305
+ offset += 4
306
+ answers: list[DnsAnswer] = []
307
+ for _ in range(answer_count):
308
+ name, offset = _read_name(payload, offset)
309
+ record_type, _, ttl, data_length = struct.unpack(
310
+ ">HHIH", payload[offset : offset + 10]
311
+ )
312
+ offset += 10
313
+ rdata = payload[offset : offset + data_length]
314
+ value = _decode_rdata(payload, offset, record_type, rdata)
315
+ offset += data_length
316
+ if value is not None:
317
+ answers.append(
318
+ DnsAnswer(
319
+ name=name,
320
+ record_type=_TYPE_NAMES.get(
321
+ record_type, str(record_type)
322
+ ),
323
+ ttl=ttl,
324
+ value=value,
325
+ )
326
+ )
327
+ return tuple(answers)
328
+
329
+
330
+ def _decode_rdata(
331
+ payload: bytes, offset: int, record_type: int, rdata: bytes
332
+ ) -> str | None:
333
+ if record_type == _TYPE_A and len(rdata) == 4: # noqa: PLR2004
334
+ return ".".join(str(byte) for byte in rdata)
335
+ if record_type == _TYPE_AAAA and len(rdata) == 16: # noqa: PLR2004
336
+ return socket.inet_ntop(socket.AF_INET6, rdata)
337
+ if record_type in (_TYPE_NS, _TYPE_CNAME):
338
+ name, _ = _read_name(payload, offset)
339
+ return name
340
+ return None
341
+
342
+
343
+ def _read_name(payload: bytes, offset: int) -> tuple[str, int]:
344
+ labels: list[str] = []
345
+ jumps = 0
346
+ cursor = offset
347
+ end = offset
348
+ jumped = False
349
+ while True:
350
+ if cursor >= len(payload) or jumps > 32: # noqa: PLR2004
351
+ code = "cutover_dns_invalid"
352
+ message = "DNS response name is malformed"
353
+ raise CutoverError(code, message)
354
+ length = payload[cursor]
355
+ if length & _POINTER_MASK == _POINTER_MASK:
356
+ pointer = struct.unpack(">H", payload[cursor : cursor + 2])[0]
357
+ if not jumped:
358
+ end = cursor + 2
359
+ jumped = True
360
+ cursor = pointer & ~(_POINTER_MASK << 8)
361
+ jumps += 1
362
+ continue
363
+ if length == 0:
364
+ if not jumped:
365
+ end = cursor + 1
366
+ break
367
+ cursor += 1
368
+ labels.append(payload[cursor : cursor + length].decode("ascii"))
369
+ cursor += length
370
+ return ".".join(labels), end