cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
@@ -0,0 +1,1228 @@
1
+ """Build a read-only operations view from desired and observed state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime, timedelta
10
+ from enum import StrEnum
11
+ from typing import TYPE_CHECKING, cast
12
+
13
+ from cloudfall.audit import AuditStatus, audit_inventory
14
+ from cloudfall.domain import AbsolutePath, FilesystemName, ResourceId, ServiceName
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable, Mapping
18
+
19
+ from cloudfall.audit import AuditCheck, ServerAudit
20
+ from cloudfall.inventory import DomainInventory, PlatformInventory, ServerInventory
21
+ from cloudfall.observation import ObservationSet, ObservedServerSnapshot
22
+ from cloudfall.service_evidence import (
23
+ DeploymentReceipt,
24
+ DeploymentReceiptSet,
25
+ DomainObservationSet,
26
+ ObservedDomainSnapshot,
27
+ )
28
+
29
+ _TASK_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9:.-]{0,127}$")
30
+ _PERSISTENT_FILESYSTEMS = frozenset(
31
+ {"btrfs", "ext2", "ext3", "ext4", "f2fs", "xfs", "zfs"}
32
+ )
33
+ _STALE_WARNING_AFTER = timedelta(hours=24)
34
+ _STALE_CRITICAL_AFTER = timedelta(days=7)
35
+ _FILESYSTEM_WARNING_BASIS_POINTS = 8_500
36
+ _FILESYSTEM_CRITICAL_BASIS_POINTS = 9_500
37
+ _MAX_BASIS_POINTS = 10_000
38
+ _SMART_WARNING_PERCENT_USED = 90
39
+ _SMART_CRITICAL_PERCENT_USED = 100
40
+ _MAX_PERCENT = 100
41
+ _MAX_UNSIGNED_BYTE = 255
42
+
43
+
44
+ class OperationsHealth(StrEnum):
45
+ """Aggregate health shown by operations clients."""
46
+
47
+ HEALTHY = "healthy"
48
+ WARNING = "warning"
49
+ CRITICAL = "critical"
50
+ UNKNOWN = "unknown"
51
+
52
+
53
+ class TaskSeverity(StrEnum):
54
+ """Priority of an evidence-derived operations task."""
55
+
56
+ CRITICAL = "critical"
57
+ WARNING = "warning"
58
+ UNKNOWN = "unknown"
59
+
60
+
61
+ class TaskKind(StrEnum):
62
+ """Source category for an evidence-derived operations task."""
63
+
64
+ DRIFT = "drift"
65
+ FILESYSTEM = "filesystem"
66
+ OBSERVATION = "observation"
67
+ SERVICE = "service"
68
+ SMART = "smart"
69
+
70
+
71
+ class TaskState(StrEnum):
72
+ """Workflow state supported by the initial read-only task projection."""
73
+
74
+ OPEN = "open"
75
+
76
+
77
+ class LifecycleEvidence(StrEnum):
78
+ """Evidence-backed answer for one service lifecycle milestone."""
79
+
80
+ YES = "yes"
81
+ NO = "no"
82
+ UNKNOWN = "unknown"
83
+
84
+
85
+ class ConfigurationStatus(StrEnum):
86
+ """Desired-versus-observed service configuration status."""
87
+
88
+ COMPLIANT = "compliant"
89
+ DRIFTED = "drifted"
90
+ UNKNOWN = "unknown"
91
+
92
+
93
+ class RouteCheckStatus(StrEnum):
94
+ """Result of one public service route check."""
95
+
96
+ HEALTHY = "healthy"
97
+ UNHEALTHY = "unhealthy"
98
+ UNKNOWN = "unknown"
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class OperationsTaskId:
103
+ """Stable identifier derived from a task's server, kind, and source."""
104
+
105
+ value: str
106
+
107
+ def __post_init__(self) -> None:
108
+ """Reject task identifiers that are unsafe in JSON and URLs."""
109
+ if not _TASK_ID_PATTERN.fullmatch(self.value):
110
+ message = f"invalid operations task id: {self.value!r}"
111
+ raise ValueError(message)
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class UtcTimestamp:
116
+ """Timezone-aware UTC timestamp used by operations projections."""
117
+
118
+ value: datetime
119
+
120
+ def __post_init__(self) -> None:
121
+ """Require an aware timestamp normalized to UTC."""
122
+ if self.value.tzinfo is None or self.value.utcoffset() != timedelta(0):
123
+ message = "operations timestamp must be timezone-aware UTC"
124
+ raise ValueError(message)
125
+
126
+ @classmethod
127
+ def now(cls) -> UtcTimestamp:
128
+ """Return the current UTC time."""
129
+ return cls(datetime.now(UTC))
130
+
131
+ @classmethod
132
+ def from_boundary(cls, value: object) -> UtcTimestamp:
133
+ """Parse a schema-validated RFC 3339 timestamp."""
134
+ if not isinstance(value, str):
135
+ message = "operations timestamp must be a string"
136
+ raise TypeError(message)
137
+ parsed = datetime.fromisoformat(value)
138
+ if parsed.tzinfo is None:
139
+ message = "operations timestamp must include a timezone"
140
+ raise ValueError(message)
141
+ return cls(parsed.astimezone(UTC))
142
+
143
+ def as_string(self) -> str:
144
+ """Serialize the timestamp as canonical UTC RFC 3339."""
145
+ return self.value.isoformat(timespec="seconds").replace("+00:00", "Z")
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class ObservedByteCount:
150
+ """Non-negative byte count collected from a server."""
151
+
152
+ value: int
153
+
154
+ def __post_init__(self) -> None:
155
+ """Reject booleans and negative byte counts."""
156
+ if isinstance(self.value, bool) or self.value < 0:
157
+ message = f"invalid observed byte count: {self.value!r}"
158
+ raise ValueError(message)
159
+
160
+ @classmethod
161
+ def from_boundary(cls, value: object) -> ObservedByteCount:
162
+ """Coerce a validated observation field into a byte count."""
163
+ if isinstance(value, bool) or not isinstance(value, int):
164
+ message = "observed byte count must be an integer"
165
+ raise TypeError(message)
166
+ return cls(value)
167
+
168
+
169
+ @dataclass(frozen=True, slots=True)
170
+ class Utilization:
171
+ """Filesystem utilization represented in integer basis points."""
172
+
173
+ basis_points: int
174
+
175
+ def __post_init__(self) -> None:
176
+ """Keep utilization within zero and one hundred percent."""
177
+ if not 0 <= self.basis_points <= _MAX_BASIS_POINTS:
178
+ message = f"invalid utilization basis points: {self.basis_points!r}"
179
+ raise ValueError(message)
180
+
181
+ @classmethod
182
+ def from_counts(
183
+ cls, used: ObservedByteCount, available: ObservedByteCount
184
+ ) -> Utilization:
185
+ """Compute df-compatible utilization, excluding reserved blocks."""
186
+ visible = used.value + available.value
187
+ if visible == 0:
188
+ return cls(0)
189
+ rounded = (used.value * _MAX_BASIS_POINTS + visible // 2) // visible
190
+ return cls(min(rounded, _MAX_BASIS_POINTS))
191
+
192
+ @property
193
+ def percent(self) -> float:
194
+ """Return a display-ready percentage."""
195
+ return self.basis_points / 100
196
+
197
+
198
+ @dataclass(frozen=True, slots=True)
199
+ class MonitoredFilesystem:
200
+ """Operational filesystem capacity and utilization."""
201
+
202
+ target: AbsolutePath
203
+ filesystem: FilesystemName
204
+ size: ObservedByteCount
205
+ used: ObservedByteCount
206
+ available: ObservedByteCount
207
+ utilization: Utilization
208
+
209
+ def as_dict(self) -> dict[str, object]:
210
+ """Serialize filesystem monitoring data."""
211
+ return {
212
+ "target": self.target.value,
213
+ "filesystem": self.filesystem.value,
214
+ "sizeBytes": self.size.value,
215
+ "usedBytes": self.used.value,
216
+ "availableBytes": self.available.value,
217
+ "usedPercent": self.utilization.percent,
218
+ }
219
+
220
+
221
+ class SmartOverallHealth(StrEnum):
222
+ """Normalized smartctl overall-health result."""
223
+
224
+ PASSED = "passed"
225
+ FAILED = "failed"
226
+
227
+
228
+ @dataclass(frozen=True, slots=True)
229
+ class NvmeCriticalWarning:
230
+ """NVMe SMART critical-warning bit field."""
231
+
232
+ value: int
233
+
234
+ def __post_init__(self) -> None:
235
+ """Keep the bit field within one unsigned byte."""
236
+ if isinstance(self.value, bool) or not 0 <= self.value <= _MAX_UNSIGNED_BYTE:
237
+ message = f"invalid NVMe critical warning: {self.value!r}"
238
+ raise ValueError(message)
239
+
240
+ @classmethod
241
+ def from_boundary(cls, value: object) -> NvmeCriticalWarning:
242
+ """Parse the schema-validated hexadecimal representation."""
243
+ if not isinstance(value, str):
244
+ message = "NVMe critical warning must be a hexadecimal string"
245
+ raise TypeError(message)
246
+ return cls(int(value, 16))
247
+
248
+ @property
249
+ def is_clear(self) -> bool:
250
+ """Return whether no NVMe critical-warning bits are set."""
251
+ return self.value == 0
252
+
253
+ def as_string(self) -> str:
254
+ """Serialize the warning as a two-digit hexadecimal value."""
255
+ return f"0x{self.value:02x}"
256
+
257
+
258
+ @dataclass(frozen=True, slots=True)
259
+ class SmartDeviceEvidence:
260
+ """Normalized operational SMART evidence for one NVMe namespace."""
261
+
262
+ path: AbsolutePath
263
+ model: str
264
+ serial: str
265
+ firmware: str
266
+ smartctl_exit_code: int
267
+ overall_health: SmartOverallHealth
268
+ critical_warning: NvmeCriticalWarning
269
+ reliability_degraded: bool
270
+ temperature_celsius: int
271
+ available_spare_percent: int
272
+ available_spare_threshold_percent: int
273
+ percentage_used: int
274
+ data_units_read: int
275
+ data_units_written: int
276
+ power_cycles: int
277
+ power_on_hours: int
278
+ unsafe_shutdowns: int
279
+ media_and_data_integrity_errors: int
280
+ error_information_log_entries: int
281
+
282
+ def __post_init__(self) -> None:
283
+ """Enforce ranges not fully represented by primitive field types."""
284
+ if not self.model or not self.serial or not self.firmware:
285
+ message = "SMART device identity fields must not be empty"
286
+ raise ValueError(message)
287
+ if not 0 <= self.smartctl_exit_code <= _MAX_UNSIGNED_BYTE:
288
+ message = f"invalid smartctl exit code: {self.smartctl_exit_code!r}"
289
+ raise ValueError(message)
290
+ for name, value in (
291
+ ("available spare", self.available_spare_percent),
292
+ ("available spare threshold", self.available_spare_threshold_percent),
293
+ ):
294
+ if not 0 <= value <= _MAX_PERCENT:
295
+ message = f"invalid {name} percentage: {value!r}"
296
+ raise ValueError(message)
297
+ for name, value in (
298
+ ("percentage used", self.percentage_used),
299
+ ("data units read", self.data_units_read),
300
+ ("data units written", self.data_units_written),
301
+ ("power cycles", self.power_cycles),
302
+ ("power-on hours", self.power_on_hours),
303
+ ("unsafe shutdowns", self.unsafe_shutdowns),
304
+ ("media errors", self.media_and_data_integrity_errors),
305
+ ("error log entries", self.error_information_log_entries),
306
+ ):
307
+ if value < 0:
308
+ message = f"invalid {name}: {value!r}"
309
+ raise ValueError(message)
310
+
311
+ @classmethod
312
+ def from_boundary(cls, content: Mapping[str, object]) -> SmartDeviceEvidence:
313
+ """Create typed SMART evidence from a validated observation object."""
314
+ return cls(
315
+ path=AbsolutePath.from_boundary(content.get("path")),
316
+ model=_string(content, "model"),
317
+ serial=_string(content, "serial"),
318
+ firmware=_string(content, "firmware"),
319
+ smartctl_exit_code=_integer(content, "smartctlExitCode"),
320
+ overall_health=SmartOverallHealth(_string(content, "overallHealth")),
321
+ critical_warning=NvmeCriticalWarning.from_boundary(
322
+ content.get("criticalWarning")
323
+ ),
324
+ reliability_degraded=_boolean(content, "reliabilityDegraded"),
325
+ temperature_celsius=_integer(content, "temperatureCelsius"),
326
+ available_spare_percent=_integer(content, "availableSparePercent"),
327
+ available_spare_threshold_percent=_integer(
328
+ content, "availableSpareThresholdPercent"
329
+ ),
330
+ percentage_used=_integer(content, "percentageUsed"),
331
+ data_units_read=_integer(content, "dataUnitsRead"),
332
+ data_units_written=_integer(content, "dataUnitsWritten"),
333
+ power_cycles=_integer(content, "powerCycles"),
334
+ power_on_hours=_integer(content, "powerOnHours"),
335
+ unsafe_shutdowns=_integer(content, "unsafeShutdowns"),
336
+ media_and_data_integrity_errors=_integer(
337
+ content, "mediaAndDataIntegrityErrors"
338
+ ),
339
+ error_information_log_entries=_integer(
340
+ content, "errorInformationLogEntries"
341
+ ),
342
+ )
343
+
344
+ @property
345
+ def is_degraded(self) -> bool:
346
+ """Return whether replacement-level SMART evidence is present."""
347
+ return (
348
+ self.overall_health is SmartOverallHealth.FAILED
349
+ or not self.critical_warning.is_clear
350
+ or self.reliability_degraded
351
+ or self.percentage_used >= _SMART_CRITICAL_PERCENT_USED
352
+ or self.available_spare_percent <= self.available_spare_threshold_percent
353
+ or self.media_and_data_integrity_errors > 0
354
+ )
355
+
356
+ def as_dict(self) -> dict[str, object]:
357
+ """Serialize SMART evidence used by operations clients."""
358
+ return {
359
+ "path": self.path.value,
360
+ "model": self.model,
361
+ "serial": self.serial,
362
+ "firmware": self.firmware,
363
+ "smartctlExitCode": self.smartctl_exit_code,
364
+ "overallHealth": self.overall_health.value,
365
+ "criticalWarning": self.critical_warning.as_string(),
366
+ "reliabilityDegraded": self.reliability_degraded,
367
+ "temperatureCelsius": self.temperature_celsius,
368
+ "availableSparePercent": self.available_spare_percent,
369
+ "availableSpareThresholdPercent": (self.available_spare_threshold_percent),
370
+ "percentageUsed": self.percentage_used,
371
+ "dataUnitsRead": self.data_units_read,
372
+ "dataUnitsWritten": self.data_units_written,
373
+ "powerCycles": self.power_cycles,
374
+ "powerOnHours": self.power_on_hours,
375
+ "unsafeShutdowns": self.unsafe_shutdowns,
376
+ "mediaAndDataIntegrityErrors": self.media_and_data_integrity_errors,
377
+ "errorInformationLogEntries": self.error_information_log_entries,
378
+ }
379
+
380
+
381
+ @dataclass(frozen=True, slots=True)
382
+ class OperationsTask:
383
+ """One open task derived from current platform evidence."""
384
+
385
+ task_id: OperationsTaskId
386
+ server_id: ResourceId
387
+ kind: TaskKind
388
+ severity: TaskSeverity
389
+ state: TaskState
390
+ title: str
391
+ detail: str
392
+ source: str
393
+
394
+ def as_dict(self) -> dict[str, object]:
395
+ """Serialize a stable operations task."""
396
+ return {
397
+ "id": self.task_id.value,
398
+ "server": self.server_id.value,
399
+ "kind": self.kind.value,
400
+ "severity": self.severity.value,
401
+ "state": self.state.value,
402
+ "title": self.title,
403
+ "detail": self.detail,
404
+ "source": self.source,
405
+ }
406
+
407
+
408
+ @dataclass(frozen=True, slots=True)
409
+ class TaskEvidence:
410
+ """Human-readable task content and its machine evidence source."""
411
+
412
+ title: str
413
+ detail: str
414
+ source: str
415
+
416
+
417
+ @dataclass(frozen=True, slots=True)
418
+ class ServerOperations:
419
+ """Operations projection for one desired server."""
420
+
421
+ server_id: ResourceId
422
+ hostname: str
423
+ health: OperationsHealth
424
+ observed_at: UtcTimestamp | None
425
+ filesystems: tuple[MonitoredFilesystem, ...]
426
+ smart_devices: tuple[SmartDeviceEvidence, ...]
427
+ tasks: tuple[OperationsTask, ...]
428
+
429
+ def as_dict(self) -> dict[str, object]:
430
+ """Serialize a server operations projection."""
431
+ counts = Counter(task.severity.value for task in self.tasks)
432
+ return {
433
+ "id": self.server_id.value,
434
+ "hostname": self.hostname,
435
+ "health": self.health.value,
436
+ "observedAt": (
437
+ self.observed_at.as_string() if self.observed_at is not None else None
438
+ ),
439
+ "summary": {
440
+ "tasks": len(self.tasks),
441
+ "critical": counts[TaskSeverity.CRITICAL.value],
442
+ "warning": counts[TaskSeverity.WARNING.value],
443
+ "unknown": counts[TaskSeverity.UNKNOWN.value],
444
+ },
445
+ "filesystems": [filesystem.as_dict() for filesystem in self.filesystems],
446
+ "smartDevices": [device.as_dict() for device in self.smart_devices],
447
+ "tasks": [task.as_dict() for task in self.tasks],
448
+ }
449
+
450
+
451
+ @dataclass(frozen=True, slots=True)
452
+ class DomainCheck:
453
+ """Display-ready status and evidence detail for one domain check."""
454
+
455
+ status: RouteCheckStatus
456
+ detail: str
457
+
458
+ def as_dict(self) -> dict[str, str]:
459
+ """Serialize one domain check."""
460
+ return {"status": self.status.value, "detail": self.detail}
461
+
462
+
463
+ @dataclass(frozen=True, slots=True)
464
+ class DomainOperations:
465
+ """Evidence-derived lifecycle and route status for one desired domain."""
466
+
467
+ domain_id: ResourceId
468
+ primary_name: str
469
+ proxy_server_id: ResourceId
470
+ origin_server_id: ResourceId
471
+ health: OperationsHealth
472
+ observed_at: UtcTimestamp | None
473
+ planned: LifecycleEvidence
474
+ ready_to_deploy: LifecycleEvidence
475
+ deployed: LifecycleEvidence
476
+ deployed_at: UtcTimestamp | None
477
+ configuration: ConfigurationStatus
478
+ configuration_detail: str
479
+ dns: DomainCheck
480
+ tls: DomainCheck
481
+ origin: DomainCheck
482
+ public_route: DomainCheck
483
+
484
+ def as_dict(self) -> dict[str, object]:
485
+ """Serialize service lifecycle and route evidence."""
486
+ return {
487
+ "id": self.domain_id.value,
488
+ "primaryName": self.primary_name,
489
+ "proxyServer": self.proxy_server_id.value,
490
+ "originServer": self.origin_server_id.value,
491
+ "health": self.health.value,
492
+ "observedAt": (
493
+ self.observed_at.as_string() if self.observed_at is not None else None
494
+ ),
495
+ "lifecycle": {
496
+ "planned": self.planned.value,
497
+ "readyToDeploy": self.ready_to_deploy.value,
498
+ "deployed": self.deployed.value,
499
+ "deployedAt": (
500
+ self.deployed_at.as_string()
501
+ if self.deployed_at is not None
502
+ else None
503
+ ),
504
+ "configured": self.configuration.value,
505
+ "configurationDetail": self.configuration_detail,
506
+ },
507
+ "checks": {
508
+ "dns": self.dns.as_dict(),
509
+ "tls": self.tls.as_dict(),
510
+ "origin": self.origin.as_dict(),
511
+ "public": self.public_route.as_dict(),
512
+ },
513
+ }
514
+
515
+
516
+ @dataclass(frozen=True, slots=True)
517
+ class DomainOperationEvidence:
518
+ """Evidence inputs used to derive one domain operations view."""
519
+
520
+ server_observation: ObservedServerSnapshot | None
521
+ receipt: DeploymentReceipt | None
522
+ observation: ObservedDomainSnapshot | None
523
+ generated_at: UtcTimestamp
524
+
525
+
526
+ @dataclass(frozen=True, slots=True)
527
+ class FleetOperations:
528
+ """Fleet-wide read-only projection for monitoring and dashboards."""
529
+
530
+ generated_at: UtcTimestamp
531
+ health: OperationsHealth
532
+ servers: tuple[ServerOperations, ...]
533
+ domains: tuple[DomainOperations, ...]
534
+
535
+ @property
536
+ def tasks(self) -> tuple[OperationsTask, ...]:
537
+ """Return all tasks in deterministic priority order."""
538
+ return _sort_tasks(task for server in self.servers for task in server.tasks)
539
+
540
+ def as_dict(self) -> dict[str, object]:
541
+ """Serialize the complete operations view."""
542
+ tasks = self.tasks
543
+ severity_counts = Counter(task.severity.value for task in tasks)
544
+ health_counts = Counter(server.health.value for server in self.servers)
545
+ domain_health_counts = Counter(domain.health.value for domain in self.domains)
546
+ return {
547
+ "generatedAt": self.generated_at.as_string(),
548
+ "health": self.health.value,
549
+ "summary": {
550
+ "servers": len(self.servers),
551
+ "serverHealth": {
552
+ status.value: health_counts[status.value]
553
+ for status in OperationsHealth
554
+ },
555
+ "tasks": {
556
+ "open": len(tasks),
557
+ "critical": severity_counts[TaskSeverity.CRITICAL.value],
558
+ "warning": severity_counts[TaskSeverity.WARNING.value],
559
+ "unknown": severity_counts[TaskSeverity.UNKNOWN.value],
560
+ },
561
+ "domains": {
562
+ "total": len(self.domains),
563
+ "health": {
564
+ status.value: domain_health_counts[status.value]
565
+ for status in OperationsHealth
566
+ },
567
+ },
568
+ },
569
+ "tasks": [task.as_dict() for task in tasks],
570
+ "servers": [server.as_dict() for server in self.servers],
571
+ "domains": [domain.as_dict() for domain in self.domains],
572
+ }
573
+
574
+
575
+ def build_operations_view(
576
+ inventory: PlatformInventory,
577
+ observations: ObservationSet,
578
+ deployment_receipts: DeploymentReceiptSet,
579
+ domain_observations: DomainObservationSet,
580
+ *,
581
+ generated_at: UtcTimestamp,
582
+ ) -> FleetOperations:
583
+ """Combine audit and monitoring evidence into one operations projection."""
584
+ audit = audit_inventory(inventory, observations)
585
+ servers = tuple(
586
+ _server_operations(
587
+ server,
588
+ observations.for_server(server.resource_id),
589
+ next(
590
+ item
591
+ for item in audit.servers
592
+ if item.server_id == server.resource_id.value
593
+ ),
594
+ generated_at,
595
+ )
596
+ for server in inventory.servers
597
+ )
598
+ domains = tuple(
599
+ _domain_operations(
600
+ domain,
601
+ inventory,
602
+ DomainOperationEvidence(
603
+ server_observation=observations.for_server(domain.proxy.server_id),
604
+ receipt=deployment_receipts.for_domain(domain.resource_id),
605
+ observation=domain_observations.for_domain(domain.resource_id),
606
+ generated_at=generated_at,
607
+ ),
608
+ )
609
+ for domain in inventory.domains
610
+ )
611
+ return FleetOperations(
612
+ generated_at=generated_at,
613
+ health=_fleet_health(servers, domains),
614
+ servers=servers,
615
+ domains=domains,
616
+ )
617
+
618
+
619
+ def _domain_operations(
620
+ domain: DomainInventory,
621
+ inventory: PlatformInventory,
622
+ evidence: DomainOperationEvidence,
623
+ ) -> DomainOperations:
624
+ proxy_server = _inventory_server(inventory, domain.proxy.server_id)
625
+ origin_server = _inventory_server(inventory, domain.origin.server_id)
626
+ ready = (
627
+ LifecycleEvidence.YES
628
+ if proxy_server.lifecycle.value == "active"
629
+ and origin_server.lifecycle.value == "active"
630
+ else LifecycleEvidence.NO
631
+ )
632
+ deployed, deployed_at = _deployment_evidence(domain, evidence.receipt)
633
+ configuration, configuration_detail = _domain_configuration(
634
+ domain,
635
+ evidence.server_observation,
636
+ evidence.receipt,
637
+ )
638
+ dns = _dns_check(domain, evidence.observation)
639
+ tls = _tls_check(domain, evidence.observation, evidence.generated_at)
640
+ origin = _endpoint_check(domain, evidence.observation, endpoint="origin")
641
+ public_route = _endpoint_check(domain, evidence.observation, endpoint="public")
642
+ health = _domain_health(
643
+ ready,
644
+ deployed,
645
+ configuration,
646
+ (dns, tls, origin, public_route),
647
+ )
648
+ return DomainOperations(
649
+ domain_id=domain.resource_id,
650
+ primary_name=domain.primary_name.value,
651
+ proxy_server_id=domain.proxy.server_id,
652
+ origin_server_id=domain.origin.server_id,
653
+ health=health,
654
+ observed_at=(
655
+ UtcTimestamp(evidence.observation.observed_at.value)
656
+ if evidence.observation is not None
657
+ else None
658
+ ),
659
+ planned=LifecycleEvidence.YES,
660
+ ready_to_deploy=ready,
661
+ deployed=deployed,
662
+ deployed_at=deployed_at,
663
+ configuration=configuration,
664
+ configuration_detail=configuration_detail,
665
+ dns=dns,
666
+ tls=tls,
667
+ origin=origin,
668
+ public_route=public_route,
669
+ )
670
+
671
+
672
+ def _inventory_server(
673
+ inventory: PlatformInventory, server_id: ResourceId
674
+ ) -> ServerInventory:
675
+ server = next(
676
+ (
677
+ candidate
678
+ for candidate in inventory.servers
679
+ if candidate.resource_id == server_id
680
+ ),
681
+ None,
682
+ )
683
+ if server is None:
684
+ message = f"validated domain server does not exist: {server_id.value}"
685
+ raise KeyError(message)
686
+ return server
687
+
688
+
689
+ def _deployment_evidence(
690
+ domain: DomainInventory, receipt: DeploymentReceipt | None
691
+ ) -> tuple[LifecycleEvidence, UtcTimestamp | None]:
692
+ if receipt is None:
693
+ return LifecycleEvidence.NO, None
694
+ matches = (
695
+ receipt.server_id == domain.proxy.server_id
696
+ and receipt.configuration_path == domain.proxy.configuration_path
697
+ )
698
+ return (
699
+ LifecycleEvidence.YES if matches else LifecycleEvidence.NO,
700
+ UtcTimestamp(receipt.deployed_at.value),
701
+ )
702
+
703
+
704
+ def _domain_configuration(
705
+ domain: DomainInventory,
706
+ observation: ObservedServerSnapshot | None,
707
+ receipt: DeploymentReceipt | None,
708
+ ) -> tuple[ConfigurationStatus, str]:
709
+ if observation is None:
710
+ return ConfigurationStatus.UNKNOWN, "proxy server has no observation"
711
+ services = _mapping(observation.spec, "services")
712
+ service = _optional_mapping(services.get(domain.proxy.service_name.value))
713
+ if service is None:
714
+ return ConfigurationStatus.DRIFTED, "proxy service is not installed"
715
+ if service.get("state") != "running" or service.get("status") != "enabled":
716
+ return ConfigurationStatus.DRIFTED, "proxy service is not running and enabled"
717
+ configuration = _mapping_sequence(observation.spec, "configuration")
718
+ config = next(
719
+ (
720
+ item
721
+ for item in configuration
722
+ if item.get("path") == domain.proxy.configuration_path.value
723
+ ),
724
+ None,
725
+ )
726
+ if config is None or config.get("exists") is not True:
727
+ return ConfigurationStatus.DRIFTED, "proxy configuration file is missing"
728
+ if (
729
+ receipt is not None
730
+ and receipt.configuration_path == domain.proxy.configuration_path
731
+ and config.get("sha256") != receipt.configuration_sha256.value
732
+ ):
733
+ return (
734
+ ConfigurationStatus.DRIFTED,
735
+ "observed configuration differs from deployment receipt",
736
+ )
737
+ return (
738
+ ConfigurationStatus.COMPLIANT,
739
+ "service and configuration match available evidence",
740
+ )
741
+
742
+
743
+ def _dns_check(
744
+ domain: DomainInventory, observation: ObservedDomainSnapshot | None
745
+ ) -> DomainCheck:
746
+ if observation is None:
747
+ return DomainCheck(RouteCheckStatus.UNKNOWN, "domain has no route observation")
748
+ addresses = ", ".join(address.value for address in observation.dns_addresses)
749
+ if observation.dns_error is not None:
750
+ return DomainCheck(RouteCheckStatus.UNHEALTHY, observation.dns_error)
751
+ if domain.edge.mode.value == "proxied":
752
+ return DomainCheck(
753
+ (
754
+ RouteCheckStatus.HEALTHY
755
+ if observation.edge_detected
756
+ else RouteCheckStatus.UNHEALTHY
757
+ ),
758
+ (
759
+ f"{domain.edge.provider.value} edge detected; {addresses}"
760
+ if observation.edge_detected
761
+ else f"{domain.edge.provider.value} edge not detected; {addresses}"
762
+ ),
763
+ )
764
+ return DomainCheck(
765
+ (
766
+ RouteCheckStatus.HEALTHY
767
+ if observation.proxy_address_observed
768
+ else RouteCheckStatus.UNHEALTHY
769
+ ),
770
+ (
771
+ f"DNS resolves directly to proxy; {addresses}"
772
+ if observation.proxy_address_observed
773
+ else f"DNS does not resolve to proxy; {addresses}"
774
+ ),
775
+ )
776
+
777
+
778
+ def _tls_check(
779
+ domain: DomainInventory,
780
+ observation: ObservedDomainSnapshot | None,
781
+ generated_at: UtcTimestamp,
782
+ ) -> DomainCheck:
783
+ if domain.tls_mode.value == "disabled":
784
+ return DomainCheck(RouteCheckStatus.HEALTHY, "TLS is disabled by desired state")
785
+ if observation is None:
786
+ return DomainCheck(RouteCheckStatus.UNKNOWN, "domain has no TLS observation")
787
+ if not observation.tls.valid:
788
+ return DomainCheck(
789
+ RouteCheckStatus.UNHEALTHY,
790
+ observation.tls.error or "TLS certificate is unavailable or invalid",
791
+ )
792
+ expires = observation.tls.expires_at
793
+ if expires is None:
794
+ return DomainCheck(RouteCheckStatus.UNHEALTHY, "TLS expiry is unavailable")
795
+ if expires.value - generated_at.value <= timedelta(days=30):
796
+ return DomainCheck(
797
+ RouteCheckStatus.UNHEALTHY,
798
+ f"TLS certificate expires {expires.as_string()}",
799
+ )
800
+ return DomainCheck(
801
+ RouteCheckStatus.HEALTHY,
802
+ f"valid certificate expires {expires.as_string()}",
803
+ )
804
+
805
+
806
+ def _endpoint_check(
807
+ domain: DomainInventory,
808
+ observation: ObservedDomainSnapshot | None,
809
+ *,
810
+ endpoint: str,
811
+ ) -> DomainCheck:
812
+ if observation is None:
813
+ return DomainCheck(
814
+ RouteCheckStatus.UNKNOWN,
815
+ f"domain has no {endpoint} observation",
816
+ )
817
+ evidence = observation.origin if endpoint == "origin" else observation.public
818
+ if evidence.skipped:
819
+ return DomainCheck(
820
+ RouteCheckStatus.HEALTHY,
821
+ evidence.skip_reason or f"{endpoint} probe skipped by evidence",
822
+ )
823
+ if not evidence.reachable or evidence.status is None:
824
+ return DomainCheck(
825
+ RouteCheckStatus.UNHEALTHY,
826
+ evidence.error or f"{endpoint} endpoint is unreachable",
827
+ )
828
+ expected = frozenset(domain.health_check.expected_statuses)
829
+ healthy = evidence.status in expected
830
+ return DomainCheck(
831
+ RouteCheckStatus.HEALTHY if healthy else RouteCheckStatus.UNHEALTHY,
832
+ f"HTTP {evidence.status.value}; expected "
833
+ + ", ".join(
834
+ str(status.value) for status in domain.health_check.expected_statuses
835
+ ),
836
+ )
837
+
838
+
839
+ def _domain_health(
840
+ ready: LifecycleEvidence,
841
+ deployed: LifecycleEvidence,
842
+ configuration: ConfigurationStatus,
843
+ checks: tuple[DomainCheck, ...],
844
+ ) -> OperationsHealth:
845
+ if (
846
+ ready is LifecycleEvidence.NO
847
+ or deployed is LifecycleEvidence.NO
848
+ or configuration is ConfigurationStatus.DRIFTED
849
+ or any(check.status is RouteCheckStatus.UNHEALTHY for check in checks)
850
+ ):
851
+ return OperationsHealth.WARNING
852
+ if (
853
+ ready is LifecycleEvidence.UNKNOWN
854
+ or deployed is LifecycleEvidence.UNKNOWN
855
+ or configuration is ConfigurationStatus.UNKNOWN
856
+ or any(check.status is RouteCheckStatus.UNKNOWN for check in checks)
857
+ ):
858
+ return OperationsHealth.UNKNOWN
859
+ return OperationsHealth.HEALTHY
860
+
861
+
862
+ def _fleet_health(
863
+ servers: tuple[ServerOperations, ...], domains: tuple[DomainOperations, ...]
864
+ ) -> OperationsHealth:
865
+ values = frozenset(
866
+ (*[server.health for server in servers], *[domain.health for domain in domains])
867
+ )
868
+ if OperationsHealth.CRITICAL in values:
869
+ return OperationsHealth.CRITICAL
870
+ if OperationsHealth.UNKNOWN in values:
871
+ return OperationsHealth.UNKNOWN
872
+ if OperationsHealth.WARNING in values:
873
+ return OperationsHealth.WARNING
874
+ return OperationsHealth.HEALTHY
875
+
876
+
877
+ def _server_operations(
878
+ server: ServerInventory,
879
+ snapshot: ObservedServerSnapshot | None,
880
+ audit: ServerAudit,
881
+ generated_at: UtcTimestamp,
882
+ ) -> ServerOperations:
883
+ audit_tasks = tuple(
884
+ _audit_task(server.resource_id, check)
885
+ for check in audit.checks
886
+ if check.status is not AuditStatus.COMPLIANT
887
+ )
888
+ if snapshot is None:
889
+ tasks = _sort_tasks(audit_tasks)
890
+ return ServerOperations(
891
+ server_id=server.resource_id,
892
+ hostname=server.hostname.value,
893
+ health=_health(task.severity for task in tasks),
894
+ observed_at=None,
895
+ filesystems=(),
896
+ smart_devices=(),
897
+ tasks=tasks,
898
+ )
899
+
900
+ observed_at = UtcTimestamp.from_boundary(snapshot.spec.get("observedAt"))
901
+ filesystems = _filesystems(snapshot)
902
+ smart_available, smart_expected, smart_devices = _smart_evidence(snapshot)
903
+ monitoring_tasks = (
904
+ _observation_tasks(server.resource_id, observed_at, generated_at)
905
+ + tuple(
906
+ task
907
+ for filesystem in filesystems
908
+ if (task := _filesystem_task(server.resource_id, filesystem)) is not None
909
+ )
910
+ + _failed_service_tasks(server.resource_id, snapshot)
911
+ + _smart_tasks(
912
+ server.resource_id,
913
+ available=smart_available,
914
+ expected=smart_expected,
915
+ devices=smart_devices,
916
+ )
917
+ )
918
+ tasks = _sort_tasks((*audit_tasks, *monitoring_tasks))
919
+ return ServerOperations(
920
+ server_id=server.resource_id,
921
+ hostname=server.hostname.value,
922
+ health=_health(task.severity for task in tasks),
923
+ observed_at=observed_at,
924
+ filesystems=filesystems,
925
+ smart_devices=smart_devices,
926
+ tasks=tasks,
927
+ )
928
+
929
+
930
+ def _audit_task(server_id: ResourceId, check: AuditCheck) -> OperationsTask:
931
+ source = f"audit.{check.check}"
932
+ missing_observation = check.check == "observation.available"
933
+ severity = (
934
+ TaskSeverity.UNKNOWN
935
+ if check.status is AuditStatus.UNKNOWN
936
+ else TaskSeverity.CRITICAL
937
+ if check.check == "storage.softwareRaid.activeDevices"
938
+ else TaskSeverity.WARNING
939
+ )
940
+ return _task(
941
+ server_id,
942
+ TaskKind.OBSERVATION if missing_observation else TaskKind.DRIFT,
943
+ severity,
944
+ TaskEvidence(
945
+ title=(
946
+ "Collect a server observation"
947
+ if missing_observation
948
+ else f"Resolve desired-state drift: {check.check}"
949
+ ),
950
+ detail=check.message,
951
+ source=source,
952
+ ),
953
+ )
954
+
955
+
956
+ def _observation_tasks(
957
+ server_id: ResourceId,
958
+ observed_at: UtcTimestamp,
959
+ generated_at: UtcTimestamp,
960
+ ) -> tuple[OperationsTask, ...]:
961
+ age = generated_at.value - observed_at.value
962
+ if age <= _STALE_WARNING_AFTER:
963
+ return ()
964
+ severity = (
965
+ TaskSeverity.CRITICAL if age > _STALE_CRITICAL_AFTER else TaskSeverity.WARNING
966
+ )
967
+ age_hours = int(age.total_seconds() // 3600)
968
+ return (
969
+ _task(
970
+ server_id,
971
+ TaskKind.OBSERVATION,
972
+ severity,
973
+ TaskEvidence(
974
+ title="Refresh stale server observation",
975
+ detail=f"latest evidence is {age_hours} hours old",
976
+ source="observed.spec.observedAt",
977
+ ),
978
+ ),
979
+ )
980
+
981
+
982
+ def _filesystems(
983
+ snapshot: ObservedServerSnapshot,
984
+ ) -> tuple[MonitoredFilesystem, ...]:
985
+ storage = _mapping(snapshot.spec, "storage")
986
+ result: list[MonitoredFilesystem] = []
987
+ for raw in _mapping_sequence(storage, "filesystems"):
988
+ filesystem_name = FilesystemName.from_boundary(raw.get("fstype"))
989
+ if filesystem_name.value not in _PERSISTENT_FILESYSTEMS:
990
+ continue
991
+ used = ObservedByteCount.from_boundary(raw.get("used"))
992
+ available = ObservedByteCount.from_boundary(raw.get("avail"))
993
+ result.append(
994
+ MonitoredFilesystem(
995
+ target=AbsolutePath.from_boundary(raw.get("target")),
996
+ filesystem=filesystem_name,
997
+ size=ObservedByteCount.from_boundary(raw.get("size")),
998
+ used=used,
999
+ available=available,
1000
+ utilization=Utilization.from_counts(used, available),
1001
+ )
1002
+ )
1003
+ return tuple(sorted(result, key=lambda item: item.target.value))
1004
+
1005
+
1006
+ def _filesystem_task(
1007
+ server_id: ResourceId, filesystem: MonitoredFilesystem
1008
+ ) -> OperationsTask | None:
1009
+ basis_points = filesystem.utilization.basis_points
1010
+ if basis_points < _FILESYSTEM_WARNING_BASIS_POINTS:
1011
+ return None
1012
+ severity = (
1013
+ TaskSeverity.CRITICAL
1014
+ if basis_points >= _FILESYSTEM_CRITICAL_BASIS_POINTS
1015
+ else TaskSeverity.WARNING
1016
+ )
1017
+ percent = filesystem.utilization.percent
1018
+ return _task(
1019
+ server_id,
1020
+ TaskKind.FILESYSTEM,
1021
+ severity,
1022
+ TaskEvidence(
1023
+ title=f"Reduce disk usage on {filesystem.target.value}",
1024
+ detail=(
1025
+ f"{percent:.1f}% used; "
1026
+ f"{filesystem.available.value} bytes remain available"
1027
+ ),
1028
+ source=f"observed.storage.filesystems[{filesystem.target.value}]",
1029
+ ),
1030
+ )
1031
+
1032
+
1033
+ def _smart_evidence(
1034
+ snapshot: ObservedServerSnapshot,
1035
+ ) -> tuple[bool, bool, tuple[SmartDeviceEvidence, ...]]:
1036
+ storage = _mapping(snapshot.spec, "storage")
1037
+ smart = _mapping(storage, "smart")
1038
+ available = _boolean(smart, "available")
1039
+ expected = any(
1040
+ _string(device, "type") == "disk"
1041
+ and _string(device, "path").startswith("/dev/nvme")
1042
+ for device in _mapping_sequence(storage, "blockDevices")
1043
+ )
1044
+ devices = tuple(
1045
+ SmartDeviceEvidence.from_boundary(item)
1046
+ for item in _mapping_sequence(smart, "devices")
1047
+ )
1048
+ return available, expected, devices
1049
+
1050
+
1051
+ def _smart_tasks(
1052
+ server_id: ResourceId,
1053
+ *,
1054
+ available: bool,
1055
+ expected: bool,
1056
+ devices: tuple[SmartDeviceEvidence, ...],
1057
+ ) -> tuple[OperationsTask, ...]:
1058
+ if not available and expected:
1059
+ return (
1060
+ _task(
1061
+ server_id,
1062
+ TaskKind.SMART,
1063
+ TaskSeverity.WARNING,
1064
+ TaskEvidence(
1065
+ title="Restore SMART evidence collection",
1066
+ detail="smartctl was unavailable during inspection",
1067
+ source="observed.storage.smart.available",
1068
+ ),
1069
+ ),
1070
+ )
1071
+ if not available:
1072
+ return ()
1073
+
1074
+ tasks: list[OperationsTask] = []
1075
+ for device in devices:
1076
+ if not (
1077
+ device.is_degraded
1078
+ or device.percentage_used >= _SMART_WARNING_PERCENT_USED
1079
+ or device.smartctl_exit_code != 0
1080
+ ):
1081
+ continue
1082
+ severity = TaskSeverity.CRITICAL if device.is_degraded else TaskSeverity.WARNING
1083
+ tasks.append(
1084
+ _task(
1085
+ server_id,
1086
+ TaskKind.SMART,
1087
+ severity,
1088
+ TaskEvidence(
1089
+ title=(
1090
+ f"Plan replacement for degraded NVMe {device.path.value}"
1091
+ if device.is_degraded
1092
+ else f"Review NVMe endurance for {device.path.value}"
1093
+ ),
1094
+ detail=(
1095
+ f"SMART {device.overall_health.value}; "
1096
+ f"warning {device.critical_warning.as_string()}; "
1097
+ f"{device.percentage_used}% used; "
1098
+ f"{device.available_spare_percent}% spare; "
1099
+ f"{device.media_and_data_integrity_errors} media errors"
1100
+ ),
1101
+ source=f"observed.storage.smart.devices[{device.path.value}]",
1102
+ ),
1103
+ )
1104
+ )
1105
+ return tuple(tasks)
1106
+
1107
+
1108
+ def _failed_service_tasks(
1109
+ server_id: ResourceId, snapshot: ObservedServerSnapshot
1110
+ ) -> tuple[OperationsTask, ...]:
1111
+ services = _mapping(snapshot.spec, "services")
1112
+ tasks: list[OperationsTask] = []
1113
+ for name, value in services.items():
1114
+ service = _optional_mapping(value)
1115
+ if service is None or service.get("state") != "failed":
1116
+ continue
1117
+ service_name = ServiceName.from_boundary(name)
1118
+ tasks.append(
1119
+ _task(
1120
+ server_id,
1121
+ TaskKind.SERVICE,
1122
+ TaskSeverity.WARNING,
1123
+ TaskEvidence(
1124
+ title=f"Investigate failed service {service_name.value}",
1125
+ detail=f"systemd status is {service.get('status')!s}",
1126
+ source=f"observed.services[{service_name.value}]",
1127
+ ),
1128
+ )
1129
+ )
1130
+ return tuple(tasks)
1131
+
1132
+
1133
+ def _task(
1134
+ server_id: ResourceId,
1135
+ kind: TaskKind,
1136
+ severity: TaskSeverity,
1137
+ evidence: TaskEvidence,
1138
+ ) -> OperationsTask:
1139
+ digest = hashlib.sha256(
1140
+ f"{server_id.value}\0{kind.value}\0{evidence.source}".encode()
1141
+ ).hexdigest()[:16]
1142
+ return OperationsTask(
1143
+ task_id=OperationsTaskId(f"{server_id.value}:{kind.value}:{digest}"),
1144
+ server_id=server_id,
1145
+ kind=kind,
1146
+ severity=severity,
1147
+ state=TaskState.OPEN,
1148
+ title=evidence.title,
1149
+ detail=evidence.detail,
1150
+ source=evidence.source,
1151
+ )
1152
+
1153
+
1154
+ def _sort_tasks(tasks: Iterable[OperationsTask]) -> tuple[OperationsTask, ...]:
1155
+ rank = {
1156
+ TaskSeverity.CRITICAL: 0,
1157
+ TaskSeverity.WARNING: 1,
1158
+ TaskSeverity.UNKNOWN: 2,
1159
+ }
1160
+ return tuple(
1161
+ sorted(tasks, key=lambda task: (rank[task.severity], task.task_id.value))
1162
+ )
1163
+
1164
+
1165
+ def _health(severities: Iterable[TaskSeverity]) -> OperationsHealth:
1166
+ values = frozenset(severities)
1167
+ if TaskSeverity.CRITICAL in values:
1168
+ return OperationsHealth.CRITICAL
1169
+ if TaskSeverity.UNKNOWN in values:
1170
+ return OperationsHealth.UNKNOWN
1171
+ if TaskSeverity.WARNING in values:
1172
+ return OperationsHealth.WARNING
1173
+ return OperationsHealth.HEALTHY
1174
+
1175
+
1176
+ def _mapping(content: Mapping[str, object], key: str) -> Mapping[str, object]:
1177
+ value = _optional_mapping(content.get(key))
1178
+ if value is None:
1179
+ message = f"validated field {key!r} is not an object"
1180
+ raise TypeError(message)
1181
+ return value
1182
+
1183
+
1184
+ def _optional_mapping(value: object) -> Mapping[str, object] | None:
1185
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
1186
+ return None
1187
+ return cast("Mapping[str, object]", value)
1188
+
1189
+
1190
+ def _mapping_sequence(
1191
+ content: Mapping[str, object], key: str
1192
+ ) -> tuple[Mapping[str, object], ...]:
1193
+ value = content.get(key)
1194
+ if not isinstance(value, list):
1195
+ message = f"validated field {key!r} is not an array"
1196
+ raise TypeError(message)
1197
+ result: list[Mapping[str, object]] = []
1198
+ for item in value:
1199
+ mapping = _optional_mapping(item)
1200
+ if mapping is None:
1201
+ message = f"validated field {key!r} contains a non-object"
1202
+ raise TypeError(message)
1203
+ result.append(mapping)
1204
+ return tuple(result)
1205
+
1206
+
1207
+ def _string(content: Mapping[str, object], key: str) -> str:
1208
+ value = content.get(key)
1209
+ if not isinstance(value, str):
1210
+ message = f"validated field {key!r} is not a string"
1211
+ raise TypeError(message)
1212
+ return value
1213
+
1214
+
1215
+ def _integer(content: Mapping[str, object], key: str) -> int:
1216
+ value = content.get(key)
1217
+ if isinstance(value, bool) or not isinstance(value, int):
1218
+ message = f"validated field {key!r} is not an integer"
1219
+ raise TypeError(message)
1220
+ return value
1221
+
1222
+
1223
+ def _boolean(content: Mapping[str, object], key: str) -> bool:
1224
+ value = content.get(key)
1225
+ if not isinstance(value, bool):
1226
+ message = f"validated field {key!r} is not a boolean"
1227
+ raise TypeError(message)
1228
+ return value