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