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.
- cloudfall/__init__.py +19 -0
- cloudfall/__main__.py +5 -0
- cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
- cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
- cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
- cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
- cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
- cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
- cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
- cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
- cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
- cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
- cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
- cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
- cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
- cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
- cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
- cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
- cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
- cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
- cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
- cloudfall/agent_tools.py +868 -0
- cloudfall/arguments.py +92 -0
- cloudfall/audit.py +905 -0
- cloudfall/authoring.py +341 -0
- cloudfall/cli.py +1748 -0
- cloudfall/commands.py +257 -0
- cloudfall/cutover.py +370 -0
- cloudfall/dashboard.py +407 -0
- cloudfall/dashboard_server.py +201 -0
- cloudfall/domain.py +1157 -0
- cloudfall/importer.py +906 -0
- cloudfall/inventory.py +2116 -0
- cloudfall/lifecycle.py +874 -0
- cloudfall/mcp_server.py +704 -0
- cloudfall/migrate.py +730 -0
- cloudfall/observation.py +143 -0
- cloudfall/operations.py +1228 -0
- cloudfall/operator.py +1099 -0
- cloudfall/project.py +841 -0
- cloudfall/py.typed +0 -0
- cloudfall/render_api.py +338 -0
- cloudfall/resources.py +67 -0
- cloudfall/secrets.py +262 -0
- cloudfall/service_evidence.py +659 -0
- cloudfall/validation.py +1340 -0
- cloudfall-0.2.0.dist-info/METADATA +408 -0
- cloudfall-0.2.0.dist-info/RECORD +165 -0
- cloudfall-0.2.0.dist-info/WHEEL +4 -0
- cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
- cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
- cloudfall_engine/__init__.py +5 -0
- cloudfall_engine/__main__.py +5 -0
- cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
- cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
- cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
- cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
- cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
- cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
- cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
- cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
- cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
- cloudfall_engine/ansible_inventory.py +253 -0
- cloudfall_engine/artifact.py +258 -0
- cloudfall_engine/cli.py +273 -0
- cloudfall_engine/playbook.py +175 -0
- 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
|