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/inventory.py
ADDED
|
@@ -0,0 +1,2116 @@
|
|
|
1
|
+
"""Typed, read-only inventory projections over validated config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from cloudfall.domain import (
|
|
9
|
+
AbsolutePath,
|
|
10
|
+
AlertDuration,
|
|
11
|
+
AlertSeverity,
|
|
12
|
+
AlertSummary,
|
|
13
|
+
ByteSize,
|
|
14
|
+
ConfigCapture,
|
|
15
|
+
ConnectionAddress,
|
|
16
|
+
DatacenterCode,
|
|
17
|
+
DayTime,
|
|
18
|
+
DeploymentApproval,
|
|
19
|
+
DnsMode,
|
|
20
|
+
EmailAddress,
|
|
21
|
+
FileMode,
|
|
22
|
+
FilesystemName,
|
|
23
|
+
FirewallPolicy,
|
|
24
|
+
HealthCheckType,
|
|
25
|
+
Hostname,
|
|
26
|
+
HttpScheme,
|
|
27
|
+
HttpStatusCode,
|
|
28
|
+
IpAddress,
|
|
29
|
+
Ipv4Address,
|
|
30
|
+
Ipv6NetworkCidr,
|
|
31
|
+
LinuxUser,
|
|
32
|
+
LoggingMigrationMode,
|
|
33
|
+
LoggingStorageType,
|
|
34
|
+
NetworkProtocol,
|
|
35
|
+
OpenSshPublicKey,
|
|
36
|
+
OperatingSystemDistribution,
|
|
37
|
+
OperatingSystemMajorVersion,
|
|
38
|
+
PackageName,
|
|
39
|
+
PackageVersion,
|
|
40
|
+
PositiveCount,
|
|
41
|
+
PostgresDatabaseName,
|
|
42
|
+
PostgresMajorVersion,
|
|
43
|
+
PromqlExpression,
|
|
44
|
+
ProviderServerId,
|
|
45
|
+
RaidLevel,
|
|
46
|
+
RepositoryUrl,
|
|
47
|
+
RequiredServiceState,
|
|
48
|
+
RequiredServiceStatus,
|
|
49
|
+
ResourceDocument,
|
|
50
|
+
ResourceId,
|
|
51
|
+
ResourceKind,
|
|
52
|
+
RuntimePackageManager,
|
|
53
|
+
RuntimeType,
|
|
54
|
+
RuntimeVersion,
|
|
55
|
+
SecretScope,
|
|
56
|
+
ServerLifecycle,
|
|
57
|
+
ServiceCommand,
|
|
58
|
+
ServiceKind,
|
|
59
|
+
ServiceManager,
|
|
60
|
+
ServiceName,
|
|
61
|
+
Sha256Digest,
|
|
62
|
+
SmtpSmarthost,
|
|
63
|
+
SmtpUsername,
|
|
64
|
+
SshPublicKeyLifecycle,
|
|
65
|
+
SystemdCalendar,
|
|
66
|
+
SystemdUnitName,
|
|
67
|
+
TcpPort,
|
|
68
|
+
TlsMode,
|
|
69
|
+
WebhookUrl,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from collections.abc import Mapping
|
|
74
|
+
|
|
75
|
+
from cloudfall.validation import ValidatedConfig
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class ServerLabel:
|
|
80
|
+
"""Typed server label entry."""
|
|
81
|
+
|
|
82
|
+
key: ResourceId
|
|
83
|
+
value: str
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Reject empty label values."""
|
|
87
|
+
if not self.value:
|
|
88
|
+
message = "server label value must not be empty"
|
|
89
|
+
raise ValueError(message)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class ServerProvider:
|
|
94
|
+
"""Infrastructure-provider identity for a server."""
|
|
95
|
+
|
|
96
|
+
name: ResourceId
|
|
97
|
+
server_id: ProviderServerId | None
|
|
98
|
+
datacenter: DatacenterCode | None
|
|
99
|
+
|
|
100
|
+
def as_dict(self) -> dict[str, object]:
|
|
101
|
+
"""Serialize provider identity."""
|
|
102
|
+
result: dict[str, object] = {"name": self.name.value}
|
|
103
|
+
if self.server_id is not None:
|
|
104
|
+
result["serverId"] = self.server_id.value
|
|
105
|
+
if self.datacenter is not None:
|
|
106
|
+
result["datacenter"] = self.datacenter.value
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class ServerNetwork:
|
|
112
|
+
"""Public network allocation and reverse DNS for a server."""
|
|
113
|
+
|
|
114
|
+
ipv4: Ipv4Address
|
|
115
|
+
ipv6_cidr: Ipv6NetworkCidr
|
|
116
|
+
reverse_dns: Hostname
|
|
117
|
+
|
|
118
|
+
def as_dict(self) -> dict[str, object]:
|
|
119
|
+
"""Serialize public network details."""
|
|
120
|
+
return {
|
|
121
|
+
"ipv4": self.ipv4.value,
|
|
122
|
+
"ipv6Cidr": self.ipv6_cidr.value,
|
|
123
|
+
"reverseDns": self.reverse_dns.value,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class ServerInventory:
|
|
129
|
+
"""Connection and placement data for one managed server."""
|
|
130
|
+
|
|
131
|
+
resource_id: ResourceId
|
|
132
|
+
hostname: Hostname
|
|
133
|
+
address: ConnectionAddress
|
|
134
|
+
environment: ResourceId
|
|
135
|
+
server_type_id: ResourceId
|
|
136
|
+
lifecycle: ServerLifecycle
|
|
137
|
+
ssh_user: LinuxUser
|
|
138
|
+
ssh_port: TcpPort
|
|
139
|
+
labels: tuple[ServerLabel, ...]
|
|
140
|
+
provider: ServerProvider | None
|
|
141
|
+
network: ServerNetwork | None
|
|
142
|
+
|
|
143
|
+
def as_dict(self) -> dict[str, object]:
|
|
144
|
+
"""Serialize the server without exposing secrets."""
|
|
145
|
+
result: dict[str, object] = {
|
|
146
|
+
"id": self.resource_id.value,
|
|
147
|
+
"hostname": self.hostname.value,
|
|
148
|
+
"address": self.address.value,
|
|
149
|
+
"environment": self.environment.value,
|
|
150
|
+
"serverType": self.server_type_id.value,
|
|
151
|
+
"lifecycle": self.lifecycle.value,
|
|
152
|
+
"ssh": {
|
|
153
|
+
"user": self.ssh_user.value,
|
|
154
|
+
"port": self.ssh_port.value,
|
|
155
|
+
},
|
|
156
|
+
"labels": {label.key.value: label.value for label in self.labels},
|
|
157
|
+
}
|
|
158
|
+
if self.provider is not None:
|
|
159
|
+
result["provider"] = self.provider.as_dict()
|
|
160
|
+
if self.network is not None:
|
|
161
|
+
result["network"] = self.network.as_dict()
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class OperatingSystemRequirement:
|
|
167
|
+
"""Desired operating-system contract for a server type."""
|
|
168
|
+
|
|
169
|
+
distribution: OperatingSystemDistribution
|
|
170
|
+
versions: tuple[OperatingSystemMajorVersion, ...]
|
|
171
|
+
service_manager: ServiceManager
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True, slots=True)
|
|
175
|
+
class RaidRequirement:
|
|
176
|
+
"""Desired Linux software RAID contract."""
|
|
177
|
+
|
|
178
|
+
level: RaidLevel
|
|
179
|
+
minimum_active_devices: PositiveCount
|
|
180
|
+
minimum_usable_bytes: ByteSize
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class MountRequirement:
|
|
185
|
+
"""Desired mounted filesystem contract."""
|
|
186
|
+
|
|
187
|
+
path: AbsolutePath
|
|
188
|
+
filesystem: FilesystemName
|
|
189
|
+
minimum_bytes: ByteSize
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class PackageRequirement:
|
|
194
|
+
"""Desired installed package and optional exact version."""
|
|
195
|
+
|
|
196
|
+
name: PackageName
|
|
197
|
+
version: PackageVersion | None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class ServiceRequirement:
|
|
202
|
+
"""Desired systemd service- or timer-unit state."""
|
|
203
|
+
|
|
204
|
+
name: SystemdUnitName
|
|
205
|
+
state: RequiredServiceState
|
|
206
|
+
status: RequiredServiceStatus
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class FirewallRule:
|
|
211
|
+
"""One allowed inbound transport-layer endpoint."""
|
|
212
|
+
|
|
213
|
+
port: TcpPort
|
|
214
|
+
protocol: NetworkProtocol
|
|
215
|
+
description: str | None
|
|
216
|
+
|
|
217
|
+
def as_dict(self) -> dict[str, object]:
|
|
218
|
+
"""Serialize the rule for engine and audit consumers."""
|
|
219
|
+
result: dict[str, object] = {
|
|
220
|
+
"port": self.port.value,
|
|
221
|
+
"protocol": self.protocol.value,
|
|
222
|
+
}
|
|
223
|
+
if self.description is not None:
|
|
224
|
+
result["description"] = self.description
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class FirewallRequirement:
|
|
230
|
+
"""Desired default-deny inbound firewall contract."""
|
|
231
|
+
|
|
232
|
+
policy: FirewallPolicy
|
|
233
|
+
allowed_inbound: tuple[FirewallRule, ...]
|
|
234
|
+
|
|
235
|
+
def __post_init__(self) -> None:
|
|
236
|
+
"""Reject duplicate port/protocol rules."""
|
|
237
|
+
seen: set[tuple[int, NetworkProtocol]] = set()
|
|
238
|
+
for rule in self.allowed_inbound:
|
|
239
|
+
identity = (rule.port.value, rule.protocol)
|
|
240
|
+
if identity in seen:
|
|
241
|
+
message = (
|
|
242
|
+
"duplicate firewall rule: "
|
|
243
|
+
f"{rule.port.value}/{rule.protocol.value}"
|
|
244
|
+
)
|
|
245
|
+
raise ValueError(message)
|
|
246
|
+
seen.add(identity)
|
|
247
|
+
|
|
248
|
+
def as_dict(self) -> dict[str, object]:
|
|
249
|
+
"""Serialize the desired firewall contract."""
|
|
250
|
+
return {
|
|
251
|
+
"policy": self.policy.value,
|
|
252
|
+
"allowedInbound": [rule.as_dict() for rule in self.allowed_inbound],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True, slots=True)
|
|
257
|
+
class ConfigurationFileRequirement:
|
|
258
|
+
"""Allowlisted non-secret configuration evidence contract."""
|
|
259
|
+
|
|
260
|
+
path: AbsolutePath
|
|
261
|
+
capture: ConfigCapture
|
|
262
|
+
owner: LinuxUser | None
|
|
263
|
+
group: LinuxUser | None
|
|
264
|
+
mode: FileMode | None
|
|
265
|
+
sha256: Sha256Digest | None
|
|
266
|
+
|
|
267
|
+
def as_dict(self) -> dict[str, object]:
|
|
268
|
+
"""Serialize the safe configuration collection contract."""
|
|
269
|
+
result: dict[str, object] = {
|
|
270
|
+
"path": self.path.value,
|
|
271
|
+
"capture": self.capture.value,
|
|
272
|
+
}
|
|
273
|
+
if self.owner is not None:
|
|
274
|
+
result["owner"] = self.owner.value
|
|
275
|
+
if self.group is not None:
|
|
276
|
+
result["group"] = self.group.value
|
|
277
|
+
if self.mode is not None:
|
|
278
|
+
result["mode"] = self.mode.value
|
|
279
|
+
if self.sha256 is not None:
|
|
280
|
+
result["sha256"] = self.sha256.value
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True, slots=True)
|
|
285
|
+
class ServerTypeInventory:
|
|
286
|
+
"""Reusable desired host contract."""
|
|
287
|
+
|
|
288
|
+
resource_id: ResourceId
|
|
289
|
+
os: OperatingSystemRequirement
|
|
290
|
+
raid: RaidRequirement | None
|
|
291
|
+
mounts: tuple[MountRequirement, ...]
|
|
292
|
+
required_packages: tuple[PackageRequirement, ...]
|
|
293
|
+
forbidden_packages: tuple[PackageName, ...]
|
|
294
|
+
required_services: tuple[ServiceRequirement, ...]
|
|
295
|
+
firewall: FirewallRequirement | None
|
|
296
|
+
configuration_files: tuple[ConfigurationFileRequirement, ...]
|
|
297
|
+
|
|
298
|
+
def as_dict(self) -> dict[str, object]:
|
|
299
|
+
"""Serialize the server_type for inspection and audit consumers."""
|
|
300
|
+
required_packages: list[dict[str, object]] = []
|
|
301
|
+
for package in self.required_packages:
|
|
302
|
+
item: dict[str, object] = {"name": package.name.value}
|
|
303
|
+
if package.version is not None:
|
|
304
|
+
item["version"] = package.version.value
|
|
305
|
+
required_packages.append(item)
|
|
306
|
+
storage: dict[str, object] = {
|
|
307
|
+
"mounts": [
|
|
308
|
+
{
|
|
309
|
+
"path": mount.path.value,
|
|
310
|
+
"filesystem": mount.filesystem.value,
|
|
311
|
+
"minimumBytes": mount.minimum_bytes.value,
|
|
312
|
+
}
|
|
313
|
+
for mount in self.mounts
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
if self.raid is not None:
|
|
317
|
+
storage["softwareRaid"] = {
|
|
318
|
+
"level": self.raid.level.value,
|
|
319
|
+
"minimumActiveDevices": self.raid.minimum_active_devices.value,
|
|
320
|
+
"minimumUsableBytes": self.raid.minimum_usable_bytes.value,
|
|
321
|
+
}
|
|
322
|
+
result: dict[str, object] = {
|
|
323
|
+
"id": self.resource_id.value,
|
|
324
|
+
"os": {
|
|
325
|
+
"distribution": self.os.distribution.value,
|
|
326
|
+
"versions": [version.value for version in self.os.versions],
|
|
327
|
+
"serviceManager": self.os.service_manager.value,
|
|
328
|
+
},
|
|
329
|
+
"storage": storage,
|
|
330
|
+
"packages": {
|
|
331
|
+
"required": required_packages,
|
|
332
|
+
"forbidden": [package.value for package in self.forbidden_packages],
|
|
333
|
+
},
|
|
334
|
+
"services": {
|
|
335
|
+
"required": [
|
|
336
|
+
{
|
|
337
|
+
"name": service.name.value,
|
|
338
|
+
"state": service.state.value,
|
|
339
|
+
"status": service.status.value,
|
|
340
|
+
}
|
|
341
|
+
for service in self.required_services
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
"configuration": {
|
|
345
|
+
"files": [item.as_dict() for item in self.configuration_files]
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
if self.firewall is not None:
|
|
349
|
+
result["firewall"] = self.firewall.as_dict()
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@dataclass(frozen=True, slots=True)
|
|
354
|
+
class SecretReference:
|
|
355
|
+
"""Typed pointer to one secret source; never carries a value."""
|
|
356
|
+
|
|
357
|
+
scope: SecretScope
|
|
358
|
+
environment: ResourceId
|
|
359
|
+
path: AbsolutePath
|
|
360
|
+
|
|
361
|
+
def as_dict(self) -> dict[str, object]:
|
|
362
|
+
"""Serialize the reference itself (references are not secret)."""
|
|
363
|
+
return {
|
|
364
|
+
"scope": self.scope.value,
|
|
365
|
+
"environment": self.environment.value,
|
|
366
|
+
"path": self.path.value,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass(frozen=True, slots=True)
|
|
371
|
+
class ApplicationInventory:
|
|
372
|
+
"""Composition and ownership data for one SaaS application."""
|
|
373
|
+
|
|
374
|
+
resource_id: ResourceId
|
|
375
|
+
linux_user: LinuxUser
|
|
376
|
+
approval: DeploymentApproval
|
|
377
|
+
component_ids: tuple[ResourceId, ...]
|
|
378
|
+
secret_refs: tuple[SecretReference, ...] = ()
|
|
379
|
+
|
|
380
|
+
def as_dict(self) -> dict[str, object]:
|
|
381
|
+
"""Serialize application composition for inventory consumers."""
|
|
382
|
+
return {
|
|
383
|
+
"id": self.resource_id.value,
|
|
384
|
+
"linuxUser": self.linux_user.value,
|
|
385
|
+
"approval": self.approval.value,
|
|
386
|
+
"components": [component.value for component in self.component_ids],
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True, slots=True)
|
|
391
|
+
class ComponentRepository:
|
|
392
|
+
"""Source repository for one deployable component."""
|
|
393
|
+
|
|
394
|
+
url: RepositoryUrl
|
|
395
|
+
subdirectory: str | None
|
|
396
|
+
|
|
397
|
+
def as_dict(self) -> dict[str, object]:
|
|
398
|
+
"""Serialize the repository reference."""
|
|
399
|
+
result: dict[str, object] = {"url": self.url.value}
|
|
400
|
+
if self.subdirectory is not None:
|
|
401
|
+
result["subdirectory"] = self.subdirectory
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@dataclass(frozen=True, slots=True)
|
|
406
|
+
class ComponentRuntime:
|
|
407
|
+
"""Declared runtime and package manager for a component."""
|
|
408
|
+
|
|
409
|
+
runtime_type: RuntimeType
|
|
410
|
+
version: RuntimeVersion
|
|
411
|
+
package_manager: RuntimePackageManager
|
|
412
|
+
|
|
413
|
+
def as_dict(self) -> dict[str, object]:
|
|
414
|
+
"""Serialize the runtime contract."""
|
|
415
|
+
return {
|
|
416
|
+
"type": self.runtime_type.value,
|
|
417
|
+
"version": self.version.value,
|
|
418
|
+
"packageManager": self.package_manager.value,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass(frozen=True, slots=True)
|
|
423
|
+
class ComponentService:
|
|
424
|
+
"""Managed systemd service owned by a component."""
|
|
425
|
+
|
|
426
|
+
manager: ServiceManager
|
|
427
|
+
name: ResourceId
|
|
428
|
+
command: ServiceCommand
|
|
429
|
+
|
|
430
|
+
def as_dict(self) -> dict[str, object]:
|
|
431
|
+
"""Serialize the service contract."""
|
|
432
|
+
return {
|
|
433
|
+
"manager": self.manager.value,
|
|
434
|
+
"name": self.name.value,
|
|
435
|
+
"command": list(self.command.value),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True, slots=True)
|
|
440
|
+
class ComponentHttpHealthCheck:
|
|
441
|
+
"""HTTP endpoint contract for a component health gate."""
|
|
442
|
+
|
|
443
|
+
scheme: HttpScheme
|
|
444
|
+
port: TcpPort
|
|
445
|
+
path: str
|
|
446
|
+
expected_statuses: tuple[HttpStatusCode, ...]
|
|
447
|
+
timeout_seconds: PositiveCount
|
|
448
|
+
attempts: PositiveCount
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@dataclass(frozen=True, slots=True)
|
|
452
|
+
class ComponentHealthCheck:
|
|
453
|
+
"""Health gate for a deployed component."""
|
|
454
|
+
|
|
455
|
+
check_type: HealthCheckType
|
|
456
|
+
http: ComponentHttpHealthCheck | None
|
|
457
|
+
|
|
458
|
+
def __post_init__(self) -> None:
|
|
459
|
+
"""Require the HTTP contract exactly for HTTP health checks."""
|
|
460
|
+
if (self.check_type is HealthCheckType.HTTP) != (self.http is not None):
|
|
461
|
+
message = "HTTP health checks require an HTTP contract"
|
|
462
|
+
raise ValueError(message)
|
|
463
|
+
|
|
464
|
+
def as_dict(self) -> dict[str, object]:
|
|
465
|
+
"""Serialize the health contract."""
|
|
466
|
+
result: dict[str, object] = {"type": self.check_type.value}
|
|
467
|
+
if self.http is not None:
|
|
468
|
+
result.update(
|
|
469
|
+
{
|
|
470
|
+
"scheme": self.http.scheme.value,
|
|
471
|
+
"port": self.http.port.value,
|
|
472
|
+
"path": self.http.path,
|
|
473
|
+
"expectedStatuses": [
|
|
474
|
+
status.value for status in self.http.expected_statuses
|
|
475
|
+
],
|
|
476
|
+
"timeoutSeconds": self.http.timeout_seconds.value,
|
|
477
|
+
"attempts": self.http.attempts.value,
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@dataclass(frozen=True, slots=True)
|
|
484
|
+
class ComponentInventory:
|
|
485
|
+
"""Placement and deployment contract for one deployable component."""
|
|
486
|
+
|
|
487
|
+
resource_id: ResourceId
|
|
488
|
+
application_id: ResourceId
|
|
489
|
+
server_ids: tuple[ResourceId, ...]
|
|
490
|
+
install_root: AbsolutePath
|
|
491
|
+
retain_until_cleanup: bool
|
|
492
|
+
repository: ComponentRepository
|
|
493
|
+
runtime: ComponentRuntime
|
|
494
|
+
service: ComponentService
|
|
495
|
+
health_check: ComponentHealthCheck
|
|
496
|
+
secret_refs: tuple[SecretReference, ...] = ()
|
|
497
|
+
environment: tuple[tuple[str, str], ...] = ()
|
|
498
|
+
|
|
499
|
+
def as_dict(self) -> dict[str, object]:
|
|
500
|
+
"""Serialize the component for inventory consumers."""
|
|
501
|
+
result: dict[str, object] = {
|
|
502
|
+
"id": self.resource_id.value,
|
|
503
|
+
"application": self.application_id.value,
|
|
504
|
+
"servers": [server.value for server in self.server_ids],
|
|
505
|
+
"installRoot": self.install_root.value,
|
|
506
|
+
"retainUntilCleanup": self.retain_until_cleanup,
|
|
507
|
+
"repository": self.repository.as_dict(),
|
|
508
|
+
"runtime": self.runtime.as_dict(),
|
|
509
|
+
"service": self.service.as_dict(),
|
|
510
|
+
"healthCheck": self.health_check.as_dict(),
|
|
511
|
+
}
|
|
512
|
+
if self.environment:
|
|
513
|
+
result["environment"] = dict(self.environment)
|
|
514
|
+
return result
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True, slots=True)
|
|
518
|
+
class DomainProxy:
|
|
519
|
+
"""Desired public proxy placement and configuration evidence path."""
|
|
520
|
+
|
|
521
|
+
server_id: ResourceId
|
|
522
|
+
configuration_path: AbsolutePath
|
|
523
|
+
service_name: ServiceName
|
|
524
|
+
upstream_address: IpAddress | None = None
|
|
525
|
+
upstream_port: TcpPort | None = None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@dataclass(frozen=True, slots=True)
|
|
529
|
+
class DomainOrigin:
|
|
530
|
+
"""Pinned origin endpoint behind a public proxy."""
|
|
531
|
+
|
|
532
|
+
server_id: ResourceId
|
|
533
|
+
configuration_path: AbsolutePath
|
|
534
|
+
service_name: ServiceName
|
|
535
|
+
scheme: HttpScheme
|
|
536
|
+
port: TcpPort
|
|
537
|
+
server_name: Hostname
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@dataclass(frozen=True, slots=True)
|
|
541
|
+
class DomainEdge:
|
|
542
|
+
"""Desired external DNS/edge provider behavior."""
|
|
543
|
+
|
|
544
|
+
provider: ResourceId
|
|
545
|
+
mode: DnsMode
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@dataclass(frozen=True, slots=True)
|
|
549
|
+
class DomainHealthCheck:
|
|
550
|
+
"""End-to-end public and origin health contract."""
|
|
551
|
+
|
|
552
|
+
scheme: HttpScheme
|
|
553
|
+
path: str
|
|
554
|
+
expected_statuses: tuple[HttpStatusCode, ...]
|
|
555
|
+
timeout_seconds: PositiveCount
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass(frozen=True, slots=True)
|
|
559
|
+
class DomainInventory:
|
|
560
|
+
"""Typed desired state for one public domain route."""
|
|
561
|
+
|
|
562
|
+
resource_id: ResourceId
|
|
563
|
+
primary_name: Hostname
|
|
564
|
+
aliases: tuple[Hostname, ...]
|
|
565
|
+
proxy: DomainProxy
|
|
566
|
+
origin: DomainOrigin
|
|
567
|
+
edge: DomainEdge
|
|
568
|
+
tls_mode: TlsMode
|
|
569
|
+
health_check: DomainHealthCheck
|
|
570
|
+
|
|
571
|
+
def as_dict(self) -> dict[str, object]:
|
|
572
|
+
"""Serialize public routing intent without secret material."""
|
|
573
|
+
proxy: dict[str, object] = {
|
|
574
|
+
"server": self.proxy.server_id.value,
|
|
575
|
+
"configurationPath": self.proxy.configuration_path.value,
|
|
576
|
+
"service": self.proxy.service_name.value,
|
|
577
|
+
}
|
|
578
|
+
if (
|
|
579
|
+
self.proxy.upstream_address is not None
|
|
580
|
+
and self.proxy.upstream_port is not None
|
|
581
|
+
):
|
|
582
|
+
proxy["upstream"] = {
|
|
583
|
+
"address": self.proxy.upstream_address.value,
|
|
584
|
+
"port": self.proxy.upstream_port.value,
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
"id": self.resource_id.value,
|
|
588
|
+
"primaryName": self.primary_name.value,
|
|
589
|
+
"aliases": [alias.value for alias in self.aliases],
|
|
590
|
+
"proxy": proxy,
|
|
591
|
+
"origin": {
|
|
592
|
+
"server": self.origin.server_id.value,
|
|
593
|
+
"configurationPath": self.origin.configuration_path.value,
|
|
594
|
+
"service": self.origin.service_name.value,
|
|
595
|
+
"scheme": self.origin.scheme.value,
|
|
596
|
+
"port": self.origin.port.value,
|
|
597
|
+
"serverName": self.origin.server_name.value,
|
|
598
|
+
},
|
|
599
|
+
"edge": {
|
|
600
|
+
"provider": self.edge.provider.value,
|
|
601
|
+
"mode": self.edge.mode.value,
|
|
602
|
+
},
|
|
603
|
+
"tls": {"mode": self.tls_mode.value},
|
|
604
|
+
"healthCheck": {
|
|
605
|
+
"scheme": self.health_check.scheme.value,
|
|
606
|
+
"path": self.health_check.path,
|
|
607
|
+
"expectedStatuses": [
|
|
608
|
+
status.value for status in self.health_check.expected_statuses
|
|
609
|
+
],
|
|
610
|
+
"timeoutSeconds": self.health_check.timeout_seconds.value,
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@dataclass(frozen=True, slots=True)
|
|
616
|
+
class ServiceBind:
|
|
617
|
+
"""Loopback listener contract for an infrastructure service."""
|
|
618
|
+
|
|
619
|
+
address: IpAddress
|
|
620
|
+
port: TcpPort
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@dataclass(frozen=True, slots=True)
|
|
624
|
+
class PostgresDatabase:
|
|
625
|
+
"""One application-owned PostgreSQL database."""
|
|
626
|
+
|
|
627
|
+
name: PostgresDatabaseName
|
|
628
|
+
application_id: ResourceId
|
|
629
|
+
owner: LinuxUser
|
|
630
|
+
|
|
631
|
+
def as_dict(self) -> dict[str, object]:
|
|
632
|
+
"""Serialize the database with its resolved owner role."""
|
|
633
|
+
return {
|
|
634
|
+
"name": self.name.value,
|
|
635
|
+
"application": self.application_id.value,
|
|
636
|
+
"owner": self.owner.value,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@dataclass(frozen=True, slots=True)
|
|
641
|
+
class PostgresqlService:
|
|
642
|
+
"""PostgreSQL-specific service contract."""
|
|
643
|
+
|
|
644
|
+
major_version: PostgresMajorVersion
|
|
645
|
+
package_version: PackageVersion | None
|
|
646
|
+
databases: tuple[PostgresDatabase, ...]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@dataclass(frozen=True, slots=True)
|
|
650
|
+
class RedisService:
|
|
651
|
+
"""Redis-specific service contract."""
|
|
652
|
+
|
|
653
|
+
package_version: PackageVersion | None
|
|
654
|
+
maxmemory_mb: PositiveCount
|
|
655
|
+
append_only: bool
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass(frozen=True, slots=True)
|
|
659
|
+
class ServiceBackup:
|
|
660
|
+
"""Scheduled dump-and-prune backup contract."""
|
|
661
|
+
|
|
662
|
+
directory: AbsolutePath
|
|
663
|
+
on_calendar: SystemdCalendar
|
|
664
|
+
retention_days: PositiveCount
|
|
665
|
+
restore_check_on_calendar: SystemdCalendar | None = None
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@dataclass(frozen=True, slots=True)
|
|
669
|
+
class ServiceInventory:
|
|
670
|
+
"""Typed desired state for one infrastructure service."""
|
|
671
|
+
|
|
672
|
+
resource_id: ResourceId
|
|
673
|
+
service_kind: ServiceKind
|
|
674
|
+
environment: ResourceId
|
|
675
|
+
server_id: ResourceId
|
|
676
|
+
bind: ServiceBind
|
|
677
|
+
postgresql: PostgresqlService | None
|
|
678
|
+
redis: RedisService | None
|
|
679
|
+
backup: ServiceBackup
|
|
680
|
+
metrics_enabled: bool
|
|
681
|
+
|
|
682
|
+
def __post_init__(self) -> None:
|
|
683
|
+
"""Reject services whose payload contradicts the declared kind."""
|
|
684
|
+
expected_postgresql = self.service_kind is ServiceKind.POSTGRESQL
|
|
685
|
+
if (self.postgresql is not None) is not expected_postgresql or (
|
|
686
|
+
self.redis is not None
|
|
687
|
+
) is not (self.service_kind is ServiceKind.REDIS):
|
|
688
|
+
message = (
|
|
689
|
+
"service payload does not match its declared kind: "
|
|
690
|
+
f"{self.resource_id.value}"
|
|
691
|
+
)
|
|
692
|
+
raise ValueError(message)
|
|
693
|
+
|
|
694
|
+
def as_dict(self) -> dict[str, object]:
|
|
695
|
+
"""Serialize the service without secret material."""
|
|
696
|
+
backup: dict[str, object] = {
|
|
697
|
+
"directory": self.backup.directory.value,
|
|
698
|
+
"onCalendar": self.backup.on_calendar.value,
|
|
699
|
+
"retentionDays": self.backup.retention_days.value,
|
|
700
|
+
}
|
|
701
|
+
if self.backup.restore_check_on_calendar is not None:
|
|
702
|
+
backup["restoreCheckOnCalendar"] = (
|
|
703
|
+
self.backup.restore_check_on_calendar.value
|
|
704
|
+
)
|
|
705
|
+
result: dict[str, object] = {
|
|
706
|
+
"id": self.resource_id.value,
|
|
707
|
+
"serviceKind": self.service_kind.value,
|
|
708
|
+
"environment": self.environment.value,
|
|
709
|
+
"server": self.server_id.value,
|
|
710
|
+
"bind": {
|
|
711
|
+
"address": self.bind.address.value,
|
|
712
|
+
"port": self.bind.port.value,
|
|
713
|
+
},
|
|
714
|
+
"backup": backup,
|
|
715
|
+
}
|
|
716
|
+
if self.postgresql is not None:
|
|
717
|
+
postgresql: dict[str, object] = {
|
|
718
|
+
"majorVersion": self.postgresql.major_version.value,
|
|
719
|
+
"databases": [
|
|
720
|
+
database.as_dict()
|
|
721
|
+
for database in self.postgresql.databases
|
|
722
|
+
],
|
|
723
|
+
}
|
|
724
|
+
if self.postgresql.package_version is not None:
|
|
725
|
+
postgresql["packageVersion"] = (
|
|
726
|
+
self.postgresql.package_version.value
|
|
727
|
+
)
|
|
728
|
+
result["postgresql"] = postgresql
|
|
729
|
+
if self.redis is not None:
|
|
730
|
+
redis: dict[str, object] = {
|
|
731
|
+
"maxmemoryMb": self.redis.maxmemory_mb.value,
|
|
732
|
+
"appendOnly": self.redis.append_only,
|
|
733
|
+
}
|
|
734
|
+
if self.redis.package_version is not None:
|
|
735
|
+
redis["packageVersion"] = self.redis.package_version.value
|
|
736
|
+
result["redis"] = redis
|
|
737
|
+
if self.metrics_enabled:
|
|
738
|
+
result["metrics"] = {"enabled": True}
|
|
739
|
+
return result
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True, slots=True)
|
|
743
|
+
class AlertRuleInventory:
|
|
744
|
+
"""Typed desired state for one declared alert rule."""
|
|
745
|
+
|
|
746
|
+
resource_id: ResourceId
|
|
747
|
+
environment: ResourceId
|
|
748
|
+
expr: PromqlExpression
|
|
749
|
+
for_duration: AlertDuration
|
|
750
|
+
severity: AlertSeverity
|
|
751
|
+
summary: AlertSummary
|
|
752
|
+
|
|
753
|
+
def as_dict(self) -> dict[str, object]:
|
|
754
|
+
"""Serialize the alert rule for rendering and evidence."""
|
|
755
|
+
return {
|
|
756
|
+
"id": self.resource_id.value,
|
|
757
|
+
"environment": self.environment.value,
|
|
758
|
+
"expr": self.expr.value,
|
|
759
|
+
"for": self.for_duration.value,
|
|
760
|
+
"severity": self.severity.value,
|
|
761
|
+
"summary": self.summary.value,
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@dataclass(frozen=True, slots=True)
|
|
766
|
+
class AutonomyGrant:
|
|
767
|
+
"""Autonomy earned per operation kind after enough verified runs."""
|
|
768
|
+
|
|
769
|
+
operation_kind: str
|
|
770
|
+
required_verified_runs: PositiveCount
|
|
771
|
+
|
|
772
|
+
def as_dict(self) -> dict[str, object]:
|
|
773
|
+
"""Serialize the grant for policy consumers."""
|
|
774
|
+
return {
|
|
775
|
+
"kind": self.operation_kind,
|
|
776
|
+
"requiredVerifiedRuns": self.required_verified_runs.value,
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@dataclass(frozen=True, slots=True)
|
|
781
|
+
class QuietHours:
|
|
782
|
+
"""Wall-clock window during which autonomy is suspended."""
|
|
783
|
+
|
|
784
|
+
start: DayTime
|
|
785
|
+
end: DayTime
|
|
786
|
+
|
|
787
|
+
def contains(self, minutes_since_midnight: int) -> bool:
|
|
788
|
+
"""Return whether the window covers the given wall-clock minute."""
|
|
789
|
+
start = self.start.minutes
|
|
790
|
+
end = self.end.minutes
|
|
791
|
+
if start <= end:
|
|
792
|
+
return start <= minutes_since_midnight < end
|
|
793
|
+
return minutes_since_midnight >= start or minutes_since_midnight < end
|
|
794
|
+
|
|
795
|
+
def as_dict(self) -> dict[str, object]:
|
|
796
|
+
"""Serialize the window for policy consumers."""
|
|
797
|
+
return {"start": self.start.value, "end": self.end.value}
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@dataclass(frozen=True, slots=True)
|
|
801
|
+
class OperatorPolicyInventory:
|
|
802
|
+
"""Declared autonomy bounds for one environment's operator."""
|
|
803
|
+
|
|
804
|
+
resource_id: ResourceId
|
|
805
|
+
environment: ResourceId
|
|
806
|
+
grants: tuple[AutonomyGrant, ...]
|
|
807
|
+
max_autonomous_per_hour: PositiveCount
|
|
808
|
+
quiet_hours: QuietHours | None
|
|
809
|
+
|
|
810
|
+
def grant_for(self, operation_kind: str) -> AutonomyGrant | None:
|
|
811
|
+
"""Return the declared grant for one operation kind, if any."""
|
|
812
|
+
return next(
|
|
813
|
+
(
|
|
814
|
+
grant
|
|
815
|
+
for grant in self.grants
|
|
816
|
+
if grant.operation_kind == operation_kind
|
|
817
|
+
),
|
|
818
|
+
None,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
def as_dict(self) -> dict[str, object]:
|
|
822
|
+
"""Serialize the policy without secret material."""
|
|
823
|
+
autonomy: dict[str, object] = {
|
|
824
|
+
"operations": [grant.as_dict() for grant in self.grants],
|
|
825
|
+
"maxAutonomousPerHour": self.max_autonomous_per_hour.value,
|
|
826
|
+
}
|
|
827
|
+
if self.quiet_hours is not None:
|
|
828
|
+
autonomy["quietHours"] = self.quiet_hours.as_dict()
|
|
829
|
+
return {
|
|
830
|
+
"id": self.resource_id.value,
|
|
831
|
+
"environment": self.environment.value,
|
|
832
|
+
"autonomy": autonomy,
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@dataclass(frozen=True, slots=True)
|
|
837
|
+
class SshPublicKeyInventory:
|
|
838
|
+
"""Validated public key scoped to one Cloudfall environment."""
|
|
839
|
+
|
|
840
|
+
resource_id: ResourceId
|
|
841
|
+
owner: ResourceId
|
|
842
|
+
environment: ResourceId
|
|
843
|
+
public_key: OpenSshPublicKey
|
|
844
|
+
lifecycle: SshPublicKeyLifecycle
|
|
845
|
+
|
|
846
|
+
@property
|
|
847
|
+
def is_active(self) -> bool:
|
|
848
|
+
"""Return whether the key may be offered to execution consumers."""
|
|
849
|
+
return self.lifecycle is SshPublicKeyLifecycle.ACTIVE
|
|
850
|
+
|
|
851
|
+
def as_dict(self) -> dict[str, object]:
|
|
852
|
+
"""Serialize non-secret SSH public-key state."""
|
|
853
|
+
return {
|
|
854
|
+
"id": self.resource_id.value,
|
|
855
|
+
"owner": self.owner.value,
|
|
856
|
+
"environment": self.environment.value,
|
|
857
|
+
"algorithm": self.public_key.algorithm,
|
|
858
|
+
"publicKey": self.public_key.value,
|
|
859
|
+
"lifecycle": self.lifecycle.value,
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@dataclass(frozen=True, slots=True)
|
|
864
|
+
class LoggingSoftware:
|
|
865
|
+
"""Pinned package versions for the observability stack."""
|
|
866
|
+
|
|
867
|
+
loki: PackageVersion
|
|
868
|
+
grafana: PackageVersion
|
|
869
|
+
alloy: PackageVersion
|
|
870
|
+
prometheus: PackageVersion | None
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
@dataclass(frozen=True, slots=True)
|
|
874
|
+
class MetricsBackend:
|
|
875
|
+
"""Loopback-only Prometheus backend colocated with the logging backend."""
|
|
876
|
+
|
|
877
|
+
listen_address: IpAddress
|
|
878
|
+
port: TcpPort
|
|
879
|
+
storage_path: AbsolutePath
|
|
880
|
+
retention_hours: PositiveCount
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@dataclass(frozen=True, slots=True)
|
|
884
|
+
class MetricsCollection:
|
|
885
|
+
"""Host metrics collection contract for every declared collector."""
|
|
886
|
+
|
|
887
|
+
interval_seconds: PositiveCount
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
@dataclass(frozen=True, slots=True)
|
|
891
|
+
class LoggingMetrics:
|
|
892
|
+
"""Push-only host metrics topology through the mTLS gateway."""
|
|
893
|
+
|
|
894
|
+
backend: MetricsBackend
|
|
895
|
+
collection: MetricsCollection
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@dataclass(frozen=True, slots=True)
|
|
899
|
+
class LoggingBackendStorage:
|
|
900
|
+
"""Loki storage contract for the first single-node slice."""
|
|
901
|
+
|
|
902
|
+
storage_type: LoggingStorageType
|
|
903
|
+
path: AbsolutePath
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
@dataclass(frozen=True, slots=True)
|
|
907
|
+
class LoggingBackend:
|
|
908
|
+
"""Loopback-only Loki backend placement and retention."""
|
|
909
|
+
|
|
910
|
+
server_id: ResourceId
|
|
911
|
+
listen_address: IpAddress
|
|
912
|
+
port: TcpPort
|
|
913
|
+
storage: LoggingBackendStorage
|
|
914
|
+
retention_hours: PositiveCount
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@dataclass(frozen=True, slots=True)
|
|
918
|
+
class LoggingGatewayTls:
|
|
919
|
+
"""Server-side mTLS material paths for the ingestion gateway."""
|
|
920
|
+
|
|
921
|
+
certificate_path: AbsolutePath
|
|
922
|
+
key_path: AbsolutePath
|
|
923
|
+
client_ca_path: AbsolutePath
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@dataclass(frozen=True, slots=True)
|
|
927
|
+
class LoggingGateway:
|
|
928
|
+
"""mTLS-only Loki ingestion endpoint."""
|
|
929
|
+
|
|
930
|
+
server_name: Hostname
|
|
931
|
+
listen_address: IpAddress
|
|
932
|
+
port: TcpPort
|
|
933
|
+
tls: LoggingGatewayTls
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
@dataclass(frozen=True, slots=True)
|
|
937
|
+
class LoggingGrafana:
|
|
938
|
+
"""Loopback-only Grafana endpoint colocated with Loki."""
|
|
939
|
+
|
|
940
|
+
listen_address: IpAddress
|
|
941
|
+
port: TcpPort
|
|
942
|
+
admin_password_path: AbsolutePath
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
@dataclass(frozen=True, slots=True)
|
|
946
|
+
class LoggingClientTls:
|
|
947
|
+
"""Collector-side mTLS material paths."""
|
|
948
|
+
|
|
949
|
+
ca_path: AbsolutePath
|
|
950
|
+
certificate_path: AbsolutePath
|
|
951
|
+
key_path: AbsolutePath
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
@dataclass(frozen=True, slots=True)
|
|
955
|
+
class LoggingJournalSource:
|
|
956
|
+
"""Bounded systemd-journal collection contract."""
|
|
957
|
+
|
|
958
|
+
enabled: bool
|
|
959
|
+
max_age_hours: PositiveCount
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@dataclass(frozen=True, slots=True)
|
|
963
|
+
class LoggingFileSource:
|
|
964
|
+
"""Explicit application log-file source and ownership labels."""
|
|
965
|
+
|
|
966
|
+
resource_id: ResourceId
|
|
967
|
+
path: AbsolutePath
|
|
968
|
+
server_ids: tuple[ResourceId, ...]
|
|
969
|
+
application_id: ResourceId | None
|
|
970
|
+
component_id: ResourceId | None
|
|
971
|
+
|
|
972
|
+
def as_dict(self) -> dict[str, object]:
|
|
973
|
+
"""Serialize one source without including log content."""
|
|
974
|
+
result: dict[str, object] = {
|
|
975
|
+
"id": self.resource_id.value,
|
|
976
|
+
"path": self.path.value,
|
|
977
|
+
"servers": [server.value for server in self.server_ids],
|
|
978
|
+
}
|
|
979
|
+
if self.application_id is not None:
|
|
980
|
+
result["application"] = self.application_id.value
|
|
981
|
+
if self.component_id is not None:
|
|
982
|
+
result["component"] = self.component_id.value
|
|
983
|
+
return result
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
@dataclass(frozen=True, slots=True)
|
|
987
|
+
class EmailReceiverInventory:
|
|
988
|
+
"""SMTP delivery contract for one declared alert receiver."""
|
|
989
|
+
|
|
990
|
+
smarthost: SmtpSmarthost
|
|
991
|
+
sender: EmailAddress
|
|
992
|
+
recipient: EmailAddress
|
|
993
|
+
auth_username: SmtpUsername | None
|
|
994
|
+
auth_password_file: AbsolutePath | None
|
|
995
|
+
|
|
996
|
+
def as_dict(self) -> dict[str, object]:
|
|
997
|
+
"""Serialize the email contract without secret material."""
|
|
998
|
+
result: dict[str, object] = {
|
|
999
|
+
"smarthost": self.smarthost.value,
|
|
1000
|
+
"from": self.sender.value,
|
|
1001
|
+
"to": self.recipient.value,
|
|
1002
|
+
}
|
|
1003
|
+
if self.auth_username is not None:
|
|
1004
|
+
result["authUsername"] = self.auth_username.value
|
|
1005
|
+
if self.auth_password_file is not None:
|
|
1006
|
+
result["authPasswordFile"] = self.auth_password_file.value
|
|
1007
|
+
return result
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
@dataclass(frozen=True, slots=True)
|
|
1011
|
+
class AlertReceiverInventory:
|
|
1012
|
+
"""Exactly one declared alert delivery channel."""
|
|
1013
|
+
|
|
1014
|
+
resource_id: ResourceId
|
|
1015
|
+
webhook: WebhookUrl | None
|
|
1016
|
+
email: EmailReceiverInventory | None
|
|
1017
|
+
|
|
1018
|
+
def __post_init__(self) -> None:
|
|
1019
|
+
"""Reject receivers without exactly one channel."""
|
|
1020
|
+
if (self.webhook is None) == (self.email is None):
|
|
1021
|
+
message = (
|
|
1022
|
+
"alert receiver must declare exactly one channel: "
|
|
1023
|
+
f"{self.resource_id.value}"
|
|
1024
|
+
)
|
|
1025
|
+
raise ValueError(message)
|
|
1026
|
+
|
|
1027
|
+
def as_dict(self) -> dict[str, object]:
|
|
1028
|
+
"""Serialize the receiver for rendering and evidence."""
|
|
1029
|
+
result: dict[str, object] = {"id": self.resource_id.value}
|
|
1030
|
+
if self.webhook is not None:
|
|
1031
|
+
result["webhook"] = {"url": self.webhook.value}
|
|
1032
|
+
if self.email is not None:
|
|
1033
|
+
result["email"] = self.email.as_dict()
|
|
1034
|
+
return result
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
@dataclass(frozen=True, slots=True)
|
|
1038
|
+
class AlertmanagerBackend:
|
|
1039
|
+
"""Loopback-only Alertmanager colocated with the logging backend."""
|
|
1040
|
+
|
|
1041
|
+
listen_address: IpAddress
|
|
1042
|
+
port: TcpPort
|
|
1043
|
+
package_version: PackageVersion | None
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@dataclass(frozen=True, slots=True)
|
|
1047
|
+
class LoggingAlerting:
|
|
1048
|
+
"""Declared alert delivery topology."""
|
|
1049
|
+
|
|
1050
|
+
alertmanager: AlertmanagerBackend
|
|
1051
|
+
receivers: tuple[AlertReceiverInventory, ...]
|
|
1052
|
+
|
|
1053
|
+
def as_dict(self) -> dict[str, object]:
|
|
1054
|
+
"""Serialize the alerting topology without secret material."""
|
|
1055
|
+
alertmanager: dict[str, object] = {
|
|
1056
|
+
"listenAddress": self.alertmanager.listen_address.value,
|
|
1057
|
+
"port": self.alertmanager.port.value,
|
|
1058
|
+
}
|
|
1059
|
+
if self.alertmanager.package_version is not None:
|
|
1060
|
+
alertmanager["packageVersion"] = (
|
|
1061
|
+
self.alertmanager.package_version.value
|
|
1062
|
+
)
|
|
1063
|
+
return {
|
|
1064
|
+
"alertmanager": alertmanager,
|
|
1065
|
+
"receivers": [receiver.as_dict() for receiver in self.receivers],
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
@dataclass(frozen=True, slots=True)
|
|
1070
|
+
class LoggingCollectors:
|
|
1071
|
+
"""Fleet placement and sources for Grafana Alloy."""
|
|
1072
|
+
|
|
1073
|
+
server_ids: tuple[ResourceId, ...]
|
|
1074
|
+
client_tls: LoggingClientTls
|
|
1075
|
+
journal: LoggingJournalSource
|
|
1076
|
+
files: tuple[LoggingFileSource, ...]
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
@dataclass(frozen=True, slots=True)
|
|
1080
|
+
class LoggingStackInventory:
|
|
1081
|
+
"""Safe parallel-migration logging topology."""
|
|
1082
|
+
|
|
1083
|
+
resource_id: ResourceId
|
|
1084
|
+
environment: ResourceId
|
|
1085
|
+
migration_mode: LoggingMigrationMode
|
|
1086
|
+
preserve_legacy_agents: bool
|
|
1087
|
+
software: LoggingSoftware
|
|
1088
|
+
metrics: LoggingMetrics
|
|
1089
|
+
backend: LoggingBackend
|
|
1090
|
+
gateway: LoggingGateway
|
|
1091
|
+
grafana: LoggingGrafana
|
|
1092
|
+
collectors: LoggingCollectors
|
|
1093
|
+
alerting: LoggingAlerting | None
|
|
1094
|
+
|
|
1095
|
+
def as_dict(self) -> dict[str, object]:
|
|
1096
|
+
"""Serialize the logging topology without certificate contents."""
|
|
1097
|
+
software: dict[str, object] = {
|
|
1098
|
+
"lokiPackageVersion": self.software.loki.value,
|
|
1099
|
+
"grafanaPackageVersion": self.software.grafana.value,
|
|
1100
|
+
"alloyPackageVersion": self.software.alloy.value,
|
|
1101
|
+
}
|
|
1102
|
+
if self.software.prometheus is not None:
|
|
1103
|
+
software["prometheusPackageVersion"] = self.software.prometheus.value
|
|
1104
|
+
result: dict[str, object] = {
|
|
1105
|
+
"id": self.resource_id.value,
|
|
1106
|
+
"environment": self.environment.value,
|
|
1107
|
+
"migration": {
|
|
1108
|
+
"mode": self.migration_mode.value,
|
|
1109
|
+
"preserveLegacyAgents": self.preserve_legacy_agents,
|
|
1110
|
+
},
|
|
1111
|
+
"software": software,
|
|
1112
|
+
"metrics": {
|
|
1113
|
+
"backend": {
|
|
1114
|
+
"listenAddress": self.metrics.backend.listen_address.value,
|
|
1115
|
+
"port": self.metrics.backend.port.value,
|
|
1116
|
+
"storagePath": self.metrics.backend.storage_path.value,
|
|
1117
|
+
"retentionHours": (
|
|
1118
|
+
self.metrics.backend.retention_hours.value
|
|
1119
|
+
),
|
|
1120
|
+
},
|
|
1121
|
+
"collection": {
|
|
1122
|
+
"intervalSeconds": (
|
|
1123
|
+
self.metrics.collection.interval_seconds.value
|
|
1124
|
+
),
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
"backend": {
|
|
1128
|
+
"server": self.backend.server_id.value,
|
|
1129
|
+
"listenAddress": self.backend.listen_address.value,
|
|
1130
|
+
"port": self.backend.port.value,
|
|
1131
|
+
"storage": {
|
|
1132
|
+
"type": self.backend.storage.storage_type.value,
|
|
1133
|
+
"path": self.backend.storage.path.value,
|
|
1134
|
+
},
|
|
1135
|
+
"retentionHours": self.backend.retention_hours.value,
|
|
1136
|
+
},
|
|
1137
|
+
"gateway": {
|
|
1138
|
+
"serverName": self.gateway.server_name.value,
|
|
1139
|
+
"listenAddress": self.gateway.listen_address.value,
|
|
1140
|
+
"port": self.gateway.port.value,
|
|
1141
|
+
"tls": {
|
|
1142
|
+
"certificatePath": self.gateway.tls.certificate_path.value,
|
|
1143
|
+
"keyPath": self.gateway.tls.key_path.value,
|
|
1144
|
+
"clientCaPath": self.gateway.tls.client_ca_path.value,
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
"grafana": {
|
|
1148
|
+
"listenAddress": self.grafana.listen_address.value,
|
|
1149
|
+
"port": self.grafana.port.value,
|
|
1150
|
+
"adminPasswordPath": self.grafana.admin_password_path.value,
|
|
1151
|
+
},
|
|
1152
|
+
"collectors": {
|
|
1153
|
+
"servers": [server.value for server in self.collectors.server_ids],
|
|
1154
|
+
"clientTls": {
|
|
1155
|
+
"caPath": self.collectors.client_tls.ca_path.value,
|
|
1156
|
+
"certificatePath": (
|
|
1157
|
+
self.collectors.client_tls.certificate_path.value
|
|
1158
|
+
),
|
|
1159
|
+
"keyPath": self.collectors.client_tls.key_path.value,
|
|
1160
|
+
},
|
|
1161
|
+
"journal": {
|
|
1162
|
+
"enabled": self.collectors.journal.enabled,
|
|
1163
|
+
"maxAgeHours": self.collectors.journal.max_age_hours.value,
|
|
1164
|
+
},
|
|
1165
|
+
"files": [source.as_dict() for source in self.collectors.files],
|
|
1166
|
+
},
|
|
1167
|
+
}
|
|
1168
|
+
if self.alerting is not None:
|
|
1169
|
+
result["alerting"] = self.alerting.as_dict()
|
|
1170
|
+
return result
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
@dataclass(frozen=True, slots=True)
|
|
1174
|
+
class PlatformInventory:
|
|
1175
|
+
"""Typed inventory of validated servers, applications, and components."""
|
|
1176
|
+
|
|
1177
|
+
servers: tuple[ServerInventory, ...]
|
|
1178
|
+
server_types: tuple[ServerTypeInventory, ...]
|
|
1179
|
+
applications: tuple[ApplicationInventory, ...]
|
|
1180
|
+
components: tuple[ComponentInventory, ...]
|
|
1181
|
+
domains: tuple[DomainInventory, ...]
|
|
1182
|
+
services: tuple[ServiceInventory, ...]
|
|
1183
|
+
ssh_public_keys: tuple[SshPublicKeyInventory, ...]
|
|
1184
|
+
logging_stacks: tuple[LoggingStackInventory, ...]
|
|
1185
|
+
alert_rules: tuple[AlertRuleInventory, ...]
|
|
1186
|
+
operator_policies: tuple[OperatorPolicyInventory, ...]
|
|
1187
|
+
|
|
1188
|
+
@classmethod
|
|
1189
|
+
def from_state(cls, state: ValidatedConfig) -> PlatformInventory:
|
|
1190
|
+
"""Application validated state into stable inventory records."""
|
|
1191
|
+
servers = tuple(
|
|
1192
|
+
_server_inventory(document)
|
|
1193
|
+
for document in state.resources(ResourceKind.SERVER)
|
|
1194
|
+
)
|
|
1195
|
+
server_types = tuple(
|
|
1196
|
+
_server_type_inventory(document)
|
|
1197
|
+
for document in state.resources(ResourceKind.HOST_PROFILE)
|
|
1198
|
+
)
|
|
1199
|
+
applications = tuple(
|
|
1200
|
+
_application_inventory(document)
|
|
1201
|
+
for document in state.resources(ResourceKind.APPLICATION)
|
|
1202
|
+
)
|
|
1203
|
+
components = tuple(
|
|
1204
|
+
_component_inventory(document)
|
|
1205
|
+
for document in state.resources(ResourceKind.COMPONENT)
|
|
1206
|
+
)
|
|
1207
|
+
domains = tuple(
|
|
1208
|
+
_domain_inventory(document)
|
|
1209
|
+
for document in state.resources(ResourceKind.DOMAIN)
|
|
1210
|
+
)
|
|
1211
|
+
applications_by_id = {
|
|
1212
|
+
application.resource_id: application for application in applications
|
|
1213
|
+
}
|
|
1214
|
+
services = tuple(
|
|
1215
|
+
_service_inventory(document, applications_by_id)
|
|
1216
|
+
for document in state.resources(ResourceKind.SERVICE)
|
|
1217
|
+
)
|
|
1218
|
+
ssh_public_keys = tuple(
|
|
1219
|
+
_ssh_public_key_inventory(document)
|
|
1220
|
+
for document in state.resources(ResourceKind.SSH_PUBLIC_KEY)
|
|
1221
|
+
)
|
|
1222
|
+
logging_stacks = tuple(
|
|
1223
|
+
_logging_stack_inventory(document)
|
|
1224
|
+
for document in state.resources(ResourceKind.LOGGING_STACK)
|
|
1225
|
+
)
|
|
1226
|
+
alert_rules = tuple(
|
|
1227
|
+
_alert_rule_inventory(document)
|
|
1228
|
+
for document in state.resources(ResourceKind.ALERT_RULE)
|
|
1229
|
+
)
|
|
1230
|
+
operator_policies = tuple(
|
|
1231
|
+
_operator_policy_inventory(document)
|
|
1232
|
+
for document in state.resources(ResourceKind.OPERATOR_POLICY)
|
|
1233
|
+
)
|
|
1234
|
+
return cls(
|
|
1235
|
+
servers=servers,
|
|
1236
|
+
server_types=server_types,
|
|
1237
|
+
applications=applications,
|
|
1238
|
+
components=components,
|
|
1239
|
+
domains=domains,
|
|
1240
|
+
services=services,
|
|
1241
|
+
ssh_public_keys=ssh_public_keys,
|
|
1242
|
+
logging_stacks=logging_stacks,
|
|
1243
|
+
alert_rules=alert_rules,
|
|
1244
|
+
operator_policies=operator_policies,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
def server_type(self, server_type_id: ResourceId) -> ServerTypeInventory:
|
|
1248
|
+
"""Return an existing desired server type."""
|
|
1249
|
+
server_type = next(
|
|
1250
|
+
(
|
|
1251
|
+
candidate
|
|
1252
|
+
for candidate in self.server_types
|
|
1253
|
+
if candidate.resource_id == server_type_id
|
|
1254
|
+
),
|
|
1255
|
+
None,
|
|
1256
|
+
)
|
|
1257
|
+
if server_type is None:
|
|
1258
|
+
message = f"server type does not exist: {server_type_id}"
|
|
1259
|
+
raise KeyError(message)
|
|
1260
|
+
return server_type
|
|
1261
|
+
|
|
1262
|
+
def components_on_server(
|
|
1263
|
+
self, server_id: ResourceId
|
|
1264
|
+
) -> tuple[ComponentInventory, ...]:
|
|
1265
|
+
"""Return components assigned to an existing server."""
|
|
1266
|
+
if not any(server.resource_id == server_id for server in self.servers):
|
|
1267
|
+
message = f"server does not exist: {server_id}"
|
|
1268
|
+
raise KeyError(message)
|
|
1269
|
+
return tuple(
|
|
1270
|
+
component
|
|
1271
|
+
for component in self.components
|
|
1272
|
+
if server_id in component.server_ids
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
def servers_for_component(
|
|
1276
|
+
self, component_id: ResourceId
|
|
1277
|
+
) -> tuple[ServerInventory, ...]:
|
|
1278
|
+
"""Return servers assigned to an existing component."""
|
|
1279
|
+
component = next(
|
|
1280
|
+
(
|
|
1281
|
+
candidate
|
|
1282
|
+
for candidate in self.components
|
|
1283
|
+
if candidate.resource_id == component_id
|
|
1284
|
+
),
|
|
1285
|
+
None,
|
|
1286
|
+
)
|
|
1287
|
+
if component is None:
|
|
1288
|
+
message = f"component does not exist: {component_id}"
|
|
1289
|
+
raise KeyError(message)
|
|
1290
|
+
assigned = frozenset(component.server_ids)
|
|
1291
|
+
return tuple(
|
|
1292
|
+
server for server in self.servers if server.resource_id in assigned
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
def as_dict(self) -> dict[str, object]:
|
|
1296
|
+
"""Serialize the complete non-secret platform inventory."""
|
|
1297
|
+
return {
|
|
1298
|
+
"servers": [server.as_dict() for server in self.servers],
|
|
1299
|
+
"serverTypes": [
|
|
1300
|
+
server_type.as_dict() for server_type in self.server_types
|
|
1301
|
+
],
|
|
1302
|
+
"applications": [
|
|
1303
|
+
application.as_dict() for application in self.applications
|
|
1304
|
+
],
|
|
1305
|
+
"components": [component.as_dict() for component in self.components],
|
|
1306
|
+
"domains": [domain.as_dict() for domain in self.domains],
|
|
1307
|
+
"services": [service.as_dict() for service in self.services],
|
|
1308
|
+
"sshPublicKeys": [key.as_dict() for key in self.ssh_public_keys],
|
|
1309
|
+
"loggingStacks": [stack.as_dict() for stack in self.logging_stacks],
|
|
1310
|
+
"alertRules": [rule.as_dict() for rule in self.alert_rules],
|
|
1311
|
+
"operatorPolicies": [
|
|
1312
|
+
policy.as_dict() for policy in self.operator_policies
|
|
1313
|
+
],
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _server_inventory(document: ResourceDocument) -> ServerInventory:
|
|
1318
|
+
spec = _mapping(document.content, "spec")
|
|
1319
|
+
ssh = _mapping(spec, "ssh")
|
|
1320
|
+
labels = _labels(spec.get("labels", {}))
|
|
1321
|
+
return ServerInventory(
|
|
1322
|
+
resource_id=document.key.resource_id,
|
|
1323
|
+
hostname=Hostname.from_boundary(spec.get("hostname")),
|
|
1324
|
+
address=ConnectionAddress.from_boundary(spec.get("address")),
|
|
1325
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1326
|
+
server_type_id=ResourceId.from_boundary(spec.get("serverType")),
|
|
1327
|
+
lifecycle=ServerLifecycle.from_boundary(spec.get("lifecycle")),
|
|
1328
|
+
ssh_user=LinuxUser.from_boundary(ssh.get("user")),
|
|
1329
|
+
ssh_port=TcpPort.from_boundary(ssh.get("port")),
|
|
1330
|
+
labels=labels,
|
|
1331
|
+
provider=_server_provider(spec.get("provider")),
|
|
1332
|
+
network=_server_network(spec.get("network")),
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _server_provider(value: object) -> ServerProvider | None:
|
|
1337
|
+
if value is None:
|
|
1338
|
+
return None
|
|
1339
|
+
provider = _boundary_mapping(value, "server provider")
|
|
1340
|
+
server_id = provider.get("serverId")
|
|
1341
|
+
datacenter = provider.get("datacenter")
|
|
1342
|
+
return ServerProvider(
|
|
1343
|
+
name=ResourceId.from_boundary(provider.get("name")),
|
|
1344
|
+
server_id=(
|
|
1345
|
+
ProviderServerId.from_boundary(server_id) if server_id is not None else None
|
|
1346
|
+
),
|
|
1347
|
+
datacenter=(
|
|
1348
|
+
DatacenterCode.from_boundary(datacenter) if datacenter is not None else None
|
|
1349
|
+
),
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _server_network(value: object) -> ServerNetwork | None:
|
|
1354
|
+
if value is None:
|
|
1355
|
+
return None
|
|
1356
|
+
network = _boundary_mapping(value, "server network")
|
|
1357
|
+
return ServerNetwork(
|
|
1358
|
+
ipv4=Ipv4Address.from_boundary(network.get("ipv4")),
|
|
1359
|
+
ipv6_cidr=Ipv6NetworkCidr.from_boundary(network.get("ipv6Cidr")),
|
|
1360
|
+
reverse_dns=Hostname.from_boundary(network.get("reverseDns")),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _server_type_inventory(document: ResourceDocument) -> ServerTypeInventory:
|
|
1365
|
+
spec = _mapping(document.content, "spec")
|
|
1366
|
+
os_requirement = _mapping(spec, "os")
|
|
1367
|
+
storage = _mapping(spec, "storage")
|
|
1368
|
+
raw_raid = storage.get("softwareRaid")
|
|
1369
|
+
raid = None if raw_raid is None else _mapping(storage, "softwareRaid")
|
|
1370
|
+
packages = _mapping(spec, "packages")
|
|
1371
|
+
services = _mapping(spec, "services")
|
|
1372
|
+
configuration = _mapping(spec, "configuration")
|
|
1373
|
+
return ServerTypeInventory(
|
|
1374
|
+
resource_id=document.key.resource_id,
|
|
1375
|
+
os=OperatingSystemRequirement(
|
|
1376
|
+
distribution=OperatingSystemDistribution.from_boundary(
|
|
1377
|
+
os_requirement.get("distribution")
|
|
1378
|
+
),
|
|
1379
|
+
versions=tuple(
|
|
1380
|
+
OperatingSystemMajorVersion.from_boundary(item)
|
|
1381
|
+
for item in _list(os_requirement.get("versions"), "OS versions")
|
|
1382
|
+
),
|
|
1383
|
+
service_manager=ServiceManager.from_boundary(
|
|
1384
|
+
os_requirement.get("serviceManager")
|
|
1385
|
+
),
|
|
1386
|
+
),
|
|
1387
|
+
raid=(
|
|
1388
|
+
None
|
|
1389
|
+
if raid is None
|
|
1390
|
+
else RaidRequirement(
|
|
1391
|
+
level=RaidLevel.from_boundary(raid.get("level")),
|
|
1392
|
+
minimum_active_devices=PositiveCount.from_boundary(
|
|
1393
|
+
raid.get("minimumActiveDevices")
|
|
1394
|
+
),
|
|
1395
|
+
minimum_usable_bytes=ByteSize.from_boundary(
|
|
1396
|
+
raid.get("minimumUsableBytes")
|
|
1397
|
+
),
|
|
1398
|
+
)
|
|
1399
|
+
),
|
|
1400
|
+
mounts=tuple(
|
|
1401
|
+
_mount_requirement(item)
|
|
1402
|
+
for item in _mapping_list(storage.get("mounts"), "mount requirements")
|
|
1403
|
+
),
|
|
1404
|
+
required_packages=tuple(
|
|
1405
|
+
_package_requirement(item)
|
|
1406
|
+
for item in _mapping_list(packages.get("required"), "required packages")
|
|
1407
|
+
),
|
|
1408
|
+
forbidden_packages=tuple(
|
|
1409
|
+
PackageName.from_boundary(item)
|
|
1410
|
+
for item in _list(packages.get("forbidden"), "forbidden packages")
|
|
1411
|
+
),
|
|
1412
|
+
required_services=tuple(
|
|
1413
|
+
_service_requirement(item)
|
|
1414
|
+
for item in _mapping_list(services.get("required"), "required services")
|
|
1415
|
+
),
|
|
1416
|
+
firewall=_firewall_requirement(spec.get("firewall")),
|
|
1417
|
+
configuration_files=tuple(
|
|
1418
|
+
_configuration_requirement(item)
|
|
1419
|
+
for item in _mapping_list(configuration.get("files"), "configuration files")
|
|
1420
|
+
),
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _application_inventory(document: ResourceDocument) -> ApplicationInventory:
|
|
1425
|
+
spec = _mapping(document.content, "spec")
|
|
1426
|
+
return ApplicationInventory(
|
|
1427
|
+
resource_id=document.key.resource_id,
|
|
1428
|
+
linux_user=LinuxUser.from_boundary(spec.get("linuxUser")),
|
|
1429
|
+
approval=DeploymentApproval.from_boundary(spec.get("approval")),
|
|
1430
|
+
component_ids=_resource_ids(spec.get("components"), "application components"),
|
|
1431
|
+
secret_refs=_secret_references(spec.get("secretRefs")),
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _component_inventory(document: ResourceDocument) -> ComponentInventory:
|
|
1436
|
+
spec = _mapping(document.content, "spec")
|
|
1437
|
+
deployment = _mapping(spec, "deployment")
|
|
1438
|
+
repository = _mapping(spec, "repository")
|
|
1439
|
+
runtime = _mapping(spec, "runtime")
|
|
1440
|
+
service = _mapping(spec, "service")
|
|
1441
|
+
health = _mapping(spec, "healthCheck")
|
|
1442
|
+
raw_subdirectory = repository.get("subdirectory")
|
|
1443
|
+
if raw_subdirectory is not None and not isinstance(raw_subdirectory, str):
|
|
1444
|
+
message = "validated repository subdirectory is not a string"
|
|
1445
|
+
raise TypeError(message)
|
|
1446
|
+
return ComponentInventory(
|
|
1447
|
+
resource_id=document.key.resource_id,
|
|
1448
|
+
application_id=ResourceId.from_boundary(spec.get("application")),
|
|
1449
|
+
server_ids=_resource_ids(deployment.get("servers"), "component servers"),
|
|
1450
|
+
install_root=AbsolutePath.from_boundary(deployment.get("installRoot")),
|
|
1451
|
+
retain_until_cleanup=_boolean(
|
|
1452
|
+
deployment.get("retainUntilCleanup"), "retain until cleanup"
|
|
1453
|
+
),
|
|
1454
|
+
repository=ComponentRepository(
|
|
1455
|
+
url=RepositoryUrl.from_boundary(repository.get("url")),
|
|
1456
|
+
subdirectory=raw_subdirectory,
|
|
1457
|
+
),
|
|
1458
|
+
runtime=ComponentRuntime(
|
|
1459
|
+
runtime_type=RuntimeType.from_boundary(runtime.get("type")),
|
|
1460
|
+
version=RuntimeVersion.from_boundary(runtime.get("version")),
|
|
1461
|
+
package_manager=RuntimePackageManager.from_boundary(
|
|
1462
|
+
runtime.get("packageManager")
|
|
1463
|
+
),
|
|
1464
|
+
),
|
|
1465
|
+
service=ComponentService(
|
|
1466
|
+
manager=ServiceManager.from_boundary(service.get("manager")),
|
|
1467
|
+
name=ResourceId.from_boundary(service.get("name")),
|
|
1468
|
+
command=ServiceCommand.from_boundary(service.get("command")),
|
|
1469
|
+
),
|
|
1470
|
+
health_check=_component_health_check(health),
|
|
1471
|
+
secret_refs=_secret_references(spec.get("secretRefs")),
|
|
1472
|
+
environment=_declared_environment(spec.get("environment")),
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _declared_environment(value: object) -> tuple[tuple[str, str], ...]:
|
|
1477
|
+
if value is None:
|
|
1478
|
+
return ()
|
|
1479
|
+
if not isinstance(value, dict) or not all(
|
|
1480
|
+
isinstance(key, str) and isinstance(entry, str)
|
|
1481
|
+
for key, entry in value.items()
|
|
1482
|
+
):
|
|
1483
|
+
message = "validated component environment is not a string mapping"
|
|
1484
|
+
raise TypeError(message)
|
|
1485
|
+
return tuple(value.items())
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _secret_references(value: object) -> tuple[SecretReference, ...]:
|
|
1489
|
+
if value is None:
|
|
1490
|
+
return ()
|
|
1491
|
+
return tuple(
|
|
1492
|
+
SecretReference(
|
|
1493
|
+
scope=SecretScope.from_boundary(item.get("scope")),
|
|
1494
|
+
environment=ResourceId.from_boundary(item.get("environment")),
|
|
1495
|
+
path=AbsolutePath.from_boundary(item.get("path")),
|
|
1496
|
+
)
|
|
1497
|
+
for item in _mapping_list(value, "secret references")
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _component_health_check(
|
|
1502
|
+
health: Mapping[str, object],
|
|
1503
|
+
) -> ComponentHealthCheck:
|
|
1504
|
+
check_type = HealthCheckType.from_boundary(health.get("type"))
|
|
1505
|
+
if check_type is HealthCheckType.NONE:
|
|
1506
|
+
return ComponentHealthCheck(check_type=check_type, http=None)
|
|
1507
|
+
return ComponentHealthCheck(
|
|
1508
|
+
check_type=check_type,
|
|
1509
|
+
http=ComponentHttpHealthCheck(
|
|
1510
|
+
scheme=HttpScheme.from_boundary(health.get("scheme")),
|
|
1511
|
+
port=TcpPort.from_boundary(health.get("port")),
|
|
1512
|
+
path=_string(health, "path"),
|
|
1513
|
+
expected_statuses=tuple(
|
|
1514
|
+
HttpStatusCode.from_boundary(item)
|
|
1515
|
+
for item in _list(
|
|
1516
|
+
health.get("expectedStatuses"), "expected HTTP statuses"
|
|
1517
|
+
)
|
|
1518
|
+
),
|
|
1519
|
+
timeout_seconds=PositiveCount.from_boundary(
|
|
1520
|
+
health.get("timeoutSeconds")
|
|
1521
|
+
),
|
|
1522
|
+
attempts=PositiveCount.from_boundary(health.get("attempts")),
|
|
1523
|
+
),
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _domain_inventory(document: ResourceDocument) -> DomainInventory:
|
|
1528
|
+
spec = _mapping(document.content, "spec")
|
|
1529
|
+
proxy = _mapping(spec, "proxy")
|
|
1530
|
+
raw_proxy_upstream = proxy.get("upstream")
|
|
1531
|
+
proxy_upstream = (
|
|
1532
|
+
None if raw_proxy_upstream is None else _mapping(proxy, "upstream")
|
|
1533
|
+
)
|
|
1534
|
+
origin = _mapping(spec, "origin")
|
|
1535
|
+
edge = _mapping(spec, "edge")
|
|
1536
|
+
tls = _mapping(spec, "tls")
|
|
1537
|
+
health = _mapping(spec, "healthCheck")
|
|
1538
|
+
return DomainInventory(
|
|
1539
|
+
resource_id=document.key.resource_id,
|
|
1540
|
+
primary_name=Hostname.from_boundary(spec.get("primaryName")),
|
|
1541
|
+
aliases=tuple(
|
|
1542
|
+
Hostname.from_boundary(item)
|
|
1543
|
+
for item in _list(spec.get("aliases"), "domain aliases")
|
|
1544
|
+
),
|
|
1545
|
+
proxy=DomainProxy(
|
|
1546
|
+
server_id=ResourceId.from_boundary(proxy.get("server")),
|
|
1547
|
+
configuration_path=AbsolutePath.from_boundary(
|
|
1548
|
+
proxy.get("configurationPath")
|
|
1549
|
+
),
|
|
1550
|
+
service_name=ServiceName.from_boundary(proxy.get("service")),
|
|
1551
|
+
upstream_address=(
|
|
1552
|
+
None
|
|
1553
|
+
if proxy_upstream is None
|
|
1554
|
+
else IpAddress.from_boundary(proxy_upstream.get("address"))
|
|
1555
|
+
),
|
|
1556
|
+
upstream_port=(
|
|
1557
|
+
None
|
|
1558
|
+
if proxy_upstream is None
|
|
1559
|
+
else TcpPort.from_boundary(proxy_upstream.get("port"))
|
|
1560
|
+
),
|
|
1561
|
+
),
|
|
1562
|
+
origin=DomainOrigin(
|
|
1563
|
+
server_id=ResourceId.from_boundary(origin.get("server")),
|
|
1564
|
+
configuration_path=AbsolutePath.from_boundary(
|
|
1565
|
+
origin.get("configurationPath")
|
|
1566
|
+
),
|
|
1567
|
+
service_name=ServiceName.from_boundary(origin.get("service")),
|
|
1568
|
+
scheme=HttpScheme.from_boundary(origin.get("scheme")),
|
|
1569
|
+
port=TcpPort.from_boundary(origin.get("port")),
|
|
1570
|
+
server_name=Hostname.from_boundary(origin.get("serverName")),
|
|
1571
|
+
),
|
|
1572
|
+
edge=DomainEdge(
|
|
1573
|
+
provider=ResourceId.from_boundary(edge.get("provider")),
|
|
1574
|
+
mode=DnsMode.from_boundary(edge.get("mode")),
|
|
1575
|
+
),
|
|
1576
|
+
tls_mode=TlsMode.from_boundary(tls.get("mode")),
|
|
1577
|
+
health_check=DomainHealthCheck(
|
|
1578
|
+
scheme=HttpScheme.from_boundary(health.get("scheme")),
|
|
1579
|
+
path=_string(health, "path"),
|
|
1580
|
+
expected_statuses=tuple(
|
|
1581
|
+
HttpStatusCode.from_boundary(item)
|
|
1582
|
+
for item in _list(
|
|
1583
|
+
health.get("expectedStatuses"), "expected HTTP statuses"
|
|
1584
|
+
)
|
|
1585
|
+
),
|
|
1586
|
+
timeout_seconds=PositiveCount.from_boundary(health.get("timeoutSeconds")),
|
|
1587
|
+
),
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def _service_inventory(
|
|
1592
|
+
document: ResourceDocument,
|
|
1593
|
+
applications_by_id: Mapping[ResourceId, ApplicationInventory],
|
|
1594
|
+
) -> ServiceInventory:
|
|
1595
|
+
spec = _mapping(document.content, "spec")
|
|
1596
|
+
bind = _mapping(spec, "bind")
|
|
1597
|
+
backup = _mapping(spec, "backup")
|
|
1598
|
+
service_kind = ServiceKind.from_boundary(spec.get("serviceKind"))
|
|
1599
|
+
postgresql = None
|
|
1600
|
+
if service_kind is ServiceKind.POSTGRESQL:
|
|
1601
|
+
raw_postgresql = _mapping(spec, "postgresql")
|
|
1602
|
+
raw_package_version = raw_postgresql.get("packageVersion")
|
|
1603
|
+
postgresql = PostgresqlService(
|
|
1604
|
+
major_version=PostgresMajorVersion.from_boundary(
|
|
1605
|
+
raw_postgresql.get("majorVersion")
|
|
1606
|
+
),
|
|
1607
|
+
package_version=(
|
|
1608
|
+
PackageVersion.from_boundary(raw_package_version)
|
|
1609
|
+
if raw_package_version is not None
|
|
1610
|
+
else None
|
|
1611
|
+
),
|
|
1612
|
+
databases=tuple(
|
|
1613
|
+
_postgres_database(item, applications_by_id)
|
|
1614
|
+
for item in _mapping_list(
|
|
1615
|
+
raw_postgresql.get("databases"), "service databases"
|
|
1616
|
+
)
|
|
1617
|
+
),
|
|
1618
|
+
)
|
|
1619
|
+
redis = None
|
|
1620
|
+
if service_kind is ServiceKind.REDIS:
|
|
1621
|
+
raw_redis = _mapping(spec, "redis")
|
|
1622
|
+
raw_redis_package = raw_redis.get("packageVersion")
|
|
1623
|
+
redis = RedisService(
|
|
1624
|
+
package_version=(
|
|
1625
|
+
PackageVersion.from_boundary(raw_redis_package)
|
|
1626
|
+
if raw_redis_package is not None
|
|
1627
|
+
else None
|
|
1628
|
+
),
|
|
1629
|
+
maxmemory_mb=PositiveCount.from_boundary(
|
|
1630
|
+
raw_redis.get("maxmemoryMb")
|
|
1631
|
+
),
|
|
1632
|
+
append_only=_boolean(
|
|
1633
|
+
raw_redis.get("appendOnly"), "redis append only"
|
|
1634
|
+
),
|
|
1635
|
+
)
|
|
1636
|
+
return ServiceInventory(
|
|
1637
|
+
resource_id=document.key.resource_id,
|
|
1638
|
+
service_kind=service_kind,
|
|
1639
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1640
|
+
server_id=ResourceId.from_boundary(spec.get("server")),
|
|
1641
|
+
bind=ServiceBind(
|
|
1642
|
+
address=IpAddress.from_boundary(bind.get("address")),
|
|
1643
|
+
port=TcpPort.from_boundary(bind.get("port")),
|
|
1644
|
+
),
|
|
1645
|
+
postgresql=postgresql,
|
|
1646
|
+
redis=redis,
|
|
1647
|
+
backup=ServiceBackup(
|
|
1648
|
+
directory=AbsolutePath.from_boundary(backup.get("directory")),
|
|
1649
|
+
on_calendar=SystemdCalendar.from_boundary(backup.get("onCalendar")),
|
|
1650
|
+
retention_days=PositiveCount.from_boundary(
|
|
1651
|
+
backup.get("retentionDays")
|
|
1652
|
+
),
|
|
1653
|
+
restore_check_on_calendar=(
|
|
1654
|
+
SystemdCalendar.from_boundary(
|
|
1655
|
+
backup["restoreCheckOnCalendar"]
|
|
1656
|
+
)
|
|
1657
|
+
if "restoreCheckOnCalendar" in backup
|
|
1658
|
+
else None
|
|
1659
|
+
),
|
|
1660
|
+
),
|
|
1661
|
+
metrics_enabled=_service_metrics_enabled(spec, document),
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _alert_rule_inventory(document: ResourceDocument) -> AlertRuleInventory:
|
|
1666
|
+
spec = _mapping(document.content, "spec")
|
|
1667
|
+
return AlertRuleInventory(
|
|
1668
|
+
resource_id=document.key.resource_id,
|
|
1669
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1670
|
+
expr=PromqlExpression.from_boundary(spec.get("expr")),
|
|
1671
|
+
for_duration=AlertDuration.from_boundary(spec.get("for")),
|
|
1672
|
+
severity=AlertSeverity.from_boundary(spec.get("severity")),
|
|
1673
|
+
summary=AlertSummary.from_boundary(spec.get("summary")),
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _operator_policy_inventory(
|
|
1678
|
+
document: ResourceDocument,
|
|
1679
|
+
) -> OperatorPolicyInventory:
|
|
1680
|
+
spec = _mapping(document.content, "spec")
|
|
1681
|
+
autonomy = _mapping(spec, "autonomy")
|
|
1682
|
+
raw_quiet = autonomy.get("quietHours")
|
|
1683
|
+
quiet_hours = None
|
|
1684
|
+
if raw_quiet is not None:
|
|
1685
|
+
quiet = _mapping(autonomy, "quietHours")
|
|
1686
|
+
quiet_hours = QuietHours(
|
|
1687
|
+
start=DayTime.from_boundary(quiet.get("start")),
|
|
1688
|
+
end=DayTime.from_boundary(quiet.get("end")),
|
|
1689
|
+
)
|
|
1690
|
+
return OperatorPolicyInventory(
|
|
1691
|
+
resource_id=document.key.resource_id,
|
|
1692
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1693
|
+
grants=tuple(
|
|
1694
|
+
AutonomyGrant(
|
|
1695
|
+
operation_kind=_string(item, "kind"),
|
|
1696
|
+
required_verified_runs=PositiveCount.from_boundary(
|
|
1697
|
+
item.get("requiredVerifiedRuns")
|
|
1698
|
+
),
|
|
1699
|
+
)
|
|
1700
|
+
for item in _mapping_list(
|
|
1701
|
+
autonomy.get("operations"), "autonomy operations"
|
|
1702
|
+
)
|
|
1703
|
+
),
|
|
1704
|
+
max_autonomous_per_hour=PositiveCount.from_boundary(
|
|
1705
|
+
autonomy.get("maxAutonomousPerHour")
|
|
1706
|
+
),
|
|
1707
|
+
quiet_hours=quiet_hours,
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _service_metrics_enabled(
|
|
1712
|
+
spec: Mapping[str, object], document: ResourceDocument
|
|
1713
|
+
) -> bool:
|
|
1714
|
+
raw_metrics = spec.get("metrics")
|
|
1715
|
+
if raw_metrics is None:
|
|
1716
|
+
return False
|
|
1717
|
+
metrics = _mapping(spec, "metrics")
|
|
1718
|
+
if metrics.get("enabled") is not True:
|
|
1719
|
+
message = (
|
|
1720
|
+
"validated service metrics block must declare enabled: true "
|
|
1721
|
+
f"({document.source.display()})"
|
|
1722
|
+
)
|
|
1723
|
+
raise ValueError(message)
|
|
1724
|
+
return True
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def _postgres_database(
|
|
1728
|
+
content: Mapping[str, object],
|
|
1729
|
+
applications_by_id: Mapping[ResourceId, ApplicationInventory],
|
|
1730
|
+
) -> PostgresDatabase:
|
|
1731
|
+
application_id = ResourceId.from_boundary(content.get("application"))
|
|
1732
|
+
application = applications_by_id.get(application_id)
|
|
1733
|
+
if application is None:
|
|
1734
|
+
message = (
|
|
1735
|
+
f"service database references an unknown application: {application_id}"
|
|
1736
|
+
)
|
|
1737
|
+
raise KeyError(message)
|
|
1738
|
+
return PostgresDatabase(
|
|
1739
|
+
name=PostgresDatabaseName.from_boundary(content.get("name")),
|
|
1740
|
+
application_id=application_id,
|
|
1741
|
+
owner=application.linux_user,
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _ssh_public_key_inventory(
|
|
1746
|
+
document: ResourceDocument,
|
|
1747
|
+
) -> SshPublicKeyInventory:
|
|
1748
|
+
spec = _mapping(document.content, "spec")
|
|
1749
|
+
return SshPublicKeyInventory(
|
|
1750
|
+
resource_id=document.key.resource_id,
|
|
1751
|
+
owner=ResourceId.from_boundary(spec.get("owner")),
|
|
1752
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1753
|
+
public_key=OpenSshPublicKey.from_boundary(spec.get("publicKey")),
|
|
1754
|
+
lifecycle=SshPublicKeyLifecycle.from_boundary(spec.get("lifecycle")),
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _logging_stack_inventory(
|
|
1759
|
+
document: ResourceDocument,
|
|
1760
|
+
) -> LoggingStackInventory:
|
|
1761
|
+
spec = _mapping(document.content, "spec")
|
|
1762
|
+
migration = _mapping(spec, "migration")
|
|
1763
|
+
software = _mapping(spec, "software")
|
|
1764
|
+
raw_prometheus = software.get("prometheusPackageVersion")
|
|
1765
|
+
metrics = _mapping(spec, "metrics")
|
|
1766
|
+
metrics_backend = _mapping(metrics, "backend")
|
|
1767
|
+
metrics_collection = _mapping(metrics, "collection")
|
|
1768
|
+
backend = _mapping(spec, "backend")
|
|
1769
|
+
storage = _mapping(backend, "storage")
|
|
1770
|
+
gateway = _mapping(spec, "gateway")
|
|
1771
|
+
gateway_tls = _mapping(gateway, "tls")
|
|
1772
|
+
grafana = _mapping(spec, "grafana")
|
|
1773
|
+
collectors = _mapping(spec, "collectors")
|
|
1774
|
+
client_tls = _mapping(collectors, "clientTls")
|
|
1775
|
+
journal = _mapping(collectors, "journal")
|
|
1776
|
+
alerting = (
|
|
1777
|
+
_logging_alerting(_mapping(spec, "alerting"))
|
|
1778
|
+
if spec.get("alerting") is not None
|
|
1779
|
+
else None
|
|
1780
|
+
)
|
|
1781
|
+
return LoggingStackInventory(
|
|
1782
|
+
resource_id=document.key.resource_id,
|
|
1783
|
+
environment=ResourceId.from_boundary(spec.get("environment")),
|
|
1784
|
+
migration_mode=LoggingMigrationMode.from_boundary(migration.get("mode")),
|
|
1785
|
+
preserve_legacy_agents=_boolean(
|
|
1786
|
+
migration.get("preserveLegacyAgents"), "preserve legacy agents"
|
|
1787
|
+
),
|
|
1788
|
+
software=LoggingSoftware(
|
|
1789
|
+
loki=PackageVersion.from_boundary(software.get("lokiPackageVersion")),
|
|
1790
|
+
grafana=PackageVersion.from_boundary(software.get("grafanaPackageVersion")),
|
|
1791
|
+
alloy=PackageVersion.from_boundary(software.get("alloyPackageVersion")),
|
|
1792
|
+
prometheus=(
|
|
1793
|
+
PackageVersion.from_boundary(raw_prometheus)
|
|
1794
|
+
if raw_prometheus is not None
|
|
1795
|
+
else None
|
|
1796
|
+
),
|
|
1797
|
+
),
|
|
1798
|
+
metrics=LoggingMetrics(
|
|
1799
|
+
backend=MetricsBackend(
|
|
1800
|
+
listen_address=IpAddress.from_boundary(
|
|
1801
|
+
metrics_backend.get("listenAddress")
|
|
1802
|
+
),
|
|
1803
|
+
port=TcpPort.from_boundary(metrics_backend.get("port")),
|
|
1804
|
+
storage_path=AbsolutePath.from_boundary(
|
|
1805
|
+
metrics_backend.get("storagePath")
|
|
1806
|
+
),
|
|
1807
|
+
retention_hours=PositiveCount.from_boundary(
|
|
1808
|
+
metrics_backend.get("retentionHours")
|
|
1809
|
+
),
|
|
1810
|
+
),
|
|
1811
|
+
collection=MetricsCollection(
|
|
1812
|
+
interval_seconds=PositiveCount.from_boundary(
|
|
1813
|
+
metrics_collection.get("intervalSeconds")
|
|
1814
|
+
),
|
|
1815
|
+
),
|
|
1816
|
+
),
|
|
1817
|
+
backend=LoggingBackend(
|
|
1818
|
+
server_id=ResourceId.from_boundary(backend.get("server")),
|
|
1819
|
+
listen_address=IpAddress.from_boundary(backend.get("listenAddress")),
|
|
1820
|
+
port=TcpPort.from_boundary(backend.get("port")),
|
|
1821
|
+
storage=LoggingBackendStorage(
|
|
1822
|
+
storage_type=LoggingStorageType.from_boundary(storage.get("type")),
|
|
1823
|
+
path=AbsolutePath.from_boundary(storage.get("path")),
|
|
1824
|
+
),
|
|
1825
|
+
retention_hours=PositiveCount.from_boundary(backend.get("retentionHours")),
|
|
1826
|
+
),
|
|
1827
|
+
gateway=LoggingGateway(
|
|
1828
|
+
server_name=Hostname.from_boundary(gateway.get("serverName")),
|
|
1829
|
+
listen_address=IpAddress.from_boundary(gateway.get("listenAddress")),
|
|
1830
|
+
port=TcpPort.from_boundary(gateway.get("port")),
|
|
1831
|
+
tls=LoggingGatewayTls(
|
|
1832
|
+
certificate_path=AbsolutePath.from_boundary(
|
|
1833
|
+
gateway_tls.get("certificatePath")
|
|
1834
|
+
),
|
|
1835
|
+
key_path=AbsolutePath.from_boundary(gateway_tls.get("keyPath")),
|
|
1836
|
+
client_ca_path=AbsolutePath.from_boundary(
|
|
1837
|
+
gateway_tls.get("clientCaPath")
|
|
1838
|
+
),
|
|
1839
|
+
),
|
|
1840
|
+
),
|
|
1841
|
+
grafana=LoggingGrafana(
|
|
1842
|
+
listen_address=IpAddress.from_boundary(grafana.get("listenAddress")),
|
|
1843
|
+
port=TcpPort.from_boundary(grafana.get("port")),
|
|
1844
|
+
admin_password_path=AbsolutePath.from_boundary(
|
|
1845
|
+
grafana.get("adminPasswordPath")
|
|
1846
|
+
),
|
|
1847
|
+
),
|
|
1848
|
+
collectors=LoggingCollectors(
|
|
1849
|
+
server_ids=_resource_ids(
|
|
1850
|
+
collectors.get("servers"), "logging collector servers"
|
|
1851
|
+
),
|
|
1852
|
+
client_tls=LoggingClientTls(
|
|
1853
|
+
ca_path=AbsolutePath.from_boundary(client_tls.get("caPath")),
|
|
1854
|
+
certificate_path=AbsolutePath.from_boundary(
|
|
1855
|
+
client_tls.get("certificatePath")
|
|
1856
|
+
),
|
|
1857
|
+
key_path=AbsolutePath.from_boundary(client_tls.get("keyPath")),
|
|
1858
|
+
),
|
|
1859
|
+
journal=LoggingJournalSource(
|
|
1860
|
+
enabled=_boolean(journal.get("enabled"), "journal enabled"),
|
|
1861
|
+
max_age_hours=PositiveCount.from_boundary(journal.get("maxAgeHours")),
|
|
1862
|
+
),
|
|
1863
|
+
files=tuple(
|
|
1864
|
+
_logging_file_source(item)
|
|
1865
|
+
for item in _mapping_list(
|
|
1866
|
+
collectors.get("files"), "logging file sources"
|
|
1867
|
+
)
|
|
1868
|
+
),
|
|
1869
|
+
),
|
|
1870
|
+
alerting=alerting,
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _logging_alerting(content: Mapping[str, object]) -> LoggingAlerting:
|
|
1875
|
+
alertmanager = _mapping(content, "alertmanager")
|
|
1876
|
+
raw_package_version = alertmanager.get("packageVersion")
|
|
1877
|
+
return LoggingAlerting(
|
|
1878
|
+
alertmanager=AlertmanagerBackend(
|
|
1879
|
+
listen_address=IpAddress.from_boundary(
|
|
1880
|
+
alertmanager.get("listenAddress")
|
|
1881
|
+
),
|
|
1882
|
+
port=TcpPort.from_boundary(alertmanager.get("port")),
|
|
1883
|
+
package_version=(
|
|
1884
|
+
PackageVersion.from_boundary(raw_package_version)
|
|
1885
|
+
if raw_package_version is not None
|
|
1886
|
+
else None
|
|
1887
|
+
),
|
|
1888
|
+
),
|
|
1889
|
+
receivers=tuple(
|
|
1890
|
+
_alert_receiver(item)
|
|
1891
|
+
for item in _mapping_list(
|
|
1892
|
+
content.get("receivers"), "alert receivers"
|
|
1893
|
+
)
|
|
1894
|
+
),
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def _alert_receiver(content: Mapping[str, object]) -> AlertReceiverInventory:
|
|
1899
|
+
raw_webhook = content.get("webhook")
|
|
1900
|
+
raw_email = content.get("email")
|
|
1901
|
+
webhook = None
|
|
1902
|
+
if raw_webhook is not None:
|
|
1903
|
+
webhook_mapping = _boundary_mapping(raw_webhook, "webhook receiver")
|
|
1904
|
+
webhook = WebhookUrl.from_boundary(webhook_mapping.get("url"))
|
|
1905
|
+
email = None
|
|
1906
|
+
if raw_email is not None:
|
|
1907
|
+
email_mapping = _boundary_mapping(raw_email, "email receiver")
|
|
1908
|
+
raw_auth_username = email_mapping.get("authUsername")
|
|
1909
|
+
raw_auth_password_file = email_mapping.get("authPasswordFile")
|
|
1910
|
+
email = EmailReceiverInventory(
|
|
1911
|
+
smarthost=SmtpSmarthost.from_boundary(
|
|
1912
|
+
email_mapping.get("smarthost")
|
|
1913
|
+
),
|
|
1914
|
+
sender=EmailAddress.from_boundary(email_mapping.get("from")),
|
|
1915
|
+
recipient=EmailAddress.from_boundary(email_mapping.get("to")),
|
|
1916
|
+
auth_username=(
|
|
1917
|
+
SmtpUsername.from_boundary(raw_auth_username)
|
|
1918
|
+
if raw_auth_username is not None
|
|
1919
|
+
else None
|
|
1920
|
+
),
|
|
1921
|
+
auth_password_file=(
|
|
1922
|
+
AbsolutePath.from_boundary(raw_auth_password_file)
|
|
1923
|
+
if raw_auth_password_file is not None
|
|
1924
|
+
else None
|
|
1925
|
+
),
|
|
1926
|
+
)
|
|
1927
|
+
return AlertReceiverInventory(
|
|
1928
|
+
resource_id=ResourceId.from_boundary(content.get("id")),
|
|
1929
|
+
webhook=webhook,
|
|
1930
|
+
email=email,
|
|
1931
|
+
)
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _logging_file_source(content: Mapping[str, object]) -> LoggingFileSource:
|
|
1935
|
+
raw_application = content.get("application")
|
|
1936
|
+
raw_component = content.get("component")
|
|
1937
|
+
return LoggingFileSource(
|
|
1938
|
+
resource_id=ResourceId.from_boundary(content.get("id")),
|
|
1939
|
+
path=AbsolutePath.from_boundary(content.get("path")),
|
|
1940
|
+
server_ids=_resource_ids(content.get("servers"), "log-file servers"),
|
|
1941
|
+
application_id=(
|
|
1942
|
+
ResourceId.from_boundary(raw_application)
|
|
1943
|
+
if raw_application is not None
|
|
1944
|
+
else None
|
|
1945
|
+
),
|
|
1946
|
+
component_id=(
|
|
1947
|
+
ResourceId.from_boundary(raw_component)
|
|
1948
|
+
if raw_component is not None
|
|
1949
|
+
else None
|
|
1950
|
+
),
|
|
1951
|
+
)
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
def _mount_requirement(content: Mapping[str, object]) -> MountRequirement:
|
|
1955
|
+
return MountRequirement(
|
|
1956
|
+
path=AbsolutePath.from_boundary(content.get("path")),
|
|
1957
|
+
filesystem=FilesystemName.from_boundary(content.get("filesystem")),
|
|
1958
|
+
minimum_bytes=ByteSize.from_boundary(content.get("minimumBytes")),
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def _package_requirement(content: Mapping[str, object]) -> PackageRequirement:
|
|
1963
|
+
raw_version = content.get("version")
|
|
1964
|
+
version = (
|
|
1965
|
+
PackageVersion.from_boundary(raw_version) if raw_version is not None else None
|
|
1966
|
+
)
|
|
1967
|
+
return PackageRequirement(
|
|
1968
|
+
name=PackageName.from_boundary(content.get("name")),
|
|
1969
|
+
version=version,
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _service_requirement(content: Mapping[str, object]) -> ServiceRequirement:
|
|
1974
|
+
return ServiceRequirement(
|
|
1975
|
+
name=SystemdUnitName.from_boundary(content.get("name")),
|
|
1976
|
+
state=RequiredServiceState.from_boundary(content.get("state")),
|
|
1977
|
+
status=RequiredServiceStatus.from_boundary(content.get("status")),
|
|
1978
|
+
)
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
def _firewall_requirement(value: object) -> FirewallRequirement | None:
|
|
1982
|
+
if value is None:
|
|
1983
|
+
return None
|
|
1984
|
+
firewall = _boundary_mapping(value, "server type firewall")
|
|
1985
|
+
return FirewallRequirement(
|
|
1986
|
+
policy=FirewallPolicy.from_boundary(firewall.get("policy")),
|
|
1987
|
+
allowed_inbound=tuple(
|
|
1988
|
+
_firewall_rule(item)
|
|
1989
|
+
for item in _mapping_list(
|
|
1990
|
+
firewall.get("allowedInbound"), "firewall rules"
|
|
1991
|
+
)
|
|
1992
|
+
),
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def _firewall_rule(content: Mapping[str, object]) -> FirewallRule:
|
|
1997
|
+
raw_description = content.get("description")
|
|
1998
|
+
if raw_description is not None and not isinstance(raw_description, str):
|
|
1999
|
+
message = "validated firewall rule description is not a string"
|
|
2000
|
+
raise TypeError(message)
|
|
2001
|
+
return FirewallRule(
|
|
2002
|
+
port=TcpPort.from_boundary(content.get("port")),
|
|
2003
|
+
protocol=NetworkProtocol.from_boundary(content.get("protocol")),
|
|
2004
|
+
description=raw_description,
|
|
2005
|
+
)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def firewall_rules_for_server(
|
|
2009
|
+
firewall: FirewallRequirement, ssh_port: TcpPort
|
|
2010
|
+
) -> tuple[FirewallRule, ...]:
|
|
2011
|
+
"""Return effective inbound rules with the server's SSH port guaranteed."""
|
|
2012
|
+
declared = {
|
|
2013
|
+
(rule.port.value, rule.protocol) for rule in firewall.allowed_inbound
|
|
2014
|
+
}
|
|
2015
|
+
if (ssh_port.value, NetworkProtocol.TCP) in declared:
|
|
2016
|
+
return firewall.allowed_inbound
|
|
2017
|
+
ssh_rule = FirewallRule(
|
|
2018
|
+
port=ssh_port,
|
|
2019
|
+
protocol=NetworkProtocol.TCP,
|
|
2020
|
+
description="ssh",
|
|
2021
|
+
)
|
|
2022
|
+
return (ssh_rule, *firewall.allowed_inbound)
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _configuration_requirement(
|
|
2026
|
+
content: Mapping[str, object],
|
|
2027
|
+
) -> ConfigurationFileRequirement:
|
|
2028
|
+
raw_owner = content.get("owner")
|
|
2029
|
+
raw_group = content.get("group")
|
|
2030
|
+
raw_mode = content.get("mode")
|
|
2031
|
+
raw_sha256 = content.get("sha256")
|
|
2032
|
+
return ConfigurationFileRequirement(
|
|
2033
|
+
path=AbsolutePath.from_boundary(content.get("path")),
|
|
2034
|
+
capture=ConfigCapture.from_boundary(content.get("capture")),
|
|
2035
|
+
owner=LinuxUser.from_boundary(raw_owner) if raw_owner is not None else None,
|
|
2036
|
+
group=LinuxUser.from_boundary(raw_group) if raw_group is not None else None,
|
|
2037
|
+
mode=FileMode.from_boundary(raw_mode) if raw_mode is not None else None,
|
|
2038
|
+
sha256=(
|
|
2039
|
+
Sha256Digest.from_boundary(raw_sha256) if raw_sha256 is not None else None
|
|
2040
|
+
),
|
|
2041
|
+
)
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _mapping(content: Mapping[str, object], key: str) -> Mapping[str, object]:
|
|
2045
|
+
value = content.get(key)
|
|
2046
|
+
if not isinstance(value, dict) or not all(
|
|
2047
|
+
isinstance(child_key, str) for child_key in value
|
|
2048
|
+
):
|
|
2049
|
+
message = f"validated field {key!r} is not an object"
|
|
2050
|
+
raise TypeError(message)
|
|
2051
|
+
return cast("Mapping[str, object]", value)
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _string(content: Mapping[str, object], key: str) -> str:
|
|
2055
|
+
value = content.get(key)
|
|
2056
|
+
if not isinstance(value, str):
|
|
2057
|
+
message = f"validated field {key!r} is not a string"
|
|
2058
|
+
raise TypeError(message)
|
|
2059
|
+
return value
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _boundary_mapping(value: object, concept: str) -> Mapping[str, object]:
|
|
2063
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
2064
|
+
message = f"validated {concept} is not an object"
|
|
2065
|
+
raise TypeError(message)
|
|
2066
|
+
return cast("Mapping[str, object]", value)
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _resource_ids(value: object, concept: str) -> tuple[ResourceId, ...]:
|
|
2070
|
+
if not isinstance(value, list):
|
|
2071
|
+
message = f"validated {concept} is not a list"
|
|
2072
|
+
raise TypeError(message)
|
|
2073
|
+
return tuple(ResourceId.from_boundary(item) for item in value)
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
def _list(value: object, concept: str) -> tuple[object, ...]:
|
|
2077
|
+
if not isinstance(value, list):
|
|
2078
|
+
message = f"validated {concept} is not a list"
|
|
2079
|
+
raise TypeError(message)
|
|
2080
|
+
return tuple(value)
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def _mapping_list(value: object, concept: str) -> tuple[Mapping[str, object], ...]:
|
|
2084
|
+
items = _list(value, concept)
|
|
2085
|
+
mappings: list[Mapping[str, object]] = []
|
|
2086
|
+
for item in items:
|
|
2087
|
+
if not isinstance(item, dict) or not all(isinstance(key, str) for key in item):
|
|
2088
|
+
message = f"validated {concept} contains a non-object item"
|
|
2089
|
+
raise TypeError(message)
|
|
2090
|
+
mappings.append(cast("Mapping[str, object]", item))
|
|
2091
|
+
return tuple(mappings)
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def _labels(value: object) -> tuple[ServerLabel, ...]:
|
|
2095
|
+
if not isinstance(value, dict):
|
|
2096
|
+
message = "validated server labels are not an object"
|
|
2097
|
+
raise TypeError(message)
|
|
2098
|
+
labels: list[ServerLabel] = []
|
|
2099
|
+
for key, label_value in value.items():
|
|
2100
|
+
if not isinstance(label_value, str):
|
|
2101
|
+
message = f"validated server label {key!r} is not a string"
|
|
2102
|
+
raise TypeError(message)
|
|
2103
|
+
labels.append(
|
|
2104
|
+
ServerLabel(
|
|
2105
|
+
key=ResourceId.from_boundary(key),
|
|
2106
|
+
value=label_value,
|
|
2107
|
+
)
|
|
2108
|
+
)
|
|
2109
|
+
return tuple(sorted(labels, key=lambda label: label.key.value))
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _boolean(value: object, concept: str) -> bool:
|
|
2113
|
+
if not isinstance(value, bool):
|
|
2114
|
+
message = f"validated {concept} is not a boolean"
|
|
2115
|
+
raise TypeError(message)
|
|
2116
|
+
return value
|