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/py.typed ADDED
File without changes
@@ -0,0 +1,338 @@
1
+ """Fetch live Render services and synthesize an importable blueprint.
2
+
3
+ The Render REST API is read through a small injectable client and adapted
4
+ into the exact mapping shape the blueprint importer already consumes, so
5
+ both entry points share one mapping pipeline and one gap report. Secret
6
+ material (connection strings fetched for matching) is compared in memory
7
+ and never written anywhere.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Protocol, cast
16
+
17
+ from cloudfall.importer import (
18
+ ImportGap,
19
+ RenderImportError,
20
+ import_render_mapping,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable, Mapping
25
+ from pathlib import Path
26
+
27
+ from cloudfall.importer import ImportTargets, RenderImportResult
28
+
29
+ _ERROR_API_UNREACHABLE = "render_api_unreachable"
30
+ _ERROR_API_INVALID = "render_api_invalid"
31
+ _PAGE_LIMIT = 100
32
+ _SERVICE_TYPES: Mapping[str, str] = {
33
+ "web_service": "web",
34
+ "background_worker": "worker",
35
+ "private_service": "pserv",
36
+ "cron_job": "cron",
37
+ "static_site": "static",
38
+ "keyvalue": "keyvalue",
39
+ }
40
+ _CATEGORY_ACTION = "action-required"
41
+ _CATEGORY_ASSUMPTION = "assumption"
42
+
43
+
44
+ class RenderApiClient(Protocol):
45
+ """Read-only view of one Render workspace."""
46
+
47
+ def list_services(self) -> tuple[Mapping[str, object], ...]:
48
+ """Return every service object in the workspace."""
49
+ ...
50
+
51
+ def list_env_vars(
52
+ self, service_id: str
53
+ ) -> tuple[Mapping[str, object], ...]:
54
+ """Return every environment variable of one service."""
55
+ ...
56
+
57
+ def list_postgres(self) -> tuple[Mapping[str, object], ...]:
58
+ """Return every managed PostgreSQL instance."""
59
+ ...
60
+
61
+ def postgres_connection_strings(self, postgres_id: str) -> tuple[str, ...]:
62
+ """Return the connection strings of one managed PostgreSQL."""
63
+ ...
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class HttpRenderApiClient:
68
+ """Bearer-authenticated client for the Render REST API."""
69
+
70
+ api_key: str
71
+ base_url: str = "https://api.render.com/v1"
72
+ timeout_seconds: float = 30.0
73
+ transport: Callable[[str, str], object] | None = None
74
+
75
+ def list_services(self) -> tuple[Mapping[str, object], ...]:
76
+ """Return every service object in the workspace."""
77
+ return self._paginate("/services", "service")
78
+
79
+ def list_env_vars(
80
+ self, service_id: str
81
+ ) -> tuple[Mapping[str, object], ...]:
82
+ """Return every environment variable of one service."""
83
+ return self._paginate(f"/services/{service_id}/env-vars", "envVar")
84
+
85
+ def list_postgres(self) -> tuple[Mapping[str, object], ...]:
86
+ """Return every managed PostgreSQL instance."""
87
+ return self._paginate("/postgres", "postgres")
88
+
89
+ def postgres_connection_strings(self, postgres_id: str) -> tuple[str, ...]:
90
+ """Return the connection strings of one managed PostgreSQL."""
91
+ payload = self._get(f"/postgres/{postgres_id}/connection-info")
92
+ info = _require_mapping(payload, "postgres connection info")
93
+ return tuple(
94
+ value
95
+ for key in ("internalConnectionString", "externalConnectionString")
96
+ if isinstance((value := info.get(key)), str) and value
97
+ )
98
+
99
+ def _paginate(
100
+ self, path: str, envelope: str
101
+ ) -> tuple[Mapping[str, object], ...]:
102
+ items: list[Mapping[str, object]] = []
103
+ cursor: str | None = None
104
+ while True:
105
+ query = f"?limit={_PAGE_LIMIT}"
106
+ if cursor is not None:
107
+ query += f"&cursor={cursor}"
108
+ payload = self._get(f"{path}{query}")
109
+ if not isinstance(payload, list):
110
+ message = f"Render API returned a non-list page for {path}"
111
+ raise RenderImportError(_ERROR_API_INVALID, message)
112
+ rows = cast("list[object]", payload)
113
+ for raw_row in rows:
114
+ row = _require_mapping(raw_row, f"{envelope} page row")
115
+ items.append(_require_mapping(row.get(envelope), envelope))
116
+ raw_cursor = row.get("cursor")
117
+ if isinstance(raw_cursor, str):
118
+ cursor = raw_cursor
119
+ if len(rows) < _PAGE_LIMIT:
120
+ return tuple(items)
121
+
122
+ def _get(self, path: str) -> object:
123
+ url = f"{self.base_url}{path}"
124
+ if self.transport is not None:
125
+ return self.transport(url, self.api_key)
126
+ request = urllib.request.Request( # noqa: S310 - fixed https base
127
+ url,
128
+ headers={
129
+ "Authorization": f"Bearer {self.api_key}",
130
+ "Accept": "application/json",
131
+ },
132
+ )
133
+ try:
134
+ with urllib.request.urlopen( # noqa: S310 - fixed https base
135
+ request, timeout=self.timeout_seconds
136
+ ) as response:
137
+ body = response.read()
138
+ except OSError as error:
139
+ message = f"Render API unreachable: {url}: {error}"
140
+ raise RenderImportError(_ERROR_API_UNREACHABLE, message) from error
141
+ try:
142
+ return cast("object", json.loads(body.decode("utf-8")))
143
+ except json.JSONDecodeError as error:
144
+ message = f"Render API returned invalid JSON for {path}"
145
+ raise RenderImportError(_ERROR_API_INVALID, message) from error
146
+
147
+
148
+ @dataclass(slots=True)
149
+ class _Synthesis:
150
+ """Accumulates the blueprint and API-side gap notes."""
151
+
152
+ services: list[dict[str, object]] = field(default_factory=list)
153
+ databases: list[dict[str, object]] = field(default_factory=list)
154
+ gaps: list[ImportGap] = field(default_factory=list)
155
+
156
+ def gap(self, category: str, subject: str, detail: str) -> None:
157
+ self.gaps.append(
158
+ ImportGap(category=category, subject=subject, detail=detail)
159
+ )
160
+
161
+
162
+ def synthesize_blueprint(
163
+ client: RenderApiClient,
164
+ ) -> tuple[dict[str, object], tuple[ImportGap, ...]]:
165
+ """Fetch the live workspace and shape it like a Render blueprint."""
166
+ synthesis = _Synthesis()
167
+ connection_index = _database_connection_index(client, synthesis)
168
+ for service in client.list_services():
169
+ _synthesize_service(client, service, connection_index, synthesis)
170
+ blueprint: dict[str, object] = {"services": synthesis.services}
171
+ if synthesis.databases:
172
+ blueprint["databases"] = synthesis.databases
173
+ return blueprint, tuple(synthesis.gaps)
174
+
175
+
176
+ def read_api_key(path: Path) -> str:
177
+ """Read the Render API key from a controller-side file."""
178
+ code = "render_api_key_missing"
179
+ if not path.is_file():
180
+ message = f"Render API key file does not exist: {path}"
181
+ raise RenderImportError(code, message)
182
+ api_key = path.read_text(encoding="utf-8").strip()
183
+ if not api_key:
184
+ message = f"Render API key file is empty: {path}"
185
+ raise RenderImportError(code, message)
186
+ return api_key
187
+
188
+
189
+ def import_render_api(
190
+ client: RenderApiClient,
191
+ targets: ImportTargets,
192
+ schema_directory: Path,
193
+ ) -> RenderImportResult:
194
+ """Map one live Render workspace onto validated Cloudfall state files."""
195
+ blueprint, gaps = synthesize_blueprint(client)
196
+ return import_render_mapping(blueprint, targets, schema_directory, gaps)
197
+
198
+
199
+ def _database_connection_index(
200
+ client: RenderApiClient, synthesis: _Synthesis
201
+ ) -> dict[str, str]:
202
+ """Map known connection strings onto blueprint database names."""
203
+ index: dict[str, str] = {}
204
+ for postgres in client.list_postgres():
205
+ raw_id = postgres.get("id")
206
+ name = str(postgres.get("name", "database"))
207
+ database: dict[str, object] = {"name": name}
208
+ database_name = postgres.get("databaseName")
209
+ if isinstance(database_name, str) and database_name:
210
+ database["databaseName"] = database_name
211
+ version = postgres.get("version")
212
+ if isinstance(version, str) and version:
213
+ database["postgresMajorVersion"] = version.split(".")[0]
214
+ if postgres.get("databaseUser") is not None:
215
+ database["user"] = postgres.get("databaseUser")
216
+ synthesis.databases.append(database)
217
+ if not isinstance(raw_id, str) or not raw_id:
218
+ synthesis.gap(
219
+ _CATEGORY_ACTION,
220
+ name,
221
+ "managed PostgreSQL has no id; connection strings in "
222
+ "environment variables cannot be rewritten automatically",
223
+ )
224
+ continue
225
+ for connection in client.postgres_connection_strings(raw_id):
226
+ index[connection] = name
227
+ return index
228
+
229
+
230
+ def _synthesize_service(
231
+ client: RenderApiClient,
232
+ service: Mapping[str, object],
233
+ connection_index: Mapping[str, str],
234
+ synthesis: _Synthesis,
235
+ ) -> None:
236
+ name = str(service.get("name", "unnamed"))
237
+ raw_type = str(service.get("type", ""))
238
+ blueprint_type = _SERVICE_TYPES.get(raw_type, raw_type)
239
+ entry = _service_entry(service, name, blueprint_type)
240
+ if service.get("suspended") == "suspended":
241
+ synthesis.gap(
242
+ _CATEGORY_ASSUMPTION,
243
+ name,
244
+ "service is suspended on Render; imported anyway",
245
+ )
246
+ raw_id = service.get("id")
247
+ if isinstance(raw_id, str) and raw_id and blueprint_type not in (
248
+ "static",
249
+ "keyvalue",
250
+ ):
251
+ entry["envVars"] = _synthesize_env_vars(
252
+ client.list_env_vars(raw_id), name, connection_index, synthesis
253
+ )
254
+ synthesis.services.append(entry)
255
+
256
+
257
+ def _service_entry(
258
+ service: Mapping[str, object],
259
+ name: str,
260
+ blueprint_type: str,
261
+ ) -> dict[str, object]:
262
+ details = _optional_mapping(service.get("serviceDetails"))
263
+ specifics = (
264
+ _optional_mapping(details.get("envSpecificDetails"))
265
+ if details is not None
266
+ else None
267
+ )
268
+ entry: dict[str, object] = {"type": blueprint_type, "name": name}
269
+ detail_keys = (("env", "runtime"), ("healthCheckPath", "healthCheckPath"),
270
+ ("schedule", "schedule"))
271
+ if details is not None:
272
+ for source_key, target_key in detail_keys:
273
+ value = details.get(source_key)
274
+ if isinstance(value, str) and value:
275
+ entry[target_key] = value
276
+ for source_key in ("repo", "branch"):
277
+ value = service.get(source_key)
278
+ if isinstance(value, str) and value:
279
+ entry[source_key] = value
280
+ if specifics is not None:
281
+ for source_key in ("buildCommand", "startCommand"):
282
+ value = specifics.get(source_key)
283
+ if isinstance(value, str) and value:
284
+ entry[source_key] = value
285
+ return entry
286
+
287
+
288
+ def _synthesize_env_vars(
289
+ env_vars: tuple[Mapping[str, object], ...],
290
+ name: str,
291
+ connection_index: Mapping[str, str],
292
+ synthesis: _Synthesis,
293
+ ) -> list[dict[str, object]]:
294
+ entries: list[dict[str, object]] = []
295
+ for env_var in env_vars:
296
+ key = env_var.get("key")
297
+ if not isinstance(key, str) or not key:
298
+ synthesis.gap(
299
+ _CATEGORY_ACTION,
300
+ name,
301
+ "the Render API returned an environment entry without a key",
302
+ )
303
+ continue
304
+ value = env_var.get("value")
305
+ if not isinstance(value, str):
306
+ entries.append({"key": key, "sync": False})
307
+ continue
308
+ database_name = connection_index.get(value)
309
+ if database_name is not None:
310
+ entries.append(
311
+ {
312
+ "key": key,
313
+ "fromDatabase": {
314
+ "name": database_name,
315
+ "property": "connectionString",
316
+ },
317
+ }
318
+ )
319
+ continue
320
+ entries.append({"key": key, "value": value})
321
+ return entries
322
+
323
+
324
+ def _require_mapping(value: object, concept: str) -> Mapping[str, object]:
325
+ if not isinstance(value, dict) or not all(
326
+ isinstance(key, str) for key in value
327
+ ):
328
+ message = f"Render API {concept} is not an object"
329
+ raise RenderImportError(_ERROR_API_INVALID, message)
330
+ return cast("Mapping[str, object]", value)
331
+
332
+
333
+ def _optional_mapping(value: object) -> Mapping[str, object] | None:
334
+ if not isinstance(value, dict) or not all(
335
+ isinstance(key, str) for key in value
336
+ ):
337
+ return None
338
+ return cast("Mapping[str, object]", value)
cloudfall/resources.py ADDED
@@ -0,0 +1,67 @@
1
+ """Locate the schema catalog and execution engine that ship with Cloudfall.
2
+
3
+ Cloudfall runs in two shapes. Installed as a wheel, the versioned JSON
4
+ schemas and the Ansible engine travel inside the package as bundled data.
5
+ Run from a source checkout, they live in the repository tree next to the
6
+ packages. Every command-line default resolves through this module so a
7
+ project never has to know which shape it is talking to.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ import cloudfall
16
+
17
+ _SCHEMA_VERSION = "v1"
18
+ _BUNDLE = "_bundled"
19
+
20
+
21
+ class ResourceError(RuntimeError):
22
+ """Raised when neither a bundled nor a checkout resource exists."""
23
+
24
+
25
+ def _package_directory(package: object) -> Path:
26
+ spec = getattr(package, "__file__", None)
27
+ if not isinstance(spec, str):
28
+ message = f"package has no filesystem location: {package!r}"
29
+ raise ResourceError(message)
30
+ return Path(spec).resolve().parent
31
+
32
+
33
+ def _checkout_root() -> Path:
34
+ # sdk/src/cloudfall -> sdk/src -> sdk -> repository root
35
+ return _package_directory(cloudfall).parents[2]
36
+
37
+
38
+ def select_resource(bundled: Path, checkout: Path, description: str) -> Path:
39
+ """Prefer the bundled copy, fall back to the checkout, else fail."""
40
+ if bundled.is_dir():
41
+ return bundled
42
+ if checkout.is_dir():
43
+ return checkout
44
+ message = (
45
+ f"{description} is missing: neither the bundled copy {bundled} nor "
46
+ f"the source checkout copy {checkout} exists"
47
+ )
48
+ raise ResourceError(message)
49
+
50
+
51
+ def default_schema_directory() -> Path:
52
+ """Return the versioned schema directory Cloudfall ships with."""
53
+ bundled = (
54
+ Path(str(resources.files("cloudfall").joinpath(_BUNDLE, "schemas")))
55
+ / _SCHEMA_VERSION
56
+ )
57
+ checkout = _checkout_root() / "config" / "schemas" / _SCHEMA_VERSION
58
+ return select_resource(bundled, checkout, "schema directory")
59
+
60
+
61
+ def default_engine_directory() -> Path:
62
+ """Return the engine directory holding the bundled Ansible contracts."""
63
+ bundled = Path(str(resources.files("cloudfall_engine").joinpath(_BUNDLE)))
64
+ checkout = _checkout_root() / "engine"
65
+ return select_resource(
66
+ bundled / "ansible", checkout / "ansible", "engine directory"
67
+ ).parent
cloudfall/secrets.py ADDED
@@ -0,0 +1,262 @@
1
+ """Resolve declared secret references into component environment files.
2
+
3
+ Values live sops-encrypted in a private repository and are decrypted only
4
+ on the controller, at render time, into a single 0600 environment file per
5
+ component. State carries references, receipts carry hashes and key names;
6
+ a secret value never appears in state, output envelopes, or the agent
7
+ surface.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from datetime import UTC, datetime
20
+ from typing import TYPE_CHECKING, cast
21
+
22
+ from cloudfall.inventory import PlatformInventory
23
+ from cloudfall.validation import SchemaCatalog, validate_config
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+ from pathlib import Path
28
+
29
+ from cloudfall.domain import ResourceId
30
+ from cloudfall.inventory import ComponentInventory, SecretReference
31
+ from cloudfall.lifecycle import EngineContext
32
+
33
+ _ERROR_SOPS_MISSING = "secrets_sops_missing"
34
+ _ERROR_SOURCE_MISSING = "secrets_source_missing"
35
+ _ERROR_SOURCE_INVALID = "secrets_source_invalid"
36
+ _ERROR_COMPONENT_UNKNOWN = "secrets_component_unknown"
37
+ _ERROR_DECRYPT_FAILED = "secrets_decrypt_failed"
38
+ _ERROR_KEY_CONFLICT = "secrets_key_conflict"
39
+ _RECEIPT_SCHEMA = "environment-receipt.schema.json"
40
+ _KEY_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
41
+ _FILE_MODE = 0o600
42
+
43
+
44
+ class SecretsError(RuntimeError):
45
+ """Structured secrets failure that never carries a value."""
46
+
47
+ def __init__(self, code: str, message: str) -> None:
48
+ """Capture a stable error code alongside the human message."""
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.message = message
52
+
53
+ def as_dict(self) -> dict[str, object]:
54
+ """Serialize the failure for structured output."""
55
+ return {
56
+ "status": "error",
57
+ "error": {"code": self.code, "message": self.message},
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class SopsSecretProvider:
63
+ """Reads dotenv fragments sops-encrypted in a secrets directory.
64
+
65
+ The on-disk layout mirrors the reference itself:
66
+ ``<secrets_directory>/<environment><path>.env`` — for example the
67
+ reference ``{environment: production, path: /crm/backend}`` resolves
68
+ from ``secrets/production/crm/backend.env``.
69
+ """
70
+
71
+ secrets_directory: Path
72
+ decrypt: Callable[[Path], str] | None = None
73
+
74
+ def fetch(
75
+ self, reference: SecretReference
76
+ ) -> tuple[tuple[str, str], ...]:
77
+ """Decrypt one reference's fragment into ordered key pairs."""
78
+ source = self.source_path(reference)
79
+ if not source.is_file():
80
+ message = (
81
+ f"secret source does not exist: {source} (referenced as "
82
+ f"scope={reference.scope.value} path={reference.path.value})"
83
+ )
84
+ raise SecretsError(_ERROR_SOURCE_MISSING, message)
85
+ text = (
86
+ self.decrypt(source)
87
+ if self.decrypt is not None
88
+ else _sops_decrypt(source)
89
+ )
90
+ return _parse_dotenv(text, source)
91
+
92
+ def source_path(self, reference: SecretReference) -> Path:
93
+ """Return the encrypted file backing one reference."""
94
+ relative = reference.path.value.lstrip("/")
95
+ return (
96
+ self.secrets_directory
97
+ / reference.environment.value
98
+ / f"{relative}.env"
99
+ )
100
+
101
+
102
+ def render_environment(
103
+ context: EngineContext,
104
+ component_id: ResourceId,
105
+ provider: SopsSecretProvider,
106
+ output_path: Path,
107
+ receipt_directory: Path | None = None,
108
+ ) -> dict[str, object]:
109
+ """Resolve a component's references into one 0600 environment file."""
110
+ state = validate_config(context.project_directory, context.schema_directory)
111
+ inventory = PlatformInventory.from_state(state)
112
+ component = _declared_component(inventory, component_id)
113
+ application = next(
114
+ application
115
+ for application in inventory.applications
116
+ if application.resource_id == component.application_id
117
+ )
118
+ references = (*application.secret_refs, *component.secret_refs)
119
+ declared = dict(component.environment)
120
+ merged: dict[str, str] = dict(declared)
121
+ for reference in references:
122
+ for key, value in provider.fetch(reference):
123
+ if key in declared:
124
+ message = (
125
+ f"{key} is declared in the component's non-secret "
126
+ "environment and also provided by the secret source "
127
+ f"{provider.source_path(reference)}; a key must live "
128
+ "in exactly one place"
129
+ )
130
+ raise SecretsError(_ERROR_KEY_CONFLICT, message)
131
+ merged[key] = value
132
+ content = "".join(f"{key}={value}\n" for key, value in merged.items())
133
+ output_path.parent.mkdir(parents=True, exist_ok=True)
134
+ descriptor = os.open(
135
+ output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _FILE_MODE
136
+ )
137
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
138
+ handle.write(content)
139
+ output_path.chmod(_FILE_MODE)
140
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
141
+ envelope: dict[str, object] = {
142
+ "status": "ok",
143
+ "component": component_id.value,
144
+ "environmentFile": str(output_path),
145
+ "sha256": digest,
146
+ "keys": sorted(merged),
147
+ "declaredKeys": sorted(declared),
148
+ "references": [reference.as_dict() for reference in references],
149
+ }
150
+ if receipt_directory is not None:
151
+ envelope["receipt"] = str(
152
+ _write_receipt(
153
+ context, component_id, digest, sorted(merged),
154
+ receipt_directory,
155
+ )
156
+ )
157
+ return envelope
158
+
159
+
160
+ def _write_receipt(
161
+ context: EngineContext,
162
+ component_id: ResourceId,
163
+ digest: str,
164
+ keys: list[str],
165
+ receipt_directory: Path,
166
+ ) -> Path:
167
+ document: dict[str, object] = {
168
+ "apiVersion": "cloudfall/v1",
169
+ "kind": "EnvironmentReceipt",
170
+ "metadata": {
171
+ "id": component_id.value,
172
+ "description": "Rendered environment receipt",
173
+ },
174
+ "spec": {
175
+ "component": component_id.value,
176
+ "sha256": digest,
177
+ "renderedAt": datetime.now(UTC).isoformat(timespec="seconds"),
178
+ "keys": keys,
179
+ },
180
+ }
181
+ SchemaCatalog(context.schema_directory).validate_named(
182
+ _RECEIPT_SCHEMA, document
183
+ )
184
+ receipt_directory.mkdir(parents=True, exist_ok=True)
185
+ path = receipt_directory / f"{component_id.value}.json"
186
+ path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
187
+ return path
188
+
189
+
190
+ def load_environment_receipts(
191
+ receipt_directory: Path, schema_directory: Path
192
+ ) -> dict[str, str]:
193
+ """Load rendered-environment hashes by component id."""
194
+ if not receipt_directory.is_dir():
195
+ return {}
196
+ catalog = SchemaCatalog(schema_directory)
197
+ hashes: dict[str, str] = {}
198
+ for path in sorted(receipt_directory.glob("*.json")):
199
+ document = cast(
200
+ "dict[str, object]",
201
+ json.loads(path.read_text(encoding="utf-8")),
202
+ )
203
+ catalog.validate_named(_RECEIPT_SCHEMA, document)
204
+ spec = cast("dict[str, object]", document["spec"])
205
+ hashes[cast("str", spec["component"])] = cast("str", spec["sha256"])
206
+ return hashes
207
+
208
+
209
+ def _declared_component(
210
+ inventory: PlatformInventory, component_id: ResourceId
211
+ ) -> ComponentInventory:
212
+ component = next(
213
+ (
214
+ candidate
215
+ for candidate in inventory.components
216
+ if candidate.resource_id == component_id
217
+ ),
218
+ None,
219
+ )
220
+ if component is None:
221
+ message = f"declared component does not exist: {component_id.value}"
222
+ raise SecretsError(_ERROR_COMPONENT_UNKNOWN, message)
223
+ return component
224
+
225
+
226
+ def _sops_decrypt(source: Path) -> str:
227
+ binary = shutil.which("sops")
228
+ if binary is None:
229
+ message = (
230
+ "the sops binary is not installed; install sops and age, and "
231
+ "set SOPS_AGE_KEY_FILE to your age key"
232
+ )
233
+ raise SecretsError(_ERROR_SOPS_MISSING, message)
234
+ completed = subprocess.run( # noqa: S603 - fixed binary, declared file
235
+ [binary, "--decrypt", str(source)],
236
+ capture_output=True,
237
+ text=True,
238
+ check=False,
239
+ )
240
+ if completed.returncode != 0:
241
+ message = (
242
+ f"sops could not decrypt {source}: "
243
+ f"{completed.stderr.strip()[:300]}"
244
+ )
245
+ raise SecretsError(_ERROR_DECRYPT_FAILED, message)
246
+ return completed.stdout
247
+
248
+
249
+ def _parse_dotenv(text: str, source: Path) -> tuple[tuple[str, str], ...]:
250
+ pairs: list[tuple[str, str]] = []
251
+ for line_number, raw_line in enumerate(text.splitlines(), start=1):
252
+ line = raw_line.strip()
253
+ if not line or line.startswith("#"):
254
+ continue
255
+ key, separator, value = line.partition("=")
256
+ if not separator or not _KEY_PATTERN.fullmatch(key):
257
+ message = (
258
+ f"{source} line {line_number} is not a KEY=VALUE entry"
259
+ )
260
+ raise SecretsError(_ERROR_SOURCE_INVALID, message)
261
+ pairs.append((key, value))
262
+ return tuple(pairs)