cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
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