cloudfall 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudfall/__init__.py +19 -0
- cloudfall/__main__.py +5 -0
- cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
- cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
- cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
- cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
- cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
- cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
- cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
- cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
- cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
- cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
- cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
- cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
- cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
- cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
- cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
- cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
- cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
- cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
- cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
- cloudfall/agent_tools.py +868 -0
- cloudfall/arguments.py +92 -0
- cloudfall/audit.py +905 -0
- cloudfall/authoring.py +341 -0
- cloudfall/cli.py +1748 -0
- cloudfall/commands.py +257 -0
- cloudfall/cutover.py +370 -0
- cloudfall/dashboard.py +407 -0
- cloudfall/dashboard_server.py +201 -0
- cloudfall/domain.py +1157 -0
- cloudfall/importer.py +906 -0
- cloudfall/inventory.py +2116 -0
- cloudfall/lifecycle.py +874 -0
- cloudfall/mcp_server.py +704 -0
- cloudfall/migrate.py +730 -0
- cloudfall/observation.py +143 -0
- cloudfall/operations.py +1228 -0
- cloudfall/operator.py +1099 -0
- cloudfall/project.py +841 -0
- cloudfall/py.typed +0 -0
- cloudfall/render_api.py +338 -0
- cloudfall/resources.py +67 -0
- cloudfall/secrets.py +262 -0
- cloudfall/service_evidence.py +659 -0
- cloudfall/validation.py +1340 -0
- cloudfall-0.2.0.dist-info/METADATA +408 -0
- cloudfall-0.2.0.dist-info/RECORD +165 -0
- cloudfall-0.2.0.dist-info/WHEEL +4 -0
- cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
- cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
- cloudfall_engine/__init__.py +5 -0
- cloudfall_engine/__main__.py +5 -0
- cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
- cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
- cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
- cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
- cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
- cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
- cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
- cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
- cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
- cloudfall_engine/ansible_inventory.py +253 -0
- cloudfall_engine/artifact.py +258 -0
- cloudfall_engine/cli.py +273 -0
- cloudfall_engine/playbook.py +175 -0
- cloudfall_engine/py.typed +0 -0
cloudfall/operations.py
ADDED
|
@@ -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
|