cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
cloudfall/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)