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/importer.py ADDED
@@ -0,0 +1,906 @@
1
+ """Import a Render blueprint into declarative Cloudfall state.
2
+
3
+ The importer maps ``render.yaml`` services onto Cloudfall resources, writes
4
+ environment files outside the project directory (the config never contains secret
5
+ values), and records every assumption, unsupported feature, and required
6
+ follow-up action in a structured gap report instead of guessing silently.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shlex
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, cast
15
+
16
+ import yaml
17
+
18
+ from cloudfall.domain import LinuxUser, ResourceId, ResourceKind
19
+ from cloudfall.validation import SchemaCatalog
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping, Sequence
23
+ from pathlib import Path
24
+
25
+ _FIRST_COMPONENT_PORT = 8100
26
+ _MAX_TCP_PORT = 65535
27
+ _DEFAULT_PYTHON_VERSION = "3.14"
28
+ _DEFAULT_NODE_VERSION = "22"
29
+ _DEFAULT_POSTGRES_MAJOR = "16"
30
+ _POSTGRES_SERVICE_ID = "postgresql-main"
31
+ _RESOURCE_SLUG_MAX = 63
32
+ _ERROR_BLUEPRINT_INVALID = "import_blueprint_invalid"
33
+ _ERROR_NAME_INVALID = "import_name_invalid"
34
+ _ERROR_NAME_COLLISION = "import_name_collision"
35
+ _ERROR_PROJECT_INVALID = "import_application_invalid"
36
+ _CATEGORY_UNSUPPORTED = "unsupported"
37
+ _CATEGORY_ASSUMPTION = "assumption"
38
+ _CATEGORY_ACTION = "action"
39
+
40
+
41
+ class RenderImportError(RuntimeError):
42
+ """Fail-fast import error with a stable machine-readable code."""
43
+
44
+ def __init__(self, code: str, message: str) -> None:
45
+ """Record the failure code and human-readable detail."""
46
+ self.code = code
47
+ self.detail = message
48
+ super().__init__(f"{code}: {message}")
49
+
50
+ def as_dict(self) -> dict[str, object]:
51
+ """Serialize the error envelope for system boundaries."""
52
+ return {
53
+ "status": "error",
54
+ "error": {"code": self.code, "message": self.detail},
55
+ }
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ImportGap:
60
+ """One assumption, unsupported feature, or required follow-up."""
61
+
62
+ category: str
63
+ subject: str
64
+ detail: str
65
+
66
+ def as_dict(self) -> dict[str, object]:
67
+ """Serialize the gap for report consumers."""
68
+ return {
69
+ "category": self.category,
70
+ "subject": self.subject,
71
+ "detail": self.detail,
72
+ }
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ImportTargets:
77
+ """Where imported resources are placed."""
78
+
79
+ application_id: ResourceId
80
+ server_id: ResourceId
81
+ project_directory: Path
82
+ environment_directory: Path
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class RenderImportResult:
87
+ """Structured outcome of one blueprint import."""
88
+
89
+ application_id: ResourceId
90
+ components: tuple[str, ...]
91
+ services: tuple[str, ...]
92
+ domains: tuple[str, ...]
93
+ written: tuple[Path, ...]
94
+ environment_files: tuple[Path, ...]
95
+ report: Path
96
+ gaps: tuple[ImportGap, ...]
97
+
98
+ def as_dict(self) -> dict[str, object]:
99
+ """Serialize the import result for agent consumers."""
100
+ return {
101
+ "status": "ok",
102
+ "application": self.application_id.value,
103
+ "components": list(self.components),
104
+ "services": list(self.services),
105
+ "domains": list(self.domains),
106
+ "written": [str(path) for path in self.written],
107
+ "environmentFiles": [
108
+ str(path) for path in self.environment_files
109
+ ],
110
+ "report": str(self.report),
111
+ "gaps": [gap.as_dict() for gap in self.gaps],
112
+ }
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class _ImportState:
117
+ """Mutable working state accumulated while mapping a blueprint."""
118
+
119
+ gaps: list[ImportGap] = field(default_factory=list)
120
+ component_documents: list[dict[str, object]] = field(default_factory=list)
121
+ environment_lines: dict[str, list[str]] = field(default_factory=dict)
122
+ domain_documents: list[dict[str, object]] = field(default_factory=list)
123
+ used_ids: set[str] = field(default_factory=set)
124
+ database_names: dict[str, str] = field(default_factory=dict)
125
+ next_port: int = _FIRST_COMPONENT_PORT
126
+
127
+ def gap(self, category: str, subject: str, detail: str) -> None:
128
+ self.gaps.append(
129
+ ImportGap(category=category, subject=subject, detail=detail)
130
+ )
131
+
132
+ def claim_id(self, slug: str, subject: str) -> None:
133
+ if slug in self.used_ids:
134
+ detail = f"{subject} maps to an already-used resource id: {slug}"
135
+ raise RenderImportError(_ERROR_NAME_COLLISION, detail)
136
+ self.used_ids.add(slug)
137
+
138
+ def claim_port(self, declared: object, subject: str) -> int:
139
+ if isinstance(declared, str) and declared.isdigit():
140
+ declared = int(declared)
141
+ if (
142
+ isinstance(declared, int)
143
+ and not isinstance(declared, bool)
144
+ and 1 <= declared <= _MAX_TCP_PORT
145
+ ):
146
+ return declared
147
+ port = self.next_port
148
+ self.next_port += 1
149
+ self.gap(
150
+ _CATEGORY_ASSUMPTION,
151
+ subject,
152
+ f"assigned listen port {port}; PORT is written to the component "
153
+ "environment file and the application must honor it",
154
+ )
155
+ return port
156
+
157
+
158
+ def import_render_blueprint(
159
+ blueprint_path: Path,
160
+ targets: ImportTargets,
161
+ schema_directory: Path,
162
+ ) -> RenderImportResult:
163
+ """Map one Render blueprint onto validated Cloudfall state files."""
164
+ blueprint = _load_blueprint(blueprint_path)
165
+ return import_render_mapping(blueprint, targets, schema_directory)
166
+
167
+
168
+ def import_render_mapping(
169
+ blueprint: Mapping[str, object],
170
+ targets: ImportTargets,
171
+ schema_directory: Path,
172
+ initial_gaps: tuple[ImportGap, ...] = (),
173
+ ) -> RenderImportResult:
174
+ """Map one blueprint-shaped Render description onto state files."""
175
+ _require_application_user(targets.application_id)
176
+ catalog = SchemaCatalog(schema_directory)
177
+ state = _ImportState()
178
+ state.gaps.extend(initial_gaps)
179
+ state.database_names = _database_name_map(blueprint)
180
+ groups = _environment_groups(blueprint, state)
181
+
182
+ for raw_service in _sequence(blueprint.get("services"), "services"):
183
+ _import_service(_mapping(raw_service, "service"), targets, groups, state)
184
+
185
+ if not state.component_documents:
186
+ code = "render_import_empty"
187
+ reasons = "; ".join(
188
+ f"{gap.subject}: {gap.detail}" for gap in state.gaps
189
+ )
190
+ message = (
191
+ "no importable services were found, so there is nothing to "
192
+ f"migrate; every exclusion is explained by a gap: {reasons}"
193
+ if reasons
194
+ else "no services were found to import"
195
+ )
196
+ raise RenderImportError(code, message)
197
+
198
+ service_documents = _import_databases(blueprint, targets, state)
199
+ application_document = _application_document(targets, state)
200
+
201
+ written: list[Path] = [
202
+ _write_resource(
203
+ catalog,
204
+ ResourceKind.APPLICATION,
205
+ application_document,
206
+ targets.project_directory / "applications",
207
+ )
208
+ ]
209
+ written.extend(
210
+ _write_resource(
211
+ catalog,
212
+ ResourceKind.COMPONENT,
213
+ document,
214
+ targets.project_directory / "components",
215
+ )
216
+ for document in state.component_documents
217
+ )
218
+ written.extend(
219
+ _write_resource(
220
+ catalog,
221
+ ResourceKind.SERVICE,
222
+ document,
223
+ targets.project_directory / "services",
224
+ )
225
+ for document in service_documents
226
+ )
227
+ written.extend(
228
+ _write_resource(
229
+ catalog,
230
+ ResourceKind.DOMAIN,
231
+ document,
232
+ targets.project_directory / "domains",
233
+ )
234
+ for document in state.domain_documents
235
+ )
236
+
237
+ environment_files = _write_environment_files(targets, state)
238
+ report = _write_report(targets, state)
239
+ return RenderImportResult(
240
+ application_id=targets.application_id,
241
+ components=tuple(
242
+ _document_id(document) for document in state.component_documents
243
+ ),
244
+ services=tuple(
245
+ _document_id(document) for document in service_documents
246
+ ),
247
+ domains=tuple(
248
+ _document_id(document) for document in state.domain_documents
249
+ ),
250
+ written=tuple(written),
251
+ environment_files=environment_files,
252
+ report=report,
253
+ gaps=tuple(state.gaps),
254
+ )
255
+
256
+
257
+ def _import_service(
258
+ raw_service: Mapping[str, object],
259
+ targets: ImportTargets,
260
+ groups: Mapping[str, Sequence[Mapping[str, object]]],
261
+ state: _ImportState,
262
+ ) -> None:
263
+ name = str(raw_service.get("name", "unnamed"))
264
+ service_type = str(raw_service.get("type", ""))
265
+ runtime = str(
266
+ raw_service.get("runtime", raw_service.get("env", ""))
267
+ )
268
+ if service_type == "cron":
269
+ state.gap(
270
+ _CATEGORY_UNSUPPORTED,
271
+ name,
272
+ "cron services are not modeled yet; recreate the schedule "
273
+ f"({raw_service.get('schedule')!r}) as a systemd timer manually",
274
+ )
275
+ return
276
+ if service_type in {"static", "redis", "keyvalue"}:
277
+ state.gap(
278
+ _CATEGORY_UNSUPPORTED,
279
+ name,
280
+ f"{service_type} services are not part of the v1 catalog",
281
+ )
282
+ return
283
+ if runtime in {"docker", "image"}:
284
+ state.gap(
285
+ _CATEGORY_UNSUPPORTED,
286
+ name,
287
+ "container runtimes are a tracked decision; only native python "
288
+ "and node services import today",
289
+ )
290
+ return
291
+ if service_type not in {"web", "worker", "pserv"}:
292
+ state.gap(
293
+ _CATEGORY_UNSUPPORTED,
294
+ name,
295
+ f"service type {service_type!r} is not supported by the importer",
296
+ )
297
+ return
298
+ if runtime not in {"python", "node"}:
299
+ state.gap(
300
+ _CATEGORY_UNSUPPORTED,
301
+ name,
302
+ f"runtime {runtime!r} is not supported by the importer",
303
+ )
304
+ return
305
+ _import_component(
306
+ raw_service, name, service_type, runtime, targets, groups, state
307
+ )
308
+
309
+
310
+ def _import_component( # noqa: PLR0913 - one boundary mapping, many inputs.
311
+ raw_service: Mapping[str, object],
312
+ name: str,
313
+ service_type: str,
314
+ runtime: str,
315
+ targets: ImportTargets,
316
+ groups: Mapping[str, Sequence[Mapping[str, object]]],
317
+ state: _ImportState,
318
+ ) -> None:
319
+ repo = raw_service.get("repo")
320
+ if not isinstance(repo, str) or not repo:
321
+ state.gap(
322
+ _CATEGORY_ACTION,
323
+ name,
324
+ "declares no repository; add repo to the blueprint or create "
325
+ "the Component manually",
326
+ )
327
+ return
328
+ raw_start = raw_service.get("startCommand")
329
+ if not isinstance(raw_start, str) or not raw_start.strip():
330
+ state.gap(
331
+ _CATEGORY_ACTION,
332
+ name,
333
+ "declares no startCommand; Cloudfall needs an explicit argv "
334
+ "service command",
335
+ )
336
+ return
337
+
338
+ component_id = _resource_slug(name)
339
+ state.claim_id(component_id, f"service {name}")
340
+ environment, resolved = _resolve_environment(raw_service, groups, name, state)
341
+ port: int | None = None
342
+ if service_type in {"web", "pserv"}:
343
+ port = state.claim_port(resolved.get("PORT"), name)
344
+ environment = [
345
+ f"PORT={port}",
346
+ *(
347
+ line
348
+ for line in environment
349
+ if not line.startswith("PORT=")
350
+ ),
351
+ ]
352
+
353
+ if raw_service.get("buildCommand"):
354
+ state.gap(
355
+ _CATEGORY_ASSUMPTION,
356
+ name,
357
+ "buildCommand is not executed; Cloudfall materializes locked "
358
+ "dependencies at deploy time instead",
359
+ )
360
+ branch = raw_service.get("branch")
361
+ if isinstance(branch, str) and branch:
362
+ state.gap(
363
+ _CATEGORY_ACTION,
364
+ name,
365
+ f"build releases from the declared branch: cloudfall-engine "
366
+ f"artifact build ... --ref {branch}",
367
+ )
368
+
369
+ document: dict[str, object] = {
370
+ "apiVersion": "cloudfall/v1",
371
+ "kind": "Component",
372
+ "metadata": {
373
+ "id": component_id,
374
+ "description": f"Imported from Render service {name}",
375
+ },
376
+ "spec": {
377
+ "application": targets.application_id.value,
378
+ "repository": _repository(raw_service, repo),
379
+ "runtime": _runtime(runtime, resolved, name, state),
380
+ "deployment": {
381
+ "strategy": "artifact-symlink",
382
+ "servers": [targets.server_id.value],
383
+ "installRoot": (
384
+ f"/srv/apps/{targets.application_id.value}/{component_id}"
385
+ ),
386
+ "retainUntilCleanup": True,
387
+ },
388
+ "service": {
389
+ "manager": "systemd",
390
+ "name": component_id,
391
+ "command": shlex.split(raw_start),
392
+ },
393
+ "healthCheck": _health_check(raw_service, port, name, state),
394
+ },
395
+ }
396
+ state.component_documents.append(document)
397
+ if environment:
398
+ state.environment_lines[component_id] = environment
399
+ _import_domains(raw_service, component_id, port, targets, state)
400
+
401
+
402
+ def _repository(
403
+ raw_service: Mapping[str, object], repo: str
404
+ ) -> dict[str, object]:
405
+ repository: dict[str, object] = {"url": repo}
406
+ root_directory = raw_service.get("rootDir")
407
+ if isinstance(root_directory, str) and root_directory:
408
+ repository["subdirectory"] = root_directory
409
+ return repository
410
+
411
+
412
+ def _runtime(
413
+ runtime: str,
414
+ resolved: Mapping[str, str],
415
+ name: str,
416
+ state: _ImportState,
417
+ ) -> dict[str, object]:
418
+ if runtime == "python":
419
+ version = resolved.get("PYTHON_VERSION", _DEFAULT_PYTHON_VERSION)
420
+ if "PYTHON_VERSION" not in resolved:
421
+ state.gap(
422
+ _CATEGORY_ASSUMPTION,
423
+ name,
424
+ f"no PYTHON_VERSION declared; assuming "
425
+ f"{_DEFAULT_PYTHON_VERSION}",
426
+ )
427
+ state.gap(
428
+ _CATEGORY_ACTION,
429
+ name,
430
+ "Cloudfall deploys python components with uv sync --frozen; the "
431
+ "repository needs pyproject.toml and uv.lock",
432
+ )
433
+ return {
434
+ "type": "python",
435
+ "version": version,
436
+ "packageManager": "uv",
437
+ }
438
+ version = resolved.get("NODE_VERSION", _DEFAULT_NODE_VERSION)
439
+ if "NODE_VERSION" not in resolved:
440
+ state.gap(
441
+ _CATEGORY_ASSUMPTION,
442
+ name,
443
+ f"no NODE_VERSION declared; assuming {_DEFAULT_NODE_VERSION}",
444
+ )
445
+ state.gap(
446
+ _CATEGORY_ACTION,
447
+ name,
448
+ "node components are modeled in state but the deploy role supports "
449
+ "uv-managed python only in this slice",
450
+ )
451
+ return {"type": "node", "version": version, "packageManager": "npm"}
452
+
453
+
454
+ def _health_check(
455
+ raw_service: Mapping[str, object],
456
+ port: int | None,
457
+ name: str,
458
+ state: _ImportState,
459
+ ) -> dict[str, object]:
460
+ if port is None:
461
+ return {"type": "none"}
462
+ raw_path = raw_service.get("healthCheckPath")
463
+ path = raw_path if isinstance(raw_path, str) and raw_path else "/"
464
+ if path == "/":
465
+ state.gap(
466
+ _CATEGORY_ASSUMPTION,
467
+ name,
468
+ "no healthCheckPath declared; probing / instead",
469
+ )
470
+ return {
471
+ "type": "http",
472
+ "scheme": "http",
473
+ "port": port,
474
+ "path": path,
475
+ "expectedStatuses": [200],
476
+ "timeoutSeconds": 5,
477
+ "attempts": 5,
478
+ }
479
+
480
+
481
+ def _import_domains(
482
+ raw_service: Mapping[str, object],
483
+ component_id: str,
484
+ port: int | None,
485
+ targets: ImportTargets,
486
+ state: _ImportState,
487
+ ) -> None:
488
+ raw_domains = raw_service.get("domains")
489
+ if raw_domains is None:
490
+ return
491
+ if port is None:
492
+ state.gap(
493
+ _CATEGORY_ACTION,
494
+ component_id,
495
+ "declares domains but exposes no HTTP port; route it manually",
496
+ )
497
+ return
498
+ for raw_domain in _sequence(raw_domains, "domains"):
499
+ domain_name = str(raw_domain)
500
+ domain_id = _resource_slug(domain_name)
501
+ state.claim_id(domain_id, f"domain {domain_name}")
502
+ state.domain_documents.append(
503
+ {
504
+ "apiVersion": "cloudfall/v1",
505
+ "kind": "Domain",
506
+ "metadata": {
507
+ "id": domain_id,
508
+ "description": (
509
+ f"Imported Render domain for {component_id}"
510
+ ),
511
+ },
512
+ "spec": {
513
+ "primaryName": domain_name,
514
+ "aliases": [],
515
+ "proxy": {
516
+ "server": targets.server_id.value,
517
+ "configurationPath": (
518
+ f"/etc/nginx/sites-available/{domain_name}.conf"
519
+ ),
520
+ "service": "nginx.service",
521
+ "upstream": {"address": "127.0.0.1", "port": port},
522
+ },
523
+ "origin": {
524
+ "server": targets.server_id.value,
525
+ "configurationPath": (
526
+ f"/etc/nginx/sites-available/{domain_name}.conf"
527
+ ),
528
+ "service": "nginx.service",
529
+ "scheme": "http",
530
+ "port": port,
531
+ "serverName": domain_name,
532
+ },
533
+ "edge": {"provider": "external", "mode": "dns-only"},
534
+ "tls": {"mode": "required"},
535
+ "healthCheck": {
536
+ "scheme": "https",
537
+ "path": "/",
538
+ "expectedStatuses": [200],
539
+ "timeoutSeconds": 10,
540
+ },
541
+ },
542
+ }
543
+ )
544
+ state.gap(
545
+ _CATEGORY_ACTION,
546
+ domain_name,
547
+ "lower the DNS TTL, point the record at the target server, and "
548
+ "deploy the route with task domains:deploy",
549
+ )
550
+
551
+
552
+ def _import_databases(
553
+ blueprint: Mapping[str, object],
554
+ targets: ImportTargets,
555
+ state: _ImportState,
556
+ ) -> list[dict[str, object]]:
557
+ raw_databases = blueprint.get("databases")
558
+ if raw_databases is None:
559
+ return []
560
+ databases: list[dict[str, object]] = []
561
+ major_version: str | None = None
562
+ for raw_database in _sequence(raw_databases, "databases"):
563
+ entry = _mapping(raw_database, "database")
564
+ name = str(entry.get("name", "database"))
565
+ databases.append(
566
+ {
567
+ "name": state.database_names[name],
568
+ "application": targets.application_id.value,
569
+ }
570
+ )
571
+ declared_major = entry.get("postgresMajorVersion")
572
+ if isinstance(declared_major, str) and major_version is None:
573
+ major_version = declared_major
574
+ if entry.get("user") is not None:
575
+ state.gap(
576
+ _CATEGORY_ACTION,
577
+ name,
578
+ "database roles use peer authentication as the application user; "
579
+ "update connection strings that referenced the Render user",
580
+ )
581
+ if major_version is None:
582
+ major_version = _DEFAULT_POSTGRES_MAJOR
583
+ state.gap(
584
+ _CATEGORY_ASSUMPTION,
585
+ _POSTGRES_SERVICE_ID,
586
+ "no postgresMajorVersion declared; assuming "
587
+ f"{_DEFAULT_POSTGRES_MAJOR}",
588
+ )
589
+ state.gap(
590
+ _CATEGORY_ACTION,
591
+ _POSTGRES_SERVICE_ID,
592
+ "before cutover, save the Render database connection URL into a "
593
+ "local file and migrate the data with: cloudfall data migrate "
594
+ f"{_POSTGRES_SERVICE_ID} --database <name> --source-url-file <file> "
595
+ "--yes (or pass --data <name>=<file> to cloudfall migrate)",
596
+ )
597
+ return [
598
+ {
599
+ "apiVersion": "cloudfall/v1",
600
+ "kind": "Service",
601
+ "metadata": {
602
+ "id": _POSTGRES_SERVICE_ID,
603
+ "description": "Imported from Render managed PostgreSQL",
604
+ },
605
+ "spec": {
606
+ "serviceKind": "postgresql",
607
+ "environment": "production",
608
+ "server": targets.server_id.value,
609
+ "bind": {"address": "127.0.0.1", "port": 5432},
610
+ "postgresql": {
611
+ "majorVersion": major_version,
612
+ "databases": databases,
613
+ },
614
+ "backup": {
615
+ "directory": (
616
+ f"/var/backups/cloudfall/{_POSTGRES_SERVICE_ID}"
617
+ ),
618
+ "onCalendar": "*-*-* 02:00:00 UTC",
619
+ "retentionDays": 14,
620
+ },
621
+ },
622
+ }
623
+ ]
624
+
625
+
626
+ def _resolve_environment(
627
+ raw_service: Mapping[str, object],
628
+ groups: Mapping[str, Sequence[Mapping[str, object]]],
629
+ name: str,
630
+ state: _ImportState,
631
+ ) -> tuple[list[str], dict[str, str]]:
632
+ lines: list[str] = []
633
+ resolved: dict[str, str] = {}
634
+ raw_env = raw_service.get("envVars")
635
+ if raw_env is None:
636
+ return lines, resolved
637
+ entries: list[Mapping[str, object]] = []
638
+ for raw_entry in _sequence(raw_env, "envVars"):
639
+ entry = _mapping(raw_entry, "environment variable")
640
+ group_name = entry.get("fromGroup")
641
+ if isinstance(group_name, str):
642
+ group = groups.get(group_name)
643
+ if group is None:
644
+ state.gap(
645
+ _CATEGORY_ACTION,
646
+ name,
647
+ f"references undeclared env group {group_name!r}",
648
+ )
649
+ continue
650
+ entries.extend(group)
651
+ continue
652
+ entries.append(entry)
653
+ for entry in entries:
654
+ _resolve_environment_entry(entry, name, lines, resolved, state)
655
+ return lines, resolved
656
+
657
+
658
+ def _resolve_environment_entry(
659
+ entry: Mapping[str, object],
660
+ name: str,
661
+ lines: list[str],
662
+ resolved: dict[str, str],
663
+ state: _ImportState,
664
+ ) -> None:
665
+ key = entry.get("key")
666
+ if not isinstance(key, str) or not key:
667
+ state.gap(
668
+ _CATEGORY_ACTION, name, "contains an environment entry without a key"
669
+ )
670
+ return
671
+ value = entry.get("value")
672
+ from_database = entry.get("fromDatabase")
673
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool):
674
+ resolved[key] = str(value)
675
+ lines.append(f"{key}={value}")
676
+ return
677
+ if entry.get("generateValue") is True or entry.get("sync") is False:
678
+ lines.append(f"{key}=")
679
+ state.gap(
680
+ _CATEGORY_ACTION,
681
+ name,
682
+ f"provide a value for {key} in the component environment file",
683
+ )
684
+ return
685
+ if isinstance(from_database, dict):
686
+ database = cast("Mapping[str, object]", from_database)
687
+ if database.get("property") == "connectionString":
688
+ render_name = str(database.get("name", "database"))
689
+ slug = state.database_names.get(
690
+ render_name, _postgres_slug(render_name)
691
+ )
692
+ lines.append(
693
+ f"{key}=postgresql:///{slug}?host=/var/run/postgresql"
694
+ )
695
+ state.gap(
696
+ _CATEGORY_ASSUMPTION,
697
+ name,
698
+ f"{key} now uses peer authentication over the local socket",
699
+ )
700
+ return
701
+ lines.append(f"{key}=")
702
+ state.gap(
703
+ _CATEGORY_ACTION,
704
+ name,
705
+ f"{key} referenced database property "
706
+ f"{database.get('property')!r}; resolve it manually",
707
+ )
708
+ return
709
+ lines.append(f"{key}=")
710
+ state.gap(
711
+ _CATEGORY_ACTION,
712
+ name,
713
+ f"{key} uses an unsupported source; resolve it manually",
714
+ )
715
+
716
+
717
+ def _database_name_map(blueprint: Mapping[str, object]) -> dict[str, str]:
718
+ mapping: dict[str, str] = {}
719
+ raw_databases = blueprint.get("databases")
720
+ if raw_databases is None:
721
+ return mapping
722
+ for raw_database in _sequence(raw_databases, "databases"):
723
+ entry = _mapping(raw_database, "database")
724
+ name = str(entry.get("name", "database"))
725
+ declared = entry.get("databaseName")
726
+ mapping[name] = _postgres_slug(
727
+ declared if isinstance(declared, str) else name
728
+ )
729
+ return mapping
730
+
731
+
732
+ def _environment_groups(
733
+ blueprint: Mapping[str, object], state: _ImportState
734
+ ) -> dict[str, list[Mapping[str, object]]]:
735
+ groups: dict[str, list[Mapping[str, object]]] = {}
736
+ raw_groups = blueprint.get("envVarGroups")
737
+ if raw_groups is None:
738
+ return groups
739
+ for raw_group in _sequence(raw_groups, "envVarGroups"):
740
+ group = _mapping(raw_group, "environment group")
741
+ group_name = str(group.get("name", ""))
742
+ entries = [
743
+ _mapping(item, "environment variable")
744
+ for item in _sequence(group.get("envVars", []), "group envVars")
745
+ ]
746
+ groups[group_name] = entries
747
+ state.gap(
748
+ _CATEGORY_ASSUMPTION,
749
+ group_name,
750
+ "env group entries were inlined into each referencing component",
751
+ )
752
+ return groups
753
+
754
+
755
+ def _application_document(
756
+ targets: ImportTargets, state: _ImportState
757
+ ) -> dict[str, object]:
758
+ return {
759
+ "apiVersion": "cloudfall/v1",
760
+ "kind": "Application",
761
+ "metadata": {
762
+ "id": targets.application_id.value,
763
+ "description": "Imported from a Render blueprint",
764
+ },
765
+ "spec": {
766
+ "linuxUser": targets.application_id.value,
767
+ "approval": "manual",
768
+ "components": [
769
+ _document_id(document)
770
+ for document in state.component_documents
771
+ ],
772
+ },
773
+ }
774
+
775
+
776
+ def _write_resource(
777
+ catalog: SchemaCatalog,
778
+ kind: ResourceKind,
779
+ document: dict[str, object],
780
+ directory: Path,
781
+ ) -> Path:
782
+ catalog.validate(kind, document)
783
+ directory.mkdir(parents=True, exist_ok=True)
784
+ path = directory / f"{_document_id(document)}.yaml"
785
+ path.write_text(
786
+ f"---\n{yaml.safe_dump(document, sort_keys=False)}",
787
+ encoding="utf-8",
788
+ )
789
+ return path
790
+
791
+
792
+ def _write_environment_files(
793
+ targets: ImportTargets, state: _ImportState
794
+ ) -> tuple[Path, ...]:
795
+ paths: list[Path] = []
796
+ for component_id, lines in sorted(state.environment_lines.items()):
797
+ targets.environment_directory.mkdir(parents=True, exist_ok=True)
798
+ path = targets.environment_directory / f"{component_id}.env"
799
+ content = "\n".join(lines)
800
+ path.write_text(f"{content}\n", encoding="utf-8")
801
+ path.chmod(0o600)
802
+ paths.append(path)
803
+ state.gap(
804
+ _CATEGORY_ACTION,
805
+ component_id,
806
+ f"review {path} and pass it to deployment with --env-file",
807
+ )
808
+ return tuple(paths)
809
+
810
+
811
+ def _write_report(targets: ImportTargets, state: _ImportState) -> Path:
812
+ lines = [
813
+ "# Render import report",
814
+ "",
815
+ "Imported resources are declarative state only. Merge this",
816
+ "directory with your servers and server types, then run",
817
+ "`cloudfall config validate` before deploying anything.",
818
+ "",
819
+ ]
820
+ for category, title in (
821
+ (_CATEGORY_UNSUPPORTED, "Not imported"),
822
+ (_CATEGORY_ASSUMPTION, "Assumptions"),
823
+ (_CATEGORY_ACTION, "Required actions"),
824
+ ):
825
+ entries = [gap for gap in state.gaps if gap.category == category]
826
+ if not entries:
827
+ continue
828
+ lines.append(f"## {title}")
829
+ lines.append("")
830
+ lines.extend(
831
+ f"- **{gap.subject}**: {gap.detail}" for gap in entries
832
+ )
833
+ lines.append("")
834
+ path = targets.project_directory / "IMPORT-REPORT.md"
835
+ targets.project_directory.mkdir(parents=True, exist_ok=True)
836
+ path.write_text("\n".join(lines), encoding="utf-8")
837
+ return path
838
+
839
+
840
+ def _require_application_user(application_id: ResourceId) -> None:
841
+ try:
842
+ LinuxUser(application_id.value)
843
+ except ValueError as error:
844
+ detail = (
845
+ f"application id {application_id.value!r} is not usable as a Linux user"
846
+ )
847
+ raise RenderImportError(_ERROR_PROJECT_INVALID, detail) from error
848
+
849
+
850
+ def _load_blueprint(blueprint_path: Path) -> Mapping[str, object]:
851
+ if not blueprint_path.is_file():
852
+ detail = f"blueprint does not exist: {blueprint_path}"
853
+ raise RenderImportError(_ERROR_BLUEPRINT_INVALID, detail)
854
+ try:
855
+ raw = cast(
856
+ "object",
857
+ yaml.safe_load(blueprint_path.read_text(encoding="utf-8")),
858
+ )
859
+ except yaml.YAMLError as error:
860
+ detail = f"blueprint is not valid YAML: {error}"
861
+ raise RenderImportError(_ERROR_BLUEPRINT_INVALID, detail) from error
862
+ if not isinstance(raw, dict) or not all(
863
+ isinstance(key, str) for key in raw
864
+ ):
865
+ detail = "blueprint root must be a mapping"
866
+ raise RenderImportError(_ERROR_BLUEPRINT_INVALID, detail)
867
+ return cast("Mapping[str, object]", raw)
868
+
869
+
870
+ def _resource_slug(name: str) -> str:
871
+ slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", name.lower()))
872
+ slug = slug.strip("-")[:_RESOURCE_SLUG_MAX].strip("-")
873
+ if not slug or not slug[0].isalpha():
874
+ detail = f"cannot derive a resource id from {name!r}"
875
+ raise RenderImportError(_ERROR_NAME_INVALID, detail)
876
+ return slug
877
+
878
+
879
+ def _postgres_slug(name: str) -> str:
880
+ slug = re.sub(r"_+", "_", re.sub(r"[^a-z0-9]", "_", name.lower()))
881
+ slug = slug.strip("_")[:_RESOURCE_SLUG_MAX].strip("_")
882
+ if not slug or not slug[0].isalpha():
883
+ detail = f"cannot derive a database name from {name!r}"
884
+ raise RenderImportError(_ERROR_NAME_INVALID, detail)
885
+ return slug
886
+
887
+
888
+ def _document_id(document: Mapping[str, object]) -> str:
889
+ metadata = _mapping(document.get("metadata"), "metadata")
890
+ return str(metadata.get("id"))
891
+
892
+
893
+ def _sequence(value: object, concept: str) -> tuple[object, ...]:
894
+ if not isinstance(value, list):
895
+ detail = f"blueprint {concept} must be a list"
896
+ raise RenderImportError(_ERROR_BLUEPRINT_INVALID, detail)
897
+ return tuple(value)
898
+
899
+
900
+ def _mapping(value: object, concept: str) -> Mapping[str, object]:
901
+ if not isinstance(value, dict) or not all(
902
+ isinstance(key, str) for key in value
903
+ ):
904
+ detail = f"blueprint {concept} must be a mapping"
905
+ raise RenderImportError(_ERROR_BLUEPRINT_INVALID, detail)
906
+ return cast("Mapping[str, object]", value)