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,273 @@
1
+ """Command-line boundary for the Cloudfall execution engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from cloudfall.arguments import (
12
+ StrictArgumentParser,
13
+ parse_arguments,
14
+ project_path_argument,
15
+ )
16
+ from cloudfall.inventory import PlatformInventory
17
+ from cloudfall.project import (
18
+ PROJECT_DIRECTORY_VARIABLE,
19
+ ProjectError,
20
+ project_context,
21
+ )
22
+ from cloudfall.resources import default_engine_directory, default_schema_directory
23
+ from cloudfall.validation import ConfigValidationError, validate_config
24
+
25
+ from cloudfall_engine.ansible_inventory import render_ansible_inventory
26
+ from cloudfall_engine.artifact import ArtifactBuildError, build_artifact
27
+ from cloudfall_engine.playbook import (
28
+ PlaybookError,
29
+ PlaybookRun,
30
+ bundled_playbooks,
31
+ execute_playbook,
32
+ resolve_playbook,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ import argparse
37
+ from collections.abc import Sequence
38
+
39
+
40
+ def _add_project_directory_argument(parser: argparse.ArgumentParser) -> None:
41
+ parser.add_argument(
42
+ "--project",
43
+ type=Path,
44
+ default=None,
45
+ help=(
46
+ f"project directory to run in (default: ${PROJECT_DIRECTORY_VARIABLE}, "
47
+ "else the current directory when it is a project)"
48
+ ),
49
+ )
50
+
51
+
52
+ def _parser() -> StrictArgumentParser:
53
+ parser = StrictArgumentParser(prog="cloudfall-engine")
54
+ commands = parser.add_subparsers(dest="command", required=True)
55
+
56
+ inventory_parser = commands.add_parser(
57
+ "inventory", help="generate execution inventory"
58
+ )
59
+ inventory_commands = inventory_parser.add_subparsers(
60
+ dest="inventory_command", required=True
61
+ )
62
+ render_parser = inventory_commands.add_parser(
63
+ "render", help="render Ansible JSON inventory"
64
+ )
65
+ _add_project_directory_argument(render_parser)
66
+ render_parser.add_argument(
67
+ "--schemas",
68
+ type=Path,
69
+ default=default_schema_directory(),
70
+ help="versioned schema directory (default: bundled schemas)",
71
+ )
72
+ render_parser.add_argument(
73
+ "--output",
74
+ type=project_path_argument,
75
+ help="write inventory JSON to this file instead of stdout",
76
+ )
77
+
78
+ artifact_parser = commands.add_parser("artifact", help="build release artifacts")
79
+ artifact_commands = artifact_parser.add_subparsers(
80
+ dest="artifact_command", required=True
81
+ )
82
+ build_parser = artifact_commands.add_parser(
83
+ "build", help="clone, package, and hash one component release"
84
+ )
85
+ _add_project_directory_argument(build_parser)
86
+ build_parser.add_argument("component")
87
+ build_parser.add_argument(
88
+ "--ref",
89
+ required=True,
90
+ help="git ref (branch, tag, or commit) to package",
91
+ )
92
+ build_parser.add_argument(
93
+ "--schemas",
94
+ type=Path,
95
+ default=default_schema_directory(),
96
+ help="versioned schema directory (default: bundled schemas)",
97
+ )
98
+ build_parser.add_argument(
99
+ "--output-dir",
100
+ type=project_path_argument,
101
+ default=Path("tmp/artifacts"),
102
+ help="artifact output directory (default: tmp/artifacts)",
103
+ )
104
+
105
+ playbook_parser = commands.add_parser(
106
+ "playbook", help="run Ansible playbooks with the engine configuration"
107
+ )
108
+ playbook_commands = playbook_parser.add_subparsers(
109
+ dest="playbook_command", required=True
110
+ )
111
+ list_parser = playbook_commands.add_parser(
112
+ "list", help="list the playbooks bundled with the engine"
113
+ )
114
+ _add_engine_argument(list_parser)
115
+ run_parser = playbook_commands.add_parser(
116
+ "run", help="run one bundled playbook or a playbook file"
117
+ )
118
+ run_parser.add_argument(
119
+ "playbook",
120
+ help="bundled playbook name (see `playbook list`) or a playbook path",
121
+ )
122
+ _add_engine_argument(run_parser)
123
+ run_parser.add_argument(
124
+ "--inventory",
125
+ type=project_path_argument,
126
+ default=Path("tmp/ansible-inventory.json"),
127
+ help="rendered inventory path (default: tmp/ansible-inventory.json)",
128
+ )
129
+ run_parser.add_argument(
130
+ "--roles",
131
+ type=Path,
132
+ action="append",
133
+ default=[],
134
+ help="role directory searched before the bundled roles (repeatable)",
135
+ )
136
+ run_parser.add_argument(
137
+ "--extra-vars",
138
+ action="append",
139
+ default=[],
140
+ help="passed through to ansible-playbook unchanged (repeatable)",
141
+ )
142
+ run_parser.add_argument(
143
+ "--tags",
144
+ action="append",
145
+ default=[],
146
+ help="only run plays and tasks tagged with this value (repeatable)",
147
+ )
148
+ run_parser.add_argument("--limit", help="restrict the run to a host pattern")
149
+ run_parser.add_argument("--check", action="store_true", help="run in check mode")
150
+ run_parser.add_argument("--diff", action="store_true", help="show file diffs")
151
+ run_parser.add_argument(
152
+ "--syntax-check",
153
+ action="store_true",
154
+ help="only check the playbook syntax",
155
+ )
156
+ return parser
157
+
158
+
159
+ def _add_engine_argument(parser: argparse.ArgumentParser) -> None:
160
+ parser.add_argument(
161
+ "--engine",
162
+ type=Path,
163
+ default=default_engine_directory(),
164
+ help="engine directory containing ansible contracts (default: bundled engine)",
165
+ )
166
+
167
+
168
+ def _render_inventory(arguments: argparse.Namespace) -> int:
169
+ try:
170
+ state = validate_config(
171
+ Path(arguments.project_directory), Path(arguments.schemas)
172
+ )
173
+ except ConfigValidationError as error:
174
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
175
+ return 2
176
+
177
+ inventory = PlatformInventory.from_state(state)
178
+ rendered = render_ansible_inventory(inventory)
179
+ serialized = f"{json.dumps(rendered, sort_keys=True)}\n"
180
+ if arguments.output is None:
181
+ sys.stdout.write(serialized)
182
+ else:
183
+ output_path = Path(arguments.output)
184
+ output_path.parent.mkdir(parents=True, exist_ok=True)
185
+ output_path.write_text(serialized, encoding="utf-8")
186
+ result = {"output": str(output_path), "status": "ok"}
187
+ sys.stdout.write(f"{json.dumps(result, sort_keys=True)}\n")
188
+ return 0
189
+
190
+
191
+ def _build_artifact(arguments: argparse.Namespace) -> int:
192
+ try:
193
+ state = validate_config(
194
+ Path(arguments.project_directory), Path(arguments.schemas)
195
+ )
196
+ except ConfigValidationError as error:
197
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
198
+ return 2
199
+
200
+ inventory = PlatformInventory.from_state(state)
201
+ try:
202
+ built = build_artifact(
203
+ inventory,
204
+ arguments.component,
205
+ arguments.ref,
206
+ Path(arguments.output_dir),
207
+ Path(arguments.schemas),
208
+ )
209
+ except ArtifactBuildError as error:
210
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
211
+ return 2
212
+ except ConfigValidationError as error:
213
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
214
+ return 2
215
+ sys.stdout.write(f"{json.dumps(built.as_dict(), sort_keys=True)}\n")
216
+ return 0
217
+
218
+
219
+ def _list_playbooks(arguments: argparse.Namespace) -> int:
220
+ try:
221
+ names = bundled_playbooks(Path(arguments.engine))
222
+ except PlaybookError as error:
223
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
224
+ return 2
225
+ result = {"engine": str(arguments.engine), "playbooks": list(names)}
226
+ sys.stdout.write(f"{json.dumps(result, sort_keys=True)}\n")
227
+ return 0
228
+
229
+
230
+ def _run_playbook(arguments: argparse.Namespace) -> int:
231
+ engine_directory = Path(arguments.engine)
232
+ try:
233
+ run = PlaybookRun(
234
+ playbook=resolve_playbook(arguments.playbook, engine_directory),
235
+ inventory_file=Path(arguments.inventory),
236
+ role_directories=tuple(Path(role) for role in arguments.roles),
237
+ extra_vars=tuple(arguments.extra_vars),
238
+ tags=tuple(arguments.tags),
239
+ limit=arguments.limit,
240
+ check=arguments.check,
241
+ diff=arguments.diff,
242
+ syntax_check=arguments.syntax_check,
243
+ )
244
+ return execute_playbook(run, engine_directory)
245
+ except PlaybookError as error:
246
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
247
+ return 2
248
+
249
+
250
+ def main(argv: Sequence[str] | None = None) -> int:
251
+ """Run one engine command and return a process exit code."""
252
+ arguments = parse_arguments(_parser(), argv)
253
+ if arguments.command == "playbook":
254
+ if arguments.playbook_command == "list":
255
+ return _list_playbooks(arguments)
256
+ return _run_playbook(arguments)
257
+ try:
258
+ with project_context(arguments.project, os.environ) as project_directory:
259
+ arguments.project_directory = project_directory
260
+ if arguments.command == "inventory":
261
+ return _render_inventory(arguments)
262
+ if arguments.command == "artifact":
263
+ return _build_artifact(arguments)
264
+ except ProjectError as error:
265
+ sys.stderr.write(f"{json.dumps(error.as_dict(), sort_keys=True)}\n")
266
+ return 2
267
+ message = "argparse accepted an unsupported engine command"
268
+ raise RuntimeError(message)
269
+
270
+
271
+ def run() -> None:
272
+ """Installed engine console-script entry point."""
273
+ raise SystemExit(main())
@@ -0,0 +1,175 @@
1
+ """Run Ansible playbooks under the engine's configuration.
2
+
3
+ A project keeps its own playbooks and roles next to its config.
4
+ This module runs those, and the bundled engine playbooks, with the
5
+ engine's ``ansible.cfg`` and a role search path that always ends with the
6
+ bundled roles, so project playbooks may reuse them without a checkout.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ _PLAYBOOK_SUFFIX = ".yml"
18
+ _ERROR_PLAYBOOK_MISSING = "playbook_missing"
19
+ _ERROR_INVENTORY_MISSING = "inventory_missing"
20
+ _ERROR_ROLES_MISSING = "roles_missing"
21
+ _ERROR_ANSIBLE_MISSING = "ansible_missing"
22
+ _ERROR_ARGUMENT = "argument_invalid"
23
+
24
+
25
+ class PlaybookError(RuntimeError):
26
+ """Raised when a playbook run cannot be prepared."""
27
+
28
+ def __init__(self, code: str, detail: str) -> None:
29
+ """Record a machine-readable code alongside the human detail."""
30
+ super().__init__(detail)
31
+ self.code = code
32
+ self.detail = detail
33
+
34
+ def as_dict(self) -> dict[str, object]:
35
+ """Return the error in the CLI's JSON error envelope."""
36
+ return {
37
+ "error": {"code": self.code, "message": self.detail},
38
+ "status": "error",
39
+ }
40
+
41
+
42
+ def playbook_directory(engine_directory: Path) -> Path:
43
+ """Return the directory holding the engine's bundled playbooks."""
44
+ return engine_directory / "ansible" / "playbooks"
45
+
46
+
47
+ def bundled_playbooks(engine_directory: Path) -> tuple[str, ...]:
48
+ """Return the names of every bundled playbook, sorted."""
49
+ directory = playbook_directory(engine_directory)
50
+ if not directory.is_dir():
51
+ detail = f"engine playbook directory does not exist: {directory}"
52
+ raise PlaybookError(_ERROR_PLAYBOOK_MISSING, detail)
53
+ return tuple(
54
+ sorted(
55
+ path.stem
56
+ for path in directory.iterdir()
57
+ if path.is_file() and path.suffix == _PLAYBOOK_SUFFIX
58
+ )
59
+ )
60
+
61
+
62
+ def resolve_playbook(reference: str, engine_directory: Path) -> Path:
63
+ """Resolve a bare name inside the engine, or a path as given.
64
+
65
+ ``inspect`` and ``inspect.yml`` both name the bundled playbook; anything
66
+ containing a path separator is a playbook file outside the engine.
67
+ """
68
+ if not reference:
69
+ detail = "playbook reference must not be empty"
70
+ raise PlaybookError(_ERROR_ARGUMENT, detail)
71
+ candidate = Path(reference)
72
+ if len(candidate.parts) == 1:
73
+ name = reference
74
+ if candidate.suffix != _PLAYBOOK_SUFFIX:
75
+ name = f"{reference}{_PLAYBOOK_SUFFIX}"
76
+ candidate = playbook_directory(engine_directory) / name
77
+ if not candidate.is_file():
78
+ detail = f"playbook does not exist: {candidate}"
79
+ raise PlaybookError(_ERROR_PLAYBOOK_MISSING, detail)
80
+ return candidate
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class PlaybookRun:
85
+ """One validated ``ansible-playbook`` invocation."""
86
+
87
+ playbook: Path
88
+ inventory_file: Path
89
+ role_directories: tuple[Path, ...] = ()
90
+ extra_vars: tuple[str, ...] = ()
91
+ tags: tuple[str, ...] = ()
92
+ limit: str | None = None
93
+ check: bool = False
94
+ diff: bool = False
95
+ syntax_check: bool = False
96
+
97
+ def __post_init__(self) -> None:
98
+ """Reject inputs Ansible would only fail on later."""
99
+ if not self.playbook.is_file():
100
+ detail = f"playbook does not exist: {self.playbook}"
101
+ raise PlaybookError(_ERROR_PLAYBOOK_MISSING, detail)
102
+ if not self.inventory_file.is_file():
103
+ detail = f"inventory file does not exist: {self.inventory_file}"
104
+ raise PlaybookError(_ERROR_INVENTORY_MISSING, detail)
105
+ for directory in self.role_directories:
106
+ if not directory.is_dir():
107
+ detail = f"role directory does not exist: {directory}"
108
+ raise PlaybookError(_ERROR_ROLES_MISSING, detail)
109
+ if self.limit is not None and not self.limit:
110
+ detail = "limit must not be empty"
111
+ raise PlaybookError(_ERROR_ARGUMENT, detail)
112
+ if any(not value for value in self.extra_vars):
113
+ detail = "extra vars must not be empty"
114
+ raise PlaybookError(_ERROR_ARGUMENT, detail)
115
+ if any(not value for value in self.tags):
116
+ detail = "tags must not be empty"
117
+ raise PlaybookError(_ERROR_ARGUMENT, detail)
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class PlaybookCommand:
122
+ """The process a playbook run becomes."""
123
+
124
+ argv: tuple[str, ...]
125
+ environment: dict[str, str]
126
+
127
+
128
+ def playbook_command(run: PlaybookRun, engine_directory: Path) -> PlaybookCommand:
129
+ """Translate a run into ``ansible-playbook`` arguments and environment."""
130
+ binary = shutil.which("ansible-playbook")
131
+ if binary is None:
132
+ detail = "ansible-playbook is not installed on this controller"
133
+ raise PlaybookError(_ERROR_ANSIBLE_MISSING, detail)
134
+ ansible_directory = engine_directory / "ansible"
135
+ configuration = ansible_directory / "ansible.cfg"
136
+ bundled_roles = ansible_directory / "roles"
137
+ for required in (configuration, bundled_roles):
138
+ if not required.exists():
139
+ detail = f"engine is incomplete, missing {required}"
140
+ raise PlaybookError(_ERROR_PLAYBOOK_MISSING, detail)
141
+
142
+ argv: list[str] = [binary, "--inventory", str(run.inventory_file)]
143
+ if run.check:
144
+ argv.append("--check")
145
+ if run.diff:
146
+ argv.append("--diff")
147
+ if run.syntax_check:
148
+ argv.append("--syntax-check")
149
+ if run.limit is not None:
150
+ argv.extend(("--limit", run.limit))
151
+ for value in run.extra_vars:
152
+ argv.extend(("--extra-vars", value))
153
+ if run.tags:
154
+ argv.extend(("--tags", ",".join(run.tags)))
155
+ argv.append(str(run.playbook))
156
+
157
+ roles = (*run.role_directories, bundled_roles)
158
+ environment = {
159
+ "ANSIBLE_CONFIG": str(configuration.resolve()),
160
+ "ANSIBLE_ROLES_PATH": os.pathsep.join(
161
+ str(directory.resolve()) for directory in roles
162
+ ),
163
+ }
164
+ return PlaybookCommand(argv=tuple(argv), environment=environment)
165
+
166
+
167
+ def execute_playbook(run: PlaybookRun, engine_directory: Path) -> int:
168
+ """Run the playbook in the foreground and return its exit code."""
169
+ command = playbook_command(run, engine_directory)
170
+ completed = subprocess.run( # noqa: S603 - fixed binary, validated args.
171
+ command.argv,
172
+ env={**os.environ, **command.environment},
173
+ check=False,
174
+ )
175
+ return completed.returncode
File without changes