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
@@ -0,0 +1,868 @@
1
+ """Typed agent-facing operations behind the MCP boundary.
2
+
3
+ Every method returns a JSON-serializable envelope instead of raising, so
4
+ agents always receive structured results. Read-only evidence tools are
5
+ exposed freely; anything that changes servers demands an explicit
6
+ confirmation handshake and always produces receipts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from cloudfall.audit import audit_inventory
16
+ from cloudfall.authoring import (
17
+ AuthoringError,
18
+ ServerOptions,
19
+ ServerTypeOptions,
20
+ SshKeyOptions,
21
+ add_server,
22
+ add_server_type,
23
+ add_ssh_key,
24
+ )
25
+ from cloudfall.domain import (
26
+ ConnectionAddress,
27
+ Hostname,
28
+ LinuxUser,
29
+ ReleaseId,
30
+ ResourceId,
31
+ TcpPort,
32
+ )
33
+ from cloudfall.importer import (
34
+ ImportTargets,
35
+ RenderImportError,
36
+ import_render_blueprint,
37
+ )
38
+ from cloudfall.inventory import PlatformInventory
39
+ from cloudfall.lifecycle import (
40
+ DeployOptions,
41
+ EngineContext,
42
+ LifecycleError,
43
+ backup_service,
44
+ build_release_artifact,
45
+ deploy,
46
+ health,
47
+ migrate_data,
48
+ restart,
49
+ rollback,
50
+ run_engine_playbook,
51
+ verify_backup,
52
+ )
53
+ from cloudfall.migrate import MigrateError, MigrateOptions, execute_migration
54
+ from cloudfall.observation import load_observations
55
+ from cloudfall.operations import UtcTimestamp, build_operations_view
56
+ from cloudfall.operator import (
57
+ ApproveOptions,
58
+ OperatorError,
59
+ ProposalStatus,
60
+ ProposalStore,
61
+ TriggerKind,
62
+ alert_resolution_verifier,
63
+ drift_pass,
64
+ drift_resolution_verifier,
65
+ engine_auditor,
66
+ engine_executor,
67
+ gateway_feed,
68
+ )
69
+ from cloudfall.operator import (
70
+ approve as operator_approve_proposal,
71
+ )
72
+ from cloudfall.operator import (
73
+ run_once as operator_run_once,
74
+ )
75
+ from cloudfall.project import project_path
76
+ from cloudfall.render_api import HttpRenderApiClient, read_api_key
77
+ from cloudfall.render_api import (
78
+ import_render_api as render_api_import,
79
+ )
80
+ from cloudfall.secrets import (
81
+ SecretsError,
82
+ SopsSecretProvider,
83
+ load_environment_receipts,
84
+ render_environment,
85
+ )
86
+ from cloudfall.service_evidence import (
87
+ DeploymentReceiptSet,
88
+ DomainObservationSet,
89
+ EvidenceTimestamp,
90
+ SocketDomainNetworkClient,
91
+ inspect_domains,
92
+ load_deployment_receipts,
93
+ load_domain_observations,
94
+ )
95
+ from cloudfall.validation import ConfigValidationError, SchemaCatalog, validate_config
96
+
97
+ if TYPE_CHECKING:
98
+ from collections.abc import Callable
99
+
100
+ from cloudfall.operator import AlertFeed
101
+ from cloudfall.validation import ValidatedConfig
102
+
103
+ _CODE_INVALID_ARGUMENT = "invalid_argument"
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class AgentConfig:
108
+ """Filesystem contract for one agent-facing server instance."""
109
+
110
+ project_directory: Path
111
+ schema_directory: Path
112
+ engine_directory: Path
113
+ inventory_file: Path
114
+ observed_directory: Path
115
+ service_observed_directory: Path
116
+ deployments_directory: Path
117
+ releases_directory: Path
118
+ artifacts_directory: Path
119
+ data_migrations_directory: Path = Path("tmp/data-migrations")
120
+ backups_directory: Path = Path("tmp/backups")
121
+ secrets_directory: Path = Path("secrets")
122
+ environment_directory: Path = Path("tmp/env")
123
+ environment_receipts_directory: Path = Path("tmp/env-receipts")
124
+ proposals_directory: Path = Path("tmp/operator/proposals")
125
+ gateway_ca_path: Path | None = None
126
+ gateway_certificate_path: Path | None = None
127
+ gateway_key_path: Path | None = None
128
+
129
+ def context(self) -> EngineContext:
130
+ """Return the engine execution context shared by mutating tools."""
131
+ return EngineContext(
132
+ project_directory=self.project_directory,
133
+ schema_directory=self.schema_directory,
134
+ engine_directory=self.engine_directory,
135
+ inventory_file=self.inventory_file,
136
+ )
137
+
138
+
139
+ class AgentToolset:
140
+ """Structured operations offered to AI agents."""
141
+
142
+ def __init__(self, config: AgentConfig) -> None:
143
+ """Bind the toolset to one filesystem contract."""
144
+ self._config = config
145
+
146
+ def validate(self) -> dict[str, object]:
147
+ """Validate declared state and return the structured summary."""
148
+ try:
149
+ return self._state().as_dict()
150
+ except ConfigValidationError as error:
151
+ return error.as_dict()
152
+
153
+ def inventory(self) -> dict[str, object]:
154
+ """Return the non-secret platform inventory."""
155
+ try:
156
+ state = self._state()
157
+ except ConfigValidationError as error:
158
+ return error.as_dict()
159
+ return {
160
+ "status": "ok",
161
+ "inventory": PlatformInventory.from_state(state).as_dict(),
162
+ }
163
+
164
+ def audit(self) -> dict[str, object]:
165
+ """Compare desired state with collected server observations."""
166
+ try:
167
+ state = self._state()
168
+ observations = load_observations(
169
+ self._config.observed_directory, self._config.schema_directory
170
+ )
171
+ except ConfigValidationError as error:
172
+ return error.as_dict()
173
+ report = audit_inventory(
174
+ PlatformInventory.from_state(state),
175
+ observations,
176
+ load_environment_receipts(
177
+ self._config.environment_receipts_directory,
178
+ self._config.schema_directory,
179
+ ),
180
+ )
181
+ return report.as_dict()
182
+
183
+ def services_status(self) -> dict[str, object]:
184
+ """Derive the evidence-based public service lifecycle."""
185
+ try:
186
+ state = self._state()
187
+ observations = load_observations(
188
+ self._config.observed_directory, self._config.schema_directory
189
+ )
190
+ except ConfigValidationError as error:
191
+ return error.as_dict()
192
+ receipts = (
193
+ load_deployment_receipts(
194
+ self._config.deployments_directory,
195
+ self._config.schema_directory,
196
+ )
197
+ if self._config.deployments_directory.is_dir()
198
+ else DeploymentReceiptSet.empty()
199
+ )
200
+ domain_observations = (
201
+ load_domain_observations(
202
+ self._config.service_observed_directory,
203
+ self._config.schema_directory,
204
+ )
205
+ if self._config.service_observed_directory.is_dir()
206
+ else DomainObservationSet.empty()
207
+ )
208
+ view = build_operations_view(
209
+ PlatformInventory.from_state(state),
210
+ observations,
211
+ receipts,
212
+ domain_observations,
213
+ generated_at=UtcTimestamp.now(),
214
+ )
215
+ return {
216
+ "status": "ok",
217
+ "health": view.health.value,
218
+ "services": [domain.as_dict() for domain in view.domains],
219
+ }
220
+
221
+ def component_health(self, component: str) -> dict[str, object]:
222
+ """Probe one component's declared health check on its servers."""
223
+ try:
224
+ component_id = ResourceId.from_boundary(component)
225
+ except (TypeError, ValueError) as error:
226
+ return _invalid_argument(error)
227
+ try:
228
+ return health(self._config.context(), component_id).as_dict()
229
+ except LifecycleError as error:
230
+ return error.as_dict()
231
+
232
+ def inspect_servers(self) -> dict[str, object]:
233
+ """Collect read-only server evidence into the observation directory."""
234
+ observed = self._config.observed_directory.resolve()
235
+ try:
236
+ run_engine_playbook(
237
+ self._config.context(),
238
+ "inspect.yml",
239
+ {"cloudfall_inspect_output_directory": str(observed)},
240
+ )
241
+ except LifecycleError as error:
242
+ return error.as_dict()
243
+ return {"status": "ok", "observed": str(observed)}
244
+
245
+ def inspect_services(self) -> dict[str, object]:
246
+ """Collect DNS, TLS, origin, and public route evidence."""
247
+ try:
248
+ state = self._state()
249
+ except ConfigValidationError as error:
250
+ return error.as_dict()
251
+ paths = inspect_domains(
252
+ PlatformInventory.from_state(state),
253
+ self._config.service_observed_directory,
254
+ SocketDomainNetworkClient(),
255
+ observed_at=EvidenceTimestamp.now(),
256
+ )
257
+ return {
258
+ "status": "ok",
259
+ "observations": [str(path) for path in paths],
260
+ }
261
+
262
+ def add_ssh_key(
263
+ self,
264
+ key_file: str,
265
+ owner: str,
266
+ resource_id: str | None = None,
267
+ environment: str = "production",
268
+ description: str | None = None,
269
+ ) -> dict[str, object]:
270
+ """Declare one SSH public key read from a controller-side key file."""
271
+ try:
272
+ options = SshKeyOptions(
273
+ key_path=self._path(key_file).expanduser(),
274
+ owner=ResourceId.from_boundary(owner),
275
+ environment=ResourceId.from_boundary(environment),
276
+ resource_id=(
277
+ ResourceId.from_boundary(resource_id)
278
+ if resource_id is not None
279
+ else None
280
+ ),
281
+ description=description,
282
+ )
283
+ except (TypeError, ValueError) as error:
284
+ return _invalid_argument(error)
285
+ try:
286
+ result = add_ssh_key(
287
+ self._config.project_directory, options, self._config.schema_directory
288
+ )
289
+ except (AuthoringError, ConfigValidationError) as error:
290
+ return error.as_dict()
291
+ return result.as_dict()
292
+
293
+ def add_server_type(
294
+ self, resource_id: str, description: str | None = None
295
+ ) -> dict[str, object]:
296
+ """Declare one server type from the bundled Debian 13 baseline."""
297
+ try:
298
+ options = ServerTypeOptions(
299
+ resource_id=ResourceId.from_boundary(resource_id),
300
+ description=description,
301
+ )
302
+ except (TypeError, ValueError) as error:
303
+ return _invalid_argument(error)
304
+ try:
305
+ result = add_server_type(
306
+ self._config.project_directory, options, self._config.schema_directory
307
+ )
308
+ except (AuthoringError, ConfigValidationError) as error:
309
+ return error.as_dict()
310
+ return result.as_dict()
311
+
312
+ def add_server( # noqa: PLR0913 - boundary signature mirrors the CLI.
313
+ self,
314
+ resource_id: str,
315
+ address: str,
316
+ server_type: str = "debian-application",
317
+ environment: str = "production",
318
+ hostname: str | None = None,
319
+ ssh_user: str = "root",
320
+ ssh_port: int = 22,
321
+ description: str | None = None,
322
+ ) -> dict[str, object]:
323
+ """Declare one server, creating its server type when the project lacks it."""
324
+ try:
325
+ options = ServerOptions(
326
+ resource_id=ResourceId.from_boundary(resource_id),
327
+ address=ConnectionAddress.from_boundary(address),
328
+ server_type=ResourceId.from_boundary(server_type),
329
+ environment=ResourceId.from_boundary(environment),
330
+ ssh_user=LinuxUser.from_boundary(ssh_user),
331
+ ssh_port=TcpPort.from_boundary(ssh_port),
332
+ hostname=(
333
+ Hostname.from_boundary(hostname) if hostname is not None else None
334
+ ),
335
+ description=description,
336
+ )
337
+ except (TypeError, ValueError) as error:
338
+ return _invalid_argument(error)
339
+ try:
340
+ result = add_server(
341
+ self._config.project_directory, options, self._config.schema_directory
342
+ )
343
+ except (AuthoringError, ConfigValidationError) as error:
344
+ return error.as_dict()
345
+ return result.as_dict()
346
+
347
+ def import_render(
348
+ self,
349
+ blueprint: str,
350
+ application: str,
351
+ server: str,
352
+ output_directory: str,
353
+ environment_directory: str,
354
+ ) -> dict[str, object]:
355
+ """Map a Render blueprint onto Cloudfall config fragments."""
356
+ try:
357
+ targets = ImportTargets(
358
+ application_id=ResourceId.from_boundary(application),
359
+ server_id=ResourceId.from_boundary(server),
360
+ project_directory=project_path(output_directory),
361
+ environment_directory=project_path(environment_directory),
362
+ )
363
+ except (TypeError, ValueError) as error:
364
+ return _invalid_argument(error)
365
+ try:
366
+ result = import_render_blueprint(
367
+ self._path(blueprint), targets, self._config.schema_directory
368
+ )
369
+ except (RenderImportError, ConfigValidationError) as error:
370
+ return error.as_dict()
371
+ return result.as_dict()
372
+
373
+ def import_render_api(
374
+ self,
375
+ api_key_file: str,
376
+ application: str,
377
+ server: str,
378
+ output_directory: str,
379
+ environment_directory: str,
380
+ ) -> dict[str, object]:
381
+ """Map a live Render workspace onto Cloudfall config fragments."""
382
+ try:
383
+ targets = ImportTargets(
384
+ application_id=ResourceId.from_boundary(application),
385
+ server_id=ResourceId.from_boundary(server),
386
+ project_directory=project_path(output_directory),
387
+ environment_directory=project_path(environment_directory),
388
+ )
389
+ except (TypeError, ValueError) as error:
390
+ return _invalid_argument(error)
391
+ try:
392
+ client = HttpRenderApiClient(
393
+ api_key=read_api_key(self._path(api_key_file))
394
+ )
395
+ result = render_api_import(
396
+ client, targets, self._config.schema_directory
397
+ )
398
+ except (RenderImportError, ConfigValidationError) as error:
399
+ return error.as_dict()
400
+ return result.as_dict()
401
+
402
+ def build_artifact(self, component: str, git_ref: str) -> dict[str, object]:
403
+ """Build one hashed release artifact from the component repository."""
404
+ try:
405
+ component_id = ResourceId.from_boundary(component)
406
+ except (TypeError, ValueError) as error:
407
+ return _invalid_argument(error)
408
+ try:
409
+ return build_release_artifact(
410
+ self._config.context(),
411
+ component_id,
412
+ git_ref,
413
+ self._config.artifacts_directory,
414
+ )
415
+ except LifecycleError as error:
416
+ return error.as_dict()
417
+
418
+ def deploy_component(
419
+ self,
420
+ component: str,
421
+ release: str,
422
+ environment_file: str | None = None,
423
+ *,
424
+ confirm: bool = False,
425
+ ) -> dict[str, object]:
426
+ """Deploy one built release behind the engine's health gate."""
427
+ gate = _confirmation_gate(
428
+ confirm,
429
+ "deploy",
430
+ f"deploy release {release} of component {component} to its "
431
+ "declared servers with automatic rollback on a failed health "
432
+ "check",
433
+ )
434
+ if gate is not None:
435
+ return gate
436
+ try:
437
+ component_id = ResourceId.from_boundary(component)
438
+ release_id = ReleaseId.from_boundary(release)
439
+ except (TypeError, ValueError) as error:
440
+ return _invalid_argument(error)
441
+ try:
442
+ result = deploy(
443
+ self._config.context(),
444
+ component_id,
445
+ release_id,
446
+ self._config.artifacts_directory,
447
+ DeployOptions(
448
+ environment_file=(
449
+ self._path(environment_file)
450
+ if environment_file is not None
451
+ else None
452
+ ),
453
+ receipt_directory=self._config.releases_directory,
454
+ ),
455
+ )
456
+ except LifecycleError as error:
457
+ return error.as_dict()
458
+ return result.as_dict()
459
+
460
+ def rollback_component(
461
+ self, component: str, release: str, *, confirm: bool = False
462
+ ) -> dict[str, object]:
463
+ """Activate an already-retained release behind the health gate."""
464
+ gate = _confirmation_gate(
465
+ confirm,
466
+ "rollback",
467
+ f"switch component {component} back to retained release "
468
+ f"{release} and restart it",
469
+ )
470
+ if gate is not None:
471
+ return gate
472
+ try:
473
+ component_id = ResourceId.from_boundary(component)
474
+ release_id = ReleaseId.from_boundary(release)
475
+ except (TypeError, ValueError) as error:
476
+ return _invalid_argument(error)
477
+ try:
478
+ result = rollback(self._config.context(), component_id, release_id)
479
+ except LifecycleError as error:
480
+ return error.as_dict()
481
+ return result.as_dict()
482
+
483
+ def restart_component(
484
+ self, component: str, *, confirm: bool = False
485
+ ) -> dict[str, object]:
486
+ """Restart one component and require its declared health check."""
487
+ gate = _confirmation_gate(
488
+ confirm,
489
+ "restart",
490
+ f"restart component {component} on its declared servers",
491
+ )
492
+ if gate is not None:
493
+ return gate
494
+ try:
495
+ component_id = ResourceId.from_boundary(component)
496
+ except (TypeError, ValueError) as error:
497
+ return _invalid_argument(error)
498
+ try:
499
+ result = restart(self._config.context(), component_id)
500
+ except LifecycleError as error:
501
+ return error.as_dict()
502
+ return result.as_dict()
503
+
504
+ def migrate_database(
505
+ self,
506
+ service: str,
507
+ database: str,
508
+ source_url_file: str,
509
+ *,
510
+ confirm: bool = False,
511
+ ) -> dict[str, object]:
512
+ """Dump an external database and restore it into a declared service."""
513
+ gate = _confirmation_gate(
514
+ confirm,
515
+ "data-migration",
516
+ (
517
+ f"dump the database behind {source_url_file} and restore it "
518
+ f"into declared database {database} on service {service} "
519
+ "with per-table row-count verification"
520
+ ),
521
+ )
522
+ if gate is not None:
523
+ return gate
524
+ try:
525
+ service_id = ResourceId.from_boundary(service)
526
+ except (TypeError, ValueError) as error:
527
+ return _invalid_argument(error)
528
+ try:
529
+ return migrate_data(
530
+ self._config.context(),
531
+ service_id,
532
+ database,
533
+ Path(source_url_file),
534
+ self._config.data_migrations_directory,
535
+ )
536
+ except LifecycleError as error:
537
+ return error.as_dict()
538
+
539
+ def converge_baseline(self, *, confirm: bool = False) -> dict[str, object]:
540
+ """Converge every declared server to the managed baseline."""
541
+ return self._converge(
542
+ confirm,
543
+ "converge-baseline",
544
+ "run baseline.yml: bootstrap, UTC, SSH hardening, unattended "
545
+ "upgrades, and the declared firewall on every server",
546
+ "baseline.yml",
547
+ {},
548
+ )
549
+
550
+ def converge_services(self, *, confirm: bool = False) -> dict[str, object]:
551
+ """Converge every declared infrastructure service."""
552
+ return self._converge(
553
+ confirm,
554
+ "converge-services",
555
+ "run services.yml: install and configure every declared "
556
+ "infrastructure service",
557
+ "services.yml",
558
+ {},
559
+ )
560
+
561
+ def converge_domains(
562
+ self, *, issue_certificates: bool = False, confirm: bool = False
563
+ ) -> dict[str, object]:
564
+ """Converge every declared public domain route."""
565
+ return self._converge(
566
+ confirm,
567
+ "converge-domains",
568
+ "run domains.yml: render every declared domain route"
569
+ + (
570
+ " and issue missing certificates"
571
+ if issue_certificates
572
+ else " without certificate issuance"
573
+ ),
574
+ "domains.yml",
575
+ {
576
+ "cloudfall_domains_issue_certificates": issue_certificates,
577
+ "cloudfall_domains_receipt_directory": str(
578
+ self._config.deployments_directory.resolve()
579
+ ),
580
+ },
581
+ )
582
+
583
+ def migrate( # noqa: PLR0913 - boundary signature mirrors the CLI.
584
+ self,
585
+ builds: dict[str, str] | None = None,
586
+ releases: dict[str, str] | None = None,
587
+ environment_files: dict[str, str] | None = None,
588
+ data_migrations: dict[str, str] | None = None,
589
+ plan_file: str = "tmp/migrate/plan.json",
590
+ *,
591
+ restart_plan: bool = False,
592
+ confirm: bool = False,
593
+ ) -> dict[str, object]:
594
+ """Preview or execute the resumable end-to-end migration plan."""
595
+ try:
596
+ confined_plan_file = project_path(plan_file)
597
+ except ValueError as error:
598
+ return _invalid_argument(error)
599
+ options = MigrateOptions(
600
+ plan_file=confined_plan_file,
601
+ builds=builds if builds is not None else {},
602
+ releases=releases if releases is not None else {},
603
+ environment_files={
604
+ component: self._path(value)
605
+ for component, value in (
606
+ environment_files if environment_files is not None else {}
607
+ ).items()
608
+ },
609
+ data_migrations={
610
+ database: self._path(value)
611
+ for database, value in (
612
+ data_migrations if data_migrations is not None else {}
613
+ ).items()
614
+ },
615
+ execute=confirm,
616
+ restart=restart_plan,
617
+ )
618
+ try:
619
+ result = execute_migration(self._config, options)
620
+ except (MigrateError, ConfigValidationError) as error:
621
+ return error.as_dict()
622
+ if not confirm:
623
+ result["instruction"] = (
624
+ "review the plan and call this tool again with confirm=true "
625
+ "to execute it; interrupted runs resume automatically"
626
+ )
627
+ return result
628
+
629
+ def render_secrets(self, component: str) -> dict[str, object]:
630
+ """Render one component's secret references into its env file."""
631
+ try:
632
+ component_id = ResourceId.from_boundary(component)
633
+ except (TypeError, ValueError) as error:
634
+ return _invalid_argument(error)
635
+ try:
636
+ return render_environment(
637
+ self._config.context(),
638
+ component_id,
639
+ SopsSecretProvider(
640
+ secrets_directory=self._config.secrets_directory
641
+ ),
642
+ self._config.environment_directory
643
+ / f"{component_id.value}.env",
644
+ receipt_directory=(
645
+ self._config.environment_receipts_directory
646
+ ),
647
+ )
648
+ except (SecretsError, ConfigValidationError) as error:
649
+ return error.as_dict()
650
+
651
+ def backup_service(
652
+ self, service: str, *, confirm: bool = False
653
+ ) -> dict[str, object]:
654
+ """Run the declared backup for one service, receipted."""
655
+ return self._backup_operation(
656
+ service,
657
+ confirm=confirm,
658
+ action="backup-service",
659
+ detail_prefix="run the declared backup for",
660
+ operation=backup_service,
661
+ )
662
+
663
+ def verify_backup(
664
+ self, service: str, *, confirm: bool = False
665
+ ) -> dict[str, object]:
666
+ """Prove the newest backup of one service restores, receipted."""
667
+ return self._backup_operation(
668
+ service,
669
+ confirm=confirm,
670
+ action="verify-backup",
671
+ detail_prefix="prove the newest backup restores for",
672
+ operation=verify_backup,
673
+ )
674
+
675
+ def _backup_operation(
676
+ self,
677
+ service: str,
678
+ *,
679
+ confirm: bool,
680
+ action: str,
681
+ detail_prefix: str,
682
+ operation: Callable[
683
+ [EngineContext, ResourceId, Path], dict[str, object]
684
+ ],
685
+ ) -> dict[str, object]:
686
+ try:
687
+ service_id = ResourceId.from_boundary(service)
688
+ except (TypeError, ValueError) as error:
689
+ return _invalid_argument(error)
690
+ gate = _confirmation_gate(
691
+ confirm,
692
+ action,
693
+ f"{detail_prefix} {service_id.value} on its declared server",
694
+ )
695
+ if gate is not None:
696
+ return gate
697
+ try:
698
+ return operation(
699
+ self._config.context(),
700
+ service_id,
701
+ self._config.backups_directory,
702
+ )
703
+ except (LifecycleError, ConfigValidationError) as error:
704
+ return error.as_dict()
705
+
706
+ def operator_proposals(self) -> dict[str, object]:
707
+ """List every operator proposal receipt with its status."""
708
+ try:
709
+ proposals = [
710
+ proposal.as_document()
711
+ for proposal in self._proposal_store().list()
712
+ ]
713
+ except OperatorError as error:
714
+ return {"status": "error", "error": error.as_dict()}
715
+ return {"status": "ok", "proposals": proposals}
716
+
717
+ def operator_watch(self, *, drift: bool = False) -> dict[str, object]:
718
+ """Run one watch pass: fetch alerts, optionally audit for drift."""
719
+ try:
720
+ state = self._state()
721
+ except ConfigValidationError as error:
722
+ return error.as_dict()
723
+ inventory = PlatformInventory.from_state(state)
724
+ try:
725
+ store = self._proposal_store()
726
+ feed = self._alert_feed(inventory)
727
+ report = operator_run_once(feed, inventory, store)
728
+ passes: dict[str, object] = {"alerts": report.as_dict()}
729
+ if drift:
730
+ auditor = engine_auditor(
731
+ self._config.context(),
732
+ inventory,
733
+ self._config.observed_directory,
734
+ )
735
+ passes["drift"] = drift_pass(auditor, store).as_dict()
736
+ except (OperatorError, LifecycleError) as error:
737
+ return {"status": "error", "error": error.as_dict()}
738
+ return {"status": "ok", **passes}
739
+
740
+ def operator_approve(
741
+ self, proposal: str, *, confirm: bool = False
742
+ ) -> dict[str, object]:
743
+ """Execute one proposal after confirmation and verify its trigger."""
744
+ try:
745
+ state = self._state()
746
+ except ConfigValidationError as error:
747
+ return error.as_dict()
748
+ inventory = PlatformInventory.from_state(state)
749
+ try:
750
+ store = self._proposal_store()
751
+ proposal_id = ResourceId.from_boundary(proposal)
752
+ pending = store.load(proposal_id)
753
+ except (OperatorError, ValueError) as error:
754
+ return _invalid_argument(error)
755
+ gate = _confirmation_gate(
756
+ confirm,
757
+ "operator-approve",
758
+ f"execute {' '.join(pending.command)} because "
759
+ f"{pending.diagnosis_summary}",
760
+ )
761
+ if gate is not None:
762
+ return gate
763
+ try:
764
+ if pending.trigger_kind is TriggerKind.ALERT:
765
+ verifier = alert_resolution_verifier(
766
+ self._alert_feed(inventory)
767
+ )
768
+ else:
769
+ verifier = drift_resolution_verifier(
770
+ engine_auditor(
771
+ self._config.context(),
772
+ inventory,
773
+ self._config.observed_directory,
774
+ )
775
+ )
776
+ result = operator_approve_proposal(
777
+ store,
778
+ proposal_id,
779
+ engine_executor(self._config.context()),
780
+ verifier,
781
+ ApproveOptions(),
782
+ )
783
+ except (OperatorError, LifecycleError) as error:
784
+ return {"status": "error", "error": error.as_dict()}
785
+ return {
786
+ "status": (
787
+ "ok"
788
+ if result.status is ProposalStatus.VERIFIED
789
+ else "failed"
790
+ ),
791
+ "proposal": result.as_document(),
792
+ }
793
+
794
+ def _proposal_store(self) -> ProposalStore:
795
+ return ProposalStore(
796
+ directory=self._config.proposals_directory,
797
+ catalog=SchemaCatalog(self._config.schema_directory),
798
+ )
799
+
800
+ def _alert_feed(self, inventory: PlatformInventory) -> AlertFeed:
801
+ config = self._config
802
+ if (
803
+ config.gateway_ca_path is None
804
+ or config.gateway_certificate_path is None
805
+ or config.gateway_key_path is None
806
+ ):
807
+ code = "operator_gateway_material_missing"
808
+ message = (
809
+ "the server was started without gateway TLS material; "
810
+ "pass --gateway-ca, --gateway-cert, and --gateway-key"
811
+ )
812
+ raise OperatorError(code, message)
813
+ return gateway_feed(
814
+ inventory,
815
+ ca_path=config.gateway_ca_path,
816
+ certificate_path=config.gateway_certificate_path,
817
+ key_path=config.gateway_key_path,
818
+ )
819
+
820
+ def _converge(
821
+ self,
822
+ confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary.
823
+ action: str,
824
+ detail: str,
825
+ playbook: str,
826
+ extra_vars: dict[str, object],
827
+ ) -> dict[str, object]:
828
+ gate = _confirmation_gate(confirm, action, detail)
829
+ if gate is not None:
830
+ return gate
831
+ try:
832
+ run_engine_playbook(self._config.context(), playbook, extra_vars)
833
+ except LifecycleError as error:
834
+ return error.as_dict()
835
+ return {"status": "ok", "action": action}
836
+
837
+ def _state(self) -> ValidatedConfig:
838
+ return validate_config(
839
+ self._config.project_directory, self._config.schema_directory
840
+ )
841
+
842
+ def _path(self, value: str) -> Path:
843
+ return Path(value)
844
+
845
+
846
+ def _confirmation_gate(
847
+ confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary.
848
+ action: str,
849
+ detail: str,
850
+ ) -> dict[str, object] | None:
851
+ if confirm:
852
+ return None
853
+ return {
854
+ "status": "confirmation-required",
855
+ "action": action,
856
+ "wouldRun": detail,
857
+ "instruction": (
858
+ "review the description and call this tool again with "
859
+ "confirm=true to execute"
860
+ ),
861
+ }
862
+
863
+
864
+ def _invalid_argument(error: Exception) -> dict[str, object]:
865
+ return {
866
+ "status": "error",
867
+ "error": {"code": _CODE_INVALID_ARGUMENT, "message": str(error)},
868
+ }