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/migrate.py
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""Resumable migration orchestrator chaining the platform milestones.
|
|
2
|
+
|
|
3
|
+
One plan drives a migration end to end: server baseline, infrastructure
|
|
4
|
+
services, artifact builds, health-gated deployments, HTTP routes, a DNS
|
|
5
|
+
verification pause, TLS issuance, and a final evidence pass that proves
|
|
6
|
+
the result with inspection, audit compliance, and healthy routes. Step
|
|
7
|
+
progress persists to a plan file, so failed or deliberately paused runs
|
|
8
|
+
resume at the first incomplete step.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from ipaddress import ip_address
|
|
19
|
+
from typing import TYPE_CHECKING, cast
|
|
20
|
+
|
|
21
|
+
from cloudfall.audit import AuditStatus, audit_inventory
|
|
22
|
+
from cloudfall.cutover import (
|
|
23
|
+
SystemDnsProbe,
|
|
24
|
+
check_ttl,
|
|
25
|
+
parallel_run,
|
|
26
|
+
rollback_instructions,
|
|
27
|
+
)
|
|
28
|
+
from cloudfall.domain import ReleaseId, ResourceId, TcpPort
|
|
29
|
+
from cloudfall.inventory import PlatformInventory
|
|
30
|
+
from cloudfall.lifecycle import (
|
|
31
|
+
DeployOptions,
|
|
32
|
+
LifecycleError,
|
|
33
|
+
build_release_artifact,
|
|
34
|
+
deploy,
|
|
35
|
+
migrate_data,
|
|
36
|
+
run_engine_playbook,
|
|
37
|
+
)
|
|
38
|
+
from cloudfall.observation import load_observations
|
|
39
|
+
from cloudfall.operations import (
|
|
40
|
+
OperationsHealth,
|
|
41
|
+
UtcTimestamp,
|
|
42
|
+
build_operations_view,
|
|
43
|
+
)
|
|
44
|
+
from cloudfall.service_evidence import (
|
|
45
|
+
DeploymentReceiptSet,
|
|
46
|
+
DomainObservationSet,
|
|
47
|
+
EvidenceTimestamp,
|
|
48
|
+
SocketDomainNetworkClient,
|
|
49
|
+
inspect_domains,
|
|
50
|
+
load_deployment_receipts,
|
|
51
|
+
load_domain_observations,
|
|
52
|
+
)
|
|
53
|
+
from cloudfall.validation import validate_config
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from collections.abc import Callable, Mapping
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from cloudfall.agent_tools import AgentConfig
|
|
60
|
+
from cloudfall.inventory import DomainInventory, ServerInventory
|
|
61
|
+
|
|
62
|
+
Runner = Callable[[], dict[str, object]]
|
|
63
|
+
|
|
64
|
+
_PLAN_VERSION = 1
|
|
65
|
+
_DNS_PROBE_PORT = 443
|
|
66
|
+
_ERROR_COMPONENT_UNKNOWN = "migrate_component_unknown"
|
|
67
|
+
_ERROR_COMPONENT_UNBUILT = "migrate_component_unbuilt"
|
|
68
|
+
_ERROR_PLAN_STALE = "migrate_plan_stale"
|
|
69
|
+
_ERROR_PLAN_INVALID = "migrate_plan_invalid"
|
|
70
|
+
_ERROR_RELEASE_MISSING = "migrate_release_missing"
|
|
71
|
+
_ERROR_DNS_UNVERIFIED = "migrate_dns_unverified"
|
|
72
|
+
_ERROR_TTL_HIGH = "migrate_ttl_high"
|
|
73
|
+
_ERROR_PARALLEL_RUN = "migrate_parallel_run_failed"
|
|
74
|
+
_MAX_CUTOVER_TTL_SECONDS = 300
|
|
75
|
+
_ROLLBACK_WINDOW_HOURS = 24
|
|
76
|
+
_PAUSE_CODES = frozenset({_ERROR_DNS_UNVERIFIED, _ERROR_TTL_HIGH})
|
|
77
|
+
_ERROR_AUDIT_DRIFT = "migrate_audit_drift"
|
|
78
|
+
_ERROR_ROUTES_UNHEALTHY = "migrate_routes_unhealthy"
|
|
79
|
+
_ERROR_DATABASE_UNKNOWN = "migrate_database_unknown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MigrateError(RuntimeError):
|
|
83
|
+
"""Fail-fast migration error with a stable machine-readable code."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, code: str, message: str) -> None:
|
|
86
|
+
"""Record the failure code and human-readable detail."""
|
|
87
|
+
self.code = code
|
|
88
|
+
self.detail = message
|
|
89
|
+
super().__init__(f"{code}: {message}")
|
|
90
|
+
|
|
91
|
+
def as_dict(self) -> dict[str, object]:
|
|
92
|
+
"""Serialize the error envelope for system boundaries."""
|
|
93
|
+
return {
|
|
94
|
+
"status": "error",
|
|
95
|
+
"error": {"code": self.code, "message": self.detail},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class StepStatus(StrEnum):
|
|
100
|
+
"""Persisted status of one migration step."""
|
|
101
|
+
|
|
102
|
+
PENDING = "pending"
|
|
103
|
+
COMPLETED = "completed"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class PlanStep:
|
|
108
|
+
"""One resumable migration step."""
|
|
109
|
+
|
|
110
|
+
step_id: str
|
|
111
|
+
description: str
|
|
112
|
+
status: StepStatus = StepStatus.PENDING
|
|
113
|
+
completed_at: str | None = None
|
|
114
|
+
detail: dict[str, object] | None = None
|
|
115
|
+
|
|
116
|
+
def as_dict(self) -> dict[str, object]:
|
|
117
|
+
"""Serialize the step for the plan file and result envelopes."""
|
|
118
|
+
result: dict[str, object] = {
|
|
119
|
+
"id": self.step_id,
|
|
120
|
+
"description": self.description,
|
|
121
|
+
"status": self.status.value,
|
|
122
|
+
}
|
|
123
|
+
if self.completed_at is not None:
|
|
124
|
+
result["completedAt"] = self.completed_at
|
|
125
|
+
if self.detail is not None:
|
|
126
|
+
result["detail"] = self.detail
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class MigrateOptions:
|
|
132
|
+
"""Inputs selecting what one migration run builds and deploys."""
|
|
133
|
+
|
|
134
|
+
plan_file: Path
|
|
135
|
+
builds: Mapping[str, str]
|
|
136
|
+
releases: Mapping[str, str]
|
|
137
|
+
environment_files: Mapping[str, Path]
|
|
138
|
+
data_migrations: Mapping[str, Path] = field(default_factory=dict)
|
|
139
|
+
execute: bool = False
|
|
140
|
+
restart: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def execute_migration(
|
|
144
|
+
config: AgentConfig,
|
|
145
|
+
options: MigrateOptions,
|
|
146
|
+
runners: Mapping[str, Runner] | None = None,
|
|
147
|
+
) -> dict[str, object]:
|
|
148
|
+
"""Run or preview the migration plan, resuming persisted progress."""
|
|
149
|
+
state = validate_config(config.project_directory, config.schema_directory)
|
|
150
|
+
inventory = PlatformInventory.from_state(state)
|
|
151
|
+
_require_known_components(inventory, options)
|
|
152
|
+
steps = _load_plan(options, _computed_steps(inventory, options))
|
|
153
|
+
if not options.execute:
|
|
154
|
+
return _envelope("plan", steps)
|
|
155
|
+
|
|
156
|
+
releases = _recover_releases(steps, options)
|
|
157
|
+
resolved_runners = (
|
|
158
|
+
runners
|
|
159
|
+
if runners is not None
|
|
160
|
+
else _build_runners(config, options, inventory, releases, steps)
|
|
161
|
+
)
|
|
162
|
+
for step in steps:
|
|
163
|
+
if step.status is StepStatus.COMPLETED:
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
step.detail = resolved_runners[step.step_id]()
|
|
167
|
+
except (MigrateError, LifecycleError) as error:
|
|
168
|
+
_save_plan(options.plan_file, steps)
|
|
169
|
+
envelope = _envelope(
|
|
170
|
+
"paused"
|
|
171
|
+
if isinstance(error, MigrateError)
|
|
172
|
+
and error.code in _PAUSE_CODES
|
|
173
|
+
else "error",
|
|
174
|
+
steps,
|
|
175
|
+
)
|
|
176
|
+
envelope["step"] = step.step_id
|
|
177
|
+
envelope["error"] = error.as_dict()["error"]
|
|
178
|
+
return envelope
|
|
179
|
+
step.status = StepStatus.COMPLETED
|
|
180
|
+
step.completed_at = datetime.now(tz=UTC).strftime(
|
|
181
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
182
|
+
)
|
|
183
|
+
_save_plan(options.plan_file, steps)
|
|
184
|
+
return _envelope("ok", steps)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _envelope(status: str, steps: list[PlanStep]) -> dict[str, object]:
|
|
188
|
+
pending = [
|
|
189
|
+
step.step_id for step in steps if step.status is StepStatus.PENDING
|
|
190
|
+
]
|
|
191
|
+
envelope: dict[str, object] = {
|
|
192
|
+
"status": status,
|
|
193
|
+
"steps": [step.as_dict() for step in steps],
|
|
194
|
+
"completed": sum(
|
|
195
|
+
1 for step in steps if step.status is StepStatus.COMPLETED
|
|
196
|
+
),
|
|
197
|
+
}
|
|
198
|
+
if pending:
|
|
199
|
+
envelope["next"] = pending[0]
|
|
200
|
+
return envelope
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _computed_steps(
|
|
204
|
+
inventory: PlatformInventory, options: MigrateOptions
|
|
205
|
+
) -> list[PlanStep]:
|
|
206
|
+
steps = [
|
|
207
|
+
PlanStep("baseline", "converge every server to the managed baseline"),
|
|
208
|
+
PlanStep(
|
|
209
|
+
"services", "converge every declared infrastructure service"
|
|
210
|
+
),
|
|
211
|
+
]
|
|
212
|
+
if inventory.domains:
|
|
213
|
+
steps.append(
|
|
214
|
+
PlanStep(
|
|
215
|
+
"ttl-lower",
|
|
216
|
+
"measure authoritative DNS TTLs and require them at or "
|
|
217
|
+
f"below {_MAX_CUTOVER_TTL_SECONDS}s before the cutover",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
steps.extend(
|
|
221
|
+
PlanStep(
|
|
222
|
+
f"data:{database}",
|
|
223
|
+
f"migrate external data into database {database} with "
|
|
224
|
+
"row-count verification",
|
|
225
|
+
)
|
|
226
|
+
for database in sorted(options.data_migrations)
|
|
227
|
+
)
|
|
228
|
+
for component in inventory.components:
|
|
229
|
+
component_id = component.resource_id.value
|
|
230
|
+
if component_id not in options.releases:
|
|
231
|
+
steps.append(
|
|
232
|
+
PlanStep(
|
|
233
|
+
f"build:{component_id}",
|
|
234
|
+
f"build a release artifact for {component_id}",
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
steps.append(
|
|
238
|
+
PlanStep(
|
|
239
|
+
f"deploy:{component_id}",
|
|
240
|
+
f"deploy {component_id} behind its health gate",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
if inventory.domains:
|
|
244
|
+
steps.extend(
|
|
245
|
+
(
|
|
246
|
+
PlanStep(
|
|
247
|
+
"domains-http",
|
|
248
|
+
"render every declared domain route over HTTP",
|
|
249
|
+
),
|
|
250
|
+
PlanStep(
|
|
251
|
+
"parallel-run",
|
|
252
|
+
"prove the new origin serves every declared domain "
|
|
253
|
+
"before any DNS record changes",
|
|
254
|
+
),
|
|
255
|
+
PlanStep(
|
|
256
|
+
"dns-verify",
|
|
257
|
+
"verify public DNS points at the declared proxy servers",
|
|
258
|
+
),
|
|
259
|
+
PlanStep(
|
|
260
|
+
"domains-tls",
|
|
261
|
+
"issue certificates and enable HTTPS routes",
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
steps.extend(
|
|
266
|
+
(
|
|
267
|
+
PlanStep("inspect", "collect read-only server evidence"),
|
|
268
|
+
PlanStep("audit", "require a compliant desired-state audit"),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
if inventory.domains:
|
|
272
|
+
steps.extend(
|
|
273
|
+
(
|
|
274
|
+
PlanStep(
|
|
275
|
+
"verify-routes",
|
|
276
|
+
"require every declared route to be healthy",
|
|
277
|
+
),
|
|
278
|
+
PlanStep(
|
|
279
|
+
"rollback-window",
|
|
280
|
+
"record the pre-switch DNS answers and the explicit "
|
|
281
|
+
f"{_ROLLBACK_WINDOW_HOURS}h rollback recipe",
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return steps
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _require_known_components(
|
|
289
|
+
inventory: PlatformInventory, options: MigrateOptions
|
|
290
|
+
) -> None:
|
|
291
|
+
declared = {component.resource_id.value for component in inventory.components}
|
|
292
|
+
for source_name, mapping in (
|
|
293
|
+
("--build", options.builds),
|
|
294
|
+
("--release", options.releases),
|
|
295
|
+
("--env-file", options.environment_files),
|
|
296
|
+
):
|
|
297
|
+
unknown = sorted(set(mapping) - declared)
|
|
298
|
+
if unknown:
|
|
299
|
+
detail = (
|
|
300
|
+
f"{source_name} references undeclared components: "
|
|
301
|
+
f"{', '.join(unknown)}"
|
|
302
|
+
)
|
|
303
|
+
raise MigrateError(_ERROR_COMPONENT_UNKNOWN, detail)
|
|
304
|
+
unbuilt = sorted(
|
|
305
|
+
declared - set(options.builds) - set(options.releases)
|
|
306
|
+
)
|
|
307
|
+
if unbuilt:
|
|
308
|
+
detail = (
|
|
309
|
+
"declare a git ref (--build component=ref) or a pinned release "
|
|
310
|
+
f"(--release component=id) for: {', '.join(unbuilt)}"
|
|
311
|
+
)
|
|
312
|
+
raise MigrateError(_ERROR_COMPONENT_UNBUILT, detail)
|
|
313
|
+
_require_known_databases(inventory, options)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _require_known_databases(
|
|
317
|
+
inventory: PlatformInventory, options: MigrateOptions
|
|
318
|
+
) -> None:
|
|
319
|
+
declared_databases = {
|
|
320
|
+
database.name.value
|
|
321
|
+
for service in inventory.services
|
|
322
|
+
if service.postgresql is not None
|
|
323
|
+
for database in service.postgresql.databases
|
|
324
|
+
}
|
|
325
|
+
unknown = sorted(set(options.data_migrations) - declared_databases)
|
|
326
|
+
if unknown:
|
|
327
|
+
detail = (
|
|
328
|
+
"--data references databases not declared on any PostgreSQL "
|
|
329
|
+
f"service: {', '.join(unknown)}"
|
|
330
|
+
)
|
|
331
|
+
raise MigrateError(_ERROR_DATABASE_UNKNOWN, detail)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _load_plan(
|
|
335
|
+
options: MigrateOptions, computed: list[PlanStep]
|
|
336
|
+
) -> list[PlanStep]:
|
|
337
|
+
plan_file = options.plan_file
|
|
338
|
+
if options.restart or not plan_file.is_file():
|
|
339
|
+
return computed
|
|
340
|
+
raw = cast("object", json.loads(plan_file.read_text(encoding="utf-8")))
|
|
341
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("steps"), list):
|
|
342
|
+
detail = f"plan file is not a valid migration plan: {plan_file}"
|
|
343
|
+
raise MigrateError(_ERROR_PLAN_INVALID, detail)
|
|
344
|
+
persisted: list[PlanStep] = []
|
|
345
|
+
for raw_step in cast("list[object]", raw["steps"]):
|
|
346
|
+
if not isinstance(raw_step, dict):
|
|
347
|
+
detail = f"plan file contains a malformed step: {plan_file}"
|
|
348
|
+
raise MigrateError(_ERROR_PLAN_INVALID, detail)
|
|
349
|
+
step = cast("dict[str, object]", raw_step)
|
|
350
|
+
raw_detail = step.get("detail")
|
|
351
|
+
persisted.append(
|
|
352
|
+
PlanStep(
|
|
353
|
+
step_id=str(step.get("id")),
|
|
354
|
+
description=str(step.get("description")),
|
|
355
|
+
status=StepStatus(str(step.get("status"))),
|
|
356
|
+
completed_at=(
|
|
357
|
+
str(step["completedAt"])
|
|
358
|
+
if step.get("completedAt") is not None
|
|
359
|
+
else None
|
|
360
|
+
),
|
|
361
|
+
detail=(
|
|
362
|
+
cast("dict[str, object]", raw_detail)
|
|
363
|
+
if isinstance(raw_detail, dict)
|
|
364
|
+
else None
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
if [step.step_id for step in persisted] != [
|
|
369
|
+
step.step_id for step in computed
|
|
370
|
+
]:
|
|
371
|
+
detail = (
|
|
372
|
+
"the persisted plan no longer matches declared state; rerun "
|
|
373
|
+
"with --restart to discard it"
|
|
374
|
+
)
|
|
375
|
+
raise MigrateError(_ERROR_PLAN_STALE, detail)
|
|
376
|
+
return persisted
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _save_plan(plan_file: Path, steps: list[PlanStep]) -> None:
|
|
380
|
+
plan_file.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
payload = {
|
|
382
|
+
"version": _PLAN_VERSION,
|
|
383
|
+
"steps": [step.as_dict() for step in steps],
|
|
384
|
+
}
|
|
385
|
+
plan_file.write_text(
|
|
386
|
+
f"{json.dumps(payload, indent=2, sort_keys=True)}\n",
|
|
387
|
+
encoding="utf-8",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _recover_releases(
|
|
392
|
+
steps: list[PlanStep], options: MigrateOptions
|
|
393
|
+
) -> dict[str, str]:
|
|
394
|
+
releases = dict(options.releases)
|
|
395
|
+
for step in steps:
|
|
396
|
+
if (
|
|
397
|
+
step.step_id.startswith("build:")
|
|
398
|
+
and step.status is StepStatus.COMPLETED
|
|
399
|
+
and step.detail is not None
|
|
400
|
+
and isinstance(step.detail.get("release"), str)
|
|
401
|
+
):
|
|
402
|
+
releases[step.step_id.removeprefix("build:")] = str(
|
|
403
|
+
step.detail["release"]
|
|
404
|
+
)
|
|
405
|
+
return releases
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _build_runners(
|
|
409
|
+
config: AgentConfig,
|
|
410
|
+
options: MigrateOptions,
|
|
411
|
+
inventory: PlatformInventory,
|
|
412
|
+
releases: dict[str, str],
|
|
413
|
+
steps: list[PlanStep],
|
|
414
|
+
) -> dict[str, Runner]:
|
|
415
|
+
context = config.context()
|
|
416
|
+
runners: dict[str, Runner] = {
|
|
417
|
+
"baseline": lambda: _playbook(config, "baseline.yml", {}),
|
|
418
|
+
"services": lambda: _playbook(config, "services.yml", {}),
|
|
419
|
+
"ttl-lower": lambda: _lower_ttl(inventory),
|
|
420
|
+
"parallel-run": lambda: _parallel_run(inventory),
|
|
421
|
+
"rollback-window": lambda: _rollback_window(steps),
|
|
422
|
+
"domains-http": lambda: _playbook(
|
|
423
|
+
config, "domains.yml", _domain_vars(config)
|
|
424
|
+
),
|
|
425
|
+
"dns-verify": lambda: _verify_dns(inventory),
|
|
426
|
+
"domains-tls": lambda: _playbook(
|
|
427
|
+
config,
|
|
428
|
+
"domains.yml",
|
|
429
|
+
{
|
|
430
|
+
**_domain_vars(config),
|
|
431
|
+
"cloudfall_domains_issue_certificates": True,
|
|
432
|
+
},
|
|
433
|
+
),
|
|
434
|
+
"inspect": lambda: _playbook(
|
|
435
|
+
config,
|
|
436
|
+
"inspect.yml",
|
|
437
|
+
{
|
|
438
|
+
"cloudfall_inspect_output_directory": str(
|
|
439
|
+
config.observed_directory.resolve()
|
|
440
|
+
)
|
|
441
|
+
},
|
|
442
|
+
),
|
|
443
|
+
"audit": lambda: _verify_audit(config),
|
|
444
|
+
"verify-routes": lambda: _verify_routes(config),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def _build_runner(component_id: str, git_ref: str) -> Runner:
|
|
448
|
+
def _run() -> dict[str, object]:
|
|
449
|
+
built = build_release_artifact(
|
|
450
|
+
context,
|
|
451
|
+
ResourceId(component_id),
|
|
452
|
+
git_ref,
|
|
453
|
+
config.artifacts_directory,
|
|
454
|
+
)
|
|
455
|
+
release = built.get("release")
|
|
456
|
+
if not isinstance(release, str):
|
|
457
|
+
detail = "artifact builder returned no release id"
|
|
458
|
+
raise MigrateError(_ERROR_RELEASE_MISSING, detail)
|
|
459
|
+
releases[component_id] = release
|
|
460
|
+
return {"release": release, "gitRef": git_ref}
|
|
461
|
+
|
|
462
|
+
return _run
|
|
463
|
+
|
|
464
|
+
def _deploy_runner(component_id: str) -> Runner:
|
|
465
|
+
def _run() -> dict[str, object]:
|
|
466
|
+
release = releases.get(component_id)
|
|
467
|
+
if release is None:
|
|
468
|
+
detail = (
|
|
469
|
+
f"no built or pinned release recorded for {component_id}"
|
|
470
|
+
)
|
|
471
|
+
raise MigrateError(_ERROR_RELEASE_MISSING, detail)
|
|
472
|
+
result = deploy(
|
|
473
|
+
context,
|
|
474
|
+
ResourceId(component_id),
|
|
475
|
+
ReleaseId(release),
|
|
476
|
+
config.artifacts_directory,
|
|
477
|
+
DeployOptions(
|
|
478
|
+
environment_file=options.environment_files.get(
|
|
479
|
+
component_id
|
|
480
|
+
),
|
|
481
|
+
receipt_directory=config.releases_directory,
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
return result.as_dict()
|
|
485
|
+
|
|
486
|
+
return _run
|
|
487
|
+
|
|
488
|
+
for component in inventory.components:
|
|
489
|
+
component_id = component.resource_id.value
|
|
490
|
+
git_ref = options.builds.get(component_id)
|
|
491
|
+
if git_ref is not None:
|
|
492
|
+
runners[f"build:{component_id}"] = _build_runner(
|
|
493
|
+
component_id, git_ref
|
|
494
|
+
)
|
|
495
|
+
runners[f"deploy:{component_id}"] = _deploy_runner(component_id)
|
|
496
|
+
for database, source_url_file in options.data_migrations.items():
|
|
497
|
+
runners[f"data:{database}"] = _data_runner(
|
|
498
|
+
config, inventory, database, source_url_file
|
|
499
|
+
)
|
|
500
|
+
return runners
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _data_runner(
|
|
504
|
+
config: AgentConfig,
|
|
505
|
+
inventory: PlatformInventory,
|
|
506
|
+
database: str,
|
|
507
|
+
source_url_file: Path,
|
|
508
|
+
) -> Runner:
|
|
509
|
+
def _run() -> dict[str, object]:
|
|
510
|
+
service = next(
|
|
511
|
+
service
|
|
512
|
+
for service in inventory.services
|
|
513
|
+
if service.postgresql is not None
|
|
514
|
+
and any(
|
|
515
|
+
entry.name.value == database
|
|
516
|
+
for entry in service.postgresql.databases
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
return migrate_data(
|
|
520
|
+
config.context(),
|
|
521
|
+
service.resource_id,
|
|
522
|
+
database,
|
|
523
|
+
source_url_file,
|
|
524
|
+
config.data_migrations_directory,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
return _run
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _playbook(
|
|
531
|
+
config: AgentConfig,
|
|
532
|
+
playbook_name: str,
|
|
533
|
+
extra_vars: dict[str, object],
|
|
534
|
+
) -> dict[str, object]:
|
|
535
|
+
run_engine_playbook(config.context(), playbook_name, extra_vars)
|
|
536
|
+
return {"playbook": playbook_name}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _domain_vars(config: AgentConfig) -> dict[str, object]:
|
|
540
|
+
return {
|
|
541
|
+
"cloudfall_domains_receipt_directory": str(
|
|
542
|
+
config.deployments_directory.resolve()
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _lower_ttl(inventory: PlatformInventory) -> dict[str, object]:
|
|
548
|
+
detail, offenders = check_ttl(
|
|
549
|
+
inventory, SystemDnsProbe(), _MAX_CUTOVER_TTL_SECONDS
|
|
550
|
+
)
|
|
551
|
+
if offenders:
|
|
552
|
+
message = (
|
|
553
|
+
"lower these DNS TTLs at your provider, wait one old TTL for "
|
|
554
|
+
"propagation, and rerun the migration: " + "; ".join(offenders)
|
|
555
|
+
)
|
|
556
|
+
raise MigrateError(_ERROR_TTL_HIGH, message)
|
|
557
|
+
return detail
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _parallel_run(inventory: PlatformInventory) -> dict[str, object]:
|
|
561
|
+
detail, failures = parallel_run(inventory, SocketDomainNetworkClient())
|
|
562
|
+
if failures:
|
|
563
|
+
message = (
|
|
564
|
+
"the new origin does not serve every declared domain yet; fix "
|
|
565
|
+
"these before touching DNS: " + "; ".join(failures)
|
|
566
|
+
)
|
|
567
|
+
raise MigrateError(_ERROR_PARALLEL_RUN, message)
|
|
568
|
+
return detail
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _rollback_window(steps: list[PlanStep]) -> dict[str, object]:
|
|
572
|
+
previous = next(
|
|
573
|
+
(
|
|
574
|
+
step.detail
|
|
575
|
+
for step in steps
|
|
576
|
+
if step.step_id == "ttl-lower" and step.detail is not None
|
|
577
|
+
),
|
|
578
|
+
None,
|
|
579
|
+
)
|
|
580
|
+
switched_at = next(
|
|
581
|
+
(
|
|
582
|
+
step.completed_at
|
|
583
|
+
for step in steps
|
|
584
|
+
if step.step_id == "dns-verify" and step.completed_at is not None
|
|
585
|
+
),
|
|
586
|
+
None,
|
|
587
|
+
)
|
|
588
|
+
now = datetime.now(tz=UTC)
|
|
589
|
+
switched = switched_at if switched_at is not None else _timestamp(now)
|
|
590
|
+
window_ends = _timestamp(
|
|
591
|
+
now + timedelta(hours=_ROLLBACK_WINDOW_HOURS)
|
|
592
|
+
)
|
|
593
|
+
return rollback_instructions(
|
|
594
|
+
previous if previous is not None else {},
|
|
595
|
+
_ROLLBACK_WINDOW_HOURS,
|
|
596
|
+
switched,
|
|
597
|
+
window_ends,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _timestamp(moment: datetime) -> str:
|
|
602
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _verify_dns(inventory: PlatformInventory) -> dict[str, object]:
|
|
606
|
+
servers = {server.resource_id: server for server in inventory.servers}
|
|
607
|
+
client = SocketDomainNetworkClient()
|
|
608
|
+
verified: list[str] = []
|
|
609
|
+
skipped: list[str] = []
|
|
610
|
+
mismatched: list[str] = []
|
|
611
|
+
for domain in inventory.domains:
|
|
612
|
+
if domain.edge.mode.value != "dns-only":
|
|
613
|
+
skipped.append(domain.primary_name.value)
|
|
614
|
+
continue
|
|
615
|
+
expected = _expected_addresses(servers[domain.proxy.server_id])
|
|
616
|
+
observed = _resolved_addresses(client, domain)
|
|
617
|
+
if expected & observed:
|
|
618
|
+
verified.append(domain.primary_name.value)
|
|
619
|
+
else:
|
|
620
|
+
mismatched.append(
|
|
621
|
+
f"{domain.primary_name.value} resolves to "
|
|
622
|
+
f"{sorted(observed) or 'nothing'} instead of "
|
|
623
|
+
f"{sorted(expected)}"
|
|
624
|
+
)
|
|
625
|
+
if mismatched:
|
|
626
|
+
detail = (
|
|
627
|
+
"lower the DNS TTL, point these records at their declared proxy "
|
|
628
|
+
"servers, wait for propagation, and rerun the migration: "
|
|
629
|
+
+ "; ".join(mismatched)
|
|
630
|
+
)
|
|
631
|
+
raise MigrateError(_ERROR_DNS_UNVERIFIED, detail)
|
|
632
|
+
return {"verified": verified, "skippedProxied": skipped}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _expected_addresses(server: ServerInventory) -> set[str]:
|
|
636
|
+
addresses: set[str] = set()
|
|
637
|
+
try:
|
|
638
|
+
ip_address(server.address.value)
|
|
639
|
+
except ValueError:
|
|
640
|
+
addresses.update(_resolve_names(server.address.value))
|
|
641
|
+
else:
|
|
642
|
+
addresses.add(server.address.value)
|
|
643
|
+
if server.network is not None:
|
|
644
|
+
addresses.add(server.network.ipv4.value)
|
|
645
|
+
return addresses
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _resolved_addresses(
|
|
649
|
+
client: SocketDomainNetworkClient, domain: DomainInventory
|
|
650
|
+
) -> set[str]:
|
|
651
|
+
try:
|
|
652
|
+
resolved = client.resolve(
|
|
653
|
+
domain.primary_name, TcpPort(_DNS_PROBE_PORT)
|
|
654
|
+
)
|
|
655
|
+
except OSError:
|
|
656
|
+
return set()
|
|
657
|
+
return {address.value for address in resolved}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _resolve_names(hostname: str) -> set[str]:
|
|
661
|
+
try:
|
|
662
|
+
records = socket.getaddrinfo(
|
|
663
|
+
hostname, _DNS_PROBE_PORT, type=socket.SOCK_STREAM
|
|
664
|
+
)
|
|
665
|
+
except OSError:
|
|
666
|
+
return set()
|
|
667
|
+
return {str(record[4][0]) for record in records}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _verify_audit(config: AgentConfig) -> dict[str, object]:
|
|
671
|
+
state = validate_config(config.project_directory, config.schema_directory)
|
|
672
|
+
observations = load_observations(
|
|
673
|
+
config.observed_directory, config.schema_directory
|
|
674
|
+
)
|
|
675
|
+
report = audit_inventory(PlatformInventory.from_state(state), observations)
|
|
676
|
+
if report.status is not AuditStatus.COMPLIANT:
|
|
677
|
+
detail = (
|
|
678
|
+
f"the audit reported {report.status.value}; resolve the drift "
|
|
679
|
+
"and rerun the migration"
|
|
680
|
+
)
|
|
681
|
+
raise MigrateError(_ERROR_AUDIT_DRIFT, detail)
|
|
682
|
+
summary = report.as_dict()["summary"]
|
|
683
|
+
return {"summary": cast("dict[str, object]", summary)}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _verify_routes(config: AgentConfig) -> dict[str, object]:
|
|
687
|
+
state = validate_config(config.project_directory, config.schema_directory)
|
|
688
|
+
inventory = PlatformInventory.from_state(state)
|
|
689
|
+
inspect_domains(
|
|
690
|
+
inventory,
|
|
691
|
+
config.service_observed_directory,
|
|
692
|
+
SocketDomainNetworkClient(),
|
|
693
|
+
observed_at=EvidenceTimestamp.now(),
|
|
694
|
+
)
|
|
695
|
+
observations = load_observations(
|
|
696
|
+
config.observed_directory, config.schema_directory
|
|
697
|
+
)
|
|
698
|
+
receipts = (
|
|
699
|
+
load_deployment_receipts(
|
|
700
|
+
config.deployments_directory, config.schema_directory
|
|
701
|
+
)
|
|
702
|
+
if config.deployments_directory.is_dir()
|
|
703
|
+
else DeploymentReceiptSet.empty()
|
|
704
|
+
)
|
|
705
|
+
domain_observations = (
|
|
706
|
+
load_domain_observations(
|
|
707
|
+
config.service_observed_directory, config.schema_directory
|
|
708
|
+
)
|
|
709
|
+
if config.service_observed_directory.is_dir()
|
|
710
|
+
else DomainObservationSet.empty()
|
|
711
|
+
)
|
|
712
|
+
view = build_operations_view(
|
|
713
|
+
inventory,
|
|
714
|
+
observations,
|
|
715
|
+
receipts,
|
|
716
|
+
domain_observations,
|
|
717
|
+
generated_at=UtcTimestamp.now(),
|
|
718
|
+
)
|
|
719
|
+
unhealthy = [
|
|
720
|
+
f"{domain.primary_name}: {domain.health.value} "
|
|
721
|
+
f"({domain.public_route.detail})"
|
|
722
|
+
for domain in view.domains
|
|
723
|
+
if domain.health is not OperationsHealth.HEALTHY
|
|
724
|
+
]
|
|
725
|
+
if unhealthy:
|
|
726
|
+
detail = "routes are not healthy yet: " + "; ".join(unhealthy)
|
|
727
|
+
raise MigrateError(_ERROR_ROUTES_UNHEALTHY, detail)
|
|
728
|
+
return {
|
|
729
|
+
"routes": [domain.primary_name for domain in view.domains],
|
|
730
|
+
}
|