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/dashboard.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Render a dependency-free static Cloudfall operations dashboard."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: E501
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from html import escape
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from cloudfall.operations import (
|
|
17
|
+
DomainCheck,
|
|
18
|
+
DomainOperations,
|
|
19
|
+
FleetOperations,
|
|
20
|
+
MonitoredFilesystem,
|
|
21
|
+
OperationsTask,
|
|
22
|
+
ServerOperations,
|
|
23
|
+
SmartDeviceEvidence,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_FILESYSTEM_WARNING_BASIS_POINTS = 8_500
|
|
27
|
+
_FILESYSTEM_CRITICAL_BASIS_POINTS = 9_500
|
|
28
|
+
_SMART_WARNING_PERCENT_USED = 90
|
|
29
|
+
_REFRESH_MINIMUM_SECONDS = 1
|
|
30
|
+
_REFRESH_MAXIMUM_SECONDS = 3_600
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class RefreshInterval:
|
|
35
|
+
"""How often live dashboard consumers re-derive and re-fetch evidence."""
|
|
36
|
+
|
|
37
|
+
seconds: int
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
"""Reject intervals outside the supported live-refresh range."""
|
|
41
|
+
if not (
|
|
42
|
+
_REFRESH_MINIMUM_SECONDS <= self.seconds <= _REFRESH_MAXIMUM_SECONDS
|
|
43
|
+
):
|
|
44
|
+
message = (
|
|
45
|
+
"refresh interval must be between "
|
|
46
|
+
f"{_REFRESH_MINIMUM_SECONDS} and {_REFRESH_MAXIMUM_SECONDS} "
|
|
47
|
+
f"seconds, got {self.seconds}"
|
|
48
|
+
)
|
|
49
|
+
raise ValueError(message)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_boundary(cls, value: object) -> RefreshInterval:
|
|
53
|
+
"""Coerce a boundary value while keeping internal APIs strictly typed."""
|
|
54
|
+
if isinstance(value, RefreshInterval):
|
|
55
|
+
return value
|
|
56
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
57
|
+
message = (
|
|
58
|
+
"refresh interval must be an integer number of seconds, "
|
|
59
|
+
f"got {type(value).__name__}"
|
|
60
|
+
)
|
|
61
|
+
raise TypeError(message)
|
|
62
|
+
return cls(value)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def milliseconds(self) -> int:
|
|
66
|
+
"""Return the interval for JavaScript timer consumption."""
|
|
67
|
+
return self.seconds * 1_000
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class DashboardArtifacts:
|
|
72
|
+
"""Files emitted by one dashboard build."""
|
|
73
|
+
|
|
74
|
+
index: Path
|
|
75
|
+
operations: Path
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, str]:
|
|
78
|
+
"""Serialize generated artifact paths."""
|
|
79
|
+
return {
|
|
80
|
+
"index": str(self.index),
|
|
81
|
+
"operations": str(self.operations),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_dashboard(
|
|
86
|
+
operations: FleetOperations, output_directory: Path
|
|
87
|
+
) -> DashboardArtifacts:
|
|
88
|
+
"""Write static HTML and its machine-readable operations payload."""
|
|
89
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
index_path = output_directory / "index.html"
|
|
91
|
+
operations_path = output_directory / "operations.json"
|
|
92
|
+
_atomic_write(
|
|
93
|
+
operations_path,
|
|
94
|
+
f"{json.dumps(operations.as_dict(), indent=2, sort_keys=True)}\n",
|
|
95
|
+
)
|
|
96
|
+
_atomic_write(index_path, _render_html(operations))
|
|
97
|
+
return DashboardArtifacts(index=index_path, operations=operations_path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
101
|
+
temporary = path.with_suffix(f"{path.suffix}.tmp")
|
|
102
|
+
temporary.write_text(content, encoding="utf-8")
|
|
103
|
+
temporary.replace(path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def operations_fingerprint(operations: FleetOperations) -> str:
|
|
107
|
+
"""Hash evidence-derived content, ignoring the generation timestamp."""
|
|
108
|
+
payload = dict(operations.as_dict())
|
|
109
|
+
del payload["generatedAt"]
|
|
110
|
+
serialized = json.dumps(payload, sort_keys=True)
|
|
111
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_dashboard_html(
|
|
115
|
+
operations: FleetOperations, live: RefreshInterval | None = None
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Render the dashboard page, optionally with self-updating live refresh."""
|
|
118
|
+
return _render_html(operations, live)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_LIVE_SCRIPT_TEMPLATE = """<script>
|
|
122
|
+
(() => {
|
|
123
|
+
const REFRESH_MS = __REFRESH_MS__;
|
|
124
|
+
const schedule = () => setTimeout(refresh, REFRESH_MS);
|
|
125
|
+
const setStatus = (text, stale) => {
|
|
126
|
+
const status = document.getElementById("live-status");
|
|
127
|
+
status.textContent = text;
|
|
128
|
+
status.classList.toggle("stale", stale);
|
|
129
|
+
};
|
|
130
|
+
const refresh = async () => {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(window.location.pathname, { cache: "no-store" });
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`HTTP ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
const markup = await response.text();
|
|
137
|
+
const next = new DOMParser().parseFromString(markup, "text/html").querySelector("main");
|
|
138
|
+
if (next === null) {
|
|
139
|
+
throw new Error("refresh payload has no <main> element");
|
|
140
|
+
}
|
|
141
|
+
const current = document.querySelector("main");
|
|
142
|
+
if (next.dataset.fingerprint !== current.dataset.fingerprint) {
|
|
143
|
+
current.replaceWith(next);
|
|
144
|
+
}
|
|
145
|
+
setStatus(`live · checked ${new Date().toLocaleTimeString()}`, false);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
setStatus(`refresh failed: ${error.message}`, true);
|
|
148
|
+
}
|
|
149
|
+
schedule();
|
|
150
|
+
};
|
|
151
|
+
schedule();
|
|
152
|
+
})();
|
|
153
|
+
</script>"""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _render_html(
|
|
157
|
+
operations: FleetOperations, live: RefreshInterval | None = None
|
|
158
|
+
) -> str:
|
|
159
|
+
payload = operations.as_dict()
|
|
160
|
+
summary = payload["summary"]
|
|
161
|
+
if not isinstance(summary, dict):
|
|
162
|
+
message = "serialized operations summary is not an object"
|
|
163
|
+
raise TypeError(message)
|
|
164
|
+
tasks = summary["tasks"]
|
|
165
|
+
if not isinstance(tasks, dict):
|
|
166
|
+
message = "serialized operations task summary is not an object"
|
|
167
|
+
raise TypeError(message)
|
|
168
|
+
server_cards = "".join(_render_server(server) for server in operations.servers)
|
|
169
|
+
domain_cards = "".join(_render_domain(domain) for domain in operations.domains)
|
|
170
|
+
if not domain_cards:
|
|
171
|
+
domain_cards = '<p class="empty">No desired public domains.</p>'
|
|
172
|
+
task_rows = "".join(_render_task_row(task) for task in operations.tasks)
|
|
173
|
+
if not task_rows:
|
|
174
|
+
task_rows = (
|
|
175
|
+
'<tr><td colspan="5" class="empty">No open tasks from current '
|
|
176
|
+
"evidence.</td></tr>"
|
|
177
|
+
)
|
|
178
|
+
fingerprint = operations_fingerprint(operations)
|
|
179
|
+
live_status = (
|
|
180
|
+
f'<div class="muted" id="live-status">live · every {live.seconds}s</div>'
|
|
181
|
+
if live is not None
|
|
182
|
+
else ""
|
|
183
|
+
)
|
|
184
|
+
live_script = (
|
|
185
|
+
_LIVE_SCRIPT_TEMPLATE.replace("__REFRESH_MS__", str(live.milliseconds))
|
|
186
|
+
if live is not None
|
|
187
|
+
else ""
|
|
188
|
+
)
|
|
189
|
+
return f"""<!doctype html>
|
|
190
|
+
<html lang="en">
|
|
191
|
+
<head>
|
|
192
|
+
<meta charset="utf-8">
|
|
193
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
194
|
+
<title>Cloudfall Operations</title>
|
|
195
|
+
<style>
|
|
196
|
+
:root {{ color-scheme: dark; --bg: #0c1117; --panel: #151c24;
|
|
197
|
+
--line: #293442; --text: #e7edf5; --muted: #91a0b2;
|
|
198
|
+
--healthy: #48c78e; --warning: #ffbd59; --critical: #ff6577;
|
|
199
|
+
--unknown: #91a0b2; }}
|
|
200
|
+
* {{ box-sizing: border-box; }}
|
|
201
|
+
body {{ margin: 0; background: var(--bg); color: var(--text);
|
|
202
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif; }}
|
|
203
|
+
main {{ width: min(1180px, calc(100% - 32px)); margin: 40px auto 64px; }}
|
|
204
|
+
header {{ display: flex; justify-content: space-between; gap: 24px;
|
|
205
|
+
align-items: end; margin-bottom: 28px; }}
|
|
206
|
+
h1, h2, h3, p {{ margin-top: 0; }} h1 {{ margin-bottom: 4px; }}
|
|
207
|
+
h2 {{ margin: 36px 0 14px; font-size: 19px; }}
|
|
208
|
+
.muted, code {{ color: var(--muted); }}
|
|
209
|
+
.badge {{ display: inline-flex; align-items: center; border: 1px solid;
|
|
210
|
+
border-radius: 999px; padding: 4px 9px; font-size: 12px;
|
|
211
|
+
font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }}
|
|
212
|
+
.healthy {{ color: var(--healthy); }} .warning {{ color: var(--warning); }}
|
|
213
|
+
.critical {{ color: var(--critical); }} .unknown {{ color: var(--unknown); }}
|
|
214
|
+
.summary {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }}
|
|
215
|
+
.stat, .server, .domain, .table-wrap {{ background: var(--panel); border: 1px solid var(--line);
|
|
216
|
+
border-radius: 12px; }}
|
|
217
|
+
.stat {{ padding: 16px; }} .stat strong {{ display: block; font-size: 28px; }}
|
|
218
|
+
.stat span {{ color: var(--muted); font-size: 13px; }}
|
|
219
|
+
.servers {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
220
|
+
gap: 14px; }}
|
|
221
|
+
.domains {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(430px, 1fr));
|
|
222
|
+
gap: 14px; }}
|
|
223
|
+
.server, .domain {{ padding: 18px; }} .server-head, .domain-head {{ display: flex;
|
|
224
|
+
justify-content: space-between; gap: 16px; }}
|
|
225
|
+
.server h3, .domain h3 {{ margin-bottom: 2px; }}
|
|
226
|
+
table {{ width: 100%; border-collapse: collapse; }}
|
|
227
|
+
th, td {{ text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line);
|
|
228
|
+
vertical-align: top; }}
|
|
229
|
+
tr:last-child td {{ border-bottom: 0; }} th {{ color: var(--muted);
|
|
230
|
+
font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }}
|
|
231
|
+
.table-wrap {{ overflow-x: auto; }} .compact {{ margin-top: 14px; font-size: 13px; }}
|
|
232
|
+
.compact th, .compact td {{ padding: 7px 8px; }}
|
|
233
|
+
.usage {{ min-width: 110px; }} .bar {{ height: 6px; background: #27313d;
|
|
234
|
+
border-radius: 99px; overflow: hidden; margin-top: 5px; }}
|
|
235
|
+
.bar span {{ display: block; height: 100%; background: currentColor; }}
|
|
236
|
+
.task-title {{ font-weight: 650; }} .task-detail {{ color: var(--muted);
|
|
237
|
+
font-size: 13px; margin-top: 2px; }} .empty {{ color: var(--muted);
|
|
238
|
+
padding: 28px; text-align: center; }}
|
|
239
|
+
a {{ color: #7dc4ff; }} footer {{ margin-top: 20px; color: var(--muted);
|
|
240
|
+
font-size: 13px; }}
|
|
241
|
+
#live-status {{ margin-top: 4px; }} #live-status.stale {{ color: var(--critical); }}
|
|
242
|
+
@media (max-width: 720px) {{ header {{ display: block; }}
|
|
243
|
+
.summary {{ grid-template-columns: repeat(2, 1fr); }}
|
|
244
|
+
.servers, .domains {{ grid-template-columns: 1fr; }} main {{ margin-top: 24px; }} }}
|
|
245
|
+
</style>
|
|
246
|
+
</head>
|
|
247
|
+
<body>
|
|
248
|
+
<main data-fingerprint="{fingerprint}">
|
|
249
|
+
<header>
|
|
250
|
+
<div><h1>Cloudfall Operations</h1><p class="muted">Evidence-backed fleet status</p></div>
|
|
251
|
+
<div><span class="badge {operations.health.value}">{operations.health.value}</span>
|
|
252
|
+
<div class="muted">Generated {escape(operations.generated_at.as_string())}</div>{live_status}</div>
|
|
253
|
+
</header>
|
|
254
|
+
<section class="summary" aria-label="Fleet summary">
|
|
255
|
+
<div class="stat"><strong>{len(operations.domains)}</strong><span>services</span></div>
|
|
256
|
+
<div class="stat"><strong>{len(operations.servers)}</strong><span>servers</span></div>
|
|
257
|
+
<div class="stat critical"><strong>{tasks["critical"]}</strong><span>critical tasks</span></div>
|
|
258
|
+
<div class="stat warning"><strong>{tasks["warning"]}</strong><span>warning tasks</span></div>
|
|
259
|
+
<div class="stat unknown"><strong>{tasks["unknown"]}</strong><span>unknown tasks</span></div>
|
|
260
|
+
</section>
|
|
261
|
+
<h2>Services</h2>
|
|
262
|
+
<section class="domains">{domain_cards}</section>
|
|
263
|
+
<h2>Servers</h2>
|
|
264
|
+
<section class="servers">{server_cards}</section>
|
|
265
|
+
<h2>Open tasks</h2>
|
|
266
|
+
<div class="table-wrap"><table>
|
|
267
|
+
<thead><tr><th>Severity</th><th>Server</th><th>Task</th><th>Kind</th><th>Evidence</th></tr></thead>
|
|
268
|
+
<tbody>{task_rows}</tbody>
|
|
269
|
+
</table></div>
|
|
270
|
+
<footer>Read-only projection. <a href="operations.json">View operations JSON</a>.</footer>
|
|
271
|
+
</main>
|
|
272
|
+
{live_script}
|
|
273
|
+
</body>
|
|
274
|
+
</html>
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _render_domain(domain: DomainOperations) -> str:
|
|
279
|
+
observation = (
|
|
280
|
+
domain.observed_at.as_string()
|
|
281
|
+
if domain.observed_at is not None
|
|
282
|
+
else "No route observation"
|
|
283
|
+
)
|
|
284
|
+
deployed_at = (
|
|
285
|
+
domain.deployed_at.as_string()
|
|
286
|
+
if domain.deployed_at is not None
|
|
287
|
+
else "No deployment receipt"
|
|
288
|
+
)
|
|
289
|
+
return f"""<article class="domain">
|
|
290
|
+
<div class="domain-head"><div><h3>{escape(domain.primary_name)}</h3>
|
|
291
|
+
<div class="muted">{escape(domain.domain_id.value)} ·
|
|
292
|
+
{escape(domain.proxy_server_id.value)} → {escape(domain.origin_server_id.value)}</div></div>
|
|
293
|
+
<span class="badge {domain.health.value}">{domain.health.value}</span></div>
|
|
294
|
+
<table class="compact"><thead><tr><th>Lifecycle</th><th>Status</th><th>Evidence</th></tr></thead>
|
|
295
|
+
<tbody>
|
|
296
|
+
{_render_lifecycle_row("Planned", domain.planned.value, "Desired Domain exists")}
|
|
297
|
+
{_render_lifecycle_row("Ready", domain.ready_to_deploy.value, "Referenced servers are active")}
|
|
298
|
+
{_render_lifecycle_row("Deployed", domain.deployed.value, deployed_at)}
|
|
299
|
+
{_render_lifecycle_row("Configured", domain.configuration.value, domain.configuration_detail)}
|
|
300
|
+
</tbody></table>
|
|
301
|
+
<table class="compact"><thead><tr><th>Route check</th><th>Status</th><th>Evidence</th></tr></thead>
|
|
302
|
+
<tbody>
|
|
303
|
+
{_render_domain_check("DNS / edge", domain.dns)}
|
|
304
|
+
{_render_domain_check("TLS", domain.tls)}
|
|
305
|
+
{_render_domain_check("Origin", domain.origin)}
|
|
306
|
+
{_render_domain_check("Public", domain.public_route)}
|
|
307
|
+
</tbody></table>
|
|
308
|
+
<p class="muted" style="margin: 12px 0 0">{escape(observation)}</p>
|
|
309
|
+
</article>"""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _render_lifecycle_row(label: str, status: str, detail: str) -> str:
|
|
313
|
+
css_class = (
|
|
314
|
+
"healthy"
|
|
315
|
+
if status in {"yes", "compliant"}
|
|
316
|
+
else "warning"
|
|
317
|
+
if status in {"no", "drifted"}
|
|
318
|
+
else "unknown"
|
|
319
|
+
)
|
|
320
|
+
return f"""<tr><td>{escape(label)}</td>
|
|
321
|
+
<td><span class="badge {css_class}">{escape(status)}</span></td>
|
|
322
|
+
<td class="muted">{escape(detail)}</td></tr>"""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _render_domain_check(label: str, check: DomainCheck) -> str:
|
|
326
|
+
css_class = (
|
|
327
|
+
"healthy"
|
|
328
|
+
if check.status.value == "healthy"
|
|
329
|
+
else "warning"
|
|
330
|
+
if check.status.value == "unhealthy"
|
|
331
|
+
else "unknown"
|
|
332
|
+
)
|
|
333
|
+
return f"""<tr><td>{escape(label)}</td>
|
|
334
|
+
<td><span class="badge {css_class}">{escape(check.status.value)}</span></td>
|
|
335
|
+
<td class="muted">{escape(check.detail)}</td></tr>"""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _render_server(server: ServerOperations) -> str:
|
|
339
|
+
observation = (
|
|
340
|
+
server.observed_at.as_string()
|
|
341
|
+
if server.observed_at is not None
|
|
342
|
+
else "No observation"
|
|
343
|
+
)
|
|
344
|
+
filesystems = "".join(
|
|
345
|
+
_render_filesystem(filesystem) for filesystem in server.filesystems
|
|
346
|
+
)
|
|
347
|
+
if not filesystems:
|
|
348
|
+
filesystems = '<tr><td colspan="3" class="muted">No filesystem data</td></tr>'
|
|
349
|
+
smart_rows = "".join(
|
|
350
|
+
_render_smart_device(device) for device in server.smart_devices
|
|
351
|
+
)
|
|
352
|
+
smart_section = (
|
|
353
|
+
f"""<table class="compact"><thead><tr><th>NVMe</th><th>SMART</th>
|
|
354
|
+
<th>Endurance</th><th>Temperature</th></tr></thead>
|
|
355
|
+
<tbody>{smart_rows}</tbody></table>"""
|
|
356
|
+
if smart_rows
|
|
357
|
+
else ""
|
|
358
|
+
)
|
|
359
|
+
return f"""<article class="server">
|
|
360
|
+
<div class="server-head"><div><h3>{escape(server.hostname)}</h3>
|
|
361
|
+
<div class="muted">{escape(server.server_id.value)} · {escape(observation)}</div></div>
|
|
362
|
+
<span class="badge {server.health.value}">{server.health.value}</span></div>
|
|
363
|
+
<table class="compact"><thead><tr><th>Mount</th><th>Type</th><th>Usage</th></tr></thead>
|
|
364
|
+
<tbody>{filesystems}</tbody></table>
|
|
365
|
+
{smart_section}
|
|
366
|
+
<p class="muted" style="margin: 12px 0 0">{len(server.tasks)} open tasks</p>
|
|
367
|
+
</article>"""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render_filesystem(filesystem: MonitoredFilesystem) -> str:
|
|
371
|
+
severity = (
|
|
372
|
+
"critical"
|
|
373
|
+
if filesystem.utilization.basis_points >= _FILESYSTEM_CRITICAL_BASIS_POINTS
|
|
374
|
+
else "warning"
|
|
375
|
+
if filesystem.utilization.basis_points >= _FILESYSTEM_WARNING_BASIS_POINTS
|
|
376
|
+
else "healthy"
|
|
377
|
+
)
|
|
378
|
+
percent = filesystem.utilization.percent
|
|
379
|
+
width = min(percent, 100.0)
|
|
380
|
+
return f"""<tr><td><code>{escape(filesystem.target.value)}</code></td>
|
|
381
|
+
<td>{escape(filesystem.filesystem.value)}</td>
|
|
382
|
+
<td class="usage {severity}">{percent:.1f}%<div class="bar">
|
|
383
|
+
<span style="width:{width:.1f}%"></span></div></td></tr>"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _render_smart_device(device: SmartDeviceEvidence) -> str:
|
|
387
|
+
severity = (
|
|
388
|
+
"critical"
|
|
389
|
+
if device.is_degraded
|
|
390
|
+
else "warning"
|
|
391
|
+
if device.percentage_used >= _SMART_WARNING_PERCENT_USED
|
|
392
|
+
else "healthy"
|
|
393
|
+
)
|
|
394
|
+
return f"""<tr><td><code>{escape(device.path.value)}</code>
|
|
395
|
+
<div class="muted">{escape(device.model)}</div></td>
|
|
396
|
+
<td class="{severity}">{escape(device.overall_health.value)}</td>
|
|
397
|
+
<td class="{severity}">{device.percentage_used}% used ·
|
|
398
|
+
{device.available_spare_percent}% spare</td>
|
|
399
|
+
<td>{device.temperature_celsius}°C</td></tr>"""
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _render_task_row(task: OperationsTask) -> str:
|
|
403
|
+
return f"""<tr><td><span class="badge {task.severity.value}">{task.severity.value}</span></td>
|
|
404
|
+
<td>{escape(task.server_id.value)}</td>
|
|
405
|
+
<td><div class="task-title">{escape(task.title)}</div>
|
|
406
|
+
<div class="task-detail">{escape(task.detail)}</div></td>
|
|
407
|
+
<td>{escape(task.kind.value)}</td><td><code>{escape(task.source)}</code></td></tr>"""
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Serve a live-refreshing read-only operations dashboard over HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from http import HTTPStatus
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from cloudfall.dashboard import RefreshInterval, render_dashboard_html
|
|
14
|
+
from cloudfall.inventory import PlatformInventory
|
|
15
|
+
from cloudfall.observation import load_observations
|
|
16
|
+
from cloudfall.operations import UtcTimestamp, build_operations_view
|
|
17
|
+
from cloudfall.service_evidence import (
|
|
18
|
+
DeploymentReceiptSet,
|
|
19
|
+
DomainObservationSet,
|
|
20
|
+
EvidenceTimestamp,
|
|
21
|
+
SocketDomainNetworkClient,
|
|
22
|
+
inspect_domains,
|
|
23
|
+
load_deployment_receipts,
|
|
24
|
+
load_domain_observations,
|
|
25
|
+
)
|
|
26
|
+
from cloudfall.validation import ConfigValidationError, validate_config
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from cloudfall.operations import FleetOperations
|
|
33
|
+
|
|
34
|
+
_PORT_MAXIMUM = 65_535
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ListenEndpoint:
|
|
39
|
+
"""Network address the dashboard server binds to."""
|
|
40
|
+
|
|
41
|
+
host: str
|
|
42
|
+
port: int
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
"""Reject empty hosts and out-of-range ports."""
|
|
46
|
+
if not self.host:
|
|
47
|
+
message = "listen host must not be empty"
|
|
48
|
+
raise ValueError(message)
|
|
49
|
+
if not 0 <= self.port <= _PORT_MAXIMUM:
|
|
50
|
+
message = f"listen port must be within 0..{_PORT_MAXIMUM}, got {self.port}"
|
|
51
|
+
raise ValueError(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class EvidenceSources:
|
|
56
|
+
"""Filesystem locations the dashboard re-derives its view from."""
|
|
57
|
+
|
|
58
|
+
project_directory: Path
|
|
59
|
+
schema_directory: Path
|
|
60
|
+
observed_directory: Path
|
|
61
|
+
service_observed_directory: Path
|
|
62
|
+
deployments_directory: Path
|
|
63
|
+
inspect_services: bool = False
|
|
64
|
+
|
|
65
|
+
def operations_view(self) -> FleetOperations:
|
|
66
|
+
"""Re-validate state and evidence, then build a fresh projection."""
|
|
67
|
+
state = validate_config(self.project_directory, self.schema_directory)
|
|
68
|
+
inventory = PlatformInventory.from_state(state)
|
|
69
|
+
if self.inspect_services:
|
|
70
|
+
inspect_domains(
|
|
71
|
+
inventory,
|
|
72
|
+
self.service_observed_directory,
|
|
73
|
+
SocketDomainNetworkClient(),
|
|
74
|
+
observed_at=EvidenceTimestamp.now(),
|
|
75
|
+
)
|
|
76
|
+
observations = load_observations(
|
|
77
|
+
self.observed_directory, self.schema_directory
|
|
78
|
+
)
|
|
79
|
+
deployment_receipts = (
|
|
80
|
+
load_deployment_receipts(
|
|
81
|
+
self.deployments_directory, self.schema_directory
|
|
82
|
+
)
|
|
83
|
+
if self.deployments_directory.is_dir()
|
|
84
|
+
else DeploymentReceiptSet.empty()
|
|
85
|
+
)
|
|
86
|
+
domain_observations = (
|
|
87
|
+
load_domain_observations(
|
|
88
|
+
self.service_observed_directory, self.schema_directory
|
|
89
|
+
)
|
|
90
|
+
if self.service_observed_directory.is_dir()
|
|
91
|
+
else DomainObservationSet.empty()
|
|
92
|
+
)
|
|
93
|
+
return build_operations_view(
|
|
94
|
+
inventory,
|
|
95
|
+
observations,
|
|
96
|
+
deployment_receipts,
|
|
97
|
+
domain_observations,
|
|
98
|
+
generated_at=UtcTimestamp.now(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class DashboardSnapshot:
|
|
104
|
+
"""One rendered dashboard build held in memory by the server."""
|
|
105
|
+
|
|
106
|
+
html: str
|
|
107
|
+
operations_json: str
|
|
108
|
+
built_at: float
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True)
|
|
112
|
+
class SnapshotCache:
|
|
113
|
+
"""Rebuild dashboard artifacts from evidence at most once per interval."""
|
|
114
|
+
|
|
115
|
+
sources: EvidenceSources
|
|
116
|
+
refresh: RefreshInterval
|
|
117
|
+
clock: Callable[[], float] = time.monotonic
|
|
118
|
+
_lock: threading.Lock = field(
|
|
119
|
+
default_factory=threading.Lock, init=False, repr=False
|
|
120
|
+
)
|
|
121
|
+
_snapshot: DashboardSnapshot | None = field(default=None, init=False, repr=False)
|
|
122
|
+
|
|
123
|
+
def current(self) -> DashboardSnapshot:
|
|
124
|
+
"""Return the cached snapshot, rebuilding once it has expired."""
|
|
125
|
+
with self._lock:
|
|
126
|
+
now = self.clock()
|
|
127
|
+
snapshot = self._snapshot
|
|
128
|
+
if snapshot is None or now - snapshot.built_at >= self.refresh.seconds:
|
|
129
|
+
operations = self.sources.operations_view()
|
|
130
|
+
payload = json.dumps(
|
|
131
|
+
operations.as_dict(), indent=2, sort_keys=True
|
|
132
|
+
)
|
|
133
|
+
snapshot = DashboardSnapshot(
|
|
134
|
+
html=render_dashboard_html(operations, live=self.refresh),
|
|
135
|
+
operations_json=f"{payload}\n",
|
|
136
|
+
built_at=now,
|
|
137
|
+
)
|
|
138
|
+
self._snapshot = snapshot
|
|
139
|
+
return snapshot
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DashboardHTTPServer(ThreadingHTTPServer):
|
|
143
|
+
"""Threaded HTTP server carrying the shared snapshot cache."""
|
|
144
|
+
|
|
145
|
+
daemon_threads = True
|
|
146
|
+
|
|
147
|
+
def __init__(self, endpoint: ListenEndpoint, cache: SnapshotCache) -> None:
|
|
148
|
+
"""Bind the endpoint and expose the cache to request handlers."""
|
|
149
|
+
self.cache = cache
|
|
150
|
+
super().__init__((endpoint.host, endpoint.port), _DashboardRequestHandler)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _DashboardRequestHandler(BaseHTTPRequestHandler):
|
|
154
|
+
"""Serve the rendered dashboard page and its machine-readable payload."""
|
|
155
|
+
|
|
156
|
+
def do_GET(self) -> None:
|
|
157
|
+
"""Route dashboard requests to the current evidence snapshot."""
|
|
158
|
+
if self.path not in {"/", "/index.html", "/operations.json"}:
|
|
159
|
+
body = json.dumps({"error": "not found", "path": self.path}, sort_keys=True)
|
|
160
|
+
self._respond(HTTPStatus.NOT_FOUND, "application/json", f"{body}\n")
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
snapshot = self._cache().current()
|
|
164
|
+
except ConfigValidationError as error:
|
|
165
|
+
body = json.dumps(error.as_dict(), sort_keys=True)
|
|
166
|
+
self._respond(
|
|
167
|
+
HTTPStatus.INTERNAL_SERVER_ERROR, "application/json", f"{body}\n"
|
|
168
|
+
)
|
|
169
|
+
return
|
|
170
|
+
if self.path == "/operations.json":
|
|
171
|
+
self._respond(HTTPStatus.OK, "application/json", snapshot.operations_json)
|
|
172
|
+
return
|
|
173
|
+
self._respond(HTTPStatus.OK, "text/html; charset=utf-8", snapshot.html)
|
|
174
|
+
|
|
175
|
+
def _cache(self) -> SnapshotCache:
|
|
176
|
+
server = self.server
|
|
177
|
+
if not isinstance(server, DashboardHTTPServer):
|
|
178
|
+
message = f"handler bound to unexpected server: {type(server).__name__}"
|
|
179
|
+
raise TypeError(message)
|
|
180
|
+
return server.cache
|
|
181
|
+
|
|
182
|
+
def _respond(self, status: HTTPStatus, content_type: str, body: str) -> None:
|
|
183
|
+
encoded = body.encode("utf-8")
|
|
184
|
+
self.send_response(status)
|
|
185
|
+
self.send_header("Content-Type", content_type)
|
|
186
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
187
|
+
self.send_header("Cache-Control", "no-store")
|
|
188
|
+
self.end_headers()
|
|
189
|
+
self.wfile.write(encoded)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def create_dashboard_server(
|
|
193
|
+
sources: EvidenceSources,
|
|
194
|
+
endpoint: ListenEndpoint,
|
|
195
|
+
refresh: RefreshInterval,
|
|
196
|
+
clock: Callable[[], float] = time.monotonic,
|
|
197
|
+
) -> DashboardHTTPServer:
|
|
198
|
+
"""Build one snapshot eagerly (fail fast), then bind the HTTP server."""
|
|
199
|
+
cache = SnapshotCache(sources=sources, refresh=refresh, clock=clock)
|
|
200
|
+
cache.current()
|
|
201
|
+
return DashboardHTTPServer(endpoint, cache)
|