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/operator.py ADDED
@@ -0,0 +1,1099 @@
1
+ """Alert- and drift-driven operator loop: watch, diagnose, propose, verify.
2
+
3
+ The operator is deliberately deterministic. It never invents actions: every
4
+ proposal maps a declared trigger — a firing declared alert or an audited
5
+ drift — onto an existing engine entry point, carries the evidence that
6
+ justifies it, and waits for an explicit approval before anything mutates.
7
+ Outcomes are verified against the same evidence source the proposal came
8
+ from and every state change lands in a schema-validated receipt.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import ssl
16
+ import time
17
+ import urllib.request
18
+ from dataclasses import dataclass, field, replace
19
+ from datetime import UTC, datetime
20
+ from enum import StrEnum
21
+ from typing import TYPE_CHECKING, Protocol, cast
22
+
23
+ from cloudfall.audit import AuditStatus, audit_inventory
24
+ from cloudfall.domain import AlertSeverity, ResourceId
25
+ from cloudfall.lifecycle import LifecycleError, run_engine_playbook
26
+ from cloudfall.observation import load_observations
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable, Mapping
30
+ from pathlib import Path
31
+
32
+ from cloudfall.audit import AuditReport
33
+ from cloudfall.inventory import OperatorPolicyInventory, PlatformInventory
34
+ from cloudfall.lifecycle import EngineContext
35
+ from cloudfall.validation import SchemaCatalog
36
+
37
+ _PROPOSAL_SCHEMA = "operator-proposal.schema.json"
38
+ _FINGERPRINT_LENGTH = 16
39
+ _ERROR_FEED_UNREACHABLE = "operator_feed_unreachable"
40
+ _ERROR_FEED_INVALID = "operator_feed_invalid"
41
+ _ERROR_GATEWAY_MATERIAL_INVALID = "operator_gateway_material_invalid"
42
+ _ERROR_PROPOSAL_EXISTS = "operator_proposal_exists"
43
+ _ERROR_PROPOSAL_MISSING = "operator_proposal_missing"
44
+ _ERROR_PROPOSAL_NOT_OPEN = "operator_proposal_not_open"
45
+ _ERROR_OPERATION_UNSUPPORTED = "operator_operation_unsupported"
46
+ _ERROR_GATEWAY_UNDECLARED = "operator_gateway_undeclared"
47
+ _SERVICE_CHECK_PREFIXES = ("services.bind[", "alerting.rules[")
48
+
49
+
50
+ class OperatorError(RuntimeError):
51
+ """Structured operator failure suitable for CLI and agent consumers."""
52
+
53
+ def __init__(self, code: str, message: str) -> None:
54
+ """Capture a stable error code alongside the human message."""
55
+ super().__init__(message)
56
+ self.code = code
57
+ self.message = message
58
+
59
+ def as_dict(self) -> dict[str, object]:
60
+ """Serialize the failure for structured output."""
61
+ return {"code": self.code, "message": self.message}
62
+
63
+
64
+ class ProposalStatus(StrEnum):
65
+ """Receipted lifecycle of one operator proposal."""
66
+
67
+ PROPOSED = "proposed"
68
+ EXECUTED = "executed"
69
+ VERIFIED = "verified"
70
+ FAILED = "failed"
71
+
72
+
73
+ class TriggerKind(StrEnum):
74
+ """What kind of evidence raised a proposal."""
75
+
76
+ ALERT = "alert"
77
+ DRIFT = "drift"
78
+
79
+
80
+ class OperationKind(StrEnum):
81
+ """Existing engine entry points the operator may propose."""
82
+
83
+ CONVERGE_SERVICES = "converge-services"
84
+ CONVERGE_BASELINE = "converge-baseline"
85
+
86
+
87
+ _OPERATION_PLAYBOOKS: Mapping[OperationKind, str] = {
88
+ OperationKind.CONVERGE_SERVICES: "services.yml",
89
+ OperationKind.CONVERGE_BASELINE: "baseline.yml",
90
+ }
91
+
92
+ _BLOCKING_STATUSES = frozenset(
93
+ {ProposalStatus.PROPOSED, ProposalStatus.EXECUTED, ProposalStatus.FAILED}
94
+ )
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class OperatorAlert:
99
+ """One firing declared alert with the labels the operator acts on."""
100
+
101
+ name: str
102
+ cloudfall_rule: ResourceId
103
+ severity: AlertSeverity
104
+ environment: ResourceId
105
+ server: ResourceId
106
+ service: ResourceId
107
+ active_at: str
108
+ fingerprint: str
109
+
110
+ def as_dict(self) -> dict[str, object]:
111
+ """Serialize the alert for the proposal receipt."""
112
+ return {
113
+ "name": self.name,
114
+ "cloudfallRule": self.cloudfall_rule.value,
115
+ "severity": self.severity.value,
116
+ "environment": self.environment.value,
117
+ "server": self.server.value,
118
+ "service": self.service.value,
119
+ "activeAt": self.active_at,
120
+ }
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class DriftTrigger:
125
+ """One server's audited drift with the checks that failed."""
126
+
127
+ server: ResourceId
128
+ checks: tuple[str, ...]
129
+ fingerprint: str
130
+
131
+ def as_dict(self) -> dict[str, object]:
132
+ """Serialize the drift trigger for the proposal receipt."""
133
+ return {
134
+ "server": self.server.value,
135
+ "checks": list(self.checks),
136
+ }
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class ApprovalRecord:
141
+ """Who licensed an execution: a human confirm or a declared policy."""
142
+
143
+ mode: str
144
+ policy: ResourceId | None
145
+
146
+ def as_dict(self) -> dict[str, object]:
147
+ """Serialize the approval for the proposal receipt."""
148
+ result: dict[str, object] = {"mode": self.mode}
149
+ if self.policy is not None:
150
+ result["policy"] = self.policy.value
151
+ return result
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class ProposalOutcome:
156
+ """What actually happened after an approval."""
157
+
158
+ executed_at: str
159
+ verified_at: str | None
160
+ result: str
161
+ detail: str
162
+
163
+ def as_dict(self) -> dict[str, object]:
164
+ """Serialize the outcome for the proposal receipt."""
165
+ return {
166
+ "executedAt": self.executed_at,
167
+ "verifiedAt": self.verified_at,
168
+ "result": self.result,
169
+ "detail": self.detail,
170
+ }
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class OperatorProposal:
175
+ """One evidence-backed remediation proposal."""
176
+
177
+ resource_id: ResourceId
178
+ created_at: str
179
+ status: ProposalStatus
180
+ trigger_kind: TriggerKind
181
+ fingerprint: str
182
+ alert: OperatorAlert | None
183
+ drift: DriftTrigger | None
184
+ diagnosis_summary: str
185
+ evidence: tuple[str, ...]
186
+ operation_kind: OperationKind
187
+ operation_server: ResourceId
188
+ operation_service: ResourceId | None
189
+ command: tuple[str, ...]
190
+ approval: ApprovalRecord | None
191
+ outcome: ProposalOutcome | None
192
+
193
+ def __post_init__(self) -> None:
194
+ """Reject proposals whose trigger payload contradicts its kind."""
195
+ has_alert = self.alert is not None
196
+ expects_alert = self.trigger_kind is TriggerKind.ALERT
197
+ if has_alert is not expects_alert or (self.drift is None) is not (
198
+ self.trigger_kind is not TriggerKind.DRIFT
199
+ ):
200
+ message = (
201
+ "proposal trigger payload does not match its kind: "
202
+ f"{self.resource_id.value}"
203
+ )
204
+ raise ValueError(message)
205
+
206
+ def as_document(self) -> dict[str, object]:
207
+ """Serialize the proposal as a schema-valid receipt document."""
208
+ trigger: dict[str, object] = {
209
+ "kind": self.trigger_kind.value,
210
+ "fingerprint": self.fingerprint,
211
+ }
212
+ if self.alert is not None:
213
+ trigger["alert"] = self.alert.as_dict()
214
+ if self.drift is not None:
215
+ trigger["drift"] = self.drift.as_dict()
216
+ operation: dict[str, object] = {
217
+ "kind": self.operation_kind.value,
218
+ "server": self.operation_server.value,
219
+ "command": list(self.command),
220
+ }
221
+ if self.operation_service is not None:
222
+ operation["service"] = self.operation_service.value
223
+ spec: dict[str, object] = {
224
+ "createdAt": self.created_at,
225
+ "status": self.status.value,
226
+ "trigger": trigger,
227
+ "diagnosis": {
228
+ "summary": self.diagnosis_summary,
229
+ "evidence": list(self.evidence),
230
+ },
231
+ "operation": operation,
232
+ }
233
+ if self.approval is not None:
234
+ spec["approval"] = self.approval.as_dict()
235
+ if self.outcome is not None:
236
+ spec["outcome"] = self.outcome.as_dict()
237
+ return {
238
+ "apiVersion": "cloudfall/v1",
239
+ "kind": "OperatorProposal",
240
+ "metadata": {
241
+ "id": self.resource_id.value,
242
+ "description": self.diagnosis_summary[:500],
243
+ },
244
+ "spec": spec,
245
+ }
246
+
247
+
248
+ class AlertFeed(Protocol):
249
+ """Source of firing declared alerts."""
250
+
251
+ def fetch(self) -> tuple[OperatorAlert, ...]:
252
+ """Return the currently firing actionable alerts."""
253
+ ...
254
+
255
+
256
+ @dataclass(frozen=True, slots=True)
257
+ class GatewayAlertFeed:
258
+ """mTLS client for the logging gateway's read-only alerts route."""
259
+
260
+ url: str
261
+ ca_path: Path
262
+ certificate_path: Path
263
+ key_path: Path
264
+ timeout_seconds: float = 10.0
265
+
266
+ def fetch(self) -> tuple[OperatorAlert, ...]:
267
+ """Fetch firing alerts through the gateway."""
268
+ try:
269
+ context = ssl.create_default_context(cafile=str(self.ca_path))
270
+ context.load_cert_chain(
271
+ certfile=str(self.certificate_path), keyfile=str(self.key_path)
272
+ )
273
+ except OSError as error:
274
+ message = (
275
+ "gateway TLS material could not be loaded "
276
+ f"(ca {self.ca_path}, certificate {self.certificate_path}, "
277
+ f"key {self.key_path}): {error}"
278
+ )
279
+ raise OperatorError(_ERROR_GATEWAY_MATERIAL_INVALID, message) from error
280
+ request = urllib.request.Request(self.url) # noqa: S310 - declared https gateway
281
+ try:
282
+ with urllib.request.urlopen( # noqa: S310 - declared https gateway
283
+ request, timeout=self.timeout_seconds, context=context
284
+ ) as response:
285
+ body = response.read()
286
+ except OSError as error:
287
+ message = f"alert feed unreachable: {self.url}: {error}"
288
+ raise OperatorError(_ERROR_FEED_UNREACHABLE, message) from error
289
+ return parse_prometheus_alerts(body.decode("utf-8"))
290
+
291
+
292
+ def parse_prometheus_alerts(raw: str) -> tuple[OperatorAlert, ...]:
293
+ """Parse a Prometheus alerts API payload into actionable alerts."""
294
+ try:
295
+ payload = cast("object", json.loads(raw))
296
+ except json.JSONDecodeError as error:
297
+ message = f"alert feed returned invalid JSON: {error.msg}"
298
+ raise OperatorError(_ERROR_FEED_INVALID, message) from error
299
+ if not isinstance(payload, dict) or payload.get("status") != "success":
300
+ message = "alert feed returned a non-success payload"
301
+ raise OperatorError(_ERROR_FEED_INVALID, message)
302
+ data = payload.get("data")
303
+ if not isinstance(data, dict) or not isinstance(data.get("alerts"), list):
304
+ message = "alert feed payload is missing data.alerts"
305
+ raise OperatorError(_ERROR_FEED_INVALID, message)
306
+ alerts: list[OperatorAlert] = []
307
+ for item in cast("list[object]", data["alerts"]):
308
+ if not isinstance(item, dict):
309
+ continue
310
+ alert = _actionable_alert(cast("Mapping[str, object]", item))
311
+ if alert is not None:
312
+ alerts.append(alert)
313
+ return tuple(alerts)
314
+
315
+
316
+ def _actionable_alert(item: Mapping[str, object]) -> OperatorAlert | None:
317
+ if item.get("state") != "firing":
318
+ return None
319
+ labels = item.get("labels")
320
+ if not isinstance(labels, dict):
321
+ return None
322
+ required = ("alertname", "cloudfall_rule", "severity", "environment",
323
+ "server", "service")
324
+ if not all(isinstance(labels.get(key), str) for key in required):
325
+ return None
326
+ active_at = item.get("activeAt")
327
+ if not isinstance(active_at, str):
328
+ return None
329
+ return OperatorAlert(
330
+ name=cast("str", labels["alertname"]),
331
+ cloudfall_rule=ResourceId.from_boundary(labels["cloudfall_rule"]),
332
+ severity=AlertSeverity.from_boundary(labels["severity"]),
333
+ environment=ResourceId.from_boundary(labels["environment"]),
334
+ server=ResourceId.from_boundary(labels["server"]),
335
+ service=ResourceId.from_boundary(labels["service"]),
336
+ active_at=active_at,
337
+ fingerprint=fingerprint_of(cast("Mapping[str, object]", labels)),
338
+ )
339
+
340
+
341
+ def fingerprint_of(content: Mapping[str, object]) -> str:
342
+ """Return a stable short fingerprint over a trigger's identity."""
343
+ canonical = json.dumps(
344
+ {key: content[key] for key in sorted(content)},
345
+ separators=(",", ":"),
346
+ default=str,
347
+ )
348
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
349
+ return digest[:_FINGERPRINT_LENGTH]
350
+
351
+
352
+ @dataclass(frozen=True, slots=True)
353
+ class SkippedAlert:
354
+ """One firing alert the operator refused to act on, with the reason."""
355
+
356
+ name: str
357
+ reason: str
358
+
359
+ def as_dict(self) -> dict[str, object]:
360
+ """Serialize for structured run output."""
361
+ return {"name": self.name, "reason": self.reason}
362
+
363
+
364
+ def propose_for_alert(
365
+ alert: OperatorAlert,
366
+ inventory: PlatformInventory,
367
+ now: str,
368
+ ) -> OperatorProposal | SkippedAlert:
369
+ """Map a firing alert onto a declared remediation, or refuse."""
370
+ declared = next(
371
+ (
372
+ service
373
+ for service in inventory.services
374
+ if service.resource_id == alert.service
375
+ and service.server_id == alert.server
376
+ ),
377
+ None,
378
+ )
379
+ if declared is None:
380
+ reason = (
381
+ f"service {alert.service.value} is not declared on "
382
+ f"server {alert.server.value}"
383
+ )
384
+ return SkippedAlert(name=alert.name, reason=reason)
385
+ summary = (
386
+ f"Declared alert {alert.cloudfall_rule.value} is firing: service "
387
+ f"{alert.service.value} on server {alert.server.value} stopped "
388
+ "satisfying its rule; converging declared services restores the "
389
+ "declared state"
390
+ )
391
+ evidence = (
392
+ f"alert {alert.name} firing since {alert.active_at}",
393
+ f"labels: severity={alert.severity.value} "
394
+ f"environment={alert.environment.value} "
395
+ f"server={alert.server.value} service={alert.service.value}",
396
+ f"fingerprint {alert.fingerprint}",
397
+ )
398
+ return OperatorProposal(
399
+ resource_id=_proposal_identifier(alert.fingerprint, now),
400
+ created_at=now,
401
+ status=ProposalStatus.PROPOSED,
402
+ trigger_kind=TriggerKind.ALERT,
403
+ fingerprint=alert.fingerprint,
404
+ alert=alert,
405
+ drift=None,
406
+ diagnosis_summary=summary,
407
+ evidence=evidence,
408
+ operation_kind=OperationKind.CONVERGE_SERVICES,
409
+ operation_server=alert.server,
410
+ operation_service=alert.service,
411
+ command=("cloudfall-engine", "playbook", "services.yml"),
412
+ approval=None,
413
+ outcome=None,
414
+ )
415
+
416
+
417
+ def propose_for_drift(
418
+ server: ResourceId,
419
+ checks: tuple[str, ...],
420
+ operation_kind: OperationKind,
421
+ now: str,
422
+ ) -> OperatorProposal:
423
+ """Map one server's audited drift onto a convergence proposal."""
424
+ fingerprint = fingerprint_of(
425
+ {
426
+ "server": server.value,
427
+ "operation": operation_kind.value,
428
+ "checks": ",".join(sorted(checks)),
429
+ }
430
+ )
431
+ playbook = _OPERATION_PLAYBOOKS[operation_kind]
432
+ summary = (
433
+ f"Audit reports drift on server {server.value}: "
434
+ f"{', '.join(checks)}; converging through {playbook} restores the "
435
+ "declared state"
436
+ )
437
+ evidence = tuple(f"audit check drifted: {check}" for check in checks)
438
+ drift = DriftTrigger(server=server, checks=checks, fingerprint=fingerprint)
439
+ return OperatorProposal(
440
+ resource_id=_proposal_identifier(fingerprint, now),
441
+ created_at=now,
442
+ status=ProposalStatus.PROPOSED,
443
+ trigger_kind=TriggerKind.DRIFT,
444
+ fingerprint=fingerprint,
445
+ alert=None,
446
+ drift=drift,
447
+ diagnosis_summary=summary,
448
+ evidence=evidence,
449
+ operation_kind=operation_kind,
450
+ operation_server=server,
451
+ operation_service=None,
452
+ command=("cloudfall-engine", "playbook", playbook),
453
+ approval=None,
454
+ outcome=None,
455
+ )
456
+
457
+
458
+ def _proposal_identifier(fingerprint: str, now: str) -> ResourceId:
459
+ stamp = (
460
+ now.replace("-", "").replace(":", "").replace("+0000", "")
461
+ .replace("t", "").replace("T", "").split(".")[0].lower()
462
+ )
463
+ return ResourceId.from_boundary(f"op-{stamp}-{fingerprint}")
464
+
465
+
466
+ @dataclass(frozen=True, slots=True)
467
+ class ProposalStore:
468
+ """Schema-validated proposal receipts in one directory."""
469
+
470
+ directory: Path
471
+ catalog: SchemaCatalog
472
+
473
+ def save(self, proposal: OperatorProposal) -> Path:
474
+ """Persist a new proposal, refusing to overwrite an existing one."""
475
+ path = self._path(proposal.resource_id)
476
+ if path.exists():
477
+ message = f"proposal already exists: {path}"
478
+ raise OperatorError(_ERROR_PROPOSAL_EXISTS, message)
479
+ return self._write(path, proposal)
480
+
481
+ def update(self, proposal: OperatorProposal) -> Path:
482
+ """Persist a state change of an existing proposal."""
483
+ path = self._path(proposal.resource_id)
484
+ if not path.exists():
485
+ message = f"proposal does not exist: {path}"
486
+ raise OperatorError(_ERROR_PROPOSAL_MISSING, message)
487
+ return self._write(path, proposal)
488
+
489
+ def load(self, proposal_id: ResourceId) -> OperatorProposal:
490
+ """Load and re-validate one proposal receipt."""
491
+ path = self._path(proposal_id)
492
+ if not path.is_file():
493
+ message = f"proposal does not exist: {path}"
494
+ raise OperatorError(_ERROR_PROPOSAL_MISSING, message)
495
+ document = cast(
496
+ "dict[str, object]", json.loads(path.read_text(encoding="utf-8"))
497
+ )
498
+ self.catalog.validate_named(_PROPOSAL_SCHEMA, document)
499
+ return _proposal_from_document(document)
500
+
501
+ def list(self) -> tuple[OperatorProposal, ...]:
502
+ """Load every proposal receipt ordered by creation time."""
503
+ self.directory.mkdir(parents=True, exist_ok=True)
504
+ proposals = [
505
+ self.load(ResourceId.from_boundary(path.stem))
506
+ for path in sorted(self.directory.glob("*.json"))
507
+ ]
508
+ proposals.sort(key=lambda proposal: proposal.created_at)
509
+ return tuple(proposals)
510
+
511
+ def blocking_fingerprints(self) -> frozenset[str]:
512
+ """Fingerprints already covered by an open or failed proposal."""
513
+ return frozenset(
514
+ proposal.fingerprint
515
+ for proposal in self.list()
516
+ if proposal.status in _BLOCKING_STATUSES
517
+ )
518
+
519
+ def _path(self, proposal_id: ResourceId) -> Path:
520
+ return self.directory / f"{proposal_id.value}.json"
521
+
522
+ def _write(self, path: Path, proposal: OperatorProposal) -> Path:
523
+ document = proposal.as_document()
524
+ self.catalog.validate_named(_PROPOSAL_SCHEMA, document)
525
+ self.directory.mkdir(parents=True, exist_ok=True)
526
+ path.write_text(
527
+ json.dumps(document, indent=2) + "\n", encoding="utf-8"
528
+ )
529
+ return path
530
+
531
+
532
+ def _proposal_from_document(document: Mapping[str, object]) -> OperatorProposal:
533
+ metadata = cast("Mapping[str, object]", document["metadata"])
534
+ spec = cast("Mapping[str, object]", document["spec"])
535
+ trigger = cast("Mapping[str, object]", spec["trigger"])
536
+ diagnosis = cast("Mapping[str, object]", spec["diagnosis"])
537
+ operation = cast("Mapping[str, object]", spec["operation"])
538
+ fingerprint = cast("str", trigger["fingerprint"])
539
+ alert = None
540
+ raw_alert = trigger.get("alert")
541
+ if isinstance(raw_alert, dict):
542
+ alert = OperatorAlert(
543
+ name=cast("str", raw_alert["name"]),
544
+ cloudfall_rule=ResourceId.from_boundary(raw_alert["cloudfallRule"]),
545
+ severity=AlertSeverity.from_boundary(raw_alert["severity"]),
546
+ environment=ResourceId.from_boundary(raw_alert["environment"]),
547
+ server=ResourceId.from_boundary(raw_alert["server"]),
548
+ service=ResourceId.from_boundary(raw_alert["service"]),
549
+ active_at=cast("str", raw_alert["activeAt"]),
550
+ fingerprint=fingerprint,
551
+ )
552
+ drift = None
553
+ raw_drift = trigger.get("drift")
554
+ if isinstance(raw_drift, dict):
555
+ drift = DriftTrigger(
556
+ server=ResourceId.from_boundary(raw_drift["server"]),
557
+ checks=tuple(cast("list[str]", raw_drift["checks"])),
558
+ fingerprint=fingerprint,
559
+ )
560
+ raw_approval = spec.get("approval")
561
+ approval = None
562
+ if isinstance(raw_approval, dict):
563
+ raw_policy = raw_approval.get("policy")
564
+ approval = ApprovalRecord(
565
+ mode=cast("str", raw_approval["mode"]),
566
+ policy=(
567
+ ResourceId.from_boundary(raw_policy)
568
+ if raw_policy is not None
569
+ else None
570
+ ),
571
+ )
572
+ raw_outcome = spec.get("outcome")
573
+ outcome = None
574
+ if isinstance(raw_outcome, dict):
575
+ outcome = ProposalOutcome(
576
+ executed_at=cast("str", raw_outcome["executedAt"]),
577
+ verified_at=cast("str | None", raw_outcome["verifiedAt"]),
578
+ result=cast("str", raw_outcome["result"]),
579
+ detail=cast("str", raw_outcome["detail"]),
580
+ )
581
+ raw_service = operation.get("service")
582
+ return OperatorProposal(
583
+ resource_id=ResourceId.from_boundary(metadata["id"]),
584
+ created_at=cast("str", spec["createdAt"]),
585
+ status=ProposalStatus(cast("str", spec["status"])),
586
+ trigger_kind=TriggerKind(cast("str", trigger["kind"])),
587
+ fingerprint=fingerprint,
588
+ alert=alert,
589
+ drift=drift,
590
+ diagnosis_summary=cast("str", diagnosis["summary"]),
591
+ evidence=tuple(cast("list[str]", diagnosis["evidence"])),
592
+ operation_kind=OperationKind(cast("str", operation["kind"])),
593
+ operation_server=ResourceId.from_boundary(operation["server"]),
594
+ operation_service=(
595
+ ResourceId.from_boundary(raw_service)
596
+ if raw_service is not None
597
+ else None
598
+ ),
599
+ command=tuple(cast("list[str]", operation["command"])),
600
+ approval=approval,
601
+ outcome=outcome,
602
+ )
603
+
604
+
605
+ @dataclass(frozen=True, slots=True)
606
+ class RunReport:
607
+ """Structured result of one operator pass."""
608
+
609
+ proposed: tuple[str, ...]
610
+ skipped: tuple[SkippedAlert, ...]
611
+ open_proposals: int
612
+
613
+ def as_dict(self) -> dict[str, object]:
614
+ """Serialize for structured CLI output."""
615
+ return {
616
+ "proposed": list(self.proposed),
617
+ "skipped": [skipped.as_dict() for skipped in self.skipped],
618
+ "openProposals": self.open_proposals,
619
+ }
620
+
621
+
622
+ def run_once(
623
+ feed: AlertFeed,
624
+ inventory: PlatformInventory,
625
+ store: ProposalStore,
626
+ now: str | None = None,
627
+ ) -> RunReport:
628
+ """One watch pass: fetch alerts and propose for anything new."""
629
+ timestamp = now if now is not None else _utc_now()
630
+ blocking = store.blocking_fingerprints()
631
+ proposed: list[str] = []
632
+ skipped: list[SkippedAlert] = []
633
+ for alert in feed.fetch():
634
+ if alert.fingerprint in blocking:
635
+ continue
636
+ result = propose_for_alert(alert, inventory, timestamp)
637
+ if isinstance(result, SkippedAlert):
638
+ skipped.append(result)
639
+ continue
640
+ store.save(result)
641
+ proposed.append(result.resource_id.value)
642
+ blocking = blocking | {alert.fingerprint}
643
+ return RunReport(
644
+ proposed=tuple(proposed),
645
+ skipped=tuple(skipped),
646
+ open_proposals=_open_count(store),
647
+ )
648
+
649
+
650
+ def drift_pass(
651
+ auditor: Callable[[], AuditReport],
652
+ store: ProposalStore,
653
+ now: str | None = None,
654
+ ) -> RunReport:
655
+ """One drift pass: refresh observations, audit, propose for drift."""
656
+ timestamp = now if now is not None else _utc_now()
657
+ report = auditor()
658
+ blocking = store.blocking_fingerprints()
659
+ proposed: list[str] = []
660
+ for server_audit in report.servers:
661
+ drifted = tuple(
662
+ check.check
663
+ for check in server_audit.checks
664
+ if check.status is AuditStatus.DRIFT
665
+ )
666
+ if not drifted:
667
+ continue
668
+ server = ResourceId.from_boundary(server_audit.server_id)
669
+ service_checks = tuple(
670
+ check
671
+ for check in drifted
672
+ if check.startswith(_SERVICE_CHECK_PREFIXES)
673
+ )
674
+ baseline_checks = tuple(
675
+ check
676
+ for check in drifted
677
+ if not check.startswith(_SERVICE_CHECK_PREFIXES)
678
+ )
679
+ groups = (
680
+ (OperationKind.CONVERGE_SERVICES, service_checks),
681
+ (OperationKind.CONVERGE_BASELINE, baseline_checks),
682
+ )
683
+ for operation_kind, checks in groups:
684
+ if not checks:
685
+ continue
686
+ proposal = propose_for_drift(server, checks, operation_kind, timestamp)
687
+ if proposal.fingerprint in blocking:
688
+ continue
689
+ store.save(proposal)
690
+ proposed.append(proposal.resource_id.value)
691
+ blocking = blocking | {proposal.fingerprint}
692
+ return RunReport(
693
+ proposed=tuple(proposed),
694
+ skipped=(),
695
+ open_proposals=_open_count(store),
696
+ )
697
+
698
+
699
+ def _open_count(store: ProposalStore) -> int:
700
+ return sum(
701
+ 1
702
+ for proposal in store.list()
703
+ if proposal.status is ProposalStatus.PROPOSED
704
+ )
705
+
706
+
707
+ def engine_executor(
708
+ context: EngineContext,
709
+ ) -> Callable[[OperatorProposal], None]:
710
+ """Executor running the proposal's engine entry point."""
711
+
712
+ def _execute(proposal: OperatorProposal) -> None:
713
+ playbook = _OPERATION_PLAYBOOKS.get(proposal.operation_kind)
714
+ if playbook is None:
715
+ message = (
716
+ "unsupported operation kind: "
717
+ f"{proposal.operation_kind.value}"
718
+ )
719
+ raise OperatorError(_ERROR_OPERATION_UNSUPPORTED, message)
720
+ run_engine_playbook(context, playbook, {})
721
+
722
+ return _execute
723
+
724
+
725
+ def engine_auditor(
726
+ context: EngineContext,
727
+ inventory: PlatformInventory,
728
+ observation_directory: Path,
729
+ ) -> Callable[[], AuditReport]:
730
+ """Auditor refreshing observations through the inspect playbook."""
731
+
732
+ def _audit() -> AuditReport:
733
+ run_engine_playbook(
734
+ context,
735
+ "inspect.yml",
736
+ {
737
+ "cloudfall_inspect_output_directory": str(
738
+ observation_directory.resolve()
739
+ )
740
+ },
741
+ )
742
+ observations = load_observations(
743
+ observation_directory, context.schema_directory
744
+ )
745
+ return audit_inventory(inventory, observations)
746
+
747
+ return _audit
748
+
749
+
750
+ def alert_resolution_verifier(
751
+ feed: AlertFeed,
752
+ ) -> Callable[[OperatorProposal], bool]:
753
+ """Build a verifier passing once the proposal's alert stops firing."""
754
+
755
+ def _verify(proposal: OperatorProposal) -> bool:
756
+ firing = {alert.fingerprint for alert in feed.fetch()}
757
+ return proposal.fingerprint not in firing
758
+
759
+ return _verify
760
+
761
+
762
+ def drift_resolution_verifier(
763
+ auditor: Callable[[], AuditReport],
764
+ ) -> Callable[[OperatorProposal], bool]:
765
+ """Build a verifier passing once the drifted checks are compliant."""
766
+
767
+ def _verify(proposal: OperatorProposal) -> bool:
768
+ if proposal.drift is None:
769
+ return False
770
+ report = auditor()
771
+ server_audit = next(
772
+ (
773
+ candidate
774
+ for candidate in report.servers
775
+ if candidate.server_id == proposal.drift.server.value
776
+ ),
777
+ None,
778
+ )
779
+ if server_audit is None:
780
+ return False
781
+ still_drifting = {
782
+ check.check
783
+ for check in server_audit.checks
784
+ if check.status is AuditStatus.DRIFT
785
+ }
786
+ return not (set(proposal.drift.checks) & still_drifting)
787
+
788
+ return _verify
789
+
790
+
791
+ @dataclass(frozen=True, slots=True)
792
+ class ApproveOptions:
793
+ """Verification behavior for an approval."""
794
+
795
+ verify_timeout_seconds: float = 180.0
796
+ poll_interval_seconds: float = 10.0
797
+ sleep: Callable[[float], None] = field(default=time.sleep)
798
+
799
+
800
+ def approve(
801
+ store: ProposalStore,
802
+ proposal_id: ResourceId,
803
+ executor: Callable[[OperatorProposal], None],
804
+ verifier: Callable[[OperatorProposal], bool],
805
+ options: ApproveOptions | None = None,
806
+ ) -> OperatorProposal:
807
+ """Execute a human-approved proposal and verify its trigger resolves."""
808
+ proposal = store.load(proposal_id)
809
+ if proposal.status is not ProposalStatus.PROPOSED:
810
+ message = (
811
+ f"proposal {proposal_id.value} is {proposal.status.value}, "
812
+ "only proposed proposals can be approved"
813
+ )
814
+ raise OperatorError(_ERROR_PROPOSAL_NOT_OPEN, message)
815
+ return _execute_and_verify(
816
+ store,
817
+ proposal,
818
+ executor,
819
+ verifier,
820
+ ApprovalRecord(mode="human", policy=None),
821
+ options,
822
+ )
823
+
824
+
825
+ def _execute_and_verify( # noqa: PLR0913 - internal execution contract.
826
+ store: ProposalStore,
827
+ proposal: OperatorProposal,
828
+ executor: Callable[[OperatorProposal], None],
829
+ verifier: Callable[[OperatorProposal], bool],
830
+ approval: ApprovalRecord,
831
+ options: ApproveOptions | None = None,
832
+ ) -> OperatorProposal:
833
+ resolved_options = options if options is not None else ApproveOptions()
834
+ executed_at = _utc_now()
835
+ try:
836
+ executor(proposal)
837
+ except (OperatorError, LifecycleError) as error:
838
+ failed = replace(
839
+ proposal,
840
+ status=ProposalStatus.FAILED,
841
+ approval=approval,
842
+ outcome=ProposalOutcome(
843
+ executed_at=executed_at,
844
+ verified_at=None,
845
+ result="failed",
846
+ detail=f"execution failed: {error}",
847
+ ),
848
+ )
849
+ store.update(failed)
850
+ raise
851
+ executed = replace(
852
+ proposal,
853
+ status=ProposalStatus.EXECUTED,
854
+ approval=approval,
855
+ outcome=ProposalOutcome(
856
+ executed_at=executed_at,
857
+ verified_at=None,
858
+ result="failed",
859
+ detail="executed, verification pending",
860
+ ),
861
+ )
862
+ store.update(executed)
863
+ waited = 0.0
864
+ while True:
865
+ if verifier(proposal):
866
+ verified = replace(
867
+ executed,
868
+ status=ProposalStatus.VERIFIED,
869
+ outcome=ProposalOutcome(
870
+ executed_at=executed_at,
871
+ verified_at=_utc_now(),
872
+ result="verified",
873
+ detail=(
874
+ "trigger evidence resolved after convergence; "
875
+ "observed outcome matches the declared state"
876
+ ),
877
+ ),
878
+ )
879
+ store.update(verified)
880
+ return verified
881
+ if waited >= resolved_options.verify_timeout_seconds:
882
+ failed = replace(
883
+ executed,
884
+ status=ProposalStatus.FAILED,
885
+ outcome=ProposalOutcome(
886
+ executed_at=executed_at,
887
+ verified_at=_utc_now(),
888
+ result="failed",
889
+ detail=(
890
+ "trigger evidence unresolved after "
891
+ f"{int(resolved_options.verify_timeout_seconds)}s; "
892
+ "remediation did not restore the declared state"
893
+ ),
894
+ ),
895
+ )
896
+ store.update(failed)
897
+ return failed
898
+ resolved_options.sleep(resolved_options.poll_interval_seconds)
899
+ waited += resolved_options.poll_interval_seconds
900
+
901
+
902
+ @dataclass(frozen=True, slots=True)
903
+ class AutonomyDecision:
904
+ """Whether a declared policy licenses one execution, and why."""
905
+
906
+ granted: bool
907
+ reason: str
908
+
909
+ def as_dict(self) -> dict[str, object]:
910
+ """Serialize for structured run output."""
911
+ return {"granted": self.granted, "reason": self.reason}
912
+
913
+
914
+ def autonomy_decision(
915
+ proposal: OperatorProposal,
916
+ policy: OperatorPolicyInventory,
917
+ store: ProposalStore,
918
+ now: datetime,
919
+ ) -> AutonomyDecision:
920
+ """Decide whether the declared policy licenses this execution now."""
921
+ grant = policy.grant_for(proposal.operation_kind.value)
922
+ if grant is None:
923
+ reason = (
924
+ f"policy {policy.resource_id.value} does not grant autonomy "
925
+ f"for {proposal.operation_kind.value}"
926
+ )
927
+ return AutonomyDecision(granted=False, reason=reason)
928
+ history = [
929
+ receipt
930
+ for receipt in store.list()
931
+ if receipt.operation_kind is proposal.operation_kind
932
+ and receipt.resource_id != proposal.resource_id
933
+ and receipt.status
934
+ in (ProposalStatus.VERIFIED, ProposalStatus.FAILED)
935
+ ]
936
+ verified = sum(
937
+ 1
938
+ for receipt in history
939
+ if receipt.status is ProposalStatus.VERIFIED
940
+ )
941
+ if verified < grant.required_verified_runs.value:
942
+ reason = (
943
+ f"insufficient verified history for "
944
+ f"{proposal.operation_kind.value}: {verified} of "
945
+ f"{grant.required_verified_runs.value} required"
946
+ )
947
+ return AutonomyDecision(granted=False, reason=reason)
948
+ if history and history[-1].status is ProposalStatus.FAILED:
949
+ reason = (
950
+ f"most recent {proposal.operation_kind.value} receipt failed; "
951
+ "autonomy suspended until a human-approved run verifies"
952
+ )
953
+ return AutonomyDecision(granted=False, reason=reason)
954
+ if policy.quiet_hours is not None and policy.quiet_hours.contains(
955
+ now.hour * 60 + now.minute
956
+ ):
957
+ reason = (
958
+ "quiet hours "
959
+ f"{policy.quiet_hours.start.value}-"
960
+ f"{policy.quiet_hours.end.value} are in effect"
961
+ )
962
+ return AutonomyDecision(granted=False, reason=reason)
963
+ hour_ago = now.timestamp() - 3600
964
+ recent_autonomous = sum(
965
+ 1
966
+ for receipt in store.list()
967
+ if receipt.approval is not None
968
+ and receipt.approval.mode == "autonomous"
969
+ and receipt.outcome is not None
970
+ and datetime.fromisoformat(receipt.outcome.executed_at).timestamp()
971
+ > hour_ago
972
+ )
973
+ if recent_autonomous >= policy.max_autonomous_per_hour.value:
974
+ reason = (
975
+ "rate limit reached: "
976
+ f"{recent_autonomous} autonomous executions in the last hour "
977
+ f"(policy allows {policy.max_autonomous_per_hour.value})"
978
+ )
979
+ return AutonomyDecision(granted=False, reason=reason)
980
+ reason = (
981
+ f"policy {policy.resource_id.value} grants "
982
+ f"{proposal.operation_kind.value}: {verified} verified runs, "
983
+ "rate limit and quiet hours clear"
984
+ )
985
+ return AutonomyDecision(granted=True, reason=reason)
986
+
987
+
988
+ @dataclass(frozen=True, slots=True)
989
+ class AutonomyReport:
990
+ """Structured result of one autonomous execution pass."""
991
+
992
+ executed: tuple[tuple[str, str], ...]
993
+ withheld: tuple[tuple[str, str], ...]
994
+
995
+ def as_dict(self) -> dict[str, object]:
996
+ """Serialize for structured run output."""
997
+ return {
998
+ "executed": [
999
+ {"proposal": proposal_id, "status": status}
1000
+ for proposal_id, status in self.executed
1001
+ ],
1002
+ "withheld": [
1003
+ {"proposal": proposal_id, "reason": reason}
1004
+ for proposal_id, reason in self.withheld
1005
+ ],
1006
+ }
1007
+
1008
+
1009
+ def autonomous_pass( # noqa: PLR0913 - boundary mirrors approve().
1010
+ store: ProposalStore,
1011
+ inventory: PlatformInventory,
1012
+ executor: Callable[[OperatorProposal], None],
1013
+ verifier_for: Callable[
1014
+ [OperatorProposal], Callable[[OperatorProposal], bool]
1015
+ ],
1016
+ options: ApproveOptions | None = None,
1017
+ now: datetime | None = None,
1018
+ ) -> AutonomyReport:
1019
+ """Execute open proposals the declared policy licenses, receipted."""
1020
+ moment = now if now is not None else datetime.now(UTC)
1021
+ policies_by_environment = {
1022
+ policy.environment: policy for policy in inventory.operator_policies
1023
+ }
1024
+ environments_by_server = {
1025
+ server.resource_id: server.environment for server in inventory.servers
1026
+ }
1027
+ executed: list[tuple[str, str]] = []
1028
+ withheld: list[tuple[str, str]] = []
1029
+ for proposal in store.list():
1030
+ if proposal.status is not ProposalStatus.PROPOSED:
1031
+ continue
1032
+ environment = environments_by_server.get(proposal.operation_server)
1033
+ policy = (
1034
+ policies_by_environment.get(environment)
1035
+ if environment is not None
1036
+ else None
1037
+ )
1038
+ if policy is None:
1039
+ withheld.append(
1040
+ (
1041
+ proposal.resource_id.value,
1042
+ "no operator policy declared for this environment",
1043
+ )
1044
+ )
1045
+ continue
1046
+ decision = autonomy_decision(proposal, policy, store, moment)
1047
+ if not decision.granted:
1048
+ withheld.append((proposal.resource_id.value, decision.reason))
1049
+ continue
1050
+ result = _execute_and_verify(
1051
+ store,
1052
+ proposal,
1053
+ executor,
1054
+ verifier_for(proposal),
1055
+ ApprovalRecord(mode="autonomous", policy=policy.resource_id),
1056
+ options,
1057
+ )
1058
+ executed.append((result.resource_id.value, result.status.value))
1059
+ return AutonomyReport(executed=tuple(executed), withheld=tuple(withheld))
1060
+
1061
+
1062
+ def gateway_feed(
1063
+ inventory: PlatformInventory,
1064
+ ca_path: Path,
1065
+ certificate_path: Path,
1066
+ key_path: Path,
1067
+ url_override: str | None = None,
1068
+ ) -> GatewayAlertFeed:
1069
+ """Build the alert feed from the declared logging gateway."""
1070
+ url = url_override
1071
+ if url is None:
1072
+ stack = next(
1073
+ (
1074
+ stack
1075
+ for stack in inventory.logging_stacks
1076
+ if stack.alerting is not None
1077
+ ),
1078
+ None,
1079
+ )
1080
+ if stack is None:
1081
+ message = (
1082
+ "no LoggingStack declares alerting; pass an explicit "
1083
+ "gateway URL or declare an alerting block"
1084
+ )
1085
+ raise OperatorError(_ERROR_GATEWAY_UNDECLARED, message)
1086
+ url = (
1087
+ f"https://{stack.gateway.server_name.value}:"
1088
+ f"{stack.gateway.port.value}/api/v1/alerts"
1089
+ )
1090
+ return GatewayAlertFeed(
1091
+ url=url,
1092
+ ca_path=ca_path,
1093
+ certificate_path=certificate_path,
1094
+ key_path=key_path,
1095
+ )
1096
+
1097
+
1098
+ def _utc_now() -> str:
1099
+ return datetime.now(UTC).isoformat(timespec="seconds")