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/audit.py ADDED
@@ -0,0 +1,905 @@
1
+ """Compare desired server types with validated server observations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from typing import TYPE_CHECKING, cast
11
+
12
+ from cloudfall.inventory import firewall_rules_for_server
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable, Mapping
16
+
17
+ from cloudfall.domain import ResourceId
18
+ from cloudfall.inventory import (
19
+ AlertRuleInventory,
20
+ ComponentInventory,
21
+ ConfigurationFileRequirement,
22
+ FirewallRequirement,
23
+ LoggingStackInventory,
24
+ PlatformInventory,
25
+ RaidRequirement,
26
+ ServerInventory,
27
+ ServerTypeInventory,
28
+ ServiceInventory,
29
+ )
30
+ from cloudfall.observation import ObservationSet, ObservedServerSnapshot
31
+
32
+ _RAID_HEALTH_PATTERN = re.compile(r"\[(\d+)/(\d+)\]\s+\[([U_]+)\]")
33
+ _MANAGED_FIREWALL_TABLE = "cloudfall"
34
+ _MANAGED_FIREWALL_INPUT_CHAIN = "input"
35
+ _MANAGED_FIREWALL_INPUT_POLICY = "drop"
36
+ _LOOPBACK_ADDRESSES = frozenset({"127.0.0.1", "::1"})
37
+ _MINIMUM_SOCKET_FIELDS = 5
38
+
39
+
40
+ class AuditStatus(StrEnum):
41
+ """Compliance status of an audit check or report."""
42
+
43
+ COMPLIANT = "compliant"
44
+ DRIFT = "drift"
45
+ UNKNOWN = "unknown"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class AuditCheck:
50
+ """One desired-versus-observed comparison."""
51
+
52
+ check: str
53
+ status: AuditStatus
54
+ desired: object
55
+ observed: object
56
+ message: str
57
+
58
+ def as_dict(self) -> dict[str, object]:
59
+ """Serialize a stable machine-readable check."""
60
+ return {
61
+ "check": self.check,
62
+ "status": self.status.value,
63
+ "desired": self.desired,
64
+ "observed": self.observed,
65
+ "message": self.message,
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ServerAudit:
71
+ """Audit result for one desired server."""
72
+
73
+ server_id: str
74
+ server_type_id: str
75
+ status: AuditStatus
76
+ observation: str | None
77
+ observed_at: str | None
78
+ checks: tuple[AuditCheck, ...]
79
+
80
+ def as_dict(self) -> dict[str, object]:
81
+ """Serialize a server audit."""
82
+ return {
83
+ "server": self.server_id,
84
+ "serverType": self.server_type_id,
85
+ "status": self.status.value,
86
+ "observation": self.observation,
87
+ "observedAt": self.observed_at,
88
+ "checks": [check.as_dict() for check in self.checks],
89
+ }
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class AuditReport:
94
+ """Fleet-wide desired-versus-observed audit report."""
95
+
96
+ status: AuditStatus
97
+ servers: tuple[ServerAudit, ...]
98
+ unmatched_observations: tuple[str, ...]
99
+
100
+ def as_dict(self) -> dict[str, object]:
101
+ """Serialize the report with deterministic summary counts."""
102
+ server_counts = Counter(server.status.value for server in self.servers)
103
+ check_counts = Counter(
104
+ check.status.value
105
+ for server in self.servers
106
+ for check in server.checks
107
+ )
108
+ return {
109
+ "status": self.status.value,
110
+ "summary": {
111
+ "servers": {
112
+ status.value: server_counts[status.value]
113
+ for status in AuditStatus
114
+ },
115
+ "checks": {
116
+ status.value: check_counts[status.value]
117
+ for status in AuditStatus
118
+ },
119
+ },
120
+ "unmatchedObservations": list(self.unmatched_observations),
121
+ "servers": [server.as_dict() for server in self.servers],
122
+ }
123
+
124
+
125
+ def audit_inventory(
126
+ inventory: PlatformInventory,
127
+ observations: ObservationSet,
128
+ environment_receipts: Mapping[str, str] | None = None,
129
+ ) -> AuditReport:
130
+ """Audit every desired server against its validated observation."""
131
+ receipts = environment_receipts if environment_receipts is not None else {}
132
+ stacks_by_backend = {
133
+ stack.backend.server_id: stack for stack in inventory.logging_stacks
134
+ }
135
+ audits = tuple(
136
+ _audit_server(
137
+ server,
138
+ inventory.server_type(server.server_type_id),
139
+ tuple(
140
+ service
141
+ for service in inventory.services
142
+ if service.server_id == server.resource_id
143
+ ),
144
+ _alert_rules_for_backend(server, stacks_by_backend, inventory),
145
+ tuple(
146
+ component
147
+ for component in inventory.components
148
+ if server.resource_id in component.server_ids
149
+ ),
150
+ receipts,
151
+ observations.for_server(server.resource_id),
152
+ )
153
+ for server in inventory.servers
154
+ )
155
+ desired_ids = frozenset(server.resource_id for server in inventory.servers)
156
+ unmatched = tuple(
157
+ sorted(
158
+ snapshot.server_id.value
159
+ for snapshot in observations.snapshots
160
+ if snapshot.server_id not in desired_ids
161
+ )
162
+ )
163
+ return AuditReport(
164
+ status=_aggregate_status(audit.status for audit in audits),
165
+ servers=audits,
166
+ unmatched_observations=unmatched,
167
+ )
168
+
169
+
170
+ def _audit_server( # noqa: PLR0913 - one audit, many evidence sources.
171
+ server: ServerInventory,
172
+ server_type: ServerTypeInventory,
173
+ services: tuple[ServiceInventory, ...],
174
+ alert_rules: tuple[AlertRuleInventory, ...],
175
+ components: tuple[ComponentInventory, ...],
176
+ environment_receipts: Mapping[str, str],
177
+ snapshot: ObservedServerSnapshot | None,
178
+ ) -> ServerAudit:
179
+ if snapshot is None:
180
+ check = AuditCheck(
181
+ check="observation.available",
182
+ status=AuditStatus.UNKNOWN,
183
+ desired="validated observation",
184
+ observed=None,
185
+ message="no observation was supplied for this server",
186
+ )
187
+ return ServerAudit(
188
+ server_id=server.resource_id.value,
189
+ server_type_id=server_type.resource_id.value,
190
+ status=AuditStatus.UNKNOWN,
191
+ observation=None,
192
+ observed_at=None,
193
+ checks=(check,),
194
+ )
195
+
196
+ checks: list[AuditCheck] = []
197
+ checks.append(
198
+ _comparison(
199
+ "server_type.id",
200
+ server_type.resource_id.value,
201
+ snapshot.server_type_id.value,
202
+ matches=server_type.resource_id == snapshot.server_type_id,
203
+ )
204
+ )
205
+ checks.extend(_audit_os(server_type, snapshot))
206
+ if server_type.raid is not None:
207
+ checks.extend(_audit_raid(server_type.raid, snapshot))
208
+ checks.extend(_audit_mounts(server_type, snapshot))
209
+ checks.extend(_audit_packages(server_type, snapshot))
210
+ checks.extend(_audit_services(server_type, snapshot))
211
+ if server_type.firewall is not None:
212
+ checks.extend(_audit_firewall(server, server_type.firewall, snapshot))
213
+ checks.extend(_audit_service_binds(services, snapshot))
214
+ checks.extend(_audit_alert_rules(alert_rules, snapshot))
215
+ checks.extend(
216
+ _audit_component_environment(
217
+ components, environment_receipts, snapshot
218
+ )
219
+ )
220
+ checks.extend(_audit_configuration(server_type, snapshot))
221
+ return ServerAudit(
222
+ server_id=server.resource_id.value,
223
+ server_type_id=server_type.resource_id.value,
224
+ status=_aggregate_status(check.status for check in checks),
225
+ observation=str(snapshot.source),
226
+ observed_at=_string(snapshot.spec, "observedAt"),
227
+ checks=tuple(checks),
228
+ )
229
+
230
+
231
+ def _audit_os(
232
+ server_type: ServerTypeInventory, snapshot: ObservedServerSnapshot
233
+ ) -> tuple[AuditCheck, ...]:
234
+ observed = _mapping(snapshot.spec, "os")
235
+ distribution = _string(observed, "distribution")
236
+ major_version = _string(observed, "majorVersion")
237
+ service_manager = _string(observed, "serviceManager")
238
+ versions = [version.value for version in server_type.os.versions]
239
+ return (
240
+ _comparison(
241
+ "os.distribution",
242
+ server_type.os.distribution.value,
243
+ distribution,
244
+ matches=distribution == server_type.os.distribution.value,
245
+ ),
246
+ _comparison(
247
+ "os.majorVersion",
248
+ versions,
249
+ major_version,
250
+ matches=major_version in versions,
251
+ ),
252
+ _comparison(
253
+ "os.serviceManager",
254
+ server_type.os.service_manager.value,
255
+ service_manager,
256
+ matches=service_manager == server_type.os.service_manager.value,
257
+ ),
258
+ )
259
+
260
+
261
+ def _audit_raid(
262
+ requirement: RaidRequirement, snapshot: ObservedServerSnapshot
263
+ ) -> tuple[AuditCheck, ...]:
264
+ storage = _mapping(snapshot.spec, "storage")
265
+ software_raid = _mapping(storage, "softwareRaid")
266
+ mdstat = _string(software_raid, "mdstat")
267
+ raid_level = requirement.level.value
268
+ health_matches = tuple(_RAID_HEALTH_PATTERN.finditer(mdstat))
269
+ active_devices = max(
270
+ (match.group(3).count("U") for match in health_matches),
271
+ default=0,
272
+ )
273
+ raid_sizes = [
274
+ _integer(device, "size")
275
+ for device in _flatten_block_devices(
276
+ _mapping_sequence(storage, "blockDevices")
277
+ )
278
+ if _string(device, "type") == raid_level
279
+ ]
280
+ usable_bytes = max(raid_sizes, default=0)
281
+ return (
282
+ _comparison(
283
+ "storage.softwareRaid.level",
284
+ raid_level,
285
+ _observed_raid_levels(mdstat),
286
+ matches=f"active {raid_level}" in mdstat,
287
+ ),
288
+ _comparison(
289
+ "storage.softwareRaid.activeDevices",
290
+ {"minimum": requirement.minimum_active_devices.value},
291
+ active_devices,
292
+ matches=active_devices >= requirement.minimum_active_devices.value,
293
+ ),
294
+ _comparison(
295
+ "storage.softwareRaid.usableBytes",
296
+ {"minimum": requirement.minimum_usable_bytes.value},
297
+ usable_bytes,
298
+ matches=usable_bytes >= requirement.minimum_usable_bytes.value,
299
+ ),
300
+ )
301
+
302
+
303
+ def _audit_mounts(
304
+ server_type: ServerTypeInventory, snapshot: ObservedServerSnapshot
305
+ ) -> tuple[AuditCheck, ...]:
306
+ storage = _mapping(snapshot.spec, "storage")
307
+ filesystems = {
308
+ _string(item, "target"): item
309
+ for item in _mapping_sequence(storage, "filesystems")
310
+ }
311
+ checks: list[AuditCheck] = []
312
+ for requirement in server_type.mounts:
313
+ actual = filesystems.get(requirement.path.value)
314
+ desired = {
315
+ "filesystem": requirement.filesystem.value,
316
+ "minimumBytes": requirement.minimum_bytes.value,
317
+ }
318
+ if actual is None:
319
+ checks.append(
320
+ _comparison(
321
+ f"storage.mounts[{requirement.path.value}]",
322
+ desired,
323
+ None,
324
+ matches=False,
325
+ )
326
+ )
327
+ continue
328
+ observed_size = actual.get("size")
329
+ size_matches = (
330
+ isinstance(observed_size, int)
331
+ and not isinstance(observed_size, bool)
332
+ and observed_size >= requirement.minimum_bytes.value
333
+ )
334
+ observed = {
335
+ "filesystem": _string(actual, "fstype"),
336
+ "size": observed_size,
337
+ "source": _string(actual, "source"),
338
+ }
339
+ checks.append(
340
+ _comparison(
341
+ f"storage.mounts[{requirement.path.value}]",
342
+ desired,
343
+ observed,
344
+ matches=(
345
+ observed["filesystem"] == requirement.filesystem.value
346
+ and size_matches
347
+ ),
348
+ )
349
+ )
350
+ return tuple(checks)
351
+
352
+
353
+ def _audit_packages(
354
+ server_type: ServerTypeInventory, snapshot: ObservedServerSnapshot
355
+ ) -> tuple[AuditCheck, ...]:
356
+ packages: dict[str, list[Mapping[str, object]]] = {}
357
+ for entry in _mapping_sequence(snapshot.spec, "packages"):
358
+ packages.setdefault(_string(entry, "name"), []).append(entry)
359
+ checks: list[AuditCheck] = []
360
+ for requirement in server_type.required_packages:
361
+ entries = tuple(packages.get(requirement.name.value, []))
362
+ versions = [_string(entry, "version") for entry in entries]
363
+ desired: object = (
364
+ {"installed": True}
365
+ if requirement.version is None
366
+ else {"version": requirement.version.value}
367
+ )
368
+ matches = bool(entries) and (
369
+ requirement.version is None
370
+ or requirement.version.value in versions
371
+ )
372
+ checks.append(
373
+ _comparison(
374
+ f"packages.required[{requirement.name.value}]",
375
+ desired,
376
+ {"versions": versions} if entries else None,
377
+ matches=matches,
378
+ )
379
+ )
380
+ for package in server_type.forbidden_packages:
381
+ present = package.value in packages
382
+ checks.append(
383
+ _comparison(
384
+ f"packages.forbidden[{package.value}]",
385
+ {"installed": False},
386
+ {"installed": present},
387
+ matches=not present,
388
+ )
389
+ )
390
+ return tuple(checks)
391
+
392
+
393
+ def _audit_services(
394
+ server_type: ServerTypeInventory, snapshot: ObservedServerSnapshot
395
+ ) -> tuple[AuditCheck, ...]:
396
+ services = _mapping(snapshot.spec, "services")
397
+ timers = _mapping(snapshot.spec, "timers")
398
+ checks: list[AuditCheck] = []
399
+ for requirement in server_type.required_services:
400
+ evidence = timers if requirement.name.is_timer else services
401
+ raw_actual = evidence.get(requirement.name.value)
402
+ actual = _optional_mapping(raw_actual)
403
+ desired = {
404
+ "state": requirement.state.value,
405
+ "status": requirement.status.value,
406
+ }
407
+ observed = (
408
+ {
409
+ "state": _string(actual, "state"),
410
+ "status": _string(actual, "status"),
411
+ }
412
+ if actual is not None
413
+ else None
414
+ )
415
+ checks.append(
416
+ _comparison(
417
+ f"services.required[{requirement.name.value}]",
418
+ desired,
419
+ observed,
420
+ matches=observed == desired,
421
+ )
422
+ )
423
+ return tuple(checks)
424
+
425
+
426
+ @dataclass(frozen=True, slots=True)
427
+ class _ObservedFirewallTable:
428
+ """Managed nftables table facts parsed from observation evidence."""
429
+
430
+ input_policy: str | None
431
+ allowed_inbound: tuple[tuple[str, int], ...]
432
+
433
+
434
+ def _audit_firewall(
435
+ server: ServerInventory,
436
+ firewall: FirewallRequirement,
437
+ snapshot: ObservedServerSnapshot,
438
+ ) -> tuple[AuditCheck, ...]:
439
+ evidence = _mapping(snapshot.spec, "firewall")
440
+ nft_available = evidence.get("nftAvailable") is True
441
+ raw_table = evidence.get("managedTableJson")
442
+ table = (
443
+ _parse_managed_firewall_table(raw_table)
444
+ if nft_available and isinstance(raw_table, str)
445
+ else None
446
+ )
447
+ expected_rules = sorted(
448
+ (rule.protocol.value, rule.port.value)
449
+ for rule in firewall_rules_for_server(firewall, server.ssh_port)
450
+ )
451
+ observed_rules = None if table is None else sorted(table.allowed_inbound)
452
+ return (
453
+ _comparison(
454
+ "firewall.managedTable",
455
+ {"table": f"inet {_MANAGED_FIREWALL_TABLE}", "present": True},
456
+ {"nftAvailable": nft_available, "present": table is not None},
457
+ matches=table is not None,
458
+ ),
459
+ _comparison(
460
+ "firewall.inputPolicy",
461
+ _MANAGED_FIREWALL_INPUT_POLICY,
462
+ None if table is None else table.input_policy,
463
+ matches=(
464
+ table is not None
465
+ and table.input_policy == _MANAGED_FIREWALL_INPUT_POLICY
466
+ ),
467
+ ),
468
+ _comparison(
469
+ "firewall.allowedInbound",
470
+ [f"{protocol}/{port}" for protocol, port in expected_rules],
471
+ (
472
+ None
473
+ if observed_rules is None
474
+ else [f"{protocol}/{port}" for protocol, port in observed_rules]
475
+ ),
476
+ matches=observed_rules == expected_rules,
477
+ ),
478
+ )
479
+
480
+
481
+ def _parse_managed_firewall_table(raw: str) -> _ObservedFirewallTable | None:
482
+ try:
483
+ parsed: object = json.loads(raw)
484
+ except json.JSONDecodeError:
485
+ return None
486
+ document = _optional_mapping(parsed)
487
+ if document is None:
488
+ return None
489
+ entries = _mapping_values_sequence(document.get("nftables"))
490
+ input_policy: str | None = None
491
+ allowed: list[tuple[str, int]] = []
492
+ table_present = False
493
+ for entry in entries:
494
+ table = _optional_mapping(entry.get("table"))
495
+ if table is not None and table.get("name") == _MANAGED_FIREWALL_TABLE:
496
+ table_present = True
497
+ chain = _optional_mapping(entry.get("chain"))
498
+ if (
499
+ chain is not None
500
+ and chain.get("table") == _MANAGED_FIREWALL_TABLE
501
+ and chain.get("name") == _MANAGED_FIREWALL_INPUT_CHAIN
502
+ and isinstance(chain.get("policy"), str)
503
+ ):
504
+ input_policy = cast("str", chain.get("policy"))
505
+ rule = _optional_mapping(entry.get("rule"))
506
+ if (
507
+ rule is not None
508
+ and rule.get("table") == _MANAGED_FIREWALL_TABLE
509
+ and rule.get("chain") == _MANAGED_FIREWALL_INPUT_CHAIN
510
+ ):
511
+ endpoint = _accepted_inbound_endpoint(rule)
512
+ if endpoint is not None:
513
+ allowed.append(endpoint)
514
+ if not table_present:
515
+ return None
516
+ return _ObservedFirewallTable(
517
+ input_policy=input_policy,
518
+ allowed_inbound=tuple(allowed),
519
+ )
520
+
521
+
522
+ def _accepted_inbound_endpoint(
523
+ rule: Mapping[str, object],
524
+ ) -> tuple[str, int] | None:
525
+ expressions = _mapping_values_sequence(rule.get("expr"))
526
+ endpoint: tuple[str, int] | None = None
527
+ accepts = False
528
+ for expression in expressions:
529
+ if "accept" in expression:
530
+ accepts = True
531
+ continue
532
+ match = _optional_mapping(expression.get("match"))
533
+ if match is None:
534
+ continue
535
+ left = _optional_mapping(match.get("left"))
536
+ payload = None if left is None else _optional_mapping(left.get("payload"))
537
+ port = match.get("right")
538
+ if (
539
+ payload is not None
540
+ and payload.get("field") == "dport"
541
+ and payload.get("protocol") in ("tcp", "udp")
542
+ and isinstance(port, int)
543
+ and not isinstance(port, bool)
544
+ ):
545
+ endpoint = (cast("str", payload.get("protocol")), port)
546
+ if not accepts:
547
+ return None
548
+ return endpoint
549
+
550
+
551
+ @dataclass(frozen=True, slots=True)
552
+ class _SocketListener:
553
+ """One listening socket parsed from collected evidence."""
554
+
555
+ protocol: str
556
+ address: str
557
+ port: int
558
+
559
+
560
+ def _audit_service_binds(
561
+ services: tuple[ServiceInventory, ...],
562
+ snapshot: ObservedServerSnapshot,
563
+ ) -> tuple[AuditCheck, ...]:
564
+ if not services:
565
+ return ()
566
+ network = _mapping(snapshot.spec, "network")
567
+ listeners = _parse_listening_sockets(_string(network, "listeningSockets"))
568
+ checks: list[AuditCheck] = []
569
+ for service in services:
570
+ port = service.bind.port.value
571
+ address = service.bind.address.value
572
+ on_port = tuple(
573
+ listener
574
+ for listener in listeners
575
+ if listener.protocol == "tcp" and listener.port == port
576
+ )
577
+ declared_present = any(
578
+ listener.address == address for listener in on_port
579
+ )
580
+ exposed = tuple(
581
+ listener
582
+ for listener in on_port
583
+ if listener.address not in _LOOPBACK_ADDRESSES
584
+ )
585
+ observed = (
586
+ {
587
+ "listeners": [
588
+ f"{listener.address}:{listener.port}"
589
+ for listener in on_port
590
+ ]
591
+ }
592
+ if on_port
593
+ else None
594
+ )
595
+ checks.append(
596
+ _comparison(
597
+ f"services.bind[{service.resource_id.value}]",
598
+ {
599
+ "protocol": "tcp",
600
+ "address": address,
601
+ "port": port,
602
+ "loopbackOnly": True,
603
+ },
604
+ observed,
605
+ matches=declared_present and not exposed,
606
+ )
607
+ )
608
+ return tuple(checks)
609
+
610
+
611
+ def _parse_listening_sockets(raw: str) -> tuple[_SocketListener, ...]:
612
+ listeners: list[_SocketListener] = []
613
+ for line in raw.splitlines():
614
+ fields = line.split()
615
+ if len(fields) < _MINIMUM_SOCKET_FIELDS:
616
+ continue
617
+ protocol = fields[0]
618
+ local = fields[4]
619
+ address, separator, raw_port = local.rpartition(":")
620
+ if not separator or not raw_port.isdigit():
621
+ continue
622
+ address = address.strip("[]").split("%", maxsplit=1)[0]
623
+ listeners.append(
624
+ _SocketListener(
625
+ protocol=protocol,
626
+ address=address,
627
+ port=int(raw_port),
628
+ )
629
+ )
630
+ return tuple(listeners)
631
+
632
+
633
+ def _alert_rules_for_backend(
634
+ server: ServerInventory,
635
+ stacks_by_backend: Mapping[ResourceId, LoggingStackInventory],
636
+ inventory: PlatformInventory,
637
+ ) -> tuple[AlertRuleInventory, ...]:
638
+ stack = stacks_by_backend.get(server.resource_id)
639
+ if stack is None:
640
+ return ()
641
+ return tuple(
642
+ rule
643
+ for rule in inventory.alert_rules
644
+ if rule.environment == stack.environment
645
+ )
646
+
647
+
648
+ def _audit_alert_rules(
649
+ alert_rules: tuple[AlertRuleInventory, ...],
650
+ snapshot: ObservedServerSnapshot,
651
+ ) -> tuple[AuditCheck, ...]:
652
+ if not alert_rules:
653
+ return ()
654
+ raw_alerting = _optional_mapping(snapshot.spec.get("alerting"))
655
+ observed_rules = (
656
+ _observed_alert_rules(raw_alerting)
657
+ if raw_alerting is not None
658
+ and raw_alerting.get("prometheusAvailable") is True
659
+ else None
660
+ )
661
+ checks: list[AuditCheck] = []
662
+ for rule in alert_rules:
663
+ rule_name = rule.resource_id.value.replace("-", "_")
664
+ desired = {"name": rule_name, "health": "ok", "loaded": True}
665
+ observed = (
666
+ observed_rules.get(rule_name) if observed_rules is not None else None
667
+ )
668
+ checks.append(
669
+ _comparison(
670
+ f"alerting.rules[{rule.resource_id.value}]",
671
+ desired,
672
+ observed,
673
+ matches=(
674
+ observed is not None and observed.get("health") == "ok"
675
+ ),
676
+ )
677
+ )
678
+ return tuple(checks)
679
+
680
+
681
+ def _observed_alert_rules(
682
+ alerting: Mapping[str, object],
683
+ ) -> dict[str, dict[str, object]]:
684
+ raw_rules_json = alerting.get("rulesJson")
685
+ if not isinstance(raw_rules_json, str):
686
+ return {}
687
+ try:
688
+ payload = cast("object", json.loads(raw_rules_json))
689
+ except json.JSONDecodeError:
690
+ return {}
691
+ if not isinstance(payload, dict):
692
+ return {}
693
+ data = payload.get("data")
694
+ if not isinstance(data, dict):
695
+ return {}
696
+ observed: dict[str, dict[str, object]] = {}
697
+ for group in _mapping_values_sequence(data.get("groups")):
698
+ for raw_rule in _mapping_values_sequence(group.get("rules")):
699
+ if raw_rule.get("type") != "alerting":
700
+ continue
701
+ name = raw_rule.get("name")
702
+ if not isinstance(name, str):
703
+ continue
704
+ observed[name] = {
705
+ "name": name,
706
+ "health": raw_rule.get("health"),
707
+ "state": raw_rule.get("state"),
708
+ "loaded": True,
709
+ }
710
+ return observed
711
+
712
+
713
+ def _audit_component_environment(
714
+ components: tuple[ComponentInventory, ...],
715
+ environment_receipts: Mapping[str, str],
716
+ snapshot: ObservedServerSnapshot,
717
+ ) -> tuple[AuditCheck, ...]:
718
+ audited = tuple(
719
+ component
720
+ for component in components
721
+ if component.resource_id.value in environment_receipts
722
+ )
723
+ if not audited:
724
+ return ()
725
+ observed_entries = {
726
+ str(entry.get("component")): entry
727
+ for entry in _mapping_values_sequence(
728
+ snapshot.spec.get("componentEnvironment")
729
+ )
730
+ }
731
+ checks: list[AuditCheck] = []
732
+ for component in audited:
733
+ expected = environment_receipts[component.resource_id.value]
734
+ entry = observed_entries.get(component.resource_id.value)
735
+ observed = (
736
+ {
737
+ "exists": entry.get("exists"),
738
+ "sha256": entry.get("sha256"),
739
+ }
740
+ if entry is not None
741
+ else None
742
+ )
743
+ matches = (
744
+ entry is not None
745
+ and entry.get("exists") is True
746
+ and entry.get("sha256") == expected
747
+ )
748
+ checks.append(
749
+ _comparison(
750
+ f"environment.file[{component.resource_id.value}]",
751
+ {"exists": True, "sha256": expected},
752
+ observed,
753
+ matches=matches,
754
+ )
755
+ )
756
+ return tuple(checks)
757
+
758
+
759
+ def _audit_configuration(
760
+ server_type: ServerTypeInventory, snapshot: ObservedServerSnapshot
761
+ ) -> tuple[AuditCheck, ...]:
762
+ evidence = {
763
+ _string(item, "path"): item
764
+ for item in _mapping_sequence(snapshot.spec, "configuration")
765
+ }
766
+ return tuple(
767
+ _audit_configuration_file(requirement, evidence.get(requirement.path.value))
768
+ for requirement in server_type.configuration_files
769
+ )
770
+
771
+
772
+ def _audit_configuration_file(
773
+ requirement: ConfigurationFileRequirement,
774
+ actual: Mapping[str, object] | None,
775
+ ) -> AuditCheck:
776
+ desired: dict[str, object] = {
777
+ "exists": True,
778
+ "capture": requirement.capture.value,
779
+ }
780
+ if requirement.owner is not None:
781
+ desired["owner"] = requirement.owner.value
782
+ if requirement.group is not None:
783
+ desired["group"] = requirement.group.value
784
+ if requirement.mode is not None:
785
+ desired["mode"] = requirement.mode.value
786
+ if requirement.sha256 is not None:
787
+ desired["sha256"] = requirement.sha256.value
788
+
789
+ if actual is None:
790
+ return _comparison(
791
+ f"configuration.files[{requirement.path.value}]",
792
+ desired,
793
+ None,
794
+ matches=False,
795
+ )
796
+ observed = {
797
+ key: actual.get(key)
798
+ for key in ("exists", "capture", "owner", "group", "mode", "sha256")
799
+ }
800
+ matches = actual.get("exists") is True
801
+ if requirement.capture.value == "hash":
802
+ matches = matches and actual.get("sha256") is not None
803
+ for key, expected in desired.items():
804
+ matches = matches and observed.get(key) == expected
805
+ return _comparison(
806
+ f"configuration.files[{requirement.path.value}]",
807
+ desired,
808
+ observed,
809
+ matches=matches,
810
+ )
811
+
812
+
813
+ def _comparison(
814
+ check: str, desired: object, observed: object, *, matches: bool
815
+ ) -> AuditCheck:
816
+ status = AuditStatus.COMPLIANT if matches else AuditStatus.DRIFT
817
+ message = "desired state matches" if matches else "desired state does not match"
818
+ return AuditCheck(
819
+ check=check,
820
+ status=status,
821
+ desired=desired,
822
+ observed=observed,
823
+ message=message,
824
+ )
825
+
826
+
827
+ def _aggregate_status(statuses: Iterable[AuditStatus]) -> AuditStatus:
828
+ values = tuple(statuses)
829
+ if AuditStatus.DRIFT in values:
830
+ return AuditStatus.DRIFT
831
+ if AuditStatus.UNKNOWN in values:
832
+ return AuditStatus.UNKNOWN
833
+ return AuditStatus.COMPLIANT
834
+
835
+
836
+ def _observed_raid_levels(mdstat: str) -> list[str]:
837
+ return sorted(
838
+ {
839
+ level
840
+ for level in ("raid0", "raid1", "raid5", "raid6", "raid10")
841
+ if f"active {level}" in mdstat
842
+ }
843
+ )
844
+
845
+
846
+ def _flatten_block_devices(
847
+ devices: tuple[Mapping[str, object], ...],
848
+ ) -> tuple[Mapping[str, object], ...]:
849
+ flattened: list[Mapping[str, object]] = []
850
+ for device in devices:
851
+ flattened.append(device)
852
+ raw_children = device.get("children", [])
853
+ flattened.extend(_flatten_block_devices(_mapping_values_sequence(raw_children)))
854
+ return tuple(flattened)
855
+
856
+
857
+ def _mapping(content: Mapping[str, object], key: str) -> Mapping[str, object]:
858
+ value = content.get(key)
859
+ result = _optional_mapping(value)
860
+ if result is None:
861
+ message = f"validated field {key!r} is not an object"
862
+ raise TypeError(message)
863
+ return result
864
+
865
+
866
+ def _optional_mapping(value: object) -> Mapping[str, object] | None:
867
+ if not isinstance(value, dict) or not all(
868
+ isinstance(key, str) for key in value
869
+ ):
870
+ return None
871
+ return cast("Mapping[str, object]", value)
872
+
873
+
874
+ def _mapping_sequence(
875
+ content: Mapping[str, object], key: str
876
+ ) -> tuple[Mapping[str, object], ...]:
877
+ return _mapping_values_sequence(content.get(key))
878
+
879
+
880
+ def _mapping_values_sequence(value: object) -> tuple[Mapping[str, object], ...]:
881
+ if not isinstance(value, list):
882
+ return ()
883
+ result: list[Mapping[str, object]] = []
884
+ for item in value:
885
+ mapping = _optional_mapping(item)
886
+ if mapping is None:
887
+ return ()
888
+ result.append(mapping)
889
+ return tuple(result)
890
+
891
+
892
+ def _string(content: Mapping[str, object], key: str) -> str:
893
+ value = content.get(key)
894
+ if not isinstance(value, str):
895
+ message = f"validated field {key!r} is not a string"
896
+ raise TypeError(message)
897
+ return value
898
+
899
+
900
+ def _integer(content: Mapping[str, object], key: str) -> int:
901
+ value = content.get(key)
902
+ if isinstance(value, bool) or not isinstance(value, int):
903
+ message = f"validated field {key!r} is not an integer"
904
+ raise TypeError(message)
905
+ return value