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/agent_tools.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""Typed agent-facing operations behind the MCP boundary.
|
|
2
|
+
|
|
3
|
+
Every method returns a JSON-serializable envelope instead of raising, so
|
|
4
|
+
agents always receive structured results. Read-only evidence tools are
|
|
5
|
+
exposed freely; anything that changes servers demands an explicit
|
|
6
|
+
confirmation handshake and always produces receipts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from cloudfall.audit import audit_inventory
|
|
16
|
+
from cloudfall.authoring import (
|
|
17
|
+
AuthoringError,
|
|
18
|
+
ServerOptions,
|
|
19
|
+
ServerTypeOptions,
|
|
20
|
+
SshKeyOptions,
|
|
21
|
+
add_server,
|
|
22
|
+
add_server_type,
|
|
23
|
+
add_ssh_key,
|
|
24
|
+
)
|
|
25
|
+
from cloudfall.domain import (
|
|
26
|
+
ConnectionAddress,
|
|
27
|
+
Hostname,
|
|
28
|
+
LinuxUser,
|
|
29
|
+
ReleaseId,
|
|
30
|
+
ResourceId,
|
|
31
|
+
TcpPort,
|
|
32
|
+
)
|
|
33
|
+
from cloudfall.importer import (
|
|
34
|
+
ImportTargets,
|
|
35
|
+
RenderImportError,
|
|
36
|
+
import_render_blueprint,
|
|
37
|
+
)
|
|
38
|
+
from cloudfall.inventory import PlatformInventory
|
|
39
|
+
from cloudfall.lifecycle import (
|
|
40
|
+
DeployOptions,
|
|
41
|
+
EngineContext,
|
|
42
|
+
LifecycleError,
|
|
43
|
+
backup_service,
|
|
44
|
+
build_release_artifact,
|
|
45
|
+
deploy,
|
|
46
|
+
health,
|
|
47
|
+
migrate_data,
|
|
48
|
+
restart,
|
|
49
|
+
rollback,
|
|
50
|
+
run_engine_playbook,
|
|
51
|
+
verify_backup,
|
|
52
|
+
)
|
|
53
|
+
from cloudfall.migrate import MigrateError, MigrateOptions, execute_migration
|
|
54
|
+
from cloudfall.observation import load_observations
|
|
55
|
+
from cloudfall.operations import UtcTimestamp, build_operations_view
|
|
56
|
+
from cloudfall.operator import (
|
|
57
|
+
ApproveOptions,
|
|
58
|
+
OperatorError,
|
|
59
|
+
ProposalStatus,
|
|
60
|
+
ProposalStore,
|
|
61
|
+
TriggerKind,
|
|
62
|
+
alert_resolution_verifier,
|
|
63
|
+
drift_pass,
|
|
64
|
+
drift_resolution_verifier,
|
|
65
|
+
engine_auditor,
|
|
66
|
+
engine_executor,
|
|
67
|
+
gateway_feed,
|
|
68
|
+
)
|
|
69
|
+
from cloudfall.operator import (
|
|
70
|
+
approve as operator_approve_proposal,
|
|
71
|
+
)
|
|
72
|
+
from cloudfall.operator import (
|
|
73
|
+
run_once as operator_run_once,
|
|
74
|
+
)
|
|
75
|
+
from cloudfall.project import project_path
|
|
76
|
+
from cloudfall.render_api import HttpRenderApiClient, read_api_key
|
|
77
|
+
from cloudfall.render_api import (
|
|
78
|
+
import_render_api as render_api_import,
|
|
79
|
+
)
|
|
80
|
+
from cloudfall.secrets import (
|
|
81
|
+
SecretsError,
|
|
82
|
+
SopsSecretProvider,
|
|
83
|
+
load_environment_receipts,
|
|
84
|
+
render_environment,
|
|
85
|
+
)
|
|
86
|
+
from cloudfall.service_evidence import (
|
|
87
|
+
DeploymentReceiptSet,
|
|
88
|
+
DomainObservationSet,
|
|
89
|
+
EvidenceTimestamp,
|
|
90
|
+
SocketDomainNetworkClient,
|
|
91
|
+
inspect_domains,
|
|
92
|
+
load_deployment_receipts,
|
|
93
|
+
load_domain_observations,
|
|
94
|
+
)
|
|
95
|
+
from cloudfall.validation import ConfigValidationError, SchemaCatalog, validate_config
|
|
96
|
+
|
|
97
|
+
if TYPE_CHECKING:
|
|
98
|
+
from collections.abc import Callable
|
|
99
|
+
|
|
100
|
+
from cloudfall.operator import AlertFeed
|
|
101
|
+
from cloudfall.validation import ValidatedConfig
|
|
102
|
+
|
|
103
|
+
_CODE_INVALID_ARGUMENT = "invalid_argument"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class AgentConfig:
|
|
108
|
+
"""Filesystem contract for one agent-facing server instance."""
|
|
109
|
+
|
|
110
|
+
project_directory: Path
|
|
111
|
+
schema_directory: Path
|
|
112
|
+
engine_directory: Path
|
|
113
|
+
inventory_file: Path
|
|
114
|
+
observed_directory: Path
|
|
115
|
+
service_observed_directory: Path
|
|
116
|
+
deployments_directory: Path
|
|
117
|
+
releases_directory: Path
|
|
118
|
+
artifacts_directory: Path
|
|
119
|
+
data_migrations_directory: Path = Path("tmp/data-migrations")
|
|
120
|
+
backups_directory: Path = Path("tmp/backups")
|
|
121
|
+
secrets_directory: Path = Path("secrets")
|
|
122
|
+
environment_directory: Path = Path("tmp/env")
|
|
123
|
+
environment_receipts_directory: Path = Path("tmp/env-receipts")
|
|
124
|
+
proposals_directory: Path = Path("tmp/operator/proposals")
|
|
125
|
+
gateway_ca_path: Path | None = None
|
|
126
|
+
gateway_certificate_path: Path | None = None
|
|
127
|
+
gateway_key_path: Path | None = None
|
|
128
|
+
|
|
129
|
+
def context(self) -> EngineContext:
|
|
130
|
+
"""Return the engine execution context shared by mutating tools."""
|
|
131
|
+
return EngineContext(
|
|
132
|
+
project_directory=self.project_directory,
|
|
133
|
+
schema_directory=self.schema_directory,
|
|
134
|
+
engine_directory=self.engine_directory,
|
|
135
|
+
inventory_file=self.inventory_file,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AgentToolset:
|
|
140
|
+
"""Structured operations offered to AI agents."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, config: AgentConfig) -> None:
|
|
143
|
+
"""Bind the toolset to one filesystem contract."""
|
|
144
|
+
self._config = config
|
|
145
|
+
|
|
146
|
+
def validate(self) -> dict[str, object]:
|
|
147
|
+
"""Validate declared state and return the structured summary."""
|
|
148
|
+
try:
|
|
149
|
+
return self._state().as_dict()
|
|
150
|
+
except ConfigValidationError as error:
|
|
151
|
+
return error.as_dict()
|
|
152
|
+
|
|
153
|
+
def inventory(self) -> dict[str, object]:
|
|
154
|
+
"""Return the non-secret platform inventory."""
|
|
155
|
+
try:
|
|
156
|
+
state = self._state()
|
|
157
|
+
except ConfigValidationError as error:
|
|
158
|
+
return error.as_dict()
|
|
159
|
+
return {
|
|
160
|
+
"status": "ok",
|
|
161
|
+
"inventory": PlatformInventory.from_state(state).as_dict(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def audit(self) -> dict[str, object]:
|
|
165
|
+
"""Compare desired state with collected server observations."""
|
|
166
|
+
try:
|
|
167
|
+
state = self._state()
|
|
168
|
+
observations = load_observations(
|
|
169
|
+
self._config.observed_directory, self._config.schema_directory
|
|
170
|
+
)
|
|
171
|
+
except ConfigValidationError as error:
|
|
172
|
+
return error.as_dict()
|
|
173
|
+
report = audit_inventory(
|
|
174
|
+
PlatformInventory.from_state(state),
|
|
175
|
+
observations,
|
|
176
|
+
load_environment_receipts(
|
|
177
|
+
self._config.environment_receipts_directory,
|
|
178
|
+
self._config.schema_directory,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
return report.as_dict()
|
|
182
|
+
|
|
183
|
+
def services_status(self) -> dict[str, object]:
|
|
184
|
+
"""Derive the evidence-based public service lifecycle."""
|
|
185
|
+
try:
|
|
186
|
+
state = self._state()
|
|
187
|
+
observations = load_observations(
|
|
188
|
+
self._config.observed_directory, self._config.schema_directory
|
|
189
|
+
)
|
|
190
|
+
except ConfigValidationError as error:
|
|
191
|
+
return error.as_dict()
|
|
192
|
+
receipts = (
|
|
193
|
+
load_deployment_receipts(
|
|
194
|
+
self._config.deployments_directory,
|
|
195
|
+
self._config.schema_directory,
|
|
196
|
+
)
|
|
197
|
+
if self._config.deployments_directory.is_dir()
|
|
198
|
+
else DeploymentReceiptSet.empty()
|
|
199
|
+
)
|
|
200
|
+
domain_observations = (
|
|
201
|
+
load_domain_observations(
|
|
202
|
+
self._config.service_observed_directory,
|
|
203
|
+
self._config.schema_directory,
|
|
204
|
+
)
|
|
205
|
+
if self._config.service_observed_directory.is_dir()
|
|
206
|
+
else DomainObservationSet.empty()
|
|
207
|
+
)
|
|
208
|
+
view = build_operations_view(
|
|
209
|
+
PlatformInventory.from_state(state),
|
|
210
|
+
observations,
|
|
211
|
+
receipts,
|
|
212
|
+
domain_observations,
|
|
213
|
+
generated_at=UtcTimestamp.now(),
|
|
214
|
+
)
|
|
215
|
+
return {
|
|
216
|
+
"status": "ok",
|
|
217
|
+
"health": view.health.value,
|
|
218
|
+
"services": [domain.as_dict() for domain in view.domains],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def component_health(self, component: str) -> dict[str, object]:
|
|
222
|
+
"""Probe one component's declared health check on its servers."""
|
|
223
|
+
try:
|
|
224
|
+
component_id = ResourceId.from_boundary(component)
|
|
225
|
+
except (TypeError, ValueError) as error:
|
|
226
|
+
return _invalid_argument(error)
|
|
227
|
+
try:
|
|
228
|
+
return health(self._config.context(), component_id).as_dict()
|
|
229
|
+
except LifecycleError as error:
|
|
230
|
+
return error.as_dict()
|
|
231
|
+
|
|
232
|
+
def inspect_servers(self) -> dict[str, object]:
|
|
233
|
+
"""Collect read-only server evidence into the observation directory."""
|
|
234
|
+
observed = self._config.observed_directory.resolve()
|
|
235
|
+
try:
|
|
236
|
+
run_engine_playbook(
|
|
237
|
+
self._config.context(),
|
|
238
|
+
"inspect.yml",
|
|
239
|
+
{"cloudfall_inspect_output_directory": str(observed)},
|
|
240
|
+
)
|
|
241
|
+
except LifecycleError as error:
|
|
242
|
+
return error.as_dict()
|
|
243
|
+
return {"status": "ok", "observed": str(observed)}
|
|
244
|
+
|
|
245
|
+
def inspect_services(self) -> dict[str, object]:
|
|
246
|
+
"""Collect DNS, TLS, origin, and public route evidence."""
|
|
247
|
+
try:
|
|
248
|
+
state = self._state()
|
|
249
|
+
except ConfigValidationError as error:
|
|
250
|
+
return error.as_dict()
|
|
251
|
+
paths = inspect_domains(
|
|
252
|
+
PlatformInventory.from_state(state),
|
|
253
|
+
self._config.service_observed_directory,
|
|
254
|
+
SocketDomainNetworkClient(),
|
|
255
|
+
observed_at=EvidenceTimestamp.now(),
|
|
256
|
+
)
|
|
257
|
+
return {
|
|
258
|
+
"status": "ok",
|
|
259
|
+
"observations": [str(path) for path in paths],
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
def add_ssh_key(
|
|
263
|
+
self,
|
|
264
|
+
key_file: str,
|
|
265
|
+
owner: str,
|
|
266
|
+
resource_id: str | None = None,
|
|
267
|
+
environment: str = "production",
|
|
268
|
+
description: str | None = None,
|
|
269
|
+
) -> dict[str, object]:
|
|
270
|
+
"""Declare one SSH public key read from a controller-side key file."""
|
|
271
|
+
try:
|
|
272
|
+
options = SshKeyOptions(
|
|
273
|
+
key_path=self._path(key_file).expanduser(),
|
|
274
|
+
owner=ResourceId.from_boundary(owner),
|
|
275
|
+
environment=ResourceId.from_boundary(environment),
|
|
276
|
+
resource_id=(
|
|
277
|
+
ResourceId.from_boundary(resource_id)
|
|
278
|
+
if resource_id is not None
|
|
279
|
+
else None
|
|
280
|
+
),
|
|
281
|
+
description=description,
|
|
282
|
+
)
|
|
283
|
+
except (TypeError, ValueError) as error:
|
|
284
|
+
return _invalid_argument(error)
|
|
285
|
+
try:
|
|
286
|
+
result = add_ssh_key(
|
|
287
|
+
self._config.project_directory, options, self._config.schema_directory
|
|
288
|
+
)
|
|
289
|
+
except (AuthoringError, ConfigValidationError) as error:
|
|
290
|
+
return error.as_dict()
|
|
291
|
+
return result.as_dict()
|
|
292
|
+
|
|
293
|
+
def add_server_type(
|
|
294
|
+
self, resource_id: str, description: str | None = None
|
|
295
|
+
) -> dict[str, object]:
|
|
296
|
+
"""Declare one server type from the bundled Debian 13 baseline."""
|
|
297
|
+
try:
|
|
298
|
+
options = ServerTypeOptions(
|
|
299
|
+
resource_id=ResourceId.from_boundary(resource_id),
|
|
300
|
+
description=description,
|
|
301
|
+
)
|
|
302
|
+
except (TypeError, ValueError) as error:
|
|
303
|
+
return _invalid_argument(error)
|
|
304
|
+
try:
|
|
305
|
+
result = add_server_type(
|
|
306
|
+
self._config.project_directory, options, self._config.schema_directory
|
|
307
|
+
)
|
|
308
|
+
except (AuthoringError, ConfigValidationError) as error:
|
|
309
|
+
return error.as_dict()
|
|
310
|
+
return result.as_dict()
|
|
311
|
+
|
|
312
|
+
def add_server( # noqa: PLR0913 - boundary signature mirrors the CLI.
|
|
313
|
+
self,
|
|
314
|
+
resource_id: str,
|
|
315
|
+
address: str,
|
|
316
|
+
server_type: str = "debian-application",
|
|
317
|
+
environment: str = "production",
|
|
318
|
+
hostname: str | None = None,
|
|
319
|
+
ssh_user: str = "root",
|
|
320
|
+
ssh_port: int = 22,
|
|
321
|
+
description: str | None = None,
|
|
322
|
+
) -> dict[str, object]:
|
|
323
|
+
"""Declare one server, creating its server type when the project lacks it."""
|
|
324
|
+
try:
|
|
325
|
+
options = ServerOptions(
|
|
326
|
+
resource_id=ResourceId.from_boundary(resource_id),
|
|
327
|
+
address=ConnectionAddress.from_boundary(address),
|
|
328
|
+
server_type=ResourceId.from_boundary(server_type),
|
|
329
|
+
environment=ResourceId.from_boundary(environment),
|
|
330
|
+
ssh_user=LinuxUser.from_boundary(ssh_user),
|
|
331
|
+
ssh_port=TcpPort.from_boundary(ssh_port),
|
|
332
|
+
hostname=(
|
|
333
|
+
Hostname.from_boundary(hostname) if hostname is not None else None
|
|
334
|
+
),
|
|
335
|
+
description=description,
|
|
336
|
+
)
|
|
337
|
+
except (TypeError, ValueError) as error:
|
|
338
|
+
return _invalid_argument(error)
|
|
339
|
+
try:
|
|
340
|
+
result = add_server(
|
|
341
|
+
self._config.project_directory, options, self._config.schema_directory
|
|
342
|
+
)
|
|
343
|
+
except (AuthoringError, ConfigValidationError) as error:
|
|
344
|
+
return error.as_dict()
|
|
345
|
+
return result.as_dict()
|
|
346
|
+
|
|
347
|
+
def import_render(
|
|
348
|
+
self,
|
|
349
|
+
blueprint: str,
|
|
350
|
+
application: str,
|
|
351
|
+
server: str,
|
|
352
|
+
output_directory: str,
|
|
353
|
+
environment_directory: str,
|
|
354
|
+
) -> dict[str, object]:
|
|
355
|
+
"""Map a Render blueprint onto Cloudfall config fragments."""
|
|
356
|
+
try:
|
|
357
|
+
targets = ImportTargets(
|
|
358
|
+
application_id=ResourceId.from_boundary(application),
|
|
359
|
+
server_id=ResourceId.from_boundary(server),
|
|
360
|
+
project_directory=project_path(output_directory),
|
|
361
|
+
environment_directory=project_path(environment_directory),
|
|
362
|
+
)
|
|
363
|
+
except (TypeError, ValueError) as error:
|
|
364
|
+
return _invalid_argument(error)
|
|
365
|
+
try:
|
|
366
|
+
result = import_render_blueprint(
|
|
367
|
+
self._path(blueprint), targets, self._config.schema_directory
|
|
368
|
+
)
|
|
369
|
+
except (RenderImportError, ConfigValidationError) as error:
|
|
370
|
+
return error.as_dict()
|
|
371
|
+
return result.as_dict()
|
|
372
|
+
|
|
373
|
+
def import_render_api(
|
|
374
|
+
self,
|
|
375
|
+
api_key_file: str,
|
|
376
|
+
application: str,
|
|
377
|
+
server: str,
|
|
378
|
+
output_directory: str,
|
|
379
|
+
environment_directory: str,
|
|
380
|
+
) -> dict[str, object]:
|
|
381
|
+
"""Map a live Render workspace onto Cloudfall config fragments."""
|
|
382
|
+
try:
|
|
383
|
+
targets = ImportTargets(
|
|
384
|
+
application_id=ResourceId.from_boundary(application),
|
|
385
|
+
server_id=ResourceId.from_boundary(server),
|
|
386
|
+
project_directory=project_path(output_directory),
|
|
387
|
+
environment_directory=project_path(environment_directory),
|
|
388
|
+
)
|
|
389
|
+
except (TypeError, ValueError) as error:
|
|
390
|
+
return _invalid_argument(error)
|
|
391
|
+
try:
|
|
392
|
+
client = HttpRenderApiClient(
|
|
393
|
+
api_key=read_api_key(self._path(api_key_file))
|
|
394
|
+
)
|
|
395
|
+
result = render_api_import(
|
|
396
|
+
client, targets, self._config.schema_directory
|
|
397
|
+
)
|
|
398
|
+
except (RenderImportError, ConfigValidationError) as error:
|
|
399
|
+
return error.as_dict()
|
|
400
|
+
return result.as_dict()
|
|
401
|
+
|
|
402
|
+
def build_artifact(self, component: str, git_ref: str) -> dict[str, object]:
|
|
403
|
+
"""Build one hashed release artifact from the component repository."""
|
|
404
|
+
try:
|
|
405
|
+
component_id = ResourceId.from_boundary(component)
|
|
406
|
+
except (TypeError, ValueError) as error:
|
|
407
|
+
return _invalid_argument(error)
|
|
408
|
+
try:
|
|
409
|
+
return build_release_artifact(
|
|
410
|
+
self._config.context(),
|
|
411
|
+
component_id,
|
|
412
|
+
git_ref,
|
|
413
|
+
self._config.artifacts_directory,
|
|
414
|
+
)
|
|
415
|
+
except LifecycleError as error:
|
|
416
|
+
return error.as_dict()
|
|
417
|
+
|
|
418
|
+
def deploy_component(
|
|
419
|
+
self,
|
|
420
|
+
component: str,
|
|
421
|
+
release: str,
|
|
422
|
+
environment_file: str | None = None,
|
|
423
|
+
*,
|
|
424
|
+
confirm: bool = False,
|
|
425
|
+
) -> dict[str, object]:
|
|
426
|
+
"""Deploy one built release behind the engine's health gate."""
|
|
427
|
+
gate = _confirmation_gate(
|
|
428
|
+
confirm,
|
|
429
|
+
"deploy",
|
|
430
|
+
f"deploy release {release} of component {component} to its "
|
|
431
|
+
"declared servers with automatic rollback on a failed health "
|
|
432
|
+
"check",
|
|
433
|
+
)
|
|
434
|
+
if gate is not None:
|
|
435
|
+
return gate
|
|
436
|
+
try:
|
|
437
|
+
component_id = ResourceId.from_boundary(component)
|
|
438
|
+
release_id = ReleaseId.from_boundary(release)
|
|
439
|
+
except (TypeError, ValueError) as error:
|
|
440
|
+
return _invalid_argument(error)
|
|
441
|
+
try:
|
|
442
|
+
result = deploy(
|
|
443
|
+
self._config.context(),
|
|
444
|
+
component_id,
|
|
445
|
+
release_id,
|
|
446
|
+
self._config.artifacts_directory,
|
|
447
|
+
DeployOptions(
|
|
448
|
+
environment_file=(
|
|
449
|
+
self._path(environment_file)
|
|
450
|
+
if environment_file is not None
|
|
451
|
+
else None
|
|
452
|
+
),
|
|
453
|
+
receipt_directory=self._config.releases_directory,
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
except LifecycleError as error:
|
|
457
|
+
return error.as_dict()
|
|
458
|
+
return result.as_dict()
|
|
459
|
+
|
|
460
|
+
def rollback_component(
|
|
461
|
+
self, component: str, release: str, *, confirm: bool = False
|
|
462
|
+
) -> dict[str, object]:
|
|
463
|
+
"""Activate an already-retained release behind the health gate."""
|
|
464
|
+
gate = _confirmation_gate(
|
|
465
|
+
confirm,
|
|
466
|
+
"rollback",
|
|
467
|
+
f"switch component {component} back to retained release "
|
|
468
|
+
f"{release} and restart it",
|
|
469
|
+
)
|
|
470
|
+
if gate is not None:
|
|
471
|
+
return gate
|
|
472
|
+
try:
|
|
473
|
+
component_id = ResourceId.from_boundary(component)
|
|
474
|
+
release_id = ReleaseId.from_boundary(release)
|
|
475
|
+
except (TypeError, ValueError) as error:
|
|
476
|
+
return _invalid_argument(error)
|
|
477
|
+
try:
|
|
478
|
+
result = rollback(self._config.context(), component_id, release_id)
|
|
479
|
+
except LifecycleError as error:
|
|
480
|
+
return error.as_dict()
|
|
481
|
+
return result.as_dict()
|
|
482
|
+
|
|
483
|
+
def restart_component(
|
|
484
|
+
self, component: str, *, confirm: bool = False
|
|
485
|
+
) -> dict[str, object]:
|
|
486
|
+
"""Restart one component and require its declared health check."""
|
|
487
|
+
gate = _confirmation_gate(
|
|
488
|
+
confirm,
|
|
489
|
+
"restart",
|
|
490
|
+
f"restart component {component} on its declared servers",
|
|
491
|
+
)
|
|
492
|
+
if gate is not None:
|
|
493
|
+
return gate
|
|
494
|
+
try:
|
|
495
|
+
component_id = ResourceId.from_boundary(component)
|
|
496
|
+
except (TypeError, ValueError) as error:
|
|
497
|
+
return _invalid_argument(error)
|
|
498
|
+
try:
|
|
499
|
+
result = restart(self._config.context(), component_id)
|
|
500
|
+
except LifecycleError as error:
|
|
501
|
+
return error.as_dict()
|
|
502
|
+
return result.as_dict()
|
|
503
|
+
|
|
504
|
+
def migrate_database(
|
|
505
|
+
self,
|
|
506
|
+
service: str,
|
|
507
|
+
database: str,
|
|
508
|
+
source_url_file: str,
|
|
509
|
+
*,
|
|
510
|
+
confirm: bool = False,
|
|
511
|
+
) -> dict[str, object]:
|
|
512
|
+
"""Dump an external database and restore it into a declared service."""
|
|
513
|
+
gate = _confirmation_gate(
|
|
514
|
+
confirm,
|
|
515
|
+
"data-migration",
|
|
516
|
+
(
|
|
517
|
+
f"dump the database behind {source_url_file} and restore it "
|
|
518
|
+
f"into declared database {database} on service {service} "
|
|
519
|
+
"with per-table row-count verification"
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
if gate is not None:
|
|
523
|
+
return gate
|
|
524
|
+
try:
|
|
525
|
+
service_id = ResourceId.from_boundary(service)
|
|
526
|
+
except (TypeError, ValueError) as error:
|
|
527
|
+
return _invalid_argument(error)
|
|
528
|
+
try:
|
|
529
|
+
return migrate_data(
|
|
530
|
+
self._config.context(),
|
|
531
|
+
service_id,
|
|
532
|
+
database,
|
|
533
|
+
Path(source_url_file),
|
|
534
|
+
self._config.data_migrations_directory,
|
|
535
|
+
)
|
|
536
|
+
except LifecycleError as error:
|
|
537
|
+
return error.as_dict()
|
|
538
|
+
|
|
539
|
+
def converge_baseline(self, *, confirm: bool = False) -> dict[str, object]:
|
|
540
|
+
"""Converge every declared server to the managed baseline."""
|
|
541
|
+
return self._converge(
|
|
542
|
+
confirm,
|
|
543
|
+
"converge-baseline",
|
|
544
|
+
"run baseline.yml: bootstrap, UTC, SSH hardening, unattended "
|
|
545
|
+
"upgrades, and the declared firewall on every server",
|
|
546
|
+
"baseline.yml",
|
|
547
|
+
{},
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def converge_services(self, *, confirm: bool = False) -> dict[str, object]:
|
|
551
|
+
"""Converge every declared infrastructure service."""
|
|
552
|
+
return self._converge(
|
|
553
|
+
confirm,
|
|
554
|
+
"converge-services",
|
|
555
|
+
"run services.yml: install and configure every declared "
|
|
556
|
+
"infrastructure service",
|
|
557
|
+
"services.yml",
|
|
558
|
+
{},
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def converge_domains(
|
|
562
|
+
self, *, issue_certificates: bool = False, confirm: bool = False
|
|
563
|
+
) -> dict[str, object]:
|
|
564
|
+
"""Converge every declared public domain route."""
|
|
565
|
+
return self._converge(
|
|
566
|
+
confirm,
|
|
567
|
+
"converge-domains",
|
|
568
|
+
"run domains.yml: render every declared domain route"
|
|
569
|
+
+ (
|
|
570
|
+
" and issue missing certificates"
|
|
571
|
+
if issue_certificates
|
|
572
|
+
else " without certificate issuance"
|
|
573
|
+
),
|
|
574
|
+
"domains.yml",
|
|
575
|
+
{
|
|
576
|
+
"cloudfall_domains_issue_certificates": issue_certificates,
|
|
577
|
+
"cloudfall_domains_receipt_directory": str(
|
|
578
|
+
self._config.deployments_directory.resolve()
|
|
579
|
+
),
|
|
580
|
+
},
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
def migrate( # noqa: PLR0913 - boundary signature mirrors the CLI.
|
|
584
|
+
self,
|
|
585
|
+
builds: dict[str, str] | None = None,
|
|
586
|
+
releases: dict[str, str] | None = None,
|
|
587
|
+
environment_files: dict[str, str] | None = None,
|
|
588
|
+
data_migrations: dict[str, str] | None = None,
|
|
589
|
+
plan_file: str = "tmp/migrate/plan.json",
|
|
590
|
+
*,
|
|
591
|
+
restart_plan: bool = False,
|
|
592
|
+
confirm: bool = False,
|
|
593
|
+
) -> dict[str, object]:
|
|
594
|
+
"""Preview or execute the resumable end-to-end migration plan."""
|
|
595
|
+
try:
|
|
596
|
+
confined_plan_file = project_path(plan_file)
|
|
597
|
+
except ValueError as error:
|
|
598
|
+
return _invalid_argument(error)
|
|
599
|
+
options = MigrateOptions(
|
|
600
|
+
plan_file=confined_plan_file,
|
|
601
|
+
builds=builds if builds is not None else {},
|
|
602
|
+
releases=releases if releases is not None else {},
|
|
603
|
+
environment_files={
|
|
604
|
+
component: self._path(value)
|
|
605
|
+
for component, value in (
|
|
606
|
+
environment_files if environment_files is not None else {}
|
|
607
|
+
).items()
|
|
608
|
+
},
|
|
609
|
+
data_migrations={
|
|
610
|
+
database: self._path(value)
|
|
611
|
+
for database, value in (
|
|
612
|
+
data_migrations if data_migrations is not None else {}
|
|
613
|
+
).items()
|
|
614
|
+
},
|
|
615
|
+
execute=confirm,
|
|
616
|
+
restart=restart_plan,
|
|
617
|
+
)
|
|
618
|
+
try:
|
|
619
|
+
result = execute_migration(self._config, options)
|
|
620
|
+
except (MigrateError, ConfigValidationError) as error:
|
|
621
|
+
return error.as_dict()
|
|
622
|
+
if not confirm:
|
|
623
|
+
result["instruction"] = (
|
|
624
|
+
"review the plan and call this tool again with confirm=true "
|
|
625
|
+
"to execute it; interrupted runs resume automatically"
|
|
626
|
+
)
|
|
627
|
+
return result
|
|
628
|
+
|
|
629
|
+
def render_secrets(self, component: str) -> dict[str, object]:
|
|
630
|
+
"""Render one component's secret references into its env file."""
|
|
631
|
+
try:
|
|
632
|
+
component_id = ResourceId.from_boundary(component)
|
|
633
|
+
except (TypeError, ValueError) as error:
|
|
634
|
+
return _invalid_argument(error)
|
|
635
|
+
try:
|
|
636
|
+
return render_environment(
|
|
637
|
+
self._config.context(),
|
|
638
|
+
component_id,
|
|
639
|
+
SopsSecretProvider(
|
|
640
|
+
secrets_directory=self._config.secrets_directory
|
|
641
|
+
),
|
|
642
|
+
self._config.environment_directory
|
|
643
|
+
/ f"{component_id.value}.env",
|
|
644
|
+
receipt_directory=(
|
|
645
|
+
self._config.environment_receipts_directory
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
except (SecretsError, ConfigValidationError) as error:
|
|
649
|
+
return error.as_dict()
|
|
650
|
+
|
|
651
|
+
def backup_service(
|
|
652
|
+
self, service: str, *, confirm: bool = False
|
|
653
|
+
) -> dict[str, object]:
|
|
654
|
+
"""Run the declared backup for one service, receipted."""
|
|
655
|
+
return self._backup_operation(
|
|
656
|
+
service,
|
|
657
|
+
confirm=confirm,
|
|
658
|
+
action="backup-service",
|
|
659
|
+
detail_prefix="run the declared backup for",
|
|
660
|
+
operation=backup_service,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def verify_backup(
|
|
664
|
+
self, service: str, *, confirm: bool = False
|
|
665
|
+
) -> dict[str, object]:
|
|
666
|
+
"""Prove the newest backup of one service restores, receipted."""
|
|
667
|
+
return self._backup_operation(
|
|
668
|
+
service,
|
|
669
|
+
confirm=confirm,
|
|
670
|
+
action="verify-backup",
|
|
671
|
+
detail_prefix="prove the newest backup restores for",
|
|
672
|
+
operation=verify_backup,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def _backup_operation(
|
|
676
|
+
self,
|
|
677
|
+
service: str,
|
|
678
|
+
*,
|
|
679
|
+
confirm: bool,
|
|
680
|
+
action: str,
|
|
681
|
+
detail_prefix: str,
|
|
682
|
+
operation: Callable[
|
|
683
|
+
[EngineContext, ResourceId, Path], dict[str, object]
|
|
684
|
+
],
|
|
685
|
+
) -> dict[str, object]:
|
|
686
|
+
try:
|
|
687
|
+
service_id = ResourceId.from_boundary(service)
|
|
688
|
+
except (TypeError, ValueError) as error:
|
|
689
|
+
return _invalid_argument(error)
|
|
690
|
+
gate = _confirmation_gate(
|
|
691
|
+
confirm,
|
|
692
|
+
action,
|
|
693
|
+
f"{detail_prefix} {service_id.value} on its declared server",
|
|
694
|
+
)
|
|
695
|
+
if gate is not None:
|
|
696
|
+
return gate
|
|
697
|
+
try:
|
|
698
|
+
return operation(
|
|
699
|
+
self._config.context(),
|
|
700
|
+
service_id,
|
|
701
|
+
self._config.backups_directory,
|
|
702
|
+
)
|
|
703
|
+
except (LifecycleError, ConfigValidationError) as error:
|
|
704
|
+
return error.as_dict()
|
|
705
|
+
|
|
706
|
+
def operator_proposals(self) -> dict[str, object]:
|
|
707
|
+
"""List every operator proposal receipt with its status."""
|
|
708
|
+
try:
|
|
709
|
+
proposals = [
|
|
710
|
+
proposal.as_document()
|
|
711
|
+
for proposal in self._proposal_store().list()
|
|
712
|
+
]
|
|
713
|
+
except OperatorError as error:
|
|
714
|
+
return {"status": "error", "error": error.as_dict()}
|
|
715
|
+
return {"status": "ok", "proposals": proposals}
|
|
716
|
+
|
|
717
|
+
def operator_watch(self, *, drift: bool = False) -> dict[str, object]:
|
|
718
|
+
"""Run one watch pass: fetch alerts, optionally audit for drift."""
|
|
719
|
+
try:
|
|
720
|
+
state = self._state()
|
|
721
|
+
except ConfigValidationError as error:
|
|
722
|
+
return error.as_dict()
|
|
723
|
+
inventory = PlatformInventory.from_state(state)
|
|
724
|
+
try:
|
|
725
|
+
store = self._proposal_store()
|
|
726
|
+
feed = self._alert_feed(inventory)
|
|
727
|
+
report = operator_run_once(feed, inventory, store)
|
|
728
|
+
passes: dict[str, object] = {"alerts": report.as_dict()}
|
|
729
|
+
if drift:
|
|
730
|
+
auditor = engine_auditor(
|
|
731
|
+
self._config.context(),
|
|
732
|
+
inventory,
|
|
733
|
+
self._config.observed_directory,
|
|
734
|
+
)
|
|
735
|
+
passes["drift"] = drift_pass(auditor, store).as_dict()
|
|
736
|
+
except (OperatorError, LifecycleError) as error:
|
|
737
|
+
return {"status": "error", "error": error.as_dict()}
|
|
738
|
+
return {"status": "ok", **passes}
|
|
739
|
+
|
|
740
|
+
def operator_approve(
|
|
741
|
+
self, proposal: str, *, confirm: bool = False
|
|
742
|
+
) -> dict[str, object]:
|
|
743
|
+
"""Execute one proposal after confirmation and verify its trigger."""
|
|
744
|
+
try:
|
|
745
|
+
state = self._state()
|
|
746
|
+
except ConfigValidationError as error:
|
|
747
|
+
return error.as_dict()
|
|
748
|
+
inventory = PlatformInventory.from_state(state)
|
|
749
|
+
try:
|
|
750
|
+
store = self._proposal_store()
|
|
751
|
+
proposal_id = ResourceId.from_boundary(proposal)
|
|
752
|
+
pending = store.load(proposal_id)
|
|
753
|
+
except (OperatorError, ValueError) as error:
|
|
754
|
+
return _invalid_argument(error)
|
|
755
|
+
gate = _confirmation_gate(
|
|
756
|
+
confirm,
|
|
757
|
+
"operator-approve",
|
|
758
|
+
f"execute {' '.join(pending.command)} because "
|
|
759
|
+
f"{pending.diagnosis_summary}",
|
|
760
|
+
)
|
|
761
|
+
if gate is not None:
|
|
762
|
+
return gate
|
|
763
|
+
try:
|
|
764
|
+
if pending.trigger_kind is TriggerKind.ALERT:
|
|
765
|
+
verifier = alert_resolution_verifier(
|
|
766
|
+
self._alert_feed(inventory)
|
|
767
|
+
)
|
|
768
|
+
else:
|
|
769
|
+
verifier = drift_resolution_verifier(
|
|
770
|
+
engine_auditor(
|
|
771
|
+
self._config.context(),
|
|
772
|
+
inventory,
|
|
773
|
+
self._config.observed_directory,
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
result = operator_approve_proposal(
|
|
777
|
+
store,
|
|
778
|
+
proposal_id,
|
|
779
|
+
engine_executor(self._config.context()),
|
|
780
|
+
verifier,
|
|
781
|
+
ApproveOptions(),
|
|
782
|
+
)
|
|
783
|
+
except (OperatorError, LifecycleError) as error:
|
|
784
|
+
return {"status": "error", "error": error.as_dict()}
|
|
785
|
+
return {
|
|
786
|
+
"status": (
|
|
787
|
+
"ok"
|
|
788
|
+
if result.status is ProposalStatus.VERIFIED
|
|
789
|
+
else "failed"
|
|
790
|
+
),
|
|
791
|
+
"proposal": result.as_document(),
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
def _proposal_store(self) -> ProposalStore:
|
|
795
|
+
return ProposalStore(
|
|
796
|
+
directory=self._config.proposals_directory,
|
|
797
|
+
catalog=SchemaCatalog(self._config.schema_directory),
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
def _alert_feed(self, inventory: PlatformInventory) -> AlertFeed:
|
|
801
|
+
config = self._config
|
|
802
|
+
if (
|
|
803
|
+
config.gateway_ca_path is None
|
|
804
|
+
or config.gateway_certificate_path is None
|
|
805
|
+
or config.gateway_key_path is None
|
|
806
|
+
):
|
|
807
|
+
code = "operator_gateway_material_missing"
|
|
808
|
+
message = (
|
|
809
|
+
"the server was started without gateway TLS material; "
|
|
810
|
+
"pass --gateway-ca, --gateway-cert, and --gateway-key"
|
|
811
|
+
)
|
|
812
|
+
raise OperatorError(code, message)
|
|
813
|
+
return gateway_feed(
|
|
814
|
+
inventory,
|
|
815
|
+
ca_path=config.gateway_ca_path,
|
|
816
|
+
certificate_path=config.gateway_certificate_path,
|
|
817
|
+
key_path=config.gateway_key_path,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def _converge(
|
|
821
|
+
self,
|
|
822
|
+
confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary.
|
|
823
|
+
action: str,
|
|
824
|
+
detail: str,
|
|
825
|
+
playbook: str,
|
|
826
|
+
extra_vars: dict[str, object],
|
|
827
|
+
) -> dict[str, object]:
|
|
828
|
+
gate = _confirmation_gate(confirm, action, detail)
|
|
829
|
+
if gate is not None:
|
|
830
|
+
return gate
|
|
831
|
+
try:
|
|
832
|
+
run_engine_playbook(self._config.context(), playbook, extra_vars)
|
|
833
|
+
except LifecycleError as error:
|
|
834
|
+
return error.as_dict()
|
|
835
|
+
return {"status": "ok", "action": action}
|
|
836
|
+
|
|
837
|
+
def _state(self) -> ValidatedConfig:
|
|
838
|
+
return validate_config(
|
|
839
|
+
self._config.project_directory, self._config.schema_directory
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def _path(self, value: str) -> Path:
|
|
843
|
+
return Path(value)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _confirmation_gate(
|
|
847
|
+
confirm: bool, # noqa: FBT001 - explicit gate flag at the boundary.
|
|
848
|
+
action: str,
|
|
849
|
+
detail: str,
|
|
850
|
+
) -> dict[str, object] | None:
|
|
851
|
+
if confirm:
|
|
852
|
+
return None
|
|
853
|
+
return {
|
|
854
|
+
"status": "confirmation-required",
|
|
855
|
+
"action": action,
|
|
856
|
+
"wouldRun": detail,
|
|
857
|
+
"instruction": (
|
|
858
|
+
"review the description and call this tool again with "
|
|
859
|
+
"confirm=true to execute"
|
|
860
|
+
),
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _invalid_argument(error: Exception) -> dict[str, object]:
|
|
865
|
+
return {
|
|
866
|
+
"status": "error",
|
|
867
|
+
"error": {"code": _CODE_INVALID_ARGUMENT, "message": str(error)},
|
|
868
|
+
}
|