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.
- cloudfall/__init__.py +19 -0
- cloudfall/__main__.py +5 -0
- cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
- cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
- cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
- cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
- cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
- cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
- cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
- cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
- cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
- cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
- cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
- cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
- cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
- cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
- cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
- cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
- cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
- cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
- cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
- cloudfall/agent_tools.py +868 -0
- cloudfall/arguments.py +92 -0
- cloudfall/audit.py +905 -0
- cloudfall/authoring.py +341 -0
- cloudfall/cli.py +1748 -0
- cloudfall/commands.py +257 -0
- cloudfall/cutover.py +370 -0
- cloudfall/dashboard.py +407 -0
- cloudfall/dashboard_server.py +201 -0
- cloudfall/domain.py +1157 -0
- cloudfall/importer.py +906 -0
- cloudfall/inventory.py +2116 -0
- cloudfall/lifecycle.py +874 -0
- cloudfall/mcp_server.py +704 -0
- cloudfall/migrate.py +730 -0
- cloudfall/observation.py +143 -0
- cloudfall/operations.py +1228 -0
- cloudfall/operator.py +1099 -0
- cloudfall/project.py +841 -0
- cloudfall/py.typed +0 -0
- cloudfall/render_api.py +338 -0
- cloudfall/resources.py +67 -0
- cloudfall/secrets.py +262 -0
- cloudfall/service_evidence.py +659 -0
- cloudfall/validation.py +1340 -0
- cloudfall-0.2.0.dist-info/METADATA +408 -0
- cloudfall-0.2.0.dist-info/RECORD +165 -0
- cloudfall-0.2.0.dist-info/WHEEL +4 -0
- cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
- cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
- cloudfall_engine/__init__.py +5 -0
- cloudfall_engine/__main__.py +5 -0
- cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
- cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
- cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
- cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
- cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
- cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
- cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
- cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
- cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
- cloudfall_engine/ansible_inventory.py +253 -0
- cloudfall_engine/artifact.py +258 -0
- cloudfall_engine/cli.py +273 -0
- cloudfall_engine/playbook.py +175 -0
- 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)
|