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/lifecycle.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""Agent-facing component lifecycle operations.
|
|
2
|
+
|
|
3
|
+
The SDK orchestrates deployments through the engine's stable process
|
|
4
|
+
boundaries: the ``cloudfall_engine`` command-line contract for inventory
|
|
5
|
+
rendering and the engine's playbook contract for execution. It never
|
|
6
|
+
imports Ansible or engine internals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import TYPE_CHECKING, cast
|
|
20
|
+
|
|
21
|
+
from jsonschema.exceptions import ValidationError
|
|
22
|
+
|
|
23
|
+
from cloudfall.inventory import PlatformInventory
|
|
24
|
+
from cloudfall.validation import SchemaCatalog, validate_config
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from cloudfall.domain import ReleaseId, ResourceId
|
|
31
|
+
from cloudfall.inventory import ComponentInventory, ServiceInventory
|
|
32
|
+
|
|
33
|
+
_ARTIFACT_SCHEMA = "artifact.schema.json"
|
|
34
|
+
_STEP_TIMEOUT_SECONDS = 3600
|
|
35
|
+
_OUTPUT_TAIL_CHARACTERS = 2000
|
|
36
|
+
_ERROR_COMPONENT_MISSING = "lifecycle_component_missing"
|
|
37
|
+
_ERROR_ARTIFACT_MISSING = "lifecycle_artifact_missing"
|
|
38
|
+
_ERROR_ARTIFACT_METADATA = "lifecycle_artifact_metadata_invalid"
|
|
39
|
+
_ERROR_ARTIFACT_IDENTITY = "lifecycle_artifact_identity_mismatch"
|
|
40
|
+
_ERROR_ARTIFACT_DIGEST = "lifecycle_artifact_digest_mismatch"
|
|
41
|
+
_ERROR_ANSIBLE_MISSING = "lifecycle_ansible_missing"
|
|
42
|
+
_ERROR_ENGINE_MISSING = "lifecycle_engine_missing"
|
|
43
|
+
_ERROR_EXECUTION_FAILED = "lifecycle_execution_failed"
|
|
44
|
+
_ERROR_RECEIPT_MISSING = "lifecycle_receipt_missing"
|
|
45
|
+
_ERROR_SERVICE_MISSING = "lifecycle_service_missing"
|
|
46
|
+
_ERROR_DATABASE_MISSING = "lifecycle_database_missing"
|
|
47
|
+
_ERROR_SOURCE_URL_MISSING = "lifecycle_source_url_missing"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LifecycleError(RuntimeError):
|
|
51
|
+
"""Fail-fast lifecycle error with a stable machine-readable code."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, code: str, message: str) -> None:
|
|
54
|
+
"""Record the failure code and human-readable detail."""
|
|
55
|
+
self.code = code
|
|
56
|
+
self.detail = message
|
|
57
|
+
super().__init__(f"{code}: {message}")
|
|
58
|
+
|
|
59
|
+
def as_dict(self) -> dict[str, object]:
|
|
60
|
+
"""Serialize the error envelope for system boundaries."""
|
|
61
|
+
return {
|
|
62
|
+
"status": "error",
|
|
63
|
+
"error": {"code": self.code, "message": self.detail},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class ExecutionStep:
|
|
69
|
+
"""One subprocess invocation with explicit environment additions."""
|
|
70
|
+
|
|
71
|
+
description: str
|
|
72
|
+
argv: tuple[str, ...]
|
|
73
|
+
environment: Mapping[str, str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class ExecutionPlan:
|
|
78
|
+
"""Ordered subprocess steps for one lifecycle action."""
|
|
79
|
+
|
|
80
|
+
action: str
|
|
81
|
+
component_id: ResourceId
|
|
82
|
+
release: ReleaseId | None
|
|
83
|
+
steps: tuple[ExecutionStep, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class DeployOptions:
|
|
88
|
+
"""Optional deployment inputs."""
|
|
89
|
+
|
|
90
|
+
environment_file: Path | None = None
|
|
91
|
+
receipt_directory: Path | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class VerifiedArtifact:
|
|
96
|
+
"""A release artifact whose metadata and digest have been verified."""
|
|
97
|
+
|
|
98
|
+
archive_path: Path
|
|
99
|
+
metadata_path: Path
|
|
100
|
+
sha256: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class LifecycleResult:
|
|
105
|
+
"""Structured outcome of one lifecycle action."""
|
|
106
|
+
|
|
107
|
+
action: str
|
|
108
|
+
component_id: ResourceId
|
|
109
|
+
release: ReleaseId | None
|
|
110
|
+
servers: tuple[ResourceId, ...]
|
|
111
|
+
healthy: bool
|
|
112
|
+
detail: str | None
|
|
113
|
+
receipt: Path | None
|
|
114
|
+
|
|
115
|
+
def as_dict(self) -> dict[str, object]:
|
|
116
|
+
"""Serialize the result for agent consumers."""
|
|
117
|
+
result: dict[str, object] = {
|
|
118
|
+
"status": "ok" if self.healthy else "unhealthy",
|
|
119
|
+
"action": self.action,
|
|
120
|
+
"component": self.component_id.value,
|
|
121
|
+
"servers": [server.value for server in self.servers],
|
|
122
|
+
"healthy": self.healthy,
|
|
123
|
+
}
|
|
124
|
+
if self.release is not None:
|
|
125
|
+
result["release"] = self.release.value
|
|
126
|
+
if self.detail is not None:
|
|
127
|
+
result["detail"] = self.detail
|
|
128
|
+
if self.receipt is not None:
|
|
129
|
+
result["receipt"] = str(self.receipt)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class PreviewSubject(StrEnum):
|
|
134
|
+
"""Kind of declared resource a previewed action targets."""
|
|
135
|
+
|
|
136
|
+
COMPONENT = "component"
|
|
137
|
+
SERVICE = "service"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class LifecyclePreview:
|
|
142
|
+
"""A server-changing action validated against the config, not executed.
|
|
143
|
+
|
|
144
|
+
Previews run every controller-side check the action itself runs, so an
|
|
145
|
+
action that previews cleanly fails only on what the servers report.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
action: str
|
|
149
|
+
subject: PreviewSubject
|
|
150
|
+
subject_id: ResourceId
|
|
151
|
+
release: ReleaseId | None
|
|
152
|
+
servers: tuple[ResourceId, ...]
|
|
153
|
+
effect: str
|
|
154
|
+
|
|
155
|
+
def as_dict(self) -> dict[str, object]:
|
|
156
|
+
"""Serialize the preview for agent consumers."""
|
|
157
|
+
result: dict[str, object] = {
|
|
158
|
+
"status": "plan",
|
|
159
|
+
"action": self.action,
|
|
160
|
+
self.subject.value: self.subject_id.value,
|
|
161
|
+
"servers": [server.value for server in self.servers],
|
|
162
|
+
"wouldRun": self.effect,
|
|
163
|
+
}
|
|
164
|
+
if self.release is not None:
|
|
165
|
+
result["release"] = self.release.value
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class EngineContext:
|
|
171
|
+
"""Filesystem contract shared by every lifecycle action."""
|
|
172
|
+
|
|
173
|
+
project_directory: Path
|
|
174
|
+
schema_directory: Path
|
|
175
|
+
engine_directory: Path
|
|
176
|
+
inventory_file: Path
|
|
177
|
+
|
|
178
|
+
def playbook(self, name: str) -> Path:
|
|
179
|
+
"""Return the path of one engine playbook, requiring it to exist."""
|
|
180
|
+
path = self.engine_directory / "ansible" / "playbooks" / name
|
|
181
|
+
if not path.is_file():
|
|
182
|
+
detail = f"engine playbook does not exist: {path}"
|
|
183
|
+
raise LifecycleError(_ERROR_ENGINE_MISSING, detail)
|
|
184
|
+
return path
|
|
185
|
+
|
|
186
|
+
def ansible_environment(self) -> dict[str, str]:
|
|
187
|
+
"""Return environment additions for engine playbook execution."""
|
|
188
|
+
return {
|
|
189
|
+
"ANSIBLE_CONFIG": str(
|
|
190
|
+
self.engine_directory / "ansible" / "ansible.cfg"
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify_artifact(
|
|
196
|
+
artifact_directory: Path,
|
|
197
|
+
component_id: ResourceId,
|
|
198
|
+
release: ReleaseId,
|
|
199
|
+
schema_directory: Path,
|
|
200
|
+
) -> VerifiedArtifact:
|
|
201
|
+
"""Verify a built artifact's metadata, identity, and digest."""
|
|
202
|
+
component_directory = artifact_directory / component_id.value
|
|
203
|
+
archive_path = component_directory / f"{release.value}.tar.gz"
|
|
204
|
+
metadata_path = component_directory / f"{release.value}.json"
|
|
205
|
+
if not archive_path.is_file() or not metadata_path.is_file():
|
|
206
|
+
detail = (
|
|
207
|
+
"artifact archive or metadata does not exist for "
|
|
208
|
+
f"{component_id}/{release.value} under {artifact_directory}"
|
|
209
|
+
)
|
|
210
|
+
raise LifecycleError(_ERROR_ARTIFACT_MISSING, detail)
|
|
211
|
+
|
|
212
|
+
raw = cast(
|
|
213
|
+
"object", json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
214
|
+
)
|
|
215
|
+
if not isinstance(raw, dict) or not all(
|
|
216
|
+
isinstance(key, str) for key in raw
|
|
217
|
+
):
|
|
218
|
+
detail = f"artifact metadata is not an object: {metadata_path}"
|
|
219
|
+
raise LifecycleError(_ERROR_ARTIFACT_METADATA, detail)
|
|
220
|
+
metadata = cast("Mapping[str, object]", raw)
|
|
221
|
+
try:
|
|
222
|
+
SchemaCatalog(schema_directory).validate_named(
|
|
223
|
+
_ARTIFACT_SCHEMA, metadata
|
|
224
|
+
)
|
|
225
|
+
except ValidationError as error:
|
|
226
|
+
detail = f"artifact metadata is invalid: {error.message}"
|
|
227
|
+
raise LifecycleError(_ERROR_ARTIFACT_METADATA, detail) from error
|
|
228
|
+
|
|
229
|
+
spec = cast("Mapping[str, object]", metadata["spec"])
|
|
230
|
+
if (
|
|
231
|
+
spec.get("component") != component_id.value
|
|
232
|
+
or spec.get("release") != release.value
|
|
233
|
+
):
|
|
234
|
+
detail = (
|
|
235
|
+
"artifact metadata identifies "
|
|
236
|
+
f"{spec.get('component')}/{spec.get('release')}, not "
|
|
237
|
+
f"{component_id}/{release.value}"
|
|
238
|
+
)
|
|
239
|
+
raise LifecycleError(_ERROR_ARTIFACT_IDENTITY, detail)
|
|
240
|
+
|
|
241
|
+
declared_digest = spec.get("archiveSha256")
|
|
242
|
+
actual_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
|
243
|
+
if actual_digest != declared_digest:
|
|
244
|
+
detail = (
|
|
245
|
+
f"artifact digest {actual_digest} does not match declared "
|
|
246
|
+
f"metadata digest {declared_digest}"
|
|
247
|
+
)
|
|
248
|
+
raise LifecycleError(_ERROR_ARTIFACT_DIGEST, detail)
|
|
249
|
+
return VerifiedArtifact(
|
|
250
|
+
archive_path=archive_path,
|
|
251
|
+
metadata_path=metadata_path,
|
|
252
|
+
sha256=actual_digest,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def plan_deploy(
|
|
257
|
+
context: EngineContext,
|
|
258
|
+
component_id: ResourceId,
|
|
259
|
+
release: ReleaseId,
|
|
260
|
+
artifact: VerifiedArtifact,
|
|
261
|
+
options: DeployOptions,
|
|
262
|
+
) -> ExecutionPlan:
|
|
263
|
+
"""Compose the subprocess steps for one deployment."""
|
|
264
|
+
extra_vars: dict[str, object] = {
|
|
265
|
+
"cloudfall_deploy_component_id": component_id.value,
|
|
266
|
+
"cloudfall_deploy_release": release.value,
|
|
267
|
+
# The engine resolves relative paths against its own tree, so every
|
|
268
|
+
# controller-side path must cross this boundary absolute.
|
|
269
|
+
"cloudfall_deploy_artifact_archive": str(
|
|
270
|
+
artifact.archive_path.resolve()
|
|
271
|
+
),
|
|
272
|
+
"cloudfall_deploy_artifact_sha256": artifact.sha256,
|
|
273
|
+
}
|
|
274
|
+
if options.environment_file is not None:
|
|
275
|
+
extra_vars["cloudfall_deploy_environment_file"] = str(
|
|
276
|
+
options.environment_file.resolve()
|
|
277
|
+
)
|
|
278
|
+
if options.receipt_directory is not None:
|
|
279
|
+
extra_vars["cloudfall_deploy_receipt_directory"] = str(
|
|
280
|
+
options.receipt_directory.resolve()
|
|
281
|
+
)
|
|
282
|
+
return ExecutionPlan(
|
|
283
|
+
action="deploy",
|
|
284
|
+
component_id=component_id,
|
|
285
|
+
release=release,
|
|
286
|
+
steps=(
|
|
287
|
+
_render_inventory_step(context),
|
|
288
|
+
_playbook_step(context, "deploy.yml", extra_vars),
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def plan_rollback(
|
|
294
|
+
context: EngineContext,
|
|
295
|
+
component_id: ResourceId,
|
|
296
|
+
release: ReleaseId,
|
|
297
|
+
) -> ExecutionPlan:
|
|
298
|
+
"""Compose the subprocess steps for one explicit rollback."""
|
|
299
|
+
extra_vars: dict[str, object] = {
|
|
300
|
+
"cloudfall_deploy_component_id": component_id.value,
|
|
301
|
+
"cloudfall_deploy_release": release.value,
|
|
302
|
+
}
|
|
303
|
+
return ExecutionPlan(
|
|
304
|
+
action="rollback",
|
|
305
|
+
component_id=component_id,
|
|
306
|
+
release=release,
|
|
307
|
+
steps=(
|
|
308
|
+
_render_inventory_step(context),
|
|
309
|
+
_playbook_step(context, "rollback.yml", extra_vars),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def plan_restart(
|
|
315
|
+
context: EngineContext, component_id: ResourceId
|
|
316
|
+
) -> ExecutionPlan:
|
|
317
|
+
"""Compose the subprocess steps for one health-gated restart."""
|
|
318
|
+
extra_vars: dict[str, object] = {
|
|
319
|
+
"cloudfall_deploy_component_id": component_id.value,
|
|
320
|
+
}
|
|
321
|
+
return ExecutionPlan(
|
|
322
|
+
action="restart",
|
|
323
|
+
component_id=component_id,
|
|
324
|
+
release=None,
|
|
325
|
+
steps=(
|
|
326
|
+
_render_inventory_step(context),
|
|
327
|
+
_playbook_step(context, "restart.yml", extra_vars),
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def plan_health(
|
|
333
|
+
context: EngineContext, component_id: ResourceId
|
|
334
|
+
) -> ExecutionPlan:
|
|
335
|
+
"""Compose the subprocess steps for one read-only health check."""
|
|
336
|
+
extra_vars: dict[str, object] = {
|
|
337
|
+
"cloudfall_deploy_component_id": component_id.value,
|
|
338
|
+
}
|
|
339
|
+
return ExecutionPlan(
|
|
340
|
+
action="health",
|
|
341
|
+
component_id=component_id,
|
|
342
|
+
release=None,
|
|
343
|
+
steps=(
|
|
344
|
+
_render_inventory_step(context),
|
|
345
|
+
_playbook_step(context, "health.yml", extra_vars),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def execute_plan(plan: ExecutionPlan) -> None:
|
|
351
|
+
"""Run every step of a plan, failing fast on the first error."""
|
|
352
|
+
_execute_steps(plan.steps)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def run_engine_playbook(
|
|
356
|
+
context: EngineContext,
|
|
357
|
+
playbook_name: str,
|
|
358
|
+
extra_vars: Mapping[str, object],
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Render inventory and run one engine playbook contract."""
|
|
361
|
+
_execute_steps(
|
|
362
|
+
(
|
|
363
|
+
_render_inventory_step(context),
|
|
364
|
+
_playbook_step(context, playbook_name, extra_vars),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def build_release_artifact(
|
|
370
|
+
context: EngineContext,
|
|
371
|
+
component_id: ResourceId,
|
|
372
|
+
git_ref: str,
|
|
373
|
+
artifact_directory: Path,
|
|
374
|
+
) -> dict[str, object]:
|
|
375
|
+
"""Build one release artifact through the engine's CLI contract."""
|
|
376
|
+
argv = (
|
|
377
|
+
sys.executable,
|
|
378
|
+
"-m",
|
|
379
|
+
"cloudfall_engine",
|
|
380
|
+
"artifact",
|
|
381
|
+
"build",
|
|
382
|
+
"--project",
|
|
383
|
+
str(context.project_directory),
|
|
384
|
+
component_id.value,
|
|
385
|
+
"--ref",
|
|
386
|
+
git_ref,
|
|
387
|
+
"--schemas",
|
|
388
|
+
str(context.schema_directory),
|
|
389
|
+
"--output-dir",
|
|
390
|
+
str(artifact_directory),
|
|
391
|
+
)
|
|
392
|
+
step = ExecutionStep(
|
|
393
|
+
description="build release artifact",
|
|
394
|
+
argv=argv,
|
|
395
|
+
environment={},
|
|
396
|
+
)
|
|
397
|
+
output = _execute_step_with_output(step)
|
|
398
|
+
parsed = cast("object", json.loads(output))
|
|
399
|
+
if not isinstance(parsed, dict) or not all(
|
|
400
|
+
isinstance(key, str) for key in parsed
|
|
401
|
+
):
|
|
402
|
+
detail = "artifact builder returned a non-object payload"
|
|
403
|
+
raise LifecycleError(_ERROR_EXECUTION_FAILED, detail)
|
|
404
|
+
return cast("dict[str, object]", parsed)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _execute_steps(steps: tuple[ExecutionStep, ...]) -> None:
|
|
408
|
+
for step in steps:
|
|
409
|
+
_execute_step_with_output(step)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _execute_step_with_output(step: ExecutionStep) -> str:
|
|
413
|
+
environment = {**os.environ, **step.environment}
|
|
414
|
+
try:
|
|
415
|
+
completed = subprocess.run( # noqa: S603 - argv from typed values.
|
|
416
|
+
list(step.argv),
|
|
417
|
+
check=True,
|
|
418
|
+
capture_output=True,
|
|
419
|
+
text=True,
|
|
420
|
+
timeout=_STEP_TIMEOUT_SECONDS,
|
|
421
|
+
env=environment,
|
|
422
|
+
)
|
|
423
|
+
except subprocess.CalledProcessError as error:
|
|
424
|
+
tail = f"{error.stdout}\n{error.stderr}"[-_OUTPUT_TAIL_CHARACTERS:]
|
|
425
|
+
detail = f"{step.description} failed: {tail.strip()}"
|
|
426
|
+
raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) from error
|
|
427
|
+
except subprocess.TimeoutExpired as error:
|
|
428
|
+
detail = (
|
|
429
|
+
f"{step.description} exceeded {_STEP_TIMEOUT_SECONDS} seconds"
|
|
430
|
+
)
|
|
431
|
+
raise LifecycleError(_ERROR_EXECUTION_FAILED, detail) from error
|
|
432
|
+
return completed.stdout
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def deploy(
|
|
436
|
+
context: EngineContext,
|
|
437
|
+
component_id: ResourceId,
|
|
438
|
+
release: ReleaseId,
|
|
439
|
+
artifact_directory: Path,
|
|
440
|
+
options: DeployOptions | None = None,
|
|
441
|
+
) -> LifecycleResult:
|
|
442
|
+
"""Deploy one verified release behind the engine's health gate."""
|
|
443
|
+
resolved_options = options if options is not None else DeployOptions()
|
|
444
|
+
component = _component(context, component_id)
|
|
445
|
+
artifact = verify_artifact(
|
|
446
|
+
artifact_directory, component_id, release, context.schema_directory
|
|
447
|
+
)
|
|
448
|
+
plan = plan_deploy(
|
|
449
|
+
context,
|
|
450
|
+
component_id,
|
|
451
|
+
release,
|
|
452
|
+
artifact,
|
|
453
|
+
resolved_options,
|
|
454
|
+
)
|
|
455
|
+
execute_plan(plan)
|
|
456
|
+
receipt = None
|
|
457
|
+
if resolved_options.receipt_directory is not None:
|
|
458
|
+
receipt = (
|
|
459
|
+
resolved_options.receipt_directory / f"{component_id.value}.json"
|
|
460
|
+
)
|
|
461
|
+
if not receipt.is_file():
|
|
462
|
+
detail = (
|
|
463
|
+
"deployment reported success but wrote no release receipt: "
|
|
464
|
+
f"{receipt}"
|
|
465
|
+
)
|
|
466
|
+
raise LifecycleError(_ERROR_RECEIPT_MISSING, detail)
|
|
467
|
+
return LifecycleResult(
|
|
468
|
+
action="deploy",
|
|
469
|
+
component_id=component_id,
|
|
470
|
+
release=release,
|
|
471
|
+
servers=component.server_ids,
|
|
472
|
+
healthy=True,
|
|
473
|
+
detail=None,
|
|
474
|
+
receipt=receipt,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def preview_deploy(
|
|
479
|
+
context: EngineContext,
|
|
480
|
+
component_id: ResourceId,
|
|
481
|
+
release: ReleaseId,
|
|
482
|
+
artifact_directory: Path,
|
|
483
|
+
) -> LifecyclePreview:
|
|
484
|
+
"""Validate a deployment without touching any server."""
|
|
485
|
+
component = _component(context, component_id)
|
|
486
|
+
verify_artifact(
|
|
487
|
+
artifact_directory, component_id, release, context.schema_directory
|
|
488
|
+
)
|
|
489
|
+
return LifecyclePreview(
|
|
490
|
+
action="deploy",
|
|
491
|
+
subject=PreviewSubject.COMPONENT,
|
|
492
|
+
subject_id=component_id,
|
|
493
|
+
release=release,
|
|
494
|
+
servers=component.server_ids,
|
|
495
|
+
effect=(
|
|
496
|
+
f"deploy release {release.value} of component {component_id} to "
|
|
497
|
+
f"{_server_list(component.server_ids)} behind its health check, "
|
|
498
|
+
"rolling back automatically if the check fails"
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def preview_rollback(
|
|
504
|
+
context: EngineContext, component_id: ResourceId, release: ReleaseId
|
|
505
|
+
) -> LifecyclePreview:
|
|
506
|
+
"""Validate a rollback without touching any server.
|
|
507
|
+
|
|
508
|
+
Whether the release is still retained is known only to the servers; the
|
|
509
|
+
rollback itself fails if it is not.
|
|
510
|
+
"""
|
|
511
|
+
component = _component(context, component_id)
|
|
512
|
+
return LifecyclePreview(
|
|
513
|
+
action="rollback",
|
|
514
|
+
subject=PreviewSubject.COMPONENT,
|
|
515
|
+
subject_id=component_id,
|
|
516
|
+
release=release,
|
|
517
|
+
servers=component.server_ids,
|
|
518
|
+
effect=(
|
|
519
|
+
f"switch component {component_id} on "
|
|
520
|
+
f"{_server_list(component.server_ids)} back to retained release "
|
|
521
|
+
f"{release.value} and restart it behind its health check"
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def preview_restart(
|
|
527
|
+
context: EngineContext, component_id: ResourceId
|
|
528
|
+
) -> LifecyclePreview:
|
|
529
|
+
"""Validate a restart without touching any server."""
|
|
530
|
+
component = _component(context, component_id)
|
|
531
|
+
return LifecyclePreview(
|
|
532
|
+
action="restart",
|
|
533
|
+
subject=PreviewSubject.COMPONENT,
|
|
534
|
+
subject_id=component_id,
|
|
535
|
+
release=None,
|
|
536
|
+
servers=component.server_ids,
|
|
537
|
+
effect=(
|
|
538
|
+
f"restart component {component_id} on "
|
|
539
|
+
f"{_server_list(component.server_ids)} and require its declared "
|
|
540
|
+
"health check"
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def rollback(
|
|
546
|
+
context: EngineContext,
|
|
547
|
+
component_id: ResourceId,
|
|
548
|
+
release: ReleaseId,
|
|
549
|
+
) -> LifecycleResult:
|
|
550
|
+
"""Switch one component back to an existing release, health gated."""
|
|
551
|
+
component = _component(context, component_id)
|
|
552
|
+
plan = plan_rollback(context, component_id, release)
|
|
553
|
+
execute_plan(plan)
|
|
554
|
+
return LifecycleResult(
|
|
555
|
+
action="rollback",
|
|
556
|
+
component_id=component_id,
|
|
557
|
+
release=release,
|
|
558
|
+
servers=component.server_ids,
|
|
559
|
+
healthy=True,
|
|
560
|
+
detail=None,
|
|
561
|
+
receipt=None,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def restart(
|
|
566
|
+
context: EngineContext, component_id: ResourceId
|
|
567
|
+
) -> LifecycleResult:
|
|
568
|
+
"""Restart one component and require its declared health check."""
|
|
569
|
+
component = _component(context, component_id)
|
|
570
|
+
plan = plan_restart(context, component_id)
|
|
571
|
+
execute_plan(plan)
|
|
572
|
+
return LifecycleResult(
|
|
573
|
+
action="restart",
|
|
574
|
+
component_id=component_id,
|
|
575
|
+
release=None,
|
|
576
|
+
servers=component.server_ids,
|
|
577
|
+
healthy=True,
|
|
578
|
+
detail=None,
|
|
579
|
+
receipt=None,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def health(
|
|
584
|
+
context: EngineContext, component_id: ResourceId
|
|
585
|
+
) -> LifecycleResult:
|
|
586
|
+
"""Probe one component's declared health check on every server."""
|
|
587
|
+
component = _component(context, component_id)
|
|
588
|
+
plan = plan_health(context, component_id)
|
|
589
|
+
healthy = True
|
|
590
|
+
detail: str | None = None
|
|
591
|
+
try:
|
|
592
|
+
execute_plan(plan)
|
|
593
|
+
except LifecycleError as error:
|
|
594
|
+
if error.code != _ERROR_EXECUTION_FAILED:
|
|
595
|
+
raise
|
|
596
|
+
healthy = False
|
|
597
|
+
detail = error.detail
|
|
598
|
+
return LifecycleResult(
|
|
599
|
+
action="health",
|
|
600
|
+
component_id=component_id,
|
|
601
|
+
release=None,
|
|
602
|
+
servers=component.server_ids,
|
|
603
|
+
healthy=healthy,
|
|
604
|
+
detail=detail,
|
|
605
|
+
receipt=None,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def plan_data_migration(
|
|
610
|
+
context: EngineContext,
|
|
611
|
+
service_id: ResourceId,
|
|
612
|
+
database: str,
|
|
613
|
+
source_url_file: Path,
|
|
614
|
+
receipt_directory: Path | None,
|
|
615
|
+
) -> ExecutionPlan:
|
|
616
|
+
"""Compose the subprocess steps for one guided data migration.
|
|
617
|
+
|
|
618
|
+
The plan reuses ``ExecutionPlan`` with the service identifier in the
|
|
619
|
+
``component_id`` slot; the source URL never crosses a process argument,
|
|
620
|
+
only the controller-side file path does.
|
|
621
|
+
"""
|
|
622
|
+
extra_vars: dict[str, object] = {
|
|
623
|
+
"cloudfall_data_service_id": service_id.value,
|
|
624
|
+
"cloudfall_data_database": database,
|
|
625
|
+
"cloudfall_data_source_url_file": str(source_url_file.resolve()),
|
|
626
|
+
}
|
|
627
|
+
if receipt_directory is not None:
|
|
628
|
+
extra_vars["cloudfall_data_receipt_directory"] = str(
|
|
629
|
+
receipt_directory.resolve()
|
|
630
|
+
)
|
|
631
|
+
return ExecutionPlan(
|
|
632
|
+
action="data-migration",
|
|
633
|
+
component_id=service_id,
|
|
634
|
+
release=None,
|
|
635
|
+
steps=(
|
|
636
|
+
_render_inventory_step(context),
|
|
637
|
+
_playbook_step(context, "data.yml", extra_vars),
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def migrate_data(
|
|
643
|
+
context: EngineContext,
|
|
644
|
+
service_id: ResourceId,
|
|
645
|
+
database: str,
|
|
646
|
+
source_url_file: Path,
|
|
647
|
+
receipt_directory: Path | None = None,
|
|
648
|
+
) -> dict[str, object]:
|
|
649
|
+
"""Dump one external database and restore it into a declared service."""
|
|
650
|
+
service = _checked_data_migration(context, service_id, database, source_url_file)
|
|
651
|
+
plan = plan_data_migration(
|
|
652
|
+
context, service_id, database, source_url_file, receipt_directory
|
|
653
|
+
)
|
|
654
|
+
execute_plan(plan)
|
|
655
|
+
result: dict[str, object] = {
|
|
656
|
+
"status": "ok",
|
|
657
|
+
"action": "data-migration",
|
|
658
|
+
"service": service_id.value,
|
|
659
|
+
"database": database,
|
|
660
|
+
"servers": [service.server_id.value],
|
|
661
|
+
"verification": "per-table row counts matched",
|
|
662
|
+
}
|
|
663
|
+
if receipt_directory is not None:
|
|
664
|
+
result["receipt"] = str(
|
|
665
|
+
receipt_directory / f"{service_id.value}-{database}.json"
|
|
666
|
+
)
|
|
667
|
+
return result
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def preview_data_migration(
|
|
671
|
+
context: EngineContext,
|
|
672
|
+
service_id: ResourceId,
|
|
673
|
+
database: str,
|
|
674
|
+
source_url_file: Path,
|
|
675
|
+
) -> LifecyclePreview:
|
|
676
|
+
"""Validate a data migration without touching any server."""
|
|
677
|
+
service = _checked_data_migration(context, service_id, database, source_url_file)
|
|
678
|
+
return LifecyclePreview(
|
|
679
|
+
action="data-migration",
|
|
680
|
+
subject=PreviewSubject.SERVICE,
|
|
681
|
+
subject_id=service_id,
|
|
682
|
+
release=None,
|
|
683
|
+
servers=(service.server_id,),
|
|
684
|
+
effect=(
|
|
685
|
+
f"dump the database whose URL is in {source_url_file} on "
|
|
686
|
+
f"{service.server_id} and restore it into declared database "
|
|
687
|
+
f"{database} of service {service_id}, refusing a non-empty target "
|
|
688
|
+
"and verifying per-table row counts"
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _checked_data_migration(
|
|
694
|
+
context: EngineContext,
|
|
695
|
+
service_id: ResourceId,
|
|
696
|
+
database: str,
|
|
697
|
+
source_url_file: Path,
|
|
698
|
+
) -> ServiceInventory:
|
|
699
|
+
service = _postgresql_service(context, service_id)
|
|
700
|
+
postgresql = service.postgresql
|
|
701
|
+
if postgresql is None:
|
|
702
|
+
detail = f"service {service_id} has no PostgreSQL contract"
|
|
703
|
+
raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
|
|
704
|
+
declared = {entry.name.value for entry in postgresql.databases}
|
|
705
|
+
if database not in declared:
|
|
706
|
+
detail = (
|
|
707
|
+
f"database {database!r} is not declared on service "
|
|
708
|
+
f"{service_id}; declared: {sorted(declared)}"
|
|
709
|
+
)
|
|
710
|
+
raise LifecycleError(_ERROR_DATABASE_MISSING, detail)
|
|
711
|
+
if not source_url_file.is_file() or not source_url_file.read_text(
|
|
712
|
+
encoding="utf-8"
|
|
713
|
+
).strip():
|
|
714
|
+
detail = (
|
|
715
|
+
"source URL file does not exist or is empty: "
|
|
716
|
+
f"{source_url_file}"
|
|
717
|
+
)
|
|
718
|
+
raise LifecycleError(_ERROR_SOURCE_URL_MISSING, detail)
|
|
719
|
+
return service
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def backup_service(
|
|
723
|
+
context: EngineContext,
|
|
724
|
+
service_id: ResourceId,
|
|
725
|
+
receipt_directory: Path,
|
|
726
|
+
) -> dict[str, object]:
|
|
727
|
+
"""Run the declared backup for one service and return its receipt."""
|
|
728
|
+
return _backup_operation(context, service_id, receipt_directory, "backup")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def verify_backup(
|
|
732
|
+
context: EngineContext,
|
|
733
|
+
service_id: ResourceId,
|
|
734
|
+
receipt_directory: Path,
|
|
735
|
+
) -> dict[str, object]:
|
|
736
|
+
"""Run the restore-proof check for one service, receipted."""
|
|
737
|
+
return _backup_operation(
|
|
738
|
+
context, service_id, receipt_directory, "restore-check"
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _backup_operation(
|
|
743
|
+
context: EngineContext,
|
|
744
|
+
service_id: ResourceId,
|
|
745
|
+
receipt_directory: Path,
|
|
746
|
+
action: str,
|
|
747
|
+
) -> dict[str, object]:
|
|
748
|
+
state = validate_config(context.project_directory, context.schema_directory)
|
|
749
|
+
inventory = PlatformInventory.from_state(state)
|
|
750
|
+
declared = next(
|
|
751
|
+
(
|
|
752
|
+
service
|
|
753
|
+
for service in inventory.services
|
|
754
|
+
if service.resource_id == service_id
|
|
755
|
+
),
|
|
756
|
+
None,
|
|
757
|
+
)
|
|
758
|
+
if declared is None:
|
|
759
|
+
detail = f"declared service does not exist: {service_id}"
|
|
760
|
+
raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
|
|
761
|
+
run_engine_playbook(
|
|
762
|
+
context,
|
|
763
|
+
"backup.yml",
|
|
764
|
+
{
|
|
765
|
+
"cloudfall_backup_service": service_id.value,
|
|
766
|
+
"cloudfall_backup_action": action,
|
|
767
|
+
"cloudfall_backup_receipt_directory": str(
|
|
768
|
+
receipt_directory.resolve()
|
|
769
|
+
),
|
|
770
|
+
},
|
|
771
|
+
)
|
|
772
|
+
receipt_path = receipt_directory / f"{service_id.value}-{action}.json"
|
|
773
|
+
if not receipt_path.is_file():
|
|
774
|
+
detail = (
|
|
775
|
+
f"no {action} receipt was written for {service_id}; the "
|
|
776
|
+
"service's server produced no result"
|
|
777
|
+
)
|
|
778
|
+
raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
|
|
779
|
+
receipt = cast(
|
|
780
|
+
"dict[str, object]",
|
|
781
|
+
json.loads(receipt_path.read_text(encoding="utf-8")),
|
|
782
|
+
)
|
|
783
|
+
SchemaCatalog(context.schema_directory).validate_named(
|
|
784
|
+
"backup-receipt.schema.json", receipt
|
|
785
|
+
)
|
|
786
|
+
return {"status": "ok", "receipt": receipt, "path": str(receipt_path)}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _postgresql_service(
|
|
790
|
+
context: EngineContext, service_id: ResourceId
|
|
791
|
+
) -> ServiceInventory:
|
|
792
|
+
state = validate_config(context.project_directory, context.schema_directory)
|
|
793
|
+
inventory = PlatformInventory.from_state(state)
|
|
794
|
+
service = next(
|
|
795
|
+
(
|
|
796
|
+
candidate
|
|
797
|
+
for candidate in inventory.services
|
|
798
|
+
if candidate.resource_id == service_id
|
|
799
|
+
),
|
|
800
|
+
None,
|
|
801
|
+
)
|
|
802
|
+
if service is None or service.postgresql is None:
|
|
803
|
+
detail = (
|
|
804
|
+
f"declared PostgreSQL service does not exist: {service_id}"
|
|
805
|
+
)
|
|
806
|
+
raise LifecycleError(_ERROR_SERVICE_MISSING, detail)
|
|
807
|
+
return service
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _component(
|
|
811
|
+
context: EngineContext, component_id: ResourceId
|
|
812
|
+
) -> ComponentInventory:
|
|
813
|
+
state = validate_config(context.project_directory, context.schema_directory)
|
|
814
|
+
inventory = PlatformInventory.from_state(state)
|
|
815
|
+
component = next(
|
|
816
|
+
(
|
|
817
|
+
candidate
|
|
818
|
+
for candidate in inventory.components
|
|
819
|
+
if candidate.resource_id == component_id
|
|
820
|
+
),
|
|
821
|
+
None,
|
|
822
|
+
)
|
|
823
|
+
if component is None:
|
|
824
|
+
detail = f"component does not exist: {component_id}"
|
|
825
|
+
raise LifecycleError(_ERROR_COMPONENT_MISSING, detail)
|
|
826
|
+
return component
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _server_list(servers: tuple[ResourceId, ...]) -> str:
|
|
830
|
+
return ", ".join(server.value for server in servers)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _render_inventory_step(context: EngineContext) -> ExecutionStep:
|
|
834
|
+
return ExecutionStep(
|
|
835
|
+
description="render Ansible inventory",
|
|
836
|
+
argv=(
|
|
837
|
+
sys.executable,
|
|
838
|
+
"-m",
|
|
839
|
+
"cloudfall_engine",
|
|
840
|
+
"inventory",
|
|
841
|
+
"render",
|
|
842
|
+
"--project",
|
|
843
|
+
str(context.project_directory),
|
|
844
|
+
"--schemas",
|
|
845
|
+
str(context.schema_directory),
|
|
846
|
+
"--output",
|
|
847
|
+
str(context.inventory_file),
|
|
848
|
+
),
|
|
849
|
+
environment={},
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _playbook_step(
|
|
854
|
+
context: EngineContext,
|
|
855
|
+
playbook_name: str,
|
|
856
|
+
extra_vars: Mapping[str, object],
|
|
857
|
+
) -> ExecutionStep:
|
|
858
|
+
binary = shutil.which("ansible-playbook")
|
|
859
|
+
if binary is None:
|
|
860
|
+
detail = "ansible-playbook is not installed on this controller"
|
|
861
|
+
raise LifecycleError(_ERROR_ANSIBLE_MISSING, detail)
|
|
862
|
+
playbook = context.playbook(playbook_name)
|
|
863
|
+
return ExecutionStep(
|
|
864
|
+
description=f"run {playbook_name}",
|
|
865
|
+
argv=(
|
|
866
|
+
binary,
|
|
867
|
+
"--inventory",
|
|
868
|
+
str(context.inventory_file),
|
|
869
|
+
"--extra-vars",
|
|
870
|
+
json.dumps(dict(extra_vars), sort_keys=True),
|
|
871
|
+
str(playbook),
|
|
872
|
+
),
|
|
873
|
+
environment=context.ansible_environment(),
|
|
874
|
+
)
|