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/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")
|