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
|
@@ -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)
|