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/cli.py ADDED
@@ -0,0 +1,1748 @@
1
+ """Command-line boundary for Cloudfall operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ import argparse
14
+ from argparse import Namespace
15
+ from collections.abc import Callable, Sequence
16
+
17
+ from cloudfall.operations import FleetOperations
18
+ from cloudfall.operator import AlertFeed, OperatorProposal
19
+ from cloudfall.validation import ValidatedConfig
20
+
21
+ from cloudfall.agent_tools import AgentConfig
22
+ from cloudfall.arguments import (
23
+ StrictArgumentParser,
24
+ parse_arguments,
25
+ project_path_argument,
26
+ release_id_argument,
27
+ resource_id_argument,
28
+ )
29
+ from cloudfall.audit import AuditStatus, audit_inventory
30
+ from cloudfall.authoring import (
31
+ AuthoringError,
32
+ ServerOptions,
33
+ ServerTypeOptions,
34
+ SshKeyOptions,
35
+ add_server,
36
+ add_server_type,
37
+ add_ssh_key,
38
+ )
39
+ from cloudfall.dashboard import RefreshInterval, build_dashboard
40
+ from cloudfall.dashboard_server import (
41
+ EvidenceSources,
42
+ ListenEndpoint,
43
+ create_dashboard_server,
44
+ )
45
+ from cloudfall.domain import (
46
+ ConnectionAddress,
47
+ Hostname,
48
+ LinuxUser,
49
+ ResourceId,
50
+ TcpPort,
51
+ )
52
+ from cloudfall.importer import (
53
+ ImportTargets,
54
+ RenderImportError,
55
+ import_render_blueprint,
56
+ )
57
+ from cloudfall.inventory import PlatformInventory
58
+ from cloudfall.lifecycle import (
59
+ DeployOptions,
60
+ EngineContext,
61
+ LifecycleError,
62
+ LifecyclePreview,
63
+ backup_service,
64
+ deploy,
65
+ health,
66
+ migrate_data,
67
+ preview_data_migration,
68
+ preview_deploy,
69
+ preview_restart,
70
+ preview_rollback,
71
+ restart,
72
+ rollback,
73
+ verify_backup,
74
+ )
75
+ from cloudfall.migrate import MigrateError, MigrateOptions, execute_migration
76
+ from cloudfall.observation import load_observations
77
+ from cloudfall.operations import UtcTimestamp, build_operations_view
78
+ from cloudfall.operator import (
79
+ ApproveOptions,
80
+ OperatorError,
81
+ ProposalStatus,
82
+ ProposalStore,
83
+ TriggerKind,
84
+ alert_resolution_verifier,
85
+ autonomous_pass,
86
+ drift_pass,
87
+ drift_resolution_verifier,
88
+ engine_auditor,
89
+ engine_executor,
90
+ gateway_feed,
91
+ )
92
+ from cloudfall.operator import (
93
+ approve as operator_approve,
94
+ )
95
+ from cloudfall.operator import (
96
+ run_once as operator_run_once,
97
+ )
98
+ from cloudfall.project import (
99
+ DEFAULT_SOURCE_URL,
100
+ PROJECT_DIRECTORY_VARIABLE,
101
+ GitRevision,
102
+ GitSourceUrl,
103
+ InitOptions,
104
+ ProjectDescription,
105
+ ProjectError,
106
+ ProjectName,
107
+ init_project,
108
+ project_context,
109
+ resolve_installed_revision,
110
+ )
111
+ from cloudfall.render_api import (
112
+ HttpRenderApiClient,
113
+ import_render_api,
114
+ read_api_key,
115
+ )
116
+ from cloudfall.resources import default_engine_directory, default_schema_directory
117
+ from cloudfall.secrets import (
118
+ SecretsError,
119
+ SopsSecretProvider,
120
+ load_environment_receipts,
121
+ render_environment,
122
+ )
123
+ from cloudfall.service_evidence import (
124
+ DeploymentReceiptSet,
125
+ DomainObservationSet,
126
+ EvidenceTimestamp,
127
+ SocketDomainNetworkClient,
128
+ inspect_domains,
129
+ load_deployment_receipts,
130
+ load_domain_observations,
131
+ )
132
+ from cloudfall.validation import (
133
+ ConfigValidationError,
134
+ SchemaCatalog,
135
+ validate_config,
136
+ )
137
+
138
+
139
+ def _add_config_parsers(
140
+ commands: argparse._SubParsersAction[StrictArgumentParser],
141
+ ) -> None:
142
+ config_parser = commands.add_parser("config", help="operate on the config")
143
+ config_commands = config_parser.add_subparsers(dest="config_command", required=True)
144
+ validate_parser = config_commands.add_parser(
145
+ "validate", help="validate the YAML config and resource references"
146
+ )
147
+ _add_project_directory_argument(validate_parser)
148
+ validate_parser.add_argument(
149
+ "--schemas",
150
+ type=Path,
151
+ default=default_schema_directory(),
152
+ help="versioned schema directory (default: bundled schemas)",
153
+ )
154
+
155
+ inventory_parser = commands.add_parser(
156
+ "inventory", help="query validated platform inventory"
157
+ )
158
+ inventory_commands = inventory_parser.add_subparsers(
159
+ dest="inventory_command", required=True
160
+ )
161
+ show_parser = inventory_commands.add_parser(
162
+ "show", help="show non-secret platform inventory"
163
+ )
164
+ _add_project_directory_argument(show_parser)
165
+ show_parser.add_argument(
166
+ "--schemas",
167
+ type=Path,
168
+ default=default_schema_directory(),
169
+ help="versioned schema directory (default: bundled schemas)",
170
+ )
171
+
172
+
173
+ def _add_project_directory_argument(parser: argparse.ArgumentParser) -> None:
174
+ parser.add_argument(
175
+ "--project",
176
+ type=Path,
177
+ default=None,
178
+ help=(
179
+ f"project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, "
180
+ "else the current directory when it is a project)"
181
+ ),
182
+ )
183
+
184
+
185
+ def _add_init_parser(
186
+ commands: argparse._SubParsersAction[StrictArgumentParser],
187
+ ) -> None:
188
+ init_parser = commands.add_parser(
189
+ "init", help="create a new project: fleet, applications, and operations"
190
+ )
191
+ init_parser.add_argument(
192
+ "directory",
193
+ type=Path,
194
+ nargs="?",
195
+ default=Path(),
196
+ help="project directory to create; must be empty or absent (default: .)",
197
+ )
198
+ init_parser.add_argument(
199
+ "--name",
200
+ help="package name written to pyproject.toml (default: the directory name)",
201
+ )
202
+ init_parser.add_argument(
203
+ "--rev",
204
+ help=(
205
+ "Cloudfall commit to pin (default: the commit the running "
206
+ "cloudfall was installed from)"
207
+ ),
208
+ )
209
+ init_parser.add_argument(
210
+ "--description",
211
+ help=(
212
+ "one line saying what the project manages, written to the README "
213
+ "and pyproject.toml"
214
+ ),
215
+ )
216
+ init_parser.add_argument(
217
+ "--source",
218
+ default=DEFAULT_SOURCE_URL,
219
+ help=(
220
+ f"git location of Cloudfall to install from (default: {DEFAULT_SOURCE_URL})"
221
+ ),
222
+ )
223
+
224
+
225
+ def _add_add_parsers(
226
+ commands: argparse._SubParsersAction[StrictArgumentParser],
227
+ ) -> None:
228
+ add_parser = commands.add_parser(
229
+ "add", help="write a fleet resource into the project"
230
+ )
231
+ add_commands = add_parser.add_subparsers(dest="add_command", required=True)
232
+
233
+ key_parser = add_commands.add_parser(
234
+ "ssh-key", help="declare an SSH public key read from a file"
235
+ )
236
+ key_parser.add_argument("key_file", type=Path, help="e.g. ~/.ssh/id_ed25519.pub")
237
+ key_parser.add_argument("--owner", required=True, help="who the key belongs to")
238
+ key_parser.add_argument("--id", help="resource id (default: the owner)")
239
+ key_parser.add_argument(
240
+ "--environment", default="production", help="(default: production)"
241
+ )
242
+ key_parser.add_argument("--description")
243
+
244
+ type_parser = add_commands.add_parser(
245
+ "server-type",
246
+ help="declare a server type from the bundled Debian 13 baseline",
247
+ )
248
+ type_parser.add_argument("id", help="resource id, e.g. debian-application")
249
+ type_parser.add_argument("--description")
250
+
251
+ server_parser = add_commands.add_parser(
252
+ "server", help="declare a server; creates its server type when missing"
253
+ )
254
+ server_parser.add_argument("id", help="resource id, e.g. h1")
255
+ server_parser.add_argument(
256
+ "--address", required=True, help="IP address or hostname to connect to"
257
+ )
258
+ server_parser.add_argument(
259
+ "--type",
260
+ default="debian-application",
261
+ help="server type id (default: debian-application)",
262
+ )
263
+ server_parser.add_argument(
264
+ "--environment", default="production", help="(default: production)"
265
+ )
266
+ server_parser.add_argument(
267
+ "--hostname", help="(default: the address when it is a hostname, else the id)"
268
+ )
269
+ server_parser.add_argument("--ssh-user", default="root", help="(default: root)")
270
+ server_parser.add_argument("--ssh-port", type=int, default=22, help="(default: 22)")
271
+ server_parser.add_argument("--description")
272
+
273
+ for subparser in (key_parser, type_parser, server_parser):
274
+ _add_project_directory_argument(subparser)
275
+ subparser.add_argument(
276
+ "--schemas",
277
+ type=Path,
278
+ default=default_schema_directory(),
279
+ help="versioned schema directory (default: bundled schemas)",
280
+ )
281
+
282
+
283
+ def _parser() -> StrictArgumentParser:
284
+ parser = StrictArgumentParser(prog="cloudfall")
285
+ commands = parser.add_subparsers(dest="command", required=True)
286
+ _add_init_parser(commands)
287
+ _add_add_parsers(commands)
288
+ _add_config_parsers(commands)
289
+
290
+ audit_parser = commands.add_parser(
291
+ "audit", help="compare the config with observed server snapshots"
292
+ )
293
+ _add_project_directory_argument(audit_parser)
294
+ audit_parser.add_argument(
295
+ "--observed",
296
+ type=project_path_argument,
297
+ required=True,
298
+ help="directory containing observed-server JSON snapshots",
299
+ )
300
+ audit_parser.add_argument(
301
+ "--env-receipts",
302
+ type=project_path_argument,
303
+ default=Path("tmp/env-receipts"),
304
+ help=(
305
+ "rendered environment receipt directory used for env-file "
306
+ "drift checks (default: tmp/env-receipts)"
307
+ ),
308
+ )
309
+
310
+ _add_operator_parsers(commands)
311
+ _add_backup_parsers(commands)
312
+ _add_secrets_parsers(commands)
313
+
314
+ services_parser = commands.add_parser(
315
+ "services", help="inspect and report public service lifecycles"
316
+ )
317
+ services_commands = services_parser.add_subparsers(
318
+ dest="services_command", required=True
319
+ )
320
+ services_inspect_parser = services_commands.add_parser(
321
+ "inspect", help="collect DNS, TLS, origin, and public route evidence"
322
+ )
323
+ _add_project_directory_argument(services_inspect_parser)
324
+ services_inspect_parser.add_argument(
325
+ "--output",
326
+ type=project_path_argument,
327
+ default=Path("tmp/observed-services"),
328
+ help="service observation directory (default: tmp/observed-services)",
329
+ )
330
+ services_inspect_parser.add_argument(
331
+ "--schemas",
332
+ type=Path,
333
+ default=default_schema_directory(),
334
+ help="versioned schema directory (default: bundled schemas)",
335
+ )
336
+ services_status_parser = services_commands.add_parser(
337
+ "status", help="derive service lifecycle status from current evidence"
338
+ )
339
+ _add_project_directory_argument(services_status_parser)
340
+ services_status_parser.add_argument(
341
+ "--observed",
342
+ type=project_path_argument,
343
+ required=True,
344
+ help="directory containing observed-server JSON snapshots",
345
+ )
346
+ services_status_parser.add_argument(
347
+ "--service-observed",
348
+ type=project_path_argument,
349
+ default=Path("tmp/observed-services"),
350
+ help="domain observation directory (default: tmp/observed-services)",
351
+ )
352
+ services_status_parser.add_argument(
353
+ "--deployments",
354
+ type=project_path_argument,
355
+ default=Path("tmp/deployments"),
356
+ help="deployment receipt directory (default: tmp/deployments)",
357
+ )
358
+ services_status_parser.add_argument(
359
+ "--schemas",
360
+ type=Path,
361
+ default=default_schema_directory(),
362
+ help="versioned schema directory (default: bundled schemas)",
363
+ )
364
+ audit_parser.add_argument(
365
+ "--schemas",
366
+ type=Path,
367
+ default=default_schema_directory(),
368
+ help="versioned schema directory (default: bundled schemas)",
369
+ )
370
+
371
+ dashboard_parser = commands.add_parser(
372
+ "dashboard", help="build operations dashboard artifacts"
373
+ )
374
+ dashboard_commands = dashboard_parser.add_subparsers(
375
+ dest="dashboard_command", required=True
376
+ )
377
+ dashboard_build_parser = dashboard_commands.add_parser(
378
+ "build", help="build a static read-only operations dashboard"
379
+ )
380
+ _add_project_directory_argument(dashboard_build_parser)
381
+ dashboard_build_parser.add_argument(
382
+ "--observed",
383
+ type=project_path_argument,
384
+ required=True,
385
+ help="directory containing observed-server JSON snapshots",
386
+ )
387
+ dashboard_build_parser.add_argument(
388
+ "--service-observed",
389
+ type=project_path_argument,
390
+ default=Path("tmp/observed-services"),
391
+ help="domain observation directory (default: tmp/observed-services)",
392
+ )
393
+ dashboard_build_parser.add_argument(
394
+ "--deployments",
395
+ type=project_path_argument,
396
+ default=Path("tmp/deployments"),
397
+ help="deployment receipt directory (default: tmp/deployments)",
398
+ )
399
+ dashboard_build_parser.add_argument(
400
+ "--output",
401
+ type=project_path_argument,
402
+ default=Path("tmp/dashboard"),
403
+ help="dashboard output directory (default: tmp/dashboard)",
404
+ )
405
+ dashboard_build_parser.add_argument(
406
+ "--schemas",
407
+ type=Path,
408
+ default=default_schema_directory(),
409
+ help="versioned schema directory (default: bundled schemas)",
410
+ )
411
+
412
+ dashboard_serve_parser = dashboard_commands.add_parser(
413
+ "serve", help="serve a live-refreshing read-only dashboard over HTTP"
414
+ )
415
+ _add_project_directory_argument(dashboard_serve_parser)
416
+ dashboard_serve_parser.add_argument(
417
+ "--observed",
418
+ type=project_path_argument,
419
+ required=True,
420
+ help="directory containing observed-server JSON snapshots",
421
+ )
422
+ dashboard_serve_parser.add_argument(
423
+ "--service-observed",
424
+ type=project_path_argument,
425
+ default=Path("tmp/observed-services"),
426
+ help="domain observation directory (default: tmp/observed-services)",
427
+ )
428
+ dashboard_serve_parser.add_argument(
429
+ "--deployments",
430
+ type=project_path_argument,
431
+ default=Path("tmp/deployments"),
432
+ help="deployment receipt directory (default: tmp/deployments)",
433
+ )
434
+ dashboard_serve_parser.add_argument(
435
+ "--schemas",
436
+ type=Path,
437
+ default=default_schema_directory(),
438
+ help="versioned schema directory (default: bundled schemas)",
439
+ )
440
+ dashboard_serve_parser.add_argument(
441
+ "--host",
442
+ default="127.0.0.1",
443
+ help="listen address; use the VPS address to expose it (default: 127.0.0.1)",
444
+ )
445
+ dashboard_serve_parser.add_argument(
446
+ "--port",
447
+ type=int,
448
+ default=8100,
449
+ help="listen port; 0 picks a free port (default: 8100)",
450
+ )
451
+ dashboard_serve_parser.add_argument(
452
+ "--refresh",
453
+ type=int,
454
+ default=10,
455
+ help="evidence refresh interval in seconds (default: 10)",
456
+ )
457
+ dashboard_serve_parser.add_argument(
458
+ "--inspect-services",
459
+ action="store_true",
460
+ help="probe DNS, TLS, origin, and public routes on every refresh",
461
+ )
462
+
463
+ _add_lifecycle_parsers(commands)
464
+ _add_import_parsers(commands)
465
+ _add_migrate_parser(commands)
466
+ return parser
467
+
468
+
469
+ def _add_migrate_parser(
470
+ commands: argparse._SubParsersAction[StrictArgumentParser],
471
+ ) -> None:
472
+ migrate_parser = commands.add_parser(
473
+ "migrate",
474
+ help="run the resumable end-to-end migration plan",
475
+ )
476
+ _add_project_directory_argument(migrate_parser)
477
+ migrate_parser.add_argument(
478
+ "--schemas",
479
+ type=Path,
480
+ default=default_schema_directory(),
481
+ help="versioned schema directory (default: bundled schemas)",
482
+ )
483
+ migrate_parser.add_argument(
484
+ "--engine",
485
+ type=Path,
486
+ default=default_engine_directory(),
487
+ help="engine directory containing ansible contracts (default: bundled engine)",
488
+ )
489
+ migrate_parser.add_argument(
490
+ "--inventory-file",
491
+ type=project_path_argument,
492
+ default=Path("tmp/ansible-inventory.json"),
493
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
494
+ )
495
+ migrate_parser.add_argument(
496
+ "--observed",
497
+ type=project_path_argument,
498
+ default=Path("tmp/observed"),
499
+ help="server observation directory (default: tmp/observed)",
500
+ )
501
+ migrate_parser.add_argument(
502
+ "--service-observed",
503
+ type=project_path_argument,
504
+ default=Path("tmp/observed-services"),
505
+ help="domain observation directory (default: tmp/observed-services)",
506
+ )
507
+ migrate_parser.add_argument(
508
+ "--deployments",
509
+ type=project_path_argument,
510
+ default=Path("tmp/deployments"),
511
+ help="domain receipt directory (default: tmp/deployments)",
512
+ )
513
+ migrate_parser.add_argument(
514
+ "--receipts",
515
+ type=project_path_argument,
516
+ default=Path("tmp/releases"),
517
+ help="release receipt directory (default: tmp/releases)",
518
+ )
519
+ migrate_parser.add_argument(
520
+ "--artifacts",
521
+ type=project_path_argument,
522
+ default=Path("tmp/artifacts"),
523
+ help="artifact directory (default: tmp/artifacts)",
524
+ )
525
+ migrate_parser.add_argument(
526
+ "--plan-file",
527
+ type=project_path_argument,
528
+ default=Path("tmp/migrate/plan.json"),
529
+ help="persisted plan location (default: tmp/migrate/plan.json)",
530
+ )
531
+ migrate_parser.add_argument(
532
+ "--build",
533
+ action="append",
534
+ default=[],
535
+ metavar="COMPONENT=GIT_REF",
536
+ help="build this component from a git ref (repeatable)",
537
+ )
538
+ migrate_parser.add_argument(
539
+ "--release",
540
+ action="append",
541
+ default=[],
542
+ metavar="COMPONENT=RELEASE_ID",
543
+ help="deploy this component from an already-built release",
544
+ )
545
+ migrate_parser.add_argument(
546
+ "--env-file",
547
+ action="append",
548
+ default=[],
549
+ metavar="COMPONENT=PATH",
550
+ help="environment file passed to this component's deployment",
551
+ )
552
+ migrate_parser.add_argument(
553
+ "--data",
554
+ action="append",
555
+ default=[],
556
+ metavar="DATABASE=URL_FILE",
557
+ help=(
558
+ "migrate external data into a declared database before cutover; "
559
+ "URL_FILE is a controller file containing only the source "
560
+ "connection URL"
561
+ ),
562
+ )
563
+ migrate_parser.add_argument(
564
+ "--yes",
565
+ action="store_true",
566
+ help="execute the plan; without this flag only the plan is shown",
567
+ )
568
+ migrate_parser.add_argument(
569
+ "--restart",
570
+ action="store_true",
571
+ help="discard the persisted plan and start over",
572
+ )
573
+
574
+
575
+ def _add_lifecycle_parsers(
576
+ commands: argparse._SubParsersAction[StrictArgumentParser],
577
+ ) -> None:
578
+ data_parser = commands.add_parser(
579
+ "data", help="migrate data into declared services"
580
+ )
581
+ data_commands = data_parser.add_subparsers(dest="data_command", required=True)
582
+ data_migrate_parser = data_commands.add_parser(
583
+ "migrate",
584
+ help=(
585
+ "dump an external PostgreSQL database and restore it into a "
586
+ "declared service with row-count verification"
587
+ ),
588
+ )
589
+ _add_project_directory_argument(data_migrate_parser)
590
+ data_migrate_parser.add_argument("service", type=resource_id_argument)
591
+ _add_execute_argument(data_migrate_parser)
592
+ data_migrate_parser.add_argument(
593
+ "--database",
594
+ required=True,
595
+ help="declared database name inside the service",
596
+ )
597
+ data_migrate_parser.add_argument(
598
+ "--source-url-file",
599
+ type=Path,
600
+ required=True,
601
+ help=(
602
+ "controller-side file whose only content is the source "
603
+ "database connection URL"
604
+ ),
605
+ )
606
+ data_migrate_parser.add_argument(
607
+ "--receipts",
608
+ type=project_path_argument,
609
+ default=Path("tmp/data-migrations"),
610
+ help="migration receipt directory (default: tmp/data-migrations)",
611
+ )
612
+ data_migrate_parser.add_argument(
613
+ "--schemas",
614
+ type=Path,
615
+ default=default_schema_directory(),
616
+ help="versioned schema directory (default: bundled schemas)",
617
+ )
618
+ data_migrate_parser.add_argument(
619
+ "--engine",
620
+ type=Path,
621
+ default=default_engine_directory(),
622
+ help="engine directory containing ansible contracts (default: bundled engine)",
623
+ )
624
+ data_migrate_parser.add_argument(
625
+ "--inventory-file",
626
+ type=project_path_argument,
627
+ default=Path("tmp/ansible-inventory.json"),
628
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
629
+ )
630
+
631
+ deploy_parser = commands.add_parser(
632
+ "deploy", help="deploy one built component release"
633
+ )
634
+ _add_lifecycle_arguments(deploy_parser)
635
+ _add_execute_argument(deploy_parser)
636
+ deploy_parser.add_argument(
637
+ "--release",
638
+ type=release_id_argument,
639
+ required=True,
640
+ help="release id produced by cloudfall-engine artifact build",
641
+ )
642
+ deploy_parser.add_argument(
643
+ "--artifacts",
644
+ type=project_path_argument,
645
+ default=Path("tmp/artifacts"),
646
+ help="artifact directory (default: tmp/artifacts)",
647
+ )
648
+ deploy_parser.add_argument(
649
+ "--env-file",
650
+ type=Path,
651
+ help="optional controller-side environment file for the component",
652
+ )
653
+ deploy_parser.add_argument(
654
+ "--receipts",
655
+ type=project_path_argument,
656
+ default=Path("tmp/releases"),
657
+ help="release receipt directory (default: tmp/releases)",
658
+ )
659
+
660
+ rollback_parser = commands.add_parser(
661
+ "rollback", help="switch one component back to an existing release"
662
+ )
663
+ _add_lifecycle_arguments(rollback_parser)
664
+ _add_execute_argument(rollback_parser)
665
+ rollback_parser.add_argument(
666
+ "--release",
667
+ type=release_id_argument,
668
+ required=True,
669
+ help="existing release id to activate",
670
+ )
671
+
672
+ restart_parser = commands.add_parser(
673
+ "restart", help="restart one component behind its health check"
674
+ )
675
+ _add_lifecycle_arguments(restart_parser)
676
+ _add_execute_argument(restart_parser)
677
+
678
+ health_parser = commands.add_parser(
679
+ "health", help="probe one component's declared health check"
680
+ )
681
+ _add_lifecycle_arguments(health_parser)
682
+
683
+
684
+ def _add_import_parsers(
685
+ commands: argparse._SubParsersAction[StrictArgumentParser],
686
+ ) -> None:
687
+ import_parser = commands.add_parser(
688
+ "import", help="import external platform definitions"
689
+ )
690
+ import_commands = import_parser.add_subparsers(dest="import_command", required=True)
691
+ render_parser = import_commands.add_parser(
692
+ "render", help="map a render.yaml blueprint onto Cloudfall config"
693
+ )
694
+ _add_project_directory_argument(render_parser)
695
+ render_parser.add_argument("blueprint", type=Path)
696
+ render_parser.add_argument(
697
+ "--application",
698
+ type=resource_id_argument,
699
+ required=True,
700
+ help="Cloudfall application id (also the application's Linux user)",
701
+ )
702
+ render_parser.add_argument(
703
+ "--server",
704
+ type=resource_id_argument,
705
+ required=True,
706
+ help="declared server id that receives every imported resource",
707
+ )
708
+ render_parser.add_argument(
709
+ "--output",
710
+ type=project_path_argument,
711
+ default=Path("tmp/import/config"),
712
+ help="config fragment output directory (default: tmp/import/config)",
713
+ )
714
+ render_parser.add_argument(
715
+ "--env-dir",
716
+ type=project_path_argument,
717
+ default=Path("tmp/import/env"),
718
+ help="environment file output directory (default: tmp/import/env)",
719
+ )
720
+ render_parser.add_argument(
721
+ "--schemas",
722
+ type=Path,
723
+ default=default_schema_directory(),
724
+ help="versioned schema directory (default: bundled schemas)",
725
+ )
726
+ render_api_parser = import_commands.add_parser(
727
+ "render-api",
728
+ help="map a live Render workspace onto Cloudfall config via the API",
729
+ )
730
+ _add_project_directory_argument(render_api_parser)
731
+ render_api_parser.add_argument(
732
+ "--api-key-file",
733
+ type=Path,
734
+ required=True,
735
+ help="file containing only the Render API key",
736
+ )
737
+ render_api_parser.add_argument(
738
+ "--api-url",
739
+ default="https://api.render.com/v1",
740
+ help="Render API base URL (default: https://api.render.com/v1)",
741
+ )
742
+ render_api_parser.add_argument(
743
+ "--application",
744
+ type=resource_id_argument,
745
+ required=True,
746
+ help="Cloudfall application id (also the application's Linux user)",
747
+ )
748
+ render_api_parser.add_argument(
749
+ "--server",
750
+ type=resource_id_argument,
751
+ required=True,
752
+ help="declared server id that receives every imported resource",
753
+ )
754
+ render_api_parser.add_argument(
755
+ "--output",
756
+ type=project_path_argument,
757
+ default=Path("tmp/import/config"),
758
+ help="config fragment output directory (default: tmp/import/config)",
759
+ )
760
+ render_api_parser.add_argument(
761
+ "--env-dir",
762
+ type=project_path_argument,
763
+ default=Path("tmp/import/env"),
764
+ help="environment file output directory (default: tmp/import/env)",
765
+ )
766
+ render_api_parser.add_argument(
767
+ "--schemas",
768
+ type=Path,
769
+ default=default_schema_directory(),
770
+ help="versioned schema directory (default: bundled schemas)",
771
+ )
772
+
773
+
774
+ def _add_operator_parsers(
775
+ commands: argparse._SubParsersAction[StrictArgumentParser],
776
+ ) -> None:
777
+ operator_parser = commands.add_parser(
778
+ "operator", help="alert-driven propose-and-approve operation"
779
+ )
780
+ operator_commands = operator_parser.add_subparsers(
781
+ dest="operator_command", required=True
782
+ )
783
+ operator_run_parser = operator_commands.add_parser(
784
+ "run", help="watch declared alerts and drift, write proposals"
785
+ )
786
+ _add_operator_arguments(operator_run_parser)
787
+ _add_operator_feed_arguments(operator_run_parser, required=True)
788
+ _add_operator_engine_arguments(operator_run_parser)
789
+ operator_run_parser.add_argument(
790
+ "--interval",
791
+ type=float,
792
+ default=None,
793
+ help="seconds between watch passes (default: one pass, then exit)",
794
+ )
795
+ operator_run_parser.add_argument(
796
+ "--drift-interval",
797
+ type=float,
798
+ default=None,
799
+ help=(
800
+ "seconds between audited drift checks (default: no drift "
801
+ "checks; a single pass runs one when set)"
802
+ ),
803
+ )
804
+ operator_run_parser.add_argument(
805
+ "--observed",
806
+ type=project_path_argument,
807
+ default=Path("tmp/operator/observed"),
808
+ help=(
809
+ "observation directory for drift checks (default: tmp/operator/observed)"
810
+ ),
811
+ )
812
+ operator_list_parser = operator_commands.add_parser(
813
+ "list", help="list proposal receipts"
814
+ )
815
+ _add_operator_arguments(operator_list_parser)
816
+ operator_show_parser = operator_commands.add_parser(
817
+ "show", help="show one proposal receipt"
818
+ )
819
+ _add_operator_arguments(operator_show_parser)
820
+ operator_show_parser.add_argument("proposal", type=resource_id_argument)
821
+ operator_approve_parser = operator_commands.add_parser(
822
+ "approve", help="execute a proposal and verify its trigger resolves"
823
+ )
824
+ _add_operator_arguments(operator_approve_parser)
825
+ _add_operator_feed_arguments(operator_approve_parser, required=False)
826
+ _add_operator_engine_arguments(operator_approve_parser)
827
+ operator_approve_parser.add_argument("proposal", type=resource_id_argument)
828
+ operator_approve_parser.add_argument(
829
+ "--observed",
830
+ type=project_path_argument,
831
+ default=Path("tmp/operator/observed"),
832
+ help=(
833
+ "observation directory for drift verification "
834
+ "(default: tmp/operator/observed)"
835
+ ),
836
+ )
837
+ operator_approve_parser.add_argument(
838
+ "--verify-timeout",
839
+ type=float,
840
+ default=180.0,
841
+ help="seconds to wait for the trigger to resolve (default: 180)",
842
+ )
843
+
844
+
845
+ def _add_secrets_parsers(
846
+ commands: argparse._SubParsersAction[StrictArgumentParser],
847
+ ) -> None:
848
+ secrets_parser = commands.add_parser(
849
+ "secrets", help="resolve declared secret references"
850
+ )
851
+ secrets_commands = secrets_parser.add_subparsers(
852
+ dest="secrets_command", required=True
853
+ )
854
+ render_parser = secrets_commands.add_parser(
855
+ "render",
856
+ help="render one component's references into its environment file",
857
+ )
858
+ _add_project_directory_argument(render_parser)
859
+ render_parser.add_argument("component", type=resource_id_argument)
860
+ render_parser.add_argument(
861
+ "--schemas",
862
+ type=Path,
863
+ default=default_schema_directory(),
864
+ help="versioned schema directory (default: bundled schemas)",
865
+ )
866
+ render_parser.add_argument(
867
+ "--secrets-dir",
868
+ type=Path,
869
+ default=Path("secrets"),
870
+ help="sops-encrypted secrets directory (default: secrets)",
871
+ )
872
+ render_parser.add_argument(
873
+ "--output",
874
+ type=project_path_argument,
875
+ default=None,
876
+ help=("environment file to write (default: tmp/env/<component>.env)"),
877
+ )
878
+ render_parser.add_argument(
879
+ "--receipts",
880
+ type=project_path_argument,
881
+ default=Path("tmp/env-receipts"),
882
+ help=("environment receipt directory (default: tmp/env-receipts)"),
883
+ )
884
+ render_parser.add_argument(
885
+ "--engine",
886
+ type=Path,
887
+ default=default_engine_directory(),
888
+ help="engine directory containing ansible contracts (default: bundled engine)",
889
+ )
890
+ render_parser.add_argument(
891
+ "--inventory-file",
892
+ type=project_path_argument,
893
+ default=Path("tmp/ansible-inventory.json"),
894
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
895
+ )
896
+
897
+
898
+ def _add_backup_parsers(
899
+ commands: argparse._SubParsersAction[StrictArgumentParser],
900
+ ) -> None:
901
+ backup_parser = commands.add_parser(
902
+ "backup", help="run and prove declared service backups"
903
+ )
904
+ backup_commands = backup_parser.add_subparsers(dest="backup_command", required=True)
905
+ for name, description in (
906
+ ("run", "run the declared backup for one service"),
907
+ ("verify", "prove the newest backup restores for one service"),
908
+ ):
909
+ subparser = backup_commands.add_parser(name, help=description)
910
+ _add_project_directory_argument(subparser)
911
+ subparser.add_argument("service", type=resource_id_argument)
912
+ subparser.add_argument(
913
+ "--schemas",
914
+ type=Path,
915
+ default=default_schema_directory(),
916
+ help="versioned schema directory (default: bundled schemas)",
917
+ )
918
+ subparser.add_argument(
919
+ "--receipts",
920
+ type=project_path_argument,
921
+ default=Path("tmp/backups"),
922
+ help="backup receipt directory (default: tmp/backups)",
923
+ )
924
+ _add_operator_engine_arguments(subparser)
925
+
926
+
927
+ def _add_operator_arguments(parser: argparse.ArgumentParser) -> None:
928
+ _add_project_directory_argument(parser)
929
+ parser.add_argument(
930
+ "--schemas",
931
+ type=Path,
932
+ default=default_schema_directory(),
933
+ help="versioned schema directory (default: bundled schemas)",
934
+ )
935
+ parser.add_argument(
936
+ "--proposals",
937
+ type=project_path_argument,
938
+ default=Path("tmp/operator/proposals"),
939
+ help="proposal receipt directory (default: tmp/operator/proposals)",
940
+ )
941
+
942
+
943
+ def _add_operator_feed_arguments(
944
+ parser: argparse.ArgumentParser, *, required: bool
945
+ ) -> None:
946
+ parser.add_argument(
947
+ "--gateway-url",
948
+ default=None,
949
+ help=("alerts endpoint (default: derived from the declared logging gateway)"),
950
+ )
951
+ parser.add_argument("--gateway-ca", type=Path, required=required)
952
+ parser.add_argument("--gateway-cert", type=Path, required=required)
953
+ parser.add_argument("--gateway-key", type=Path, required=required)
954
+
955
+
956
+ def _add_operator_engine_arguments(parser: argparse.ArgumentParser) -> None:
957
+ parser.add_argument(
958
+ "--engine",
959
+ type=Path,
960
+ default=default_engine_directory(),
961
+ help="engine directory containing ansible contracts (default: bundled engine)",
962
+ )
963
+ parser.add_argument(
964
+ "--inventory-file",
965
+ type=project_path_argument,
966
+ default=Path("tmp/ansible-inventory.json"),
967
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
968
+ )
969
+
970
+
971
+ def _add_execute_argument(parser: argparse.ArgumentParser) -> None:
972
+ parser.add_argument(
973
+ "--yes",
974
+ action="store_true",
975
+ help=(
976
+ "change the servers; without this flag the command validates the "
977
+ "request and only shows what it would do"
978
+ ),
979
+ )
980
+
981
+
982
+ def _add_lifecycle_arguments(parser: argparse.ArgumentParser) -> None:
983
+ _add_project_directory_argument(parser)
984
+ parser.add_argument("component", type=resource_id_argument)
985
+ parser.add_argument(
986
+ "--schemas",
987
+ type=Path,
988
+ default=default_schema_directory(),
989
+ help="versioned schema directory (default: bundled schemas)",
990
+ )
991
+ parser.add_argument(
992
+ "--engine",
993
+ type=Path,
994
+ default=default_engine_directory(),
995
+ help="engine directory containing ansible contracts (default: bundled engine)",
996
+ )
997
+ parser.add_argument(
998
+ "--inventory-file",
999
+ type=project_path_argument,
1000
+ default=Path("tmp/ansible-inventory.json"),
1001
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
1002
+ )
1003
+
1004
+
1005
+ def main(argv: Sequence[str] | None = None) -> int:
1006
+ """Run the CLI and return a process exit code."""
1007
+ arguments = parse_arguments(_parser(), argv)
1008
+ if arguments.command == "init":
1009
+ return _run_init(arguments)
1010
+ try:
1011
+ with project_context(arguments.project, os.environ) as project_directory:
1012
+ arguments.project_directory = project_directory
1013
+ if arguments.command == "add":
1014
+ return _run_add(arguments, project_directory)
1015
+ if arguments.command == "import":
1016
+ return _run_import_render(arguments)
1017
+ schema_directory = Path(arguments.schemas)
1018
+ state = validate_config(project_directory, schema_directory)
1019
+ return _dispatch(arguments, state, schema_directory)
1020
+ except (ProjectError, ConfigValidationError) as error:
1021
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1022
+ return 2
1023
+
1024
+
1025
+ def _run_init(arguments: Namespace) -> int:
1026
+ directory = Path(arguments.directory)
1027
+ try:
1028
+ name = (
1029
+ ProjectName.from_boundary(arguments.name)
1030
+ if arguments.name is not None
1031
+ else ProjectName.from_directory(directory)
1032
+ )
1033
+ revision = (
1034
+ GitRevision.from_boundary(arguments.rev)
1035
+ if arguments.rev is not None
1036
+ else resolve_installed_revision()
1037
+ )
1038
+ options = InitOptions(
1039
+ directory=directory,
1040
+ name=name,
1041
+ revision=revision,
1042
+ source=GitSourceUrl.from_boundary(arguments.source),
1043
+ description=(
1044
+ ProjectDescription.from_boundary(arguments.description)
1045
+ if arguments.description is not None
1046
+ else None
1047
+ ),
1048
+ )
1049
+ scaffold = init_project(options)
1050
+ except ValueError as error:
1051
+ payload = {
1052
+ "status": "error",
1053
+ "error": {"code": "invalid_argument", "message": str(error)},
1054
+ }
1055
+ sys.stderr.write(f"{json.dumps(payload, sort_keys=True)}\n")
1056
+ return 2
1057
+ except ProjectError as error:
1058
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1059
+ return 2
1060
+ _write_json(scaffold.as_dict())
1061
+ return 0
1062
+
1063
+
1064
+ def _run_add(arguments: Namespace, project_directory: Path) -> int:
1065
+ schema_directory = Path(arguments.schemas)
1066
+ try:
1067
+ if arguments.add_command == "ssh-key":
1068
+ key_options = SshKeyOptions(
1069
+ key_path=Path(arguments.key_file).expanduser(),
1070
+ owner=ResourceId.from_boundary(arguments.owner),
1071
+ environment=ResourceId.from_boundary(arguments.environment),
1072
+ resource_id=(
1073
+ ResourceId.from_boundary(arguments.id)
1074
+ if arguments.id is not None
1075
+ else None
1076
+ ),
1077
+ description=arguments.description,
1078
+ )
1079
+ result = add_ssh_key(project_directory, key_options, schema_directory)
1080
+ elif arguments.add_command == "server-type":
1081
+ type_options = ServerTypeOptions(
1082
+ resource_id=ResourceId.from_boundary(arguments.id),
1083
+ description=arguments.description,
1084
+ )
1085
+ result = add_server_type(project_directory, type_options, schema_directory)
1086
+ else:
1087
+ server_options = ServerOptions(
1088
+ resource_id=ResourceId.from_boundary(arguments.id),
1089
+ address=ConnectionAddress.from_boundary(arguments.address),
1090
+ server_type=ResourceId.from_boundary(arguments.type),
1091
+ environment=ResourceId.from_boundary(arguments.environment),
1092
+ ssh_user=LinuxUser.from_boundary(arguments.ssh_user),
1093
+ ssh_port=TcpPort.from_boundary(arguments.ssh_port),
1094
+ hostname=(
1095
+ Hostname.from_boundary(arguments.hostname)
1096
+ if arguments.hostname is not None
1097
+ else None
1098
+ ),
1099
+ description=arguments.description,
1100
+ )
1101
+ result = add_server(project_directory, server_options, schema_directory)
1102
+ except (TypeError, ValueError) as error:
1103
+ payload = {
1104
+ "status": "error",
1105
+ "error": {"code": "invalid_argument", "message": str(error)},
1106
+ }
1107
+ sys.stderr.write(f"{json.dumps(payload, sort_keys=True)}\n")
1108
+ return 2
1109
+ except AuthoringError as error:
1110
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1111
+ return 2
1112
+ _write_json(result.as_dict())
1113
+ return 0
1114
+
1115
+
1116
+ def _run_import_render(arguments: Namespace) -> int:
1117
+ try:
1118
+ targets = ImportTargets(
1119
+ application_id=arguments.application,
1120
+ server_id=arguments.server,
1121
+ project_directory=Path(arguments.output),
1122
+ environment_directory=Path(arguments.env_dir),
1123
+ )
1124
+ if arguments.import_command == "render-api":
1125
+ api_key = read_api_key(Path(arguments.api_key_file))
1126
+ client = HttpRenderApiClient(api_key=api_key, base_url=arguments.api_url)
1127
+ result = import_render_api(client, targets, Path(arguments.schemas))
1128
+ else:
1129
+ result = import_render_blueprint(
1130
+ Path(arguments.blueprint), targets, Path(arguments.schemas)
1131
+ )
1132
+ except (RenderImportError, ConfigValidationError) as error:
1133
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1134
+ return 2
1135
+ _write_json(result.as_dict())
1136
+ return 0
1137
+
1138
+
1139
+ def _dispatch(
1140
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1141
+ ) -> int:
1142
+ handlers: dict[str, Callable[[Namespace, ValidatedConfig, Path], int]] = {
1143
+ "audit": _run_audit,
1144
+ "backup:run": _run_backup_run,
1145
+ "backup:verify": _run_backup_verify,
1146
+ "secrets:render": _run_secrets_render,
1147
+ "data:migrate": _run_data_migrate,
1148
+ "dashboard:build": _run_dashboard_build,
1149
+ "dashboard:serve": _run_dashboard_serve,
1150
+ "deploy": _run_deploy,
1151
+ "health": _run_health,
1152
+ "inventory:show": _run_inventory_show,
1153
+ "migrate": _run_migrate,
1154
+ "operator:approve": _run_operator_approve,
1155
+ "operator:list": _run_operator_list,
1156
+ "operator:run": _run_operator_run,
1157
+ "operator:show": _run_operator_show,
1158
+ "restart": _run_restart,
1159
+ "rollback": _run_rollback,
1160
+ "services:inspect": _run_services_inspect,
1161
+ "services:status": _run_services_status,
1162
+ "config:validate": _run_config_validate,
1163
+ }
1164
+ key = _command_key(arguments)
1165
+ try:
1166
+ handler = handlers[key]
1167
+ except KeyError as error:
1168
+ message = f"argparse accepted an unsupported command: {key}"
1169
+ raise RuntimeError(message) from error
1170
+ return handler(arguments, state, schema_directory)
1171
+
1172
+
1173
+ def _command_key(arguments: Namespace) -> str:
1174
+ top_level = {"audit", "deploy", "health", "migrate", "restart", "rollback"}
1175
+ if arguments.command in top_level:
1176
+ return str(arguments.command)
1177
+ subcommand = getattr(arguments, f"{arguments.command}_command", None)
1178
+ return f"{arguments.command}:{subcommand}"
1179
+
1180
+
1181
+ def _run_config_validate(
1182
+ _arguments: Namespace, state: ValidatedConfig, _schema_directory: Path
1183
+ ) -> int:
1184
+ _write_json(state.as_dict())
1185
+ return 0
1186
+
1187
+
1188
+ def _run_inventory_show(
1189
+ _arguments: Namespace, state: ValidatedConfig, _schema_directory: Path
1190
+ ) -> int:
1191
+ _write_json(
1192
+ {
1193
+ "status": "ok",
1194
+ "inventory": PlatformInventory.from_state(state).as_dict(),
1195
+ }
1196
+ )
1197
+ return 0
1198
+
1199
+
1200
+ def _run_audit(
1201
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1202
+ ) -> int:
1203
+ observations = load_observations(Path(arguments.observed), schema_directory)
1204
+ report = audit_inventory(
1205
+ PlatformInventory.from_state(state),
1206
+ observations,
1207
+ load_environment_receipts(Path(arguments.env_receipts), schema_directory),
1208
+ )
1209
+ _write_json(report.as_dict())
1210
+ if report.status is AuditStatus.COMPLIANT:
1211
+ return 0
1212
+ if report.status is AuditStatus.DRIFT:
1213
+ return 1
1214
+ return 3
1215
+
1216
+
1217
+ def _run_secrets_render(
1218
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1219
+ ) -> int:
1220
+ output = (
1221
+ Path(arguments.output)
1222
+ if arguments.output is not None
1223
+ else Path("tmp/env") / f"{arguments.component}.env"
1224
+ )
1225
+ try:
1226
+ result = render_environment(
1227
+ _engine_context(arguments, schema_directory),
1228
+ arguments.component,
1229
+ SopsSecretProvider(secrets_directory=Path(arguments.secrets_dir)),
1230
+ output,
1231
+ receipt_directory=Path(arguments.receipts),
1232
+ )
1233
+ except SecretsError as error:
1234
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1235
+ return 2
1236
+ _write_json(result)
1237
+ return 0
1238
+
1239
+
1240
+ def _run_backup_run(
1241
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1242
+ ) -> int:
1243
+ return _run_backup_operation(arguments, schema_directory, backup_service)
1244
+
1245
+
1246
+ def _run_backup_verify(
1247
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1248
+ ) -> int:
1249
+ return _run_backup_operation(arguments, schema_directory, verify_backup)
1250
+
1251
+
1252
+ def _run_backup_operation(
1253
+ arguments: Namespace,
1254
+ schema_directory: Path,
1255
+ operation: Callable[[EngineContext, ResourceId, Path], dict[str, object]],
1256
+ ) -> int:
1257
+ try:
1258
+ result = operation(
1259
+ _engine_context(arguments, schema_directory),
1260
+ arguments.service,
1261
+ Path(arguments.receipts),
1262
+ )
1263
+ except LifecycleError as error:
1264
+ return _lifecycle_exit(error)
1265
+ _write_json(result)
1266
+ return 0
1267
+
1268
+
1269
+ def _operator_store(arguments: Namespace, schema_directory: Path) -> ProposalStore:
1270
+ return ProposalStore(
1271
+ directory=Path(arguments.proposals),
1272
+ catalog=SchemaCatalog(schema_directory),
1273
+ )
1274
+
1275
+
1276
+ def _operator_feed(arguments: Namespace, inventory: PlatformInventory) -> AlertFeed:
1277
+ return gateway_feed(
1278
+ inventory,
1279
+ ca_path=Path(arguments.gateway_ca),
1280
+ certificate_path=Path(arguments.gateway_cert),
1281
+ key_path=Path(arguments.gateway_key),
1282
+ url_override=arguments.gateway_url,
1283
+ )
1284
+
1285
+
1286
+ def _require_gateway_material(arguments: Namespace) -> None:
1287
+ if (
1288
+ arguments.gateway_ca is None
1289
+ or arguments.gateway_cert is None
1290
+ or arguments.gateway_key is None
1291
+ ):
1292
+ code = "operator_gateway_material_missing"
1293
+ message = (
1294
+ "approving an alert-triggered proposal requires --gateway-ca, "
1295
+ "--gateway-cert, and --gateway-key"
1296
+ )
1297
+ raise OperatorError(code, message)
1298
+
1299
+
1300
+ def _operator_exit(error: OperatorError) -> int:
1301
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1302
+ return 2
1303
+
1304
+
1305
+ def _run_operator_run(
1306
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1307
+ ) -> int:
1308
+ inventory = PlatformInventory.from_state(state)
1309
+ try:
1310
+ store = _operator_store(arguments, schema_directory)
1311
+ feed = _operator_feed(arguments, inventory)
1312
+ auditor = None
1313
+ if arguments.drift_interval is not None:
1314
+ auditor = engine_auditor(
1315
+ _engine_context(arguments, schema_directory),
1316
+ inventory,
1317
+ Path(arguments.observed),
1318
+ )
1319
+ context = _engine_context(arguments, schema_directory)
1320
+
1321
+ def _verifier_for(
1322
+ proposal: OperatorProposal,
1323
+ ) -> Callable[[OperatorProposal], bool]:
1324
+ if proposal.trigger_kind is TriggerKind.ALERT:
1325
+ return alert_resolution_verifier(feed)
1326
+ return drift_resolution_verifier(
1327
+ engine_auditor(context, inventory, Path(arguments.observed))
1328
+ )
1329
+
1330
+ drift_due = 0.0
1331
+ while True:
1332
+ report = operator_run_once(feed, inventory, store)
1333
+ _write_json({"status": "ok", "pass": "alerts", **report.as_dict()})
1334
+ if auditor is not None and time.monotonic() >= drift_due:
1335
+ drift_report = drift_pass(auditor, store)
1336
+ _write_json(
1337
+ {
1338
+ "status": "ok",
1339
+ "pass": "drift",
1340
+ **drift_report.as_dict(),
1341
+ }
1342
+ )
1343
+ drift_due = time.monotonic() + arguments.drift_interval
1344
+ if inventory.operator_policies:
1345
+ autonomy_report = autonomous_pass(
1346
+ store,
1347
+ inventory,
1348
+ engine_executor(context),
1349
+ _verifier_for,
1350
+ )
1351
+ _write_json(
1352
+ {
1353
+ "status": "ok",
1354
+ "pass": "autonomy",
1355
+ **autonomy_report.as_dict(),
1356
+ }
1357
+ )
1358
+ if arguments.interval is None:
1359
+ return 0
1360
+ time.sleep(arguments.interval)
1361
+ except OperatorError as error:
1362
+ return _operator_exit(error)
1363
+
1364
+
1365
+ def _run_operator_list(
1366
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1367
+ ) -> int:
1368
+ try:
1369
+ store = _operator_store(arguments, schema_directory)
1370
+ proposals = [proposal.as_document() for proposal in store.list()]
1371
+ except OperatorError as error:
1372
+ return _operator_exit(error)
1373
+ _write_json({"status": "ok", "proposals": proposals})
1374
+ return 0
1375
+
1376
+
1377
+ def _run_operator_show(
1378
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1379
+ ) -> int:
1380
+ try:
1381
+ store = _operator_store(arguments, schema_directory)
1382
+ proposal = store.load(arguments.proposal)
1383
+ except OperatorError as error:
1384
+ return _operator_exit(error)
1385
+ _write_json(proposal.as_document())
1386
+ return 0
1387
+
1388
+
1389
+ def _run_operator_approve(
1390
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1391
+ ) -> int:
1392
+ inventory = PlatformInventory.from_state(state)
1393
+ try:
1394
+ store = _operator_store(arguments, schema_directory)
1395
+ proposal_id = arguments.proposal
1396
+ pending = store.load(proposal_id)
1397
+ context = _engine_context(arguments, schema_directory)
1398
+ if pending.trigger_kind is TriggerKind.ALERT:
1399
+ _require_gateway_material(arguments)
1400
+ verifier = alert_resolution_verifier(_operator_feed(arguments, inventory))
1401
+ else:
1402
+ verifier = drift_resolution_verifier(
1403
+ engine_auditor(context, inventory, Path(arguments.observed))
1404
+ )
1405
+ proposal = operator_approve(
1406
+ store,
1407
+ proposal_id,
1408
+ engine_executor(context),
1409
+ verifier,
1410
+ ApproveOptions(verify_timeout_seconds=arguments.verify_timeout),
1411
+ )
1412
+ except OperatorError as error:
1413
+ return _operator_exit(error)
1414
+ except LifecycleError as error:
1415
+ return _lifecycle_exit(error)
1416
+ _write_json(proposal.as_document())
1417
+ return 0 if proposal.status is ProposalStatus.VERIFIED else 1
1418
+
1419
+
1420
+ def _run_services_inspect(
1421
+ arguments: Namespace, state: ValidatedConfig, _schema_directory: Path
1422
+ ) -> int:
1423
+ paths = inspect_domains(
1424
+ PlatformInventory.from_state(state),
1425
+ Path(arguments.output),
1426
+ SocketDomainNetworkClient(),
1427
+ observed_at=EvidenceTimestamp.now(),
1428
+ )
1429
+ _write_json(
1430
+ {
1431
+ "status": "ok",
1432
+ "observations": [str(path) for path in paths],
1433
+ }
1434
+ )
1435
+ return 0
1436
+
1437
+
1438
+ def _run_services_status(
1439
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1440
+ ) -> int:
1441
+ operations = _service_operations(arguments, state, schema_directory)
1442
+ _write_json(
1443
+ {
1444
+ "status": "ok",
1445
+ "services": [domain.as_dict() for domain in operations.domains],
1446
+ }
1447
+ )
1448
+ return 0
1449
+
1450
+
1451
+ def _run_dashboard_build(
1452
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1453
+ ) -> int:
1454
+ operations = _service_operations(arguments, state, schema_directory)
1455
+ artifacts = build_dashboard(operations, Path(arguments.output))
1456
+ _write_json(
1457
+ {
1458
+ "status": "ok",
1459
+ "health": operations.health.value,
1460
+ "tasks": len(operations.tasks),
1461
+ "dashboard": artifacts.as_dict(),
1462
+ }
1463
+ )
1464
+ return 0
1465
+
1466
+
1467
+ def _run_dashboard_serve(
1468
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1469
+ ) -> int:
1470
+ sources = EvidenceSources(
1471
+ project_directory=Path(arguments.project_directory),
1472
+ schema_directory=schema_directory,
1473
+ observed_directory=Path(arguments.observed),
1474
+ service_observed_directory=Path(arguments.service_observed),
1475
+ deployments_directory=Path(arguments.deployments),
1476
+ inspect_services=bool(arguments.inspect_services),
1477
+ )
1478
+ endpoint = ListenEndpoint(host=str(arguments.host), port=int(arguments.port))
1479
+ refresh = RefreshInterval.from_boundary(int(arguments.refresh))
1480
+ server = create_dashboard_server(sources, endpoint, refresh)
1481
+ bound_port = int(server.server_address[1])
1482
+ _write_json(
1483
+ {
1484
+ "status": "ok",
1485
+ "dashboard": {
1486
+ "url": f"http://{endpoint.host}:{bound_port}/",
1487
+ "refreshSeconds": refresh.seconds,
1488
+ "inspectServices": sources.inspect_services,
1489
+ },
1490
+ }
1491
+ )
1492
+ try:
1493
+ server.serve_forever()
1494
+ except KeyboardInterrupt:
1495
+ return 0
1496
+ finally:
1497
+ server.server_close()
1498
+ return 0
1499
+
1500
+
1501
+ def _service_operations(
1502
+ arguments: Namespace, state: ValidatedConfig, schema_directory: Path
1503
+ ) -> FleetOperations:
1504
+ observations = load_observations(Path(arguments.observed), schema_directory)
1505
+ deployment_receipts = _optional_deployment_receipts(
1506
+ Path(arguments.deployments), schema_directory
1507
+ )
1508
+ domain_observations = _optional_domain_observations(
1509
+ Path(arguments.service_observed), schema_directory
1510
+ )
1511
+ return build_operations_view(
1512
+ PlatformInventory.from_state(state),
1513
+ observations,
1514
+ deployment_receipts,
1515
+ domain_observations,
1516
+ generated_at=UtcTimestamp.now(),
1517
+ )
1518
+
1519
+
1520
+ def _engine_context(arguments: Namespace, schema_directory: Path) -> EngineContext:
1521
+ return EngineContext(
1522
+ project_directory=Path(arguments.project_directory),
1523
+ schema_directory=schema_directory,
1524
+ engine_directory=Path(arguments.engine),
1525
+ inventory_file=Path(arguments.inventory_file),
1526
+ )
1527
+
1528
+
1529
+ def _lifecycle_exit(error: LifecycleError) -> int:
1530
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1531
+ return 1 if error.code == "lifecycle_execution_failed" else 2
1532
+
1533
+
1534
+ def _run_data_migrate(
1535
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1536
+ ) -> int:
1537
+ context = _engine_context(arguments, schema_directory)
1538
+ try:
1539
+ if not arguments.yes:
1540
+ return _write_plan(
1541
+ preview_data_migration(
1542
+ context,
1543
+ arguments.service,
1544
+ str(arguments.database),
1545
+ Path(arguments.source_url_file),
1546
+ )
1547
+ )
1548
+ result = migrate_data(
1549
+ context,
1550
+ arguments.service,
1551
+ str(arguments.database),
1552
+ Path(arguments.source_url_file),
1553
+ Path(arguments.receipts),
1554
+ )
1555
+ except LifecycleError as error:
1556
+ return _lifecycle_exit(error)
1557
+ _write_json(result)
1558
+ return 0
1559
+
1560
+
1561
+ def _run_deploy(
1562
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1563
+ ) -> int:
1564
+ context = _engine_context(arguments, schema_directory)
1565
+ try:
1566
+ if not arguments.yes:
1567
+ return _write_plan(
1568
+ preview_deploy(
1569
+ context,
1570
+ arguments.component,
1571
+ arguments.release,
1572
+ Path(arguments.artifacts),
1573
+ )
1574
+ )
1575
+ result = deploy(
1576
+ context,
1577
+ arguments.component,
1578
+ arguments.release,
1579
+ Path(arguments.artifacts),
1580
+ DeployOptions(
1581
+ environment_file=(
1582
+ Path(arguments.env_file) if arguments.env_file is not None else None
1583
+ ),
1584
+ receipt_directory=Path(arguments.receipts),
1585
+ ),
1586
+ )
1587
+ except LifecycleError as error:
1588
+ return _lifecycle_exit(error)
1589
+ _write_json(result.as_dict())
1590
+ return 0
1591
+
1592
+
1593
+ def _run_rollback(
1594
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1595
+ ) -> int:
1596
+ context = _engine_context(arguments, schema_directory)
1597
+ try:
1598
+ if not arguments.yes:
1599
+ return _write_plan(
1600
+ preview_rollback(context, arguments.component, arguments.release)
1601
+ )
1602
+ result = rollback(
1603
+ context,
1604
+ arguments.component,
1605
+ arguments.release,
1606
+ )
1607
+ except LifecycleError as error:
1608
+ return _lifecycle_exit(error)
1609
+ _write_json(result.as_dict())
1610
+ return 0
1611
+
1612
+
1613
+ def _run_restart(
1614
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1615
+ ) -> int:
1616
+ context = _engine_context(arguments, schema_directory)
1617
+ try:
1618
+ if not arguments.yes:
1619
+ return _write_plan(preview_restart(context, arguments.component))
1620
+ result = restart(context, arguments.component)
1621
+ except LifecycleError as error:
1622
+ return _lifecycle_exit(error)
1623
+ _write_json(result.as_dict())
1624
+ return 0
1625
+
1626
+
1627
+ def _run_health(
1628
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1629
+ ) -> int:
1630
+ try:
1631
+ result = health(
1632
+ _engine_context(arguments, schema_directory),
1633
+ arguments.component,
1634
+ )
1635
+ except LifecycleError as error:
1636
+ return _lifecycle_exit(error)
1637
+ _write_json(result.as_dict())
1638
+ return 0 if result.healthy else 1
1639
+
1640
+
1641
+ def _run_migrate(
1642
+ arguments: Namespace, _state: ValidatedConfig, schema_directory: Path
1643
+ ) -> int:
1644
+ try:
1645
+ builds = _key_value_pairs(arguments.build, "--build")
1646
+ releases = _key_value_pairs(arguments.release, "--release")
1647
+ environment_files = {
1648
+ component: Path(value)
1649
+ for component, value in _key_value_pairs(
1650
+ arguments.env_file, "--env-file"
1651
+ ).items()
1652
+ }
1653
+ data_migrations = {
1654
+ database: Path(value)
1655
+ for database, value in _key_value_pairs(arguments.data, "--data").items()
1656
+ }
1657
+ except ValueError as error:
1658
+ payload = {
1659
+ "status": "error",
1660
+ "error": {"code": "invalid_argument", "message": str(error)},
1661
+ }
1662
+ sys.stderr.write(f"{json.dumps(payload, sort_keys=True)}\n")
1663
+ return 2
1664
+ config = AgentConfig(
1665
+ project_directory=Path(arguments.project_directory),
1666
+ schema_directory=schema_directory,
1667
+ engine_directory=Path(arguments.engine),
1668
+ inventory_file=Path(arguments.inventory_file),
1669
+ observed_directory=Path(arguments.observed),
1670
+ service_observed_directory=Path(arguments.service_observed),
1671
+ deployments_directory=Path(arguments.deployments),
1672
+ releases_directory=Path(arguments.receipts),
1673
+ artifacts_directory=Path(arguments.artifacts),
1674
+ )
1675
+ options = MigrateOptions(
1676
+ plan_file=Path(arguments.plan_file),
1677
+ builds=builds,
1678
+ releases=releases,
1679
+ environment_files=environment_files,
1680
+ data_migrations=data_migrations,
1681
+ execute=arguments.yes,
1682
+ restart=arguments.restart,
1683
+ )
1684
+ try:
1685
+ result = execute_migration(config, options)
1686
+ except MigrateError as error:
1687
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
1688
+ return 2
1689
+ _write_json(result)
1690
+ status = str(result["status"])
1691
+ if status in {"ok", "plan"}:
1692
+ return 0
1693
+ if status == "paused":
1694
+ return 3
1695
+ return 1
1696
+
1697
+
1698
+ def _key_value_pairs(entries: list[str], option: str) -> dict[str, str]:
1699
+ pairs: dict[str, str] = {}
1700
+ for entry in entries:
1701
+ component, separator, value = entry.partition("=")
1702
+ if not separator or not component or not value:
1703
+ message = f"{option} expects COMPONENT=VALUE, got {entry!r}"
1704
+ raise ValueError(message)
1705
+ pairs[component] = value
1706
+ return pairs
1707
+
1708
+
1709
+ def _write_plan(preview: LifecyclePreview) -> int:
1710
+ _write_json(
1711
+ {
1712
+ **preview.as_dict(),
1713
+ "instruction": "review the plan and re-run with --yes to execute it",
1714
+ }
1715
+ )
1716
+ return 0
1717
+
1718
+
1719
+ def _write_json(payload: object) -> None:
1720
+ sys.stdout.write(f"{json.dumps(payload, sort_keys=True)}\n")
1721
+ # A pipe makes stdout block-buffered; flush so long-running commands such
1722
+ # as `operator run --interval` deliver each document when it is written.
1723
+ sys.stdout.flush()
1724
+
1725
+
1726
+ def run() -> None:
1727
+ """Installed console-script entry point."""
1728
+ raise SystemExit(main())
1729
+
1730
+
1731
+ def _optional_deployment_receipts(
1732
+ directory: Path, schema_directory: Path
1733
+ ) -> DeploymentReceiptSet:
1734
+ return (
1735
+ load_deployment_receipts(directory, schema_directory)
1736
+ if directory.is_dir()
1737
+ else DeploymentReceiptSet.empty()
1738
+ )
1739
+
1740
+
1741
+ def _optional_domain_observations(
1742
+ directory: Path, schema_directory: Path
1743
+ ) -> DomainObservationSet:
1744
+ return (
1745
+ load_domain_observations(directory, schema_directory)
1746
+ if directory.is_dir()
1747
+ else DomainObservationSet.empty()
1748
+ )