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/py.typed
ADDED
|
File without changes
|
cloudfall/render_api.py
ADDED
|
@@ -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)
|