cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
@@ -0,0 +1,659 @@
1
+ """Collect and load evidence for public domain service lifecycles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import socket
8
+ import ssl
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from typing import TYPE_CHECKING, Protocol, cast
12
+
13
+ from jsonschema.exceptions import ValidationError
14
+
15
+ from cloudfall.domain import (
16
+ AbsolutePath,
17
+ ConnectionAddress,
18
+ Hostname,
19
+ HttpScheme,
20
+ HttpStatusCode,
21
+ IpAddress,
22
+ PositiveCount,
23
+ ResourceId,
24
+ Sha256Digest,
25
+ SourceLocation,
26
+ TcpPort,
27
+ )
28
+ from cloudfall.validation import ConfigValidationError, SchemaCatalog, ValidationIssue
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Mapping
32
+ from pathlib import Path
33
+
34
+ from cloudfall.inventory import DomainInventory, PlatformInventory, ServerInventory
35
+
36
+ _DEPLOYMENT_RECEIPT_SCHEMA = "deployment-receipt.schema.json"
37
+ _OBSERVED_DOMAIN_SCHEMA = "observed-domain.schema.json"
38
+ _MAX_ERROR_LENGTH = 500
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class EvidenceTimestamp:
43
+ """UTC timestamp carried by deployment and service evidence."""
44
+
45
+ value: datetime
46
+
47
+ def __post_init__(self) -> None:
48
+ """Require timezone-aware UTC evidence timestamps."""
49
+ if self.value.tzinfo is None or self.value.utcoffset() is None:
50
+ message = "evidence timestamp must include a timezone"
51
+ raise ValueError(message)
52
+
53
+ @classmethod
54
+ def now(cls) -> EvidenceTimestamp:
55
+ """Return the current UTC time."""
56
+ return cls(datetime.now(UTC))
57
+
58
+ @classmethod
59
+ def from_boundary(cls, value: object) -> EvidenceTimestamp:
60
+ """Parse a schema-validated RFC 3339 timestamp."""
61
+ if not isinstance(value, str):
62
+ message = "evidence timestamp must be a string"
63
+ raise TypeError(message)
64
+ parsed = datetime.fromisoformat(value)
65
+ if parsed.tzinfo is None:
66
+ message = "evidence timestamp must include a timezone"
67
+ raise ValueError(message)
68
+ return cls(parsed.astimezone(UTC))
69
+
70
+ def as_string(self) -> str:
71
+ """Serialize canonical UTC RFC 3339."""
72
+ return self.value.astimezone(UTC).isoformat(timespec="seconds").replace(
73
+ "+00:00", "Z"
74
+ )
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class DeploymentReceipt:
79
+ """Evidence that one domain configuration was applied to a proxy server."""
80
+
81
+ domain_id: ResourceId
82
+ server_id: ResourceId
83
+ deployed_at: EvidenceTimestamp
84
+ configuration_path: AbsolutePath
85
+ configuration_sha256: Sha256Digest
86
+ source: Path
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class DeploymentReceiptSet:
91
+ """Deployment receipts indexed by domain identifier."""
92
+
93
+ receipts: tuple[DeploymentReceipt, ...]
94
+ _index: Mapping[ResourceId, DeploymentReceipt]
95
+
96
+ @classmethod
97
+ def empty(cls) -> DeploymentReceiptSet:
98
+ """Return an empty receipt set."""
99
+ return cls(receipts=(), _index={})
100
+
101
+ def for_domain(self, domain_id: ResourceId) -> DeploymentReceipt | None:
102
+ """Return deployment evidence for a domain, if supplied."""
103
+ return self._index.get(domain_id)
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class EndpointEvidence:
108
+ """One HTTP endpoint probe result."""
109
+
110
+ reachable: bool
111
+ status: HttpStatusCode | None
112
+ error: str | None
113
+ skipped: bool = False
114
+ skip_reason: str | None = None
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class TlsEvidence:
119
+ """TLS handshake and certificate evidence."""
120
+
121
+ available: bool
122
+ valid: bool
123
+ expires_at: EvidenceTimestamp | None
124
+ error: str | None
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class ObservedDomainSnapshot:
129
+ """Validated DNS, TLS, origin, and public route evidence."""
130
+
131
+ domain_id: ResourceId
132
+ observed_at: EvidenceTimestamp
133
+ dns_addresses: tuple[IpAddress, ...]
134
+ proxy_address_observed: bool
135
+ edge_detected: bool
136
+ dns_error: str | None
137
+ tls: TlsEvidence
138
+ origin: EndpointEvidence
139
+ public: EndpointEvidence
140
+ source: Path
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class DomainObservationSet:
145
+ """Domain observations indexed by desired domain identifier."""
146
+
147
+ snapshots: tuple[ObservedDomainSnapshot, ...]
148
+ _index: Mapping[ResourceId, ObservedDomainSnapshot]
149
+
150
+ @classmethod
151
+ def empty(cls) -> DomainObservationSet:
152
+ """Return an empty domain observation set."""
153
+ return cls(snapshots=(), _index={})
154
+
155
+ def for_domain(self, domain_id: ResourceId) -> ObservedDomainSnapshot | None:
156
+ """Return route evidence for a domain, if supplied."""
157
+ return self._index.get(domain_id)
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class NetworkResponse:
162
+ """Raw network response used to build persisted route evidence."""
163
+
164
+ endpoint: EndpointEvidence
165
+ tls: TlsEvidence
166
+ headers: Mapping[str, str]
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class ProbeTarget:
171
+ """Strict connection and HTTP request inputs for one endpoint probe."""
172
+
173
+ address: ConnectionAddress
174
+ port: TcpPort
175
+ server_name: Hostname
176
+ scheme: HttpScheme
177
+ path: str
178
+ timeout: PositiveCount
179
+
180
+
181
+ class DomainNetworkClient(Protocol):
182
+ """Network boundary used by domain inspection."""
183
+
184
+ def resolve(self, hostname: Hostname, port: TcpPort) -> tuple[IpAddress, ...]:
185
+ """Resolve every canonical address for a hostname."""
186
+
187
+ def request(self, target: ProbeTarget) -> NetworkResponse:
188
+ """Probe an endpoint with explicit connect address, Host, and TLS SNI."""
189
+ ...
190
+
191
+
192
+ class SocketDomainNetworkClient:
193
+ """Standard-library DNS, TLS, and HTTP network client."""
194
+
195
+ def resolve(self, hostname: Hostname, port: TcpPort) -> tuple[IpAddress, ...]:
196
+ """Resolve deterministic unique IPv4 and IPv6 addresses."""
197
+ records = socket.getaddrinfo(
198
+ hostname.value,
199
+ port.value,
200
+ type=socket.SOCK_STREAM,
201
+ )
202
+ addresses = {
203
+ IpAddress.from_boundary(record[4][0])
204
+ for record in records
205
+ }
206
+ return tuple(sorted(addresses, key=lambda address: address.value))
207
+
208
+ def request(self, target: ProbeTarget) -> NetworkResponse:
209
+ """Perform one certificate-verifying HTTP request."""
210
+ tls = TlsEvidence(available=False, valid=False, expires_at=None, error=None)
211
+ stream: socket.socket | ssl.SSLSocket | None = None
212
+ try:
213
+ stream = socket.create_connection(
214
+ (target.address.value, target.port.value),
215
+ timeout=target.timeout.value,
216
+ )
217
+ if target.scheme is HttpScheme.HTTPS:
218
+ context = ssl.create_default_context()
219
+ stream = context.wrap_socket(
220
+ stream,
221
+ server_hostname=target.server_name.value,
222
+ )
223
+ expires_at = _certificate_expiry(stream.getpeercert())
224
+ tls = TlsEvidence(
225
+ available=True,
226
+ valid=True,
227
+ expires_at=expires_at,
228
+ error=None,
229
+ )
230
+ request = (
231
+ f"GET {target.path} HTTP/1.1\r\n"
232
+ f"Host: {target.server_name.value}\r\n"
233
+ "User-Agent: cloudfall-service-inspect/0.1\r\n"
234
+ "Accept: */*\r\n"
235
+ "Connection: close\r\n\r\n"
236
+ )
237
+ stream.sendall(request.encode("ascii"))
238
+ response = http.client.HTTPResponse(stream)
239
+ response.begin()
240
+ headers = {key.lower(): value for key, value in response.getheaders()}
241
+ endpoint = EndpointEvidence(
242
+ reachable=True,
243
+ status=HttpStatusCode(response.status),
244
+ error=None,
245
+ )
246
+ return NetworkResponse(endpoint=endpoint, tls=tls, headers=headers)
247
+ except (OSError, ValueError, ssl.SSLError, http.client.HTTPException) as error:
248
+ message = _error_message(error)
249
+ return NetworkResponse(
250
+ endpoint=EndpointEvidence(
251
+ reachable=False,
252
+ status=None,
253
+ error=message,
254
+ ),
255
+ tls=TlsEvidence(
256
+ available=tls.available,
257
+ valid=False,
258
+ expires_at=tls.expires_at,
259
+ error=message if target.scheme is HttpScheme.HTTPS else None,
260
+ ),
261
+ headers={},
262
+ )
263
+ finally:
264
+ if stream is not None:
265
+ stream.close()
266
+
267
+
268
+ def inspect_domains(
269
+ inventory: PlatformInventory,
270
+ output_directory: Path,
271
+ client: DomainNetworkClient,
272
+ *,
273
+ observed_at: EvidenceTimestamp,
274
+ ) -> tuple[Path, ...]:
275
+ """Probe every desired domain and write normalized evidence snapshots."""
276
+ output_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
277
+ paths: list[Path] = []
278
+ for domain in inventory.domains:
279
+ content = _inspect_domain(inventory, domain, client, observed_at)
280
+ path = output_directory / f"{domain.resource_id.value}.json"
281
+ _atomic_json_write(path, content)
282
+ paths.append(path)
283
+ return tuple(paths)
284
+
285
+
286
+ def _inspect_domain(
287
+ inventory: PlatformInventory,
288
+ domain: DomainInventory,
289
+ client: DomainNetworkClient,
290
+ observed_at: EvidenceTimestamp,
291
+ ) -> dict[str, object]:
292
+ proxy = _server(inventory, domain.proxy.server_id)
293
+ origin = _server(inventory, domain.origin.server_id)
294
+ dns_error: str | None = None
295
+ try:
296
+ addresses = client.resolve(
297
+ domain.primary_name,
298
+ _public_port(domain.health_check.scheme),
299
+ )
300
+ except OSError as error:
301
+ addresses = ()
302
+ dns_error = _error_message(error)
303
+ proxy_addresses = _server_addresses(proxy, client)
304
+ if domain.origin.server_id == domain.proxy.server_id:
305
+ # A single-host route has no separate origin hop to probe: the
306
+ # public probe already traverses the same proxy and upstream, and
307
+ # the origin port is rightly firewalled from the outside.
308
+ origin_evidence: dict[str, object] = {
309
+ "reachable": False,
310
+ "status": None,
311
+ "error": None,
312
+ "skipped": True,
313
+ "skipReason": (
314
+ "origin shares the proxy host; covered by the public probe"
315
+ ),
316
+ }
317
+ else:
318
+ origin_response = client.request(
319
+ ProbeTarget(
320
+ address=origin.address,
321
+ port=domain.origin.port,
322
+ server_name=domain.origin.server_name,
323
+ scheme=domain.origin.scheme,
324
+ path=domain.health_check.path,
325
+ timeout=domain.health_check.timeout_seconds,
326
+ )
327
+ )
328
+ origin_evidence = _endpoint_dict(origin_response.endpoint)
329
+ public_response = client.request(
330
+ ProbeTarget(
331
+ address=ConnectionAddress(domain.primary_name.value),
332
+ port=_public_port(domain.health_check.scheme),
333
+ server_name=domain.primary_name,
334
+ scheme=domain.health_check.scheme,
335
+ path=domain.health_check.path,
336
+ timeout=domain.health_check.timeout_seconds,
337
+ )
338
+ )
339
+ edge_detected = _edge_detected(domain, public_response.headers)
340
+ return {
341
+ "apiVersion": "cloudfall/v1",
342
+ "kind": "ObservedDomain",
343
+ "metadata": {
344
+ "id": domain.resource_id.value,
345
+ "description": "Read-only domain route evidence collected by Cloudfall",
346
+ },
347
+ "spec": {
348
+ "domain": domain.resource_id.value,
349
+ "observedAt": observed_at.as_string(),
350
+ "dns": {
351
+ "addresses": [address.value for address in addresses],
352
+ "proxyAddressObserved": bool(set(addresses) & set(proxy_addresses)),
353
+ "edgeDetected": edge_detected,
354
+ "error": dns_error,
355
+ },
356
+ "tls": _tls_dict(public_response.tls),
357
+ "origin": origin_evidence,
358
+ "public": _endpoint_dict(public_response.endpoint),
359
+ },
360
+ }
361
+
362
+
363
+ def load_deployment_receipts(
364
+ receipt_directory: Path, schema_directory: Path
365
+ ) -> DeploymentReceiptSet:
366
+ """Load strictly validated deployment receipts."""
367
+ contents = _validated_json_documents(
368
+ receipt_directory,
369
+ schema_directory,
370
+ _DEPLOYMENT_RECEIPT_SCHEMA,
371
+ "deployment receipt",
372
+ )
373
+ receipts: list[DeploymentReceipt] = []
374
+ index: dict[ResourceId, DeploymentReceipt] = {}
375
+ for path, content in contents:
376
+ metadata = _mapping(content, "metadata")
377
+ spec = _mapping(content, "spec")
378
+ domain_id = ResourceId.from_boundary(spec.get("domain"))
379
+ _require_matching_identity(metadata, domain_id, path, "deployment")
380
+ if domain_id in index:
381
+ _raise_duplicate(domain_id, index[domain_id].source, path, "deployment")
382
+ receipt = DeploymentReceipt(
383
+ domain_id=domain_id,
384
+ server_id=ResourceId.from_boundary(spec.get("server")),
385
+ deployed_at=EvidenceTimestamp.from_boundary(spec.get("deployedAt")),
386
+ configuration_path=AbsolutePath.from_boundary(
387
+ spec.get("configurationPath")
388
+ ),
389
+ configuration_sha256=Sha256Digest.from_boundary(
390
+ spec.get("configurationSha256")
391
+ ),
392
+ source=path,
393
+ )
394
+ receipts.append(receipt)
395
+ index[domain_id] = receipt
396
+ return DeploymentReceiptSet(receipts=tuple(receipts), _index=index)
397
+
398
+
399
+ def load_domain_observations(
400
+ observation_directory: Path, schema_directory: Path
401
+ ) -> DomainObservationSet:
402
+ """Load strictly validated public domain observations."""
403
+ contents = _validated_json_documents(
404
+ observation_directory,
405
+ schema_directory,
406
+ _OBSERVED_DOMAIN_SCHEMA,
407
+ "domain observation",
408
+ )
409
+ snapshots: list[ObservedDomainSnapshot] = []
410
+ index: dict[ResourceId, ObservedDomainSnapshot] = {}
411
+ for path, content in contents:
412
+ metadata = _mapping(content, "metadata")
413
+ spec = _mapping(content, "spec")
414
+ domain_id = ResourceId.from_boundary(spec.get("domain"))
415
+ _require_matching_identity(metadata, domain_id, path, "domain observation")
416
+ if domain_id in index:
417
+ _raise_duplicate(domain_id, index[domain_id].source, path, "observation")
418
+ dns = _mapping(spec, "dns")
419
+ tls = _mapping(spec, "tls")
420
+ snapshot = ObservedDomainSnapshot(
421
+ domain_id=domain_id,
422
+ observed_at=EvidenceTimestamp.from_boundary(spec.get("observedAt")),
423
+ dns_addresses=tuple(
424
+ IpAddress.from_boundary(item)
425
+ for item in _sequence(dns.get("addresses"), "DNS addresses")
426
+ ),
427
+ proxy_address_observed=_boolean(dns, "proxyAddressObserved"),
428
+ edge_detected=_boolean(dns, "edgeDetected"),
429
+ dns_error=_optional_string(dns, "error"),
430
+ tls=TlsEvidence(
431
+ available=_boolean(tls, "available"),
432
+ valid=_boolean(tls, "valid"),
433
+ expires_at=_optional_timestamp(tls.get("expiresAt")),
434
+ error=_optional_string(tls, "error"),
435
+ ),
436
+ origin=_endpoint(_mapping(spec, "origin")),
437
+ public=_endpoint(_mapping(spec, "public")),
438
+ source=path,
439
+ )
440
+ snapshots.append(snapshot)
441
+ index[domain_id] = snapshot
442
+ return DomainObservationSet(snapshots=tuple(snapshots), _index=index)
443
+
444
+
445
+ def _validated_json_documents(
446
+ directory: Path,
447
+ schema_directory: Path,
448
+ schema_name: str,
449
+ concept: str,
450
+ ) -> tuple[tuple[Path, Mapping[str, object]], ...]:
451
+ if not directory.is_dir():
452
+ issue = ValidationIssue(
453
+ code=f"{concept.replace(' ', '_')}_directory_missing",
454
+ message=f"{concept} directory does not exist: {directory}",
455
+ )
456
+ raise ConfigValidationError(issue)
457
+ catalog = SchemaCatalog(schema_directory)
458
+ documents: list[tuple[Path, Mapping[str, object]]] = []
459
+ for path in sorted(directory.rglob("*.json")):
460
+ content = _load_json(path)
461
+ try:
462
+ catalog.validate_named(schema_name, content)
463
+ except ValidationError as error:
464
+ issue = ValidationIssue(
465
+ code=f"{concept.replace(' ', '_')}_schema_validation_failed",
466
+ message=error.message,
467
+ source=SourceLocation(path=path, document_number=1),
468
+ field_path=tuple(error.absolute_path),
469
+ )
470
+ raise ConfigValidationError(issue) from error
471
+ documents.append((path, content))
472
+ return tuple(documents)
473
+
474
+
475
+ def _load_json(path: Path) -> Mapping[str, object]:
476
+ try:
477
+ raw = cast("object", json.loads(path.read_text(encoding="utf-8")))
478
+ except json.JSONDecodeError as error:
479
+ issue = ValidationIssue(
480
+ code="service_evidence_json_invalid",
481
+ message=error.msg,
482
+ source=SourceLocation(path=path, document_number=1),
483
+ )
484
+ raise ConfigValidationError(issue) from error
485
+ if not isinstance(raw, dict) or not all(isinstance(key, str) for key in raw):
486
+ issue = ValidationIssue(
487
+ code="service_evidence_shape_invalid",
488
+ message="service evidence root must be an object",
489
+ source=SourceLocation(path=path, document_number=1),
490
+ )
491
+ raise ConfigValidationError(issue)
492
+ return cast("Mapping[str, object]", raw)
493
+
494
+
495
+ def _server(inventory: PlatformInventory, server_id: ResourceId) -> ServerInventory:
496
+ server = next(
497
+ (item for item in inventory.servers if item.resource_id == server_id),
498
+ None,
499
+ )
500
+ if server is None:
501
+ message = f"validated domain server does not exist: {server_id.value}"
502
+ raise KeyError(message)
503
+ return server
504
+
505
+
506
+ def _server_addresses(
507
+ server: ServerInventory, client: DomainNetworkClient
508
+ ) -> tuple[IpAddress, ...]:
509
+ if server.network is not None:
510
+ return (IpAddress(server.network.ipv4.value),)
511
+ try:
512
+ return (IpAddress(server.address.value),)
513
+ except ValueError:
514
+ return client.resolve(Hostname(server.address.value), TcpPort(443))
515
+
516
+
517
+ def _public_port(scheme: HttpScheme) -> TcpPort:
518
+ return TcpPort(443 if scheme is HttpScheme.HTTPS else 80)
519
+
520
+
521
+ def _edge_detected(
522
+ domain: DomainInventory, headers: Mapping[str, str]
523
+ ) -> bool:
524
+ if domain.edge.provider.value != "cloudflare":
525
+ return False
526
+ server = headers.get("server", "").lower()
527
+ return "cf-ray" in headers or server == "cloudflare"
528
+
529
+
530
+ def _endpoint(content: Mapping[str, object]) -> EndpointEvidence:
531
+ raw_status = content.get("status")
532
+ return EndpointEvidence(
533
+ reachable=_boolean(content, "reachable"),
534
+ status=(
535
+ HttpStatusCode.from_boundary(raw_status)
536
+ if raw_status is not None
537
+ else None
538
+ ),
539
+ error=_optional_string(content, "error"),
540
+ skipped=bool(content.get("skipped", False)),
541
+ skip_reason=_optional_string(content, "skipReason"),
542
+ )
543
+
544
+
545
+ def _endpoint_dict(evidence: EndpointEvidence) -> dict[str, object]:
546
+ return {
547
+ "reachable": evidence.reachable,
548
+ "status": evidence.status.value if evidence.status is not None else None,
549
+ "error": evidence.error,
550
+ }
551
+
552
+
553
+ def _tls_dict(evidence: TlsEvidence) -> dict[str, object]:
554
+ return {
555
+ "available": evidence.available,
556
+ "valid": evidence.valid,
557
+ "expiresAt": (
558
+ evidence.expires_at.as_string()
559
+ if evidence.expires_at is not None
560
+ else None
561
+ ),
562
+ "error": evidence.error,
563
+ }
564
+
565
+
566
+ def _mapping(content: Mapping[str, object], key: str) -> Mapping[str, object]:
567
+ value = content.get(key)
568
+ if not isinstance(value, dict) or not all(
569
+ isinstance(child_key, str) for child_key in value
570
+ ):
571
+ message = f"validated field {key!r} is not an object"
572
+ raise TypeError(message)
573
+ return cast("Mapping[str, object]", value)
574
+
575
+
576
+ def _sequence(value: object, concept: str) -> tuple[object, ...]:
577
+ if not isinstance(value, list):
578
+ message = f"validated {concept} is not a list"
579
+ raise TypeError(message)
580
+ return tuple(value)
581
+
582
+
583
+ def _boolean(content: Mapping[str, object], key: str) -> bool:
584
+ value = content.get(key)
585
+ if not isinstance(value, bool):
586
+ message = f"validated field {key!r} is not a boolean"
587
+ raise TypeError(message)
588
+ return value
589
+
590
+
591
+ def _optional_string(content: Mapping[str, object], key: str) -> str | None:
592
+ value = content.get(key)
593
+ if value is None:
594
+ return None
595
+ if not isinstance(value, str):
596
+ message = f"validated field {key!r} is not a string or null"
597
+ raise TypeError(message)
598
+ return value
599
+
600
+
601
+ def _optional_timestamp(value: object) -> EvidenceTimestamp | None:
602
+ return EvidenceTimestamp.from_boundary(value) if value is not None else None
603
+
604
+
605
+ def _require_matching_identity(
606
+ metadata: Mapping[str, object],
607
+ domain_id: ResourceId,
608
+ path: Path,
609
+ concept: str,
610
+ ) -> None:
611
+ metadata_id = ResourceId.from_boundary(metadata.get("id"))
612
+ if metadata_id != domain_id:
613
+ issue = ValidationIssue(
614
+ code=f"{concept.replace(' ', '_')}_identity_mismatch",
615
+ message=f"metadata.id {metadata_id} does not match domain {domain_id}",
616
+ source=SourceLocation(path=path, document_number=1),
617
+ )
618
+ raise ConfigValidationError(issue)
619
+
620
+
621
+ def _raise_duplicate(
622
+ domain_id: ResourceId,
623
+ first_path: Path,
624
+ path: Path,
625
+ concept: str,
626
+ ) -> None:
627
+ issue = ValidationIssue(
628
+ code=f"{concept}_duplicate",
629
+ message=f"domain {domain_id} already supplied at {first_path}",
630
+ source=SourceLocation(path=path, document_number=1),
631
+ )
632
+ raise ConfigValidationError(issue)
633
+
634
+
635
+ def _error_message(error: BaseException) -> str:
636
+ message = str(error) or type(error).__name__
637
+ return message[:_MAX_ERROR_LENGTH]
638
+
639
+
640
+ def _certificate_expiry(certificate: object) -> EvidenceTimestamp | None:
641
+ if not isinstance(certificate, dict):
642
+ message = "TLS peer did not provide a certificate"
643
+ raise TypeError(message)
644
+ not_after = certificate.get("notAfter")
645
+ if not isinstance(not_after, str):
646
+ return None
647
+ return EvidenceTimestamp(
648
+ datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), tz=UTC)
649
+ )
650
+
651
+
652
+ def _atomic_json_write(path: Path, content: Mapping[str, object]) -> None:
653
+ temporary = path.with_suffix(f"{path.suffix}.tmp")
654
+ temporary.write_text(
655
+ f"{json.dumps(content, indent=2, sort_keys=True)}\n",
656
+ encoding="utf-8",
657
+ )
658
+ temporary.chmod(0o600)
659
+ temporary.replace(path)