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,253 @@
1
+ """Render deterministic static Ansible inventory from typed Cloudfall inventory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cloudfall.inventory import firewall_rules_for_server
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+ from cloudfall.domain import ResourceId
13
+ from cloudfall.inventory import (
14
+ AlertRuleInventory,
15
+ ApplicationInventory,
16
+ ComponentInventory,
17
+ LoggingStackInventory,
18
+ PlatformInventory,
19
+ ServerInventory,
20
+ ServerTypeInventory,
21
+ )
22
+
23
+
24
+ def render_ansible_inventory(inventory: PlatformInventory) -> dict[str, object]:
25
+ """Render static JSON accepted by Ansible's YAML inventory plugin."""
26
+ environment_groups: dict[str, object] = {}
27
+ environments = sorted(
28
+ {server.environment for server in inventory.servers},
29
+ key=lambda environment: environment.value,
30
+ )
31
+ for environment in environments:
32
+ environment_groups[_group_name("environment", environment)] = {
33
+ "hosts": _empty_host_entries(
34
+ server.resource_id.value
35
+ for server in inventory.servers
36
+ if server.environment == environment
37
+ )
38
+ }
39
+
40
+ application_groups: dict[str, object] = {}
41
+ for application in inventory.applications:
42
+ application_components = frozenset(application.component_ids)
43
+ application_hosts = sorted(
44
+ {
45
+ server_id.value
46
+ for component in inventory.components
47
+ if component.resource_id in application_components
48
+ for server_id in component.server_ids
49
+ }
50
+ )
51
+ application_groups[_group_name("application", application.resource_id)] = {
52
+ "hosts": _empty_host_entries(application_hosts)
53
+ }
54
+
55
+ component_groups = {
56
+ _group_name("component", component.resource_id): {
57
+ "hosts": _empty_host_entries(
58
+ server.value for server in component.server_ids
59
+ )
60
+ }
61
+ for component in inventory.components
62
+ }
63
+
64
+ logging_backend_hosts = _empty_host_entries(
65
+ stack.backend.server_id.value for stack in inventory.logging_stacks
66
+ )
67
+ logging_collector_hosts = _empty_host_entries(
68
+ server_id.value
69
+ for stack in inventory.logging_stacks
70
+ for server_id in stack.collectors.server_ids
71
+ )
72
+ domain_origin_hosts = _empty_host_entries(
73
+ domain.origin.server_id.value for domain in inventory.domains
74
+ )
75
+
76
+ return {
77
+ "all": {
78
+ "children": {
79
+ "ungrouped": {"hosts": {}},
80
+ "cloudfall_servers": {"hosts": _host_variables(inventory)},
81
+ "cloudfall_environments": {"children": environment_groups},
82
+ "cloudfall_applications": {"children": application_groups},
83
+ "cloudfall_components": {"children": component_groups},
84
+ "cloudfall_logging_backends": {"hosts": logging_backend_hosts},
85
+ "cloudfall_logging_collectors": {"hosts": logging_collector_hosts},
86
+ "cloudfall_domain_origins": {"hosts": domain_origin_hosts},
87
+ }
88
+ }
89
+ }
90
+
91
+
92
+ def _host_variables(inventory: PlatformInventory) -> dict[str, object]:
93
+ applications_by_id = {
94
+ application.resource_id: application for application in inventory.applications
95
+ }
96
+ server_types_by_id = {
97
+ server_type.resource_id: server_type for server_type in inventory.server_types
98
+ }
99
+ return {
100
+ server.resource_id.value: {
101
+ "ansible_host": server.address.value,
102
+ "ansible_port": server.ssh_port.value,
103
+ "ansible_user": server.ssh_user.value,
104
+ "cloudfall_server_id": server.resource_id.value,
105
+ "cloudfall_hostname": server.hostname.value,
106
+ "cloudfall_environment": server.environment.value,
107
+ "cloudfall_lifecycle": server.lifecycle.value,
108
+ "cloudfall_labels": {
109
+ label.key.value: label.value for label in server.labels
110
+ },
111
+ "cloudfall_ssh_public_keys": [
112
+ key.as_dict()
113
+ for key in inventory.ssh_public_keys
114
+ if key.environment == server.environment and key.is_active
115
+ ],
116
+ **_host_infrastructure(server),
117
+ **_host_firewall(server, server_types_by_id[server.server_type_id]),
118
+ **_host_logging(server, inventory.logging_stacks, inventory.alert_rules),
119
+ "cloudfall_server_type": server_types_by_id[
120
+ server.server_type_id
121
+ ].as_dict(),
122
+ "cloudfall_applications": [
123
+ _host_application(application)
124
+ for application in inventory.applications
125
+ if _application_is_on_server(application, server, inventory)
126
+ ],
127
+ "cloudfall_components": [
128
+ _host_component(component, applications_by_id[component.application_id])
129
+ for component in inventory.components
130
+ if server.resource_id in component.server_ids
131
+ ],
132
+ "cloudfall_services": [
133
+ service.as_dict()
134
+ for service in inventory.services
135
+ if service.server_id == server.resource_id
136
+ ],
137
+ "cloudfall_domains": [
138
+ domain.as_dict()
139
+ for domain in inventory.domains
140
+ if domain.proxy.server_id == server.resource_id
141
+ ],
142
+ "cloudfall_origin_domains": [
143
+ domain.as_dict()
144
+ for domain in inventory.domains
145
+ if domain.origin.server_id == server.resource_id
146
+ ],
147
+ }
148
+ for server in inventory.servers
149
+ }
150
+
151
+
152
+ def _host_infrastructure(server: ServerInventory) -> dict[str, object]:
153
+ variables: dict[str, object] = {}
154
+ if server.provider is not None:
155
+ variables["cloudfall_provider"] = server.provider.as_dict()
156
+ if server.network is not None:
157
+ variables["cloudfall_network"] = server.network.as_dict()
158
+ return variables
159
+
160
+
161
+ def _host_firewall(
162
+ server: ServerInventory, server_type: ServerTypeInventory
163
+ ) -> dict[str, object]:
164
+ if server_type.firewall is None:
165
+ return {}
166
+ rules = firewall_rules_for_server(server_type.firewall, server.ssh_port)
167
+ return {
168
+ "cloudfall_firewall": {
169
+ "policy": server_type.firewall.policy.value,
170
+ "allowedInbound": [rule.as_dict() for rule in rules],
171
+ }
172
+ }
173
+
174
+
175
+ def _host_logging(
176
+ server: ServerInventory,
177
+ logging_stacks: tuple[LoggingStackInventory, ...],
178
+ alert_rules: tuple[AlertRuleInventory, ...],
179
+ ) -> dict[str, object]:
180
+ variables: dict[str, object] = {}
181
+ backend = next(
182
+ (
183
+ stack
184
+ for stack in logging_stacks
185
+ if stack.backend.server_id == server.resource_id
186
+ ),
187
+ None,
188
+ )
189
+ collector = next(
190
+ (
191
+ stack
192
+ for stack in logging_stacks
193
+ if server.resource_id in stack.collectors.server_ids
194
+ ),
195
+ None,
196
+ )
197
+ if backend is not None:
198
+ variables["cloudfall_logging_backend"] = backend.as_dict()
199
+ variables["cloudfall_alert_rules"] = [
200
+ rule.as_dict()
201
+ for rule in sorted(
202
+ alert_rules, key=lambda rule: rule.resource_id.value
203
+ )
204
+ if rule.environment == backend.environment
205
+ ]
206
+ if collector is not None:
207
+ variables["cloudfall_logging_collector"] = collector.as_dict()
208
+ return variables
209
+
210
+
211
+ def _host_application(application: ApplicationInventory) -> dict[str, object]:
212
+ return {
213
+ "id": application.resource_id.value,
214
+ "user": application.linux_user.value,
215
+ "approval": application.approval.value,
216
+ }
217
+
218
+
219
+ def _host_component(
220
+ component: ComponentInventory, application: ApplicationInventory
221
+ ) -> dict[str, object]:
222
+ return {
223
+ "id": component.resource_id.value,
224
+ "application": component.application_id.value,
225
+ "application_user": application.linux_user.value,
226
+ "install_root": component.install_root.value,
227
+ "repository": component.repository.as_dict(),
228
+ "runtime": component.runtime.as_dict(),
229
+ "service": component.service.as_dict(),
230
+ "healthCheck": component.health_check.as_dict(),
231
+ "retainUntilCleanup": component.retain_until_cleanup,
232
+ }
233
+
234
+
235
+ def _application_is_on_server(
236
+ application: ApplicationInventory,
237
+ server: ServerInventory,
238
+ inventory: PlatformInventory,
239
+ ) -> bool:
240
+ application_components = frozenset(application.component_ids)
241
+ return any(
242
+ component.resource_id in application_components
243
+ and server.resource_id in component.server_ids
244
+ for component in inventory.components
245
+ )
246
+
247
+
248
+ def _empty_host_entries(hosts: Iterable[str]) -> dict[str, object]:
249
+ return {host: {} for host in hosts}
250
+
251
+
252
+ def _group_name(prefix: str, resource_id: ResourceId) -> str:
253
+ return f"{prefix}_{resource_id.value.replace('-', '_')}"
@@ -0,0 +1,258 @@
1
+ """Build verifiable release artifacts from component repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tarfile
11
+ import tempfile
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from cloudfall.validation import (
18
+ ConfigValidationError,
19
+ SchemaCatalog,
20
+ ValidationIssue,
21
+ )
22
+ from jsonschema.exceptions import ValidationError
23
+
24
+ if TYPE_CHECKING:
25
+ from cloudfall.inventory import ComponentInventory, PlatformInventory
26
+
27
+ _ARTIFACT_SCHEMA = "artifact.schema.json"
28
+ _GIT_TIMEOUT_SECONDS = 600
29
+ _SHORT_COMMIT_LENGTH = 7
30
+ _GIT_REF_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@^~-]{0,199}")
31
+ _ERROR_COMPONENT_MISSING = "artifact_component_missing"
32
+ _ERROR_SUBDIRECTORY_MISSING = "artifact_subdirectory_missing"
33
+ _ERROR_REF_INVALID = "artifact_ref_invalid"
34
+ _ERROR_GIT_MISSING = "artifact_git_missing"
35
+ _ERROR_GIT_FAILED = "artifact_git_failed"
36
+ _ERROR_GIT_TIMEOUT = "artifact_git_timeout"
37
+
38
+
39
+ class ArtifactBuildError(RuntimeError):
40
+ """Fail-fast artifact build error with a stable machine-readable code."""
41
+
42
+ def __init__(self, code: str, message: str) -> None:
43
+ """Record the failure code and human-readable detail."""
44
+ self.code = code
45
+ self.detail = message
46
+ super().__init__(f"{code}: {message}")
47
+
48
+ def as_dict(self) -> dict[str, object]:
49
+ """Serialize the error envelope for system boundaries."""
50
+ return {
51
+ "status": "error",
52
+ "error": {"code": self.code, "message": self.detail},
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class BuiltArtifact:
58
+ """One built and hashed release artifact."""
59
+
60
+ component_id: str
61
+ release: str
62
+ git_ref: str
63
+ git_commit: str
64
+ built_at: str
65
+ archive_path: Path
66
+ metadata_path: Path
67
+ archive_sha256: str
68
+ size_bytes: int
69
+
70
+ def as_dict(self) -> dict[str, object]:
71
+ """Serialize the build result for CLI consumers."""
72
+ return {
73
+ "status": "ok",
74
+ "component": self.component_id,
75
+ "release": self.release,
76
+ "gitRef": self.git_ref,
77
+ "gitCommit": self.git_commit,
78
+ "builtAt": self.built_at,
79
+ "archive": str(self.archive_path),
80
+ "metadata": str(self.metadata_path),
81
+ "archiveSha256": self.archive_sha256,
82
+ "sizeBytes": self.size_bytes,
83
+ }
84
+
85
+
86
+ def build_artifact(
87
+ inventory: PlatformInventory,
88
+ component_id: str,
89
+ git_ref: str,
90
+ output_directory: Path,
91
+ schema_directory: Path,
92
+ ) -> BuiltArtifact:
93
+ """Clone the component repository at a ref and package a release."""
94
+ component = _component(inventory, component_id)
95
+ _validate_ref(git_ref)
96
+ built_at = datetime.now(tz=UTC)
97
+ built_at_text = built_at.strftime("%Y-%m-%dT%H:%M:%SZ")
98
+ with tempfile.TemporaryDirectory(prefix="cloudfall-artifact-") as workdir:
99
+ checkout = Path(workdir) / "source"
100
+ _run_git("clone", "--quiet", component.repository.url.value, str(checkout))
101
+ # A fresh clone has local branches only for the default branch, and
102
+ # bare branch names trigger git's remote-branch DWIM, which conflicts
103
+ # with --detach. Resolving the ref to a commit first supports
104
+ # branches, tags, and commits uniformly.
105
+ commit = _resolve_ref_commit(checkout, git_ref)
106
+ _run_git(
107
+ "-C", str(checkout), "checkout", "--quiet", "--detach", commit, "--"
108
+ )
109
+ source_root = checkout
110
+ if component.repository.subdirectory is not None:
111
+ source_root = checkout / component.repository.subdirectory
112
+ if not source_root.is_dir():
113
+ detail = (
114
+ "declared repository subdirectory does not exist: "
115
+ f"{component.repository.subdirectory}"
116
+ )
117
+ raise ArtifactBuildError(
118
+ _ERROR_SUBDIRECTORY_MISSING, detail
119
+ )
120
+
121
+ release = (
122
+ f"{built_at.strftime('%Y%m%dT%H%M%SZ')}"
123
+ f"-{commit[:_SHORT_COMMIT_LENGTH]}"
124
+ )
125
+ component_directory = output_directory / component_id
126
+ component_directory.mkdir(parents=True, exist_ok=True)
127
+ archive_path = component_directory / f"{release}.tar.gz"
128
+ _write_archive(source_root, archive_path)
129
+
130
+ digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
131
+ size_bytes = archive_path.stat().st_size
132
+ metadata = {
133
+ "apiVersion": "cloudfall/v1",
134
+ "kind": "Artifact",
135
+ "metadata": {
136
+ "id": component_id,
137
+ "description": f"Release artifact for {component_id}",
138
+ },
139
+ "spec": {
140
+ "component": component_id,
141
+ "release": release,
142
+ "gitRef": git_ref,
143
+ "gitCommit": commit,
144
+ "builtAt": built_at_text,
145
+ "archive": archive_path.name,
146
+ "archiveSha256": digest,
147
+ "sizeBytes": size_bytes,
148
+ },
149
+ }
150
+ catalog = SchemaCatalog(schema_directory)
151
+ try:
152
+ catalog.validate_named(_ARTIFACT_SCHEMA, metadata)
153
+ except ValidationError as error:
154
+ issue = ValidationIssue(
155
+ code="artifact_metadata_invalid",
156
+ message=error.message,
157
+ )
158
+ raise ConfigValidationError(issue) from error
159
+ metadata_path = archive_path.with_suffix("").with_suffix(".json")
160
+ metadata_path.write_text(
161
+ f"{json.dumps(metadata, indent=2, sort_keys=True)}\n",
162
+ encoding="utf-8",
163
+ )
164
+ return BuiltArtifact(
165
+ component_id=component_id,
166
+ release=release,
167
+ git_ref=git_ref,
168
+ git_commit=commit,
169
+ built_at=built_at_text,
170
+ archive_path=archive_path,
171
+ metadata_path=metadata_path,
172
+ archive_sha256=digest,
173
+ size_bytes=size_bytes,
174
+ )
175
+
176
+
177
+ def _component(
178
+ inventory: PlatformInventory, component_id: str
179
+ ) -> ComponentInventory:
180
+ component = next(
181
+ (
182
+ candidate
183
+ for candidate in inventory.components
184
+ if candidate.resource_id.value == component_id
185
+ ),
186
+ None,
187
+ )
188
+ if component is None:
189
+ detail = f"component does not exist: {component_id}"
190
+ raise ArtifactBuildError(_ERROR_COMPONENT_MISSING, detail)
191
+ return component
192
+
193
+
194
+ def _validate_ref(git_ref: str) -> None:
195
+ if not _GIT_REF_PATTERN.fullmatch(git_ref):
196
+ detail = f"git ref contains unsupported characters: {git_ref!r}"
197
+ raise ArtifactBuildError(_ERROR_REF_INVALID, detail)
198
+
199
+
200
+ def _resolve_ref_commit(checkout: Path, git_ref: str) -> str:
201
+ candidates = (git_ref, f"origin/{git_ref}")
202
+ for candidate in candidates:
203
+ try:
204
+ return _run_git(
205
+ "-C",
206
+ str(checkout),
207
+ "rev-parse",
208
+ "--verify",
209
+ "--quiet",
210
+ "--end-of-options",
211
+ f"{candidate}^{{commit}}",
212
+ ).strip()
213
+ except ArtifactBuildError as error:
214
+ if error.code != _ERROR_GIT_FAILED:
215
+ raise
216
+ detail = f"git ref does not resolve to a commit: {git_ref!r}"
217
+ raise ArtifactBuildError(_ERROR_GIT_FAILED, detail)
218
+
219
+
220
+ def _run_git(*arguments: str) -> str:
221
+ binary = shutil.which("git")
222
+ if binary is None:
223
+ detail = "git is not installed on the build host"
224
+ raise ArtifactBuildError(_ERROR_GIT_MISSING, detail)
225
+ try:
226
+ completed = subprocess.run( # noqa: S603 - fixed binary, validated args.
227
+ [binary, *arguments],
228
+ capture_output=True,
229
+ check=True,
230
+ text=True,
231
+ timeout=_GIT_TIMEOUT_SECONDS,
232
+ )
233
+ except subprocess.CalledProcessError as error:
234
+ detail = (
235
+ f"git {' '.join(arguments[:2])} failed: {error.stderr.strip()}"
236
+ )
237
+ raise ArtifactBuildError(_ERROR_GIT_FAILED, detail) from error
238
+ except subprocess.TimeoutExpired as error:
239
+ detail = (
240
+ f"git {' '.join(arguments[:2])} exceeded "
241
+ f"{_GIT_TIMEOUT_SECONDS} seconds"
242
+ )
243
+ raise ArtifactBuildError(_ERROR_GIT_TIMEOUT, detail) from error
244
+ return completed.stdout
245
+
246
+
247
+ def _write_archive(source_root: Path, archive_path: Path) -> None:
248
+ def _skip_git(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
249
+ if ".git" in Path(info.name).parts:
250
+ return None
251
+ info.uid = 0
252
+ info.gid = 0
253
+ info.uname = "root"
254
+ info.gname = "root"
255
+ return info
256
+
257
+ with tarfile.open(archive_path, "w:gz") as archive:
258
+ archive.add(source_root, arcname=".", filter=_skip_git)