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/validation.py
ADDED
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
"""Load and strictly validate declarative Cloudfall state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from ipaddress import IPv4Address, IPv6Network
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
18
|
+
from jsonschema.exceptions import SchemaError, ValidationError
|
|
19
|
+
from referencing import Registry, Resource
|
|
20
|
+
|
|
21
|
+
from cloudfall.domain import (
|
|
22
|
+
OpenSshPublicKey,
|
|
23
|
+
ResourceDocument,
|
|
24
|
+
ResourceId,
|
|
25
|
+
ResourceKey,
|
|
26
|
+
ResourceKind,
|
|
27
|
+
SourceLocation,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_SCHEMA_FILES: Mapping[ResourceKind, str] = {
|
|
31
|
+
ResourceKind.SERVER: "server.schema.json",
|
|
32
|
+
ResourceKind.HOST_PROFILE: "server-type.schema.json",
|
|
33
|
+
ResourceKind.APPLICATION: "application.schema.json",
|
|
34
|
+
ResourceKind.COMPONENT: "component.schema.json",
|
|
35
|
+
ResourceKind.DOMAIN: "domain.schema.json",
|
|
36
|
+
ResourceKind.SSH_PUBLIC_KEY: "ssh-public-key.schema.json",
|
|
37
|
+
ResourceKind.LOGGING_STACK: "logging-stack.schema.json",
|
|
38
|
+
ResourceKind.SERVICE: "service.schema.json",
|
|
39
|
+
ResourceKind.ALERT_RULE: "alert-rule.schema.json",
|
|
40
|
+
ResourceKind.OPERATOR_POLICY: "operator-policy.schema.json",
|
|
41
|
+
}
|
|
42
|
+
_YAML_SUFFIXES = frozenset({".yaml", ".yml"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ValidationIssue:
|
|
47
|
+
"""Structured validation failure suitable for CLI and agent consumers."""
|
|
48
|
+
|
|
49
|
+
code: str
|
|
50
|
+
message: str
|
|
51
|
+
source: SourceLocation | None = None
|
|
52
|
+
field_path: tuple[str | int, ...] = ()
|
|
53
|
+
|
|
54
|
+
def as_dict(self) -> dict[str, object]:
|
|
55
|
+
"""Serialize the issue for agent and CLI consumers."""
|
|
56
|
+
result: dict[str, object] = {
|
|
57
|
+
"code": self.code,
|
|
58
|
+
"message": self.message,
|
|
59
|
+
}
|
|
60
|
+
if self.source is not None:
|
|
61
|
+
result["source"] = self.source.display()
|
|
62
|
+
if self.field_path:
|
|
63
|
+
result["field"] = ".".join(str(part) for part in self.field_path)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConfigValidationError(ValueError):
|
|
68
|
+
"""Fail-fast state validation error with a stable machine-readable code."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, issue: ValidationIssue) -> None:
|
|
71
|
+
"""Build an exception around one actionable validation issue."""
|
|
72
|
+
self.issue = issue
|
|
73
|
+
location = f" at {issue.source.display()}" if issue.source else ""
|
|
74
|
+
field = (
|
|
75
|
+
f" ({'.'.join(str(part) for part in issue.field_path)})"
|
|
76
|
+
if issue.field_path
|
|
77
|
+
else ""
|
|
78
|
+
)
|
|
79
|
+
super().__init__(f"{issue.code}{location}{field}: {issue.message}")
|
|
80
|
+
|
|
81
|
+
def as_dict(self) -> dict[str, object]:
|
|
82
|
+
"""Serialize the error envelope for system boundaries."""
|
|
83
|
+
return {"status": "error", "error": self.issue.as_dict()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class ValidatedConfig:
|
|
88
|
+
"""Read-only, indexed config."""
|
|
89
|
+
|
|
90
|
+
documents: tuple[ResourceDocument, ...]
|
|
91
|
+
_index: Mapping[ResourceKey, ResourceDocument]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def resource_count(self) -> int:
|
|
95
|
+
"""Return the number of validated resource documents."""
|
|
96
|
+
return len(self.documents)
|
|
97
|
+
|
|
98
|
+
def counts_by_kind(self) -> dict[str, int]:
|
|
99
|
+
"""Return deterministic resource counts grouped by kind."""
|
|
100
|
+
counts = Counter(document.key.kind.value for document in self.documents)
|
|
101
|
+
return dict(sorted(counts.items()))
|
|
102
|
+
|
|
103
|
+
def get(self, kind: ResourceKind, resource_id: ResourceId) -> ResourceDocument:
|
|
104
|
+
"""Return a resource from the typed index."""
|
|
105
|
+
key = ResourceKey(kind=kind, resource_id=resource_id)
|
|
106
|
+
try:
|
|
107
|
+
return self._index[key]
|
|
108
|
+
except KeyError as error:
|
|
109
|
+
message = f"resource does not exist: {kind.value}/{resource_id}"
|
|
110
|
+
raise KeyError(message) from error
|
|
111
|
+
|
|
112
|
+
def resources(self, kind: ResourceKind) -> tuple[ResourceDocument, ...]:
|
|
113
|
+
"""Return all resources of one kind sorted by identifier."""
|
|
114
|
+
return tuple(
|
|
115
|
+
sorted(
|
|
116
|
+
(document for document in self.documents if document.key.kind is kind),
|
|
117
|
+
key=lambda document: document.key.resource_id.value,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def as_dict(self) -> dict[str, object]:
|
|
122
|
+
"""Serialize the successful validation summary."""
|
|
123
|
+
return {
|
|
124
|
+
"status": "ok",
|
|
125
|
+
"resources": self.resource_count,
|
|
126
|
+
"byKind": self.counts_by_kind(),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SchemaCatalog:
|
|
131
|
+
"""Validated v1 schemas and their reference registry."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, schema_directory: Path) -> None:
|
|
134
|
+
"""Load and validate all schemas needed by the v1 contract."""
|
|
135
|
+
if not schema_directory.is_dir():
|
|
136
|
+
issue = ValidationIssue(
|
|
137
|
+
code="schema_directory_missing",
|
|
138
|
+
message=f"schema directory does not exist: {schema_directory}",
|
|
139
|
+
)
|
|
140
|
+
raise ConfigValidationError(issue)
|
|
141
|
+
|
|
142
|
+
schemas = self._load_schemas(schema_directory)
|
|
143
|
+
registry = Registry().with_resources(
|
|
144
|
+
(self._schema_id(schema, path), Resource.from_contents(schema))
|
|
145
|
+
for path, schema in schemas.items()
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
named_validators: dict[str, Draft202012Validator] = {}
|
|
149
|
+
for path, schema in schemas.items():
|
|
150
|
+
try:
|
|
151
|
+
Draft202012Validator.check_schema(schema)
|
|
152
|
+
except SchemaError as error:
|
|
153
|
+
issue = ValidationIssue(
|
|
154
|
+
code="schema_invalid",
|
|
155
|
+
message=f"{path}: {error.message}",
|
|
156
|
+
)
|
|
157
|
+
raise ConfigValidationError(issue) from error
|
|
158
|
+
named_validators[path.name] = Draft202012Validator(
|
|
159
|
+
schema,
|
|
160
|
+
registry=registry,
|
|
161
|
+
format_checker=FormatChecker(),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
validators: dict[ResourceKind, Draft202012Validator] = {}
|
|
165
|
+
for kind, filename in _SCHEMA_FILES.items():
|
|
166
|
+
path = schema_directory / filename
|
|
167
|
+
try:
|
|
168
|
+
validators[kind] = named_validators[path.name]
|
|
169
|
+
except KeyError as error:
|
|
170
|
+
issue = ValidationIssue(
|
|
171
|
+
code="schema_missing",
|
|
172
|
+
message=f"required schema does not exist: {path}",
|
|
173
|
+
)
|
|
174
|
+
raise ConfigValidationError(issue) from error
|
|
175
|
+
self._validators = validators
|
|
176
|
+
self._named_validators = named_validators
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _load_schemas(schema_directory: Path) -> dict[Path, Mapping[str, object]]:
|
|
180
|
+
schemas: dict[Path, Mapping[str, object]] = {}
|
|
181
|
+
for path in sorted(schema_directory.glob("*.schema.json")):
|
|
182
|
+
try:
|
|
183
|
+
raw = cast("object", json.loads(path.read_text(encoding="utf-8")))
|
|
184
|
+
except json.JSONDecodeError as error:
|
|
185
|
+
issue = ValidationIssue(
|
|
186
|
+
code="schema_json_invalid",
|
|
187
|
+
message=f"{path}: {error.msg}",
|
|
188
|
+
)
|
|
189
|
+
raise ConfigValidationError(issue) from error
|
|
190
|
+
if not isinstance(raw, dict) or not all(
|
|
191
|
+
isinstance(key, str) for key in raw
|
|
192
|
+
):
|
|
193
|
+
issue = ValidationIssue(
|
|
194
|
+
code="schema_shape_invalid",
|
|
195
|
+
message=f"schema root must be an object: {path}",
|
|
196
|
+
)
|
|
197
|
+
raise ConfigValidationError(issue)
|
|
198
|
+
schemas[path] = cast("Mapping[str, object]", raw)
|
|
199
|
+
return schemas
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _schema_id(schema: Mapping[str, object], path: Path) -> str:
|
|
203
|
+
schema_id = schema.get("$id")
|
|
204
|
+
if not isinstance(schema_id, str):
|
|
205
|
+
issue = ValidationIssue(
|
|
206
|
+
code="schema_id_missing",
|
|
207
|
+
message=f"schema must define a string $id: {path}",
|
|
208
|
+
)
|
|
209
|
+
raise ConfigValidationError(issue)
|
|
210
|
+
return schema_id
|
|
211
|
+
|
|
212
|
+
def validate(self, kind: ResourceKind, content: Mapping[str, object]) -> None:
|
|
213
|
+
"""Validate one resource against the schema for its kind."""
|
|
214
|
+
self._validators[kind].validate(content)
|
|
215
|
+
|
|
216
|
+
def validate_named(self, filename: str, content: Mapping[str, object]) -> None:
|
|
217
|
+
"""Validate one document against a named schema in the catalog."""
|
|
218
|
+
try:
|
|
219
|
+
validator = self._named_validators[filename]
|
|
220
|
+
except KeyError as error:
|
|
221
|
+
issue = ValidationIssue(
|
|
222
|
+
code="schema_missing",
|
|
223
|
+
message=f"schema does not exist in catalog: {filename}",
|
|
224
|
+
)
|
|
225
|
+
raise ConfigValidationError(issue) from error
|
|
226
|
+
validator.validate(content)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _resource_files(project_directory: Path) -> Iterable[tuple[ResourceKind, Path]]:
|
|
230
|
+
"""Yield each kind directory's YAML files in deterministic order.
|
|
231
|
+
|
|
232
|
+
Resources live in their kind's directory (``servers/``, ``components/``,
|
|
233
|
+
...). Nothing else in a project is read as a resource: not ``tmp/``, not
|
|
234
|
+
playbooks, not secrets, not tooling files.
|
|
235
|
+
"""
|
|
236
|
+
for kind in ResourceKind:
|
|
237
|
+
kind_directory = project_directory / kind.directory
|
|
238
|
+
if not kind_directory.is_dir():
|
|
239
|
+
continue
|
|
240
|
+
for path in sorted(kind_directory.rglob("*")):
|
|
241
|
+
if path.is_file() and path.suffix.lower() in _YAML_SUFFIXES:
|
|
242
|
+
yield kind, path
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class StateValidator:
|
|
246
|
+
"""Schema and semantic validator for a project directory."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, schemas: SchemaCatalog) -> None:
|
|
249
|
+
"""Create a state validator backed by a validated schema catalog."""
|
|
250
|
+
self._schemas = schemas
|
|
251
|
+
|
|
252
|
+
def validate_directory(self, project_directory: Path) -> ValidatedConfig:
|
|
253
|
+
"""Validate every YAML resource in a project's kind directories."""
|
|
254
|
+
if not project_directory.is_dir():
|
|
255
|
+
issue = ValidationIssue(
|
|
256
|
+
code="project_directory_missing",
|
|
257
|
+
message=f"project directory does not exist: {project_directory}",
|
|
258
|
+
)
|
|
259
|
+
raise ConfigValidationError(issue)
|
|
260
|
+
|
|
261
|
+
paths = tuple(_resource_files(project_directory))
|
|
262
|
+
if not paths:
|
|
263
|
+
issue = ValidationIssue(
|
|
264
|
+
code="project_empty",
|
|
265
|
+
message=f"project contains no YAML resources: {project_directory}",
|
|
266
|
+
)
|
|
267
|
+
raise ConfigValidationError(issue)
|
|
268
|
+
|
|
269
|
+
documents: list[ResourceDocument] = []
|
|
270
|
+
index: dict[ResourceKey, ResourceDocument] = {}
|
|
271
|
+
for kind, path in paths:
|
|
272
|
+
for document in self._load_file(path):
|
|
273
|
+
if document.key.kind is not kind:
|
|
274
|
+
issue = ValidationIssue(
|
|
275
|
+
code="resource_misplaced",
|
|
276
|
+
message=(
|
|
277
|
+
f"{document.key.kind.value}/{document.key.resource_id} "
|
|
278
|
+
f"belongs in {document.key.kind.directory}/, "
|
|
279
|
+
f"not {kind.directory}/"
|
|
280
|
+
),
|
|
281
|
+
source=document.source,
|
|
282
|
+
)
|
|
283
|
+
raise ConfigValidationError(issue)
|
|
284
|
+
if document.key in index:
|
|
285
|
+
first = index[document.key]
|
|
286
|
+
issue = ValidationIssue(
|
|
287
|
+
code="resource_duplicate",
|
|
288
|
+
message=(
|
|
289
|
+
f"{document.key.kind.value}/{document.key.resource_id} "
|
|
290
|
+
f"already defined at {first.source.display()}"
|
|
291
|
+
),
|
|
292
|
+
source=document.source,
|
|
293
|
+
)
|
|
294
|
+
raise ConfigValidationError(issue)
|
|
295
|
+
index[document.key] = document
|
|
296
|
+
documents.append(document)
|
|
297
|
+
|
|
298
|
+
self._validate_references(documents, index)
|
|
299
|
+
return ValidatedConfig(documents=tuple(documents), _index=index)
|
|
300
|
+
|
|
301
|
+
def _load_file(self, path: Path) -> Iterable[ResourceDocument]:
|
|
302
|
+
try:
|
|
303
|
+
with path.open(encoding="utf-8") as stream:
|
|
304
|
+
raw_documents = tuple(
|
|
305
|
+
cast("object", document) for document in yaml.safe_load_all(stream)
|
|
306
|
+
)
|
|
307
|
+
except yaml.YAMLError as error:
|
|
308
|
+
issue = ValidationIssue(
|
|
309
|
+
code="yaml_invalid",
|
|
310
|
+
message=str(error),
|
|
311
|
+
source=SourceLocation(path=path, document_number=1),
|
|
312
|
+
)
|
|
313
|
+
raise ConfigValidationError(issue) from error
|
|
314
|
+
|
|
315
|
+
for number, raw in enumerate(raw_documents, start=1):
|
|
316
|
+
source = SourceLocation(path=path, document_number=number)
|
|
317
|
+
if raw is None:
|
|
318
|
+
issue = ValidationIssue(
|
|
319
|
+
code="document_empty",
|
|
320
|
+
message="YAML document is empty",
|
|
321
|
+
source=source,
|
|
322
|
+
)
|
|
323
|
+
raise ConfigValidationError(issue)
|
|
324
|
+
if not isinstance(raw, dict) or not all(
|
|
325
|
+
isinstance(key, str) for key in raw
|
|
326
|
+
):
|
|
327
|
+
issue = ValidationIssue(
|
|
328
|
+
code="document_shape_invalid",
|
|
329
|
+
message="resource document must be an object with string keys",
|
|
330
|
+
source=source,
|
|
331
|
+
)
|
|
332
|
+
raise ConfigValidationError(issue)
|
|
333
|
+
content = cast("Mapping[str, object]", raw)
|
|
334
|
+
kind = self._resource_kind(content, source)
|
|
335
|
+
try:
|
|
336
|
+
self._schemas.validate(kind, content)
|
|
337
|
+
except ValidationError as error:
|
|
338
|
+
issue = ValidationIssue(
|
|
339
|
+
code="schema_validation_failed",
|
|
340
|
+
message=error.message,
|
|
341
|
+
source=source,
|
|
342
|
+
field_path=tuple(error.absolute_path),
|
|
343
|
+
)
|
|
344
|
+
raise ConfigValidationError(issue) from error
|
|
345
|
+
resource_id = self._resource_id(content, source)
|
|
346
|
+
yield ResourceDocument(
|
|
347
|
+
key=ResourceKey(kind=kind, resource_id=resource_id),
|
|
348
|
+
content=content,
|
|
349
|
+
source=source,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _resource_kind(
|
|
354
|
+
content: Mapping[str, object], source: SourceLocation
|
|
355
|
+
) -> ResourceKind:
|
|
356
|
+
try:
|
|
357
|
+
return ResourceKind.from_boundary(content.get("kind"))
|
|
358
|
+
except (TypeError, ValueError) as error:
|
|
359
|
+
issue = ValidationIssue(
|
|
360
|
+
code="resource_kind_invalid",
|
|
361
|
+
message=str(error),
|
|
362
|
+
source=source,
|
|
363
|
+
field_path=("kind",),
|
|
364
|
+
)
|
|
365
|
+
raise ConfigValidationError(issue) from error
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _resource_id(
|
|
369
|
+
content: Mapping[str, object], source: SourceLocation
|
|
370
|
+
) -> ResourceId:
|
|
371
|
+
metadata = _required_mapping(content, "metadata", source)
|
|
372
|
+
try:
|
|
373
|
+
return ResourceId.from_boundary(metadata.get("id"))
|
|
374
|
+
except (TypeError, ValueError) as error:
|
|
375
|
+
issue = ValidationIssue(
|
|
376
|
+
code="resource_id_invalid",
|
|
377
|
+
message=str(error),
|
|
378
|
+
source=source,
|
|
379
|
+
field_path=("metadata", "id"),
|
|
380
|
+
)
|
|
381
|
+
raise ConfigValidationError(issue) from error
|
|
382
|
+
|
|
383
|
+
@staticmethod
|
|
384
|
+
def _validate_references(
|
|
385
|
+
documents: Sequence[ResourceDocument],
|
|
386
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
387
|
+
) -> None:
|
|
388
|
+
for document in documents:
|
|
389
|
+
spec = _required_mapping(document.content, "spec", document.source)
|
|
390
|
+
_REFERENCE_VALIDATORS[document.key.kind](document, spec, index)
|
|
391
|
+
_validate_logging_uniqueness(documents)
|
|
392
|
+
_validate_service_uniqueness(documents)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _validate_server_references(
|
|
396
|
+
document: ResourceDocument,
|
|
397
|
+
spec: Mapping[str, object],
|
|
398
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
399
|
+
) -> None:
|
|
400
|
+
server_type_id = _resource_id_value(spec, "serverType", document.source)
|
|
401
|
+
_require_resource(
|
|
402
|
+
index,
|
|
403
|
+
ResourceKind.HOST_PROFILE,
|
|
404
|
+
server_type_id,
|
|
405
|
+
document.source,
|
|
406
|
+
("spec", "serverType"),
|
|
407
|
+
)
|
|
408
|
+
_validate_server_network(spec, document.source)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _validate_server_type_references(
|
|
412
|
+
document: ResourceDocument,
|
|
413
|
+
spec: Mapping[str, object],
|
|
414
|
+
_index: Mapping[ResourceKey, ResourceDocument],
|
|
415
|
+
) -> None:
|
|
416
|
+
_validate_server_type(spec, document.source)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _validate_application_references(
|
|
420
|
+
document: ResourceDocument,
|
|
421
|
+
spec: Mapping[str, object],
|
|
422
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
423
|
+
) -> None:
|
|
424
|
+
component_ids = _resource_id_list(spec, "components", document.source)
|
|
425
|
+
for component_id in component_ids:
|
|
426
|
+
_require_resource(
|
|
427
|
+
index,
|
|
428
|
+
ResourceKind.COMPONENT,
|
|
429
|
+
component_id,
|
|
430
|
+
document.source,
|
|
431
|
+
("spec", "components"),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _validate_ssh_public_key_references(
|
|
436
|
+
document: ResourceDocument,
|
|
437
|
+
spec: Mapping[str, object],
|
|
438
|
+
_index: Mapping[ResourceKey, ResourceDocument],
|
|
439
|
+
) -> None:
|
|
440
|
+
_validate_ssh_public_key(document, spec)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _validate_ssh_public_key(
|
|
444
|
+
document: ResourceDocument, spec: Mapping[str, object]
|
|
445
|
+
) -> None:
|
|
446
|
+
try:
|
|
447
|
+
OpenSshPublicKey.from_boundary(spec.get("publicKey"))
|
|
448
|
+
except (TypeError, ValueError) as error:
|
|
449
|
+
issue = ValidationIssue(
|
|
450
|
+
code="ssh_public_key_invalid",
|
|
451
|
+
message=str(error),
|
|
452
|
+
source=document.source,
|
|
453
|
+
field_path=("spec", "publicKey"),
|
|
454
|
+
)
|
|
455
|
+
raise ConfigValidationError(issue) from error
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _validate_component_references(
|
|
459
|
+
document: ResourceDocument,
|
|
460
|
+
spec: Mapping[str, object],
|
|
461
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
462
|
+
) -> None:
|
|
463
|
+
application_id = _resource_id_value(spec, "application", document.source)
|
|
464
|
+
application = _require_resource(
|
|
465
|
+
index,
|
|
466
|
+
ResourceKind.APPLICATION,
|
|
467
|
+
application_id,
|
|
468
|
+
document.source,
|
|
469
|
+
("spec", "application"),
|
|
470
|
+
)
|
|
471
|
+
deployment = _required_mapping(spec, "deployment", document.source)
|
|
472
|
+
_validate_install_root(deployment, application_id, document.source)
|
|
473
|
+
server_ids = _resource_id_list(deployment, "servers", document.source)
|
|
474
|
+
for server_id in server_ids:
|
|
475
|
+
_require_resource(
|
|
476
|
+
index,
|
|
477
|
+
ResourceKind.SERVER,
|
|
478
|
+
server_id,
|
|
479
|
+
document.source,
|
|
480
|
+
("spec", "deployment", "servers"),
|
|
481
|
+
)
|
|
482
|
+
application_spec = _required_mapping(
|
|
483
|
+
application.content, "spec", application.source
|
|
484
|
+
)
|
|
485
|
+
application_components = _resource_id_list(
|
|
486
|
+
application_spec, "components", application.source
|
|
487
|
+
)
|
|
488
|
+
if document.key.resource_id not in application_components:
|
|
489
|
+
issue = ValidationIssue(
|
|
490
|
+
code="application_component_mismatch",
|
|
491
|
+
message=(
|
|
492
|
+
f"Application/{application_id} does not list "
|
|
493
|
+
f"Component/{document.key.resource_id}"
|
|
494
|
+
),
|
|
495
|
+
source=document.source,
|
|
496
|
+
field_path=("spec", "application"),
|
|
497
|
+
)
|
|
498
|
+
raise ConfigValidationError(issue)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _validate_domain_references(
|
|
502
|
+
document: ResourceDocument,
|
|
503
|
+
spec: Mapping[str, object],
|
|
504
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
505
|
+
) -> None:
|
|
506
|
+
proxy = _required_mapping(spec, "proxy", document.source)
|
|
507
|
+
origin = _required_mapping(spec, "origin", document.source)
|
|
508
|
+
for field_name, content in (("proxy", proxy), ("origin", origin)):
|
|
509
|
+
server_id = _resource_id_value(content, "server", document.source)
|
|
510
|
+
_require_resource(
|
|
511
|
+
index,
|
|
512
|
+
ResourceKind.SERVER,
|
|
513
|
+
server_id,
|
|
514
|
+
document.source,
|
|
515
|
+
("spec", field_name, "server"),
|
|
516
|
+
)
|
|
517
|
+
primary_name = spec.get("primaryName")
|
|
518
|
+
aliases = spec.get("aliases")
|
|
519
|
+
if isinstance(aliases, list) and primary_name in aliases:
|
|
520
|
+
issue = ValidationIssue(
|
|
521
|
+
code="domain_alias_invalid",
|
|
522
|
+
message="primaryName must not also appear in aliases",
|
|
523
|
+
source=document.source,
|
|
524
|
+
field_path=("spec", "aliases"),
|
|
525
|
+
)
|
|
526
|
+
raise ConfigValidationError(issue)
|
|
527
|
+
tls = _required_mapping(spec, "tls", document.source)
|
|
528
|
+
health = _required_mapping(spec, "healthCheck", document.source)
|
|
529
|
+
if tls.get("mode") == "required" and health.get("scheme") != "https":
|
|
530
|
+
issue = ValidationIssue(
|
|
531
|
+
code="domain_health_scheme_invalid",
|
|
532
|
+
message="TLS-required domains must use an HTTPS health check",
|
|
533
|
+
source=document.source,
|
|
534
|
+
field_path=("spec", "healthCheck", "scheme"),
|
|
535
|
+
)
|
|
536
|
+
raise ConfigValidationError(issue)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _validate_service_references(
|
|
540
|
+
document: ResourceDocument,
|
|
541
|
+
spec: Mapping[str, object],
|
|
542
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
543
|
+
) -> None:
|
|
544
|
+
environment = _resource_id_value(spec, "environment", document.source)
|
|
545
|
+
server_id = _resource_id_value(spec, "server", document.source)
|
|
546
|
+
server = _require_resource(
|
|
547
|
+
index,
|
|
548
|
+
ResourceKind.SERVER,
|
|
549
|
+
server_id,
|
|
550
|
+
document.source,
|
|
551
|
+
("spec", "server"),
|
|
552
|
+
)
|
|
553
|
+
server_spec = _required_mapping(server.content, "spec", server.source)
|
|
554
|
+
server_environment = _resource_id_value(
|
|
555
|
+
server_spec, "environment", server.source
|
|
556
|
+
)
|
|
557
|
+
if server_environment != environment:
|
|
558
|
+
issue = ValidationIssue(
|
|
559
|
+
code="service_environment_mismatch",
|
|
560
|
+
message=(
|
|
561
|
+
f"Server/{server_id} belongs to {server_environment}, "
|
|
562
|
+
f"not {environment}"
|
|
563
|
+
),
|
|
564
|
+
source=document.source,
|
|
565
|
+
field_path=("spec", "environment"),
|
|
566
|
+
)
|
|
567
|
+
raise ConfigValidationError(issue)
|
|
568
|
+
|
|
569
|
+
if spec.get("serviceKind") != "postgresql":
|
|
570
|
+
return
|
|
571
|
+
postgresql = _required_mapping(spec, "postgresql", document.source)
|
|
572
|
+
raw_databases = postgresql.get("databases")
|
|
573
|
+
if not isinstance(raw_databases, list):
|
|
574
|
+
issue = ValidationIssue(
|
|
575
|
+
code="internal_state_shape_invalid",
|
|
576
|
+
message="validated service databases are not a list",
|
|
577
|
+
source=document.source,
|
|
578
|
+
field_path=("spec", "postgresql", "databases"),
|
|
579
|
+
)
|
|
580
|
+
raise ConfigValidationError(issue)
|
|
581
|
+
seen_names: set[object] = set()
|
|
582
|
+
for position, raw_database in enumerate(raw_databases):
|
|
583
|
+
if not isinstance(raw_database, dict) or not all(
|
|
584
|
+
isinstance(key, str) for key in raw_database
|
|
585
|
+
):
|
|
586
|
+
issue = ValidationIssue(
|
|
587
|
+
code="internal_state_shape_invalid",
|
|
588
|
+
message="validated service database is not an object",
|
|
589
|
+
source=document.source,
|
|
590
|
+
field_path=("spec", "postgresql", "databases", position),
|
|
591
|
+
)
|
|
592
|
+
raise ConfigValidationError(issue)
|
|
593
|
+
database = cast("Mapping[str, object]", raw_database)
|
|
594
|
+
name = database.get("name")
|
|
595
|
+
if name in seen_names:
|
|
596
|
+
issue = ValidationIssue(
|
|
597
|
+
code="service_database_duplicate",
|
|
598
|
+
message=f"duplicate service database name: {name}",
|
|
599
|
+
source=document.source,
|
|
600
|
+
field_path=("spec", "postgresql", "databases", position, "name"),
|
|
601
|
+
)
|
|
602
|
+
raise ConfigValidationError(issue)
|
|
603
|
+
seen_names.add(name)
|
|
604
|
+
application_id = _resource_id_value(database, "application", document.source)
|
|
605
|
+
_require_resource(
|
|
606
|
+
index,
|
|
607
|
+
ResourceKind.APPLICATION,
|
|
608
|
+
application_id,
|
|
609
|
+
document.source,
|
|
610
|
+
("spec", "postgresql", "databases", position, "application"),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _validate_service_uniqueness(documents: Sequence[ResourceDocument]) -> None:
|
|
615
|
+
placements: dict[tuple[ResourceId, str], ResourceDocument] = {}
|
|
616
|
+
ports: dict[tuple[ResourceId, int], ResourceDocument] = {}
|
|
617
|
+
for document in documents:
|
|
618
|
+
if document.key.kind is not ResourceKind.SERVICE:
|
|
619
|
+
continue
|
|
620
|
+
spec = _required_mapping(document.content, "spec", document.source)
|
|
621
|
+
bind = _required_mapping(spec, "bind", document.source)
|
|
622
|
+
server_id = _resource_id_value(spec, "server", document.source)
|
|
623
|
+
raw_kind = spec.get("serviceKind")
|
|
624
|
+
raw_port = bind.get("port")
|
|
625
|
+
if not isinstance(raw_kind, str) or not isinstance(raw_port, int):
|
|
626
|
+
issue = ValidationIssue(
|
|
627
|
+
code="internal_state_shape_invalid",
|
|
628
|
+
message="validated service kind or port has an invalid shape",
|
|
629
|
+
source=document.source,
|
|
630
|
+
field_path=("spec",),
|
|
631
|
+
)
|
|
632
|
+
raise ConfigValidationError(issue)
|
|
633
|
+
placement = (server_id, raw_kind)
|
|
634
|
+
if placement in placements:
|
|
635
|
+
issue = ValidationIssue(
|
|
636
|
+
code="service_placement_conflict",
|
|
637
|
+
message=(
|
|
638
|
+
f"Server/{server_id} already runs a {raw_kind} service "
|
|
639
|
+
f"declared at {placements[placement].source.display()}"
|
|
640
|
+
),
|
|
641
|
+
source=document.source,
|
|
642
|
+
field_path=("spec", "server"),
|
|
643
|
+
)
|
|
644
|
+
raise ConfigValidationError(issue)
|
|
645
|
+
placements[placement] = document
|
|
646
|
+
listener = (server_id, raw_port)
|
|
647
|
+
if listener in ports:
|
|
648
|
+
issue = ValidationIssue(
|
|
649
|
+
code="service_port_conflict",
|
|
650
|
+
message=(
|
|
651
|
+
f"Server/{server_id} port {raw_port} already used by the "
|
|
652
|
+
f"service declared at {ports[listener].source.display()}"
|
|
653
|
+
),
|
|
654
|
+
source=document.source,
|
|
655
|
+
field_path=("spec", "bind", "port"),
|
|
656
|
+
)
|
|
657
|
+
raise ConfigValidationError(issue)
|
|
658
|
+
ports[listener] = document
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _validate_logging_references(
|
|
662
|
+
document: ResourceDocument,
|
|
663
|
+
spec: Mapping[str, object],
|
|
664
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
665
|
+
) -> None:
|
|
666
|
+
environment = _resource_id_value(spec, "environment", document.source)
|
|
667
|
+
backend = _required_mapping(spec, "backend", document.source)
|
|
668
|
+
backend_id = _resource_id_value(backend, "server", document.source)
|
|
669
|
+
backend_server = _require_resource(
|
|
670
|
+
index,
|
|
671
|
+
ResourceKind.SERVER,
|
|
672
|
+
backend_id,
|
|
673
|
+
document.source,
|
|
674
|
+
("spec", "backend", "server"),
|
|
675
|
+
)
|
|
676
|
+
_require_server_environment(backend_server, environment, document.source)
|
|
677
|
+
|
|
678
|
+
_validate_logging_secret_paths(spec, document.source)
|
|
679
|
+
_validate_logging_ports(spec, document.source)
|
|
680
|
+
|
|
681
|
+
collectors = _required_mapping(spec, "collectors", document.source)
|
|
682
|
+
collector_ids = _resource_id_list(collectors, "servers", document.source)
|
|
683
|
+
collector_set = frozenset(collector_ids)
|
|
684
|
+
for server_id in collector_ids:
|
|
685
|
+
server = _require_resource(
|
|
686
|
+
index,
|
|
687
|
+
ResourceKind.SERVER,
|
|
688
|
+
server_id,
|
|
689
|
+
document.source,
|
|
690
|
+
("spec", "collectors", "servers"),
|
|
691
|
+
)
|
|
692
|
+
_require_server_environment(server, environment, document.source)
|
|
693
|
+
|
|
694
|
+
raw_files = collectors.get("files")
|
|
695
|
+
if not isinstance(raw_files, list):
|
|
696
|
+
issue = ValidationIssue(
|
|
697
|
+
code="internal_state_shape_invalid",
|
|
698
|
+
message="validated logging file sources are not a list",
|
|
699
|
+
source=document.source,
|
|
700
|
+
field_path=("spec", "collectors", "files"),
|
|
701
|
+
)
|
|
702
|
+
raise ConfigValidationError(issue)
|
|
703
|
+
seen_file_ids: set[ResourceId] = set()
|
|
704
|
+
for position, raw_file in enumerate(raw_files):
|
|
705
|
+
if not isinstance(raw_file, dict) or not all(
|
|
706
|
+
isinstance(key, str) for key in raw_file
|
|
707
|
+
):
|
|
708
|
+
issue = ValidationIssue(
|
|
709
|
+
code="internal_state_shape_invalid",
|
|
710
|
+
message="validated logging file source is not an object",
|
|
711
|
+
source=document.source,
|
|
712
|
+
field_path=("spec", "collectors", "files", position),
|
|
713
|
+
)
|
|
714
|
+
raise ConfigValidationError(issue)
|
|
715
|
+
file_source = cast("Mapping[str, object]", raw_file)
|
|
716
|
+
file_id = _resource_id_value(file_source, "id", document.source)
|
|
717
|
+
if file_id in seen_file_ids:
|
|
718
|
+
issue = ValidationIssue(
|
|
719
|
+
code="logging_file_source_duplicate",
|
|
720
|
+
message=f"duplicate logging file source id: {file_id}",
|
|
721
|
+
source=document.source,
|
|
722
|
+
field_path=("spec", "collectors", "files", position, "id"),
|
|
723
|
+
)
|
|
724
|
+
raise ConfigValidationError(issue)
|
|
725
|
+
seen_file_ids.add(file_id)
|
|
726
|
+
source_servers = _resource_id_list(file_source, "servers", document.source)
|
|
727
|
+
if not frozenset(source_servers).issubset(collector_set):
|
|
728
|
+
issue = ValidationIssue(
|
|
729
|
+
code="logging_file_server_invalid",
|
|
730
|
+
message="file source servers must be declared collector servers",
|
|
731
|
+
source=document.source,
|
|
732
|
+
field_path=(
|
|
733
|
+
"spec",
|
|
734
|
+
"collectors",
|
|
735
|
+
"files",
|
|
736
|
+
position,
|
|
737
|
+
"servers",
|
|
738
|
+
),
|
|
739
|
+
)
|
|
740
|
+
raise ConfigValidationError(issue)
|
|
741
|
+
_validate_logging_file_owner(
|
|
742
|
+
file_source,
|
|
743
|
+
source_servers,
|
|
744
|
+
document.source,
|
|
745
|
+
position,
|
|
746
|
+
index,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _validate_logging_secret_paths(
|
|
751
|
+
spec: Mapping[str, object], source: SourceLocation
|
|
752
|
+
) -> None:
|
|
753
|
+
gateway = _required_mapping(spec, "gateway", source)
|
|
754
|
+
gateway_tls = _required_mapping(gateway, "tls", source)
|
|
755
|
+
grafana = _required_mapping(spec, "grafana", source)
|
|
756
|
+
collectors = _required_mapping(spec, "collectors", source)
|
|
757
|
+
client_tls = _required_mapping(collectors, "clientTls", source)
|
|
758
|
+
fields = (
|
|
759
|
+
(gateway_tls, "certificatePath"),
|
|
760
|
+
(gateway_tls, "keyPath"),
|
|
761
|
+
(gateway_tls, "clientCaPath"),
|
|
762
|
+
(grafana, "adminPasswordPath"),
|
|
763
|
+
(client_tls, "caPath"),
|
|
764
|
+
(client_tls, "certificatePath"),
|
|
765
|
+
(client_tls, "keyPath"),
|
|
766
|
+
)
|
|
767
|
+
secret_root = PurePosixPath("/etc/cloudfall/logging")
|
|
768
|
+
seen: set[PurePosixPath] = set()
|
|
769
|
+
for content, field_name in fields:
|
|
770
|
+
raw_path = content.get(field_name)
|
|
771
|
+
if not isinstance(raw_path, str):
|
|
772
|
+
issue = ValidationIssue(
|
|
773
|
+
code="internal_state_shape_invalid",
|
|
774
|
+
message=f"validated logging path {field_name!r} is not a string",
|
|
775
|
+
source=source,
|
|
776
|
+
field_path=("spec", field_name),
|
|
777
|
+
)
|
|
778
|
+
raise ConfigValidationError(issue)
|
|
779
|
+
path = PurePosixPath(raw_path)
|
|
780
|
+
if path == secret_root or not path.is_relative_to(secret_root):
|
|
781
|
+
issue = ValidationIssue(
|
|
782
|
+
code="logging_secret_path_invalid",
|
|
783
|
+
message=f"logging secret paths must be below {secret_root}: {path}",
|
|
784
|
+
source=source,
|
|
785
|
+
field_path=("spec", field_name),
|
|
786
|
+
)
|
|
787
|
+
raise ConfigValidationError(issue)
|
|
788
|
+
if path in seen:
|
|
789
|
+
issue = ValidationIssue(
|
|
790
|
+
code="logging_secret_path_duplicate",
|
|
791
|
+
message=f"logging secret paths must be distinct: {path}",
|
|
792
|
+
source=source,
|
|
793
|
+
field_path=("spec", field_name),
|
|
794
|
+
)
|
|
795
|
+
raise ConfigValidationError(issue)
|
|
796
|
+
seen.add(path)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _validate_logging_ports(
|
|
800
|
+
spec: Mapping[str, object], source: SourceLocation
|
|
801
|
+
) -> None:
|
|
802
|
+
backend = _required_mapping(spec, "backend", source)
|
|
803
|
+
gateway = _required_mapping(spec, "gateway", source)
|
|
804
|
+
grafana = _required_mapping(spec, "grafana", source)
|
|
805
|
+
metrics = _required_mapping(spec, "metrics", source)
|
|
806
|
+
metrics_backend = _required_mapping(metrics, "backend", source)
|
|
807
|
+
listeners = (
|
|
808
|
+
("backend", backend.get("port")),
|
|
809
|
+
("gateway", gateway.get("port")),
|
|
810
|
+
("grafana", grafana.get("port")),
|
|
811
|
+
("metrics.backend", metrics_backend.get("port")),
|
|
812
|
+
)
|
|
813
|
+
seen: dict[int, str] = {}
|
|
814
|
+
for name, raw_port in listeners:
|
|
815
|
+
if isinstance(raw_port, bool) or not isinstance(raw_port, int):
|
|
816
|
+
issue = ValidationIssue(
|
|
817
|
+
code="internal_state_shape_invalid",
|
|
818
|
+
message=f"validated logging {name} port is not an integer",
|
|
819
|
+
source=source,
|
|
820
|
+
field_path=("spec", name, "port"),
|
|
821
|
+
)
|
|
822
|
+
raise ConfigValidationError(issue)
|
|
823
|
+
if raw_port in seen:
|
|
824
|
+
issue = ValidationIssue(
|
|
825
|
+
code="logging_port_conflict",
|
|
826
|
+
message=(
|
|
827
|
+
f"logging {name} port {raw_port} conflicts with "
|
|
828
|
+
f"{seen[raw_port]}"
|
|
829
|
+
),
|
|
830
|
+
source=source,
|
|
831
|
+
field_path=("spec", name, "port"),
|
|
832
|
+
)
|
|
833
|
+
raise ConfigValidationError(issue)
|
|
834
|
+
seen[raw_port] = name
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _require_server_environment(
|
|
838
|
+
server: ResourceDocument,
|
|
839
|
+
environment: ResourceId,
|
|
840
|
+
source: SourceLocation,
|
|
841
|
+
) -> None:
|
|
842
|
+
server_spec = _required_mapping(server.content, "spec", server.source)
|
|
843
|
+
server_environment = _resource_id_value(server_spec, "environment", server.source)
|
|
844
|
+
if server_environment != environment:
|
|
845
|
+
issue = ValidationIssue(
|
|
846
|
+
code="logging_server_environment_mismatch",
|
|
847
|
+
message=(
|
|
848
|
+
f"Server/{server.key.resource_id} belongs to {server_environment}, "
|
|
849
|
+
f"not {environment}"
|
|
850
|
+
),
|
|
851
|
+
source=source,
|
|
852
|
+
field_path=("spec", "environment"),
|
|
853
|
+
)
|
|
854
|
+
raise ConfigValidationError(issue)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _validate_logging_file_owner(
|
|
858
|
+
file_source: Mapping[str, object],
|
|
859
|
+
source_servers: tuple[ResourceId, ...],
|
|
860
|
+
source: SourceLocation,
|
|
861
|
+
position: int,
|
|
862
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
863
|
+
) -> None:
|
|
864
|
+
raw_application = file_source.get("application")
|
|
865
|
+
raw_component = file_source.get("component")
|
|
866
|
+
if raw_application is None:
|
|
867
|
+
return
|
|
868
|
+
application_id = ResourceId.from_boundary(raw_application)
|
|
869
|
+
_require_resource(
|
|
870
|
+
index,
|
|
871
|
+
ResourceKind.APPLICATION,
|
|
872
|
+
application_id,
|
|
873
|
+
source,
|
|
874
|
+
("spec", "collectors", "files", position, "application"),
|
|
875
|
+
)
|
|
876
|
+
if raw_component is None:
|
|
877
|
+
return
|
|
878
|
+
component_id = ResourceId.from_boundary(raw_component)
|
|
879
|
+
component = _require_resource(
|
|
880
|
+
index,
|
|
881
|
+
ResourceKind.COMPONENT,
|
|
882
|
+
component_id,
|
|
883
|
+
source,
|
|
884
|
+
("spec", "collectors", "files", position, "component"),
|
|
885
|
+
)
|
|
886
|
+
component_spec = _required_mapping(component.content, "spec", component.source)
|
|
887
|
+
owner_id = _resource_id_value(component_spec, "application", component.source)
|
|
888
|
+
if owner_id != application_id:
|
|
889
|
+
issue = ValidationIssue(
|
|
890
|
+
code="logging_file_owner_mismatch",
|
|
891
|
+
message=(
|
|
892
|
+
f"Component/{component_id} belongs to Application/{owner_id}, "
|
|
893
|
+
f"not Application/{application_id}"
|
|
894
|
+
),
|
|
895
|
+
source=source,
|
|
896
|
+
field_path=("spec", "collectors", "files", position),
|
|
897
|
+
)
|
|
898
|
+
raise ConfigValidationError(issue)
|
|
899
|
+
deployment = _required_mapping(component_spec, "deployment", component.source)
|
|
900
|
+
component_servers = frozenset(
|
|
901
|
+
_resource_id_list(deployment, "servers", component.source)
|
|
902
|
+
)
|
|
903
|
+
if not frozenset(source_servers).issubset(component_servers):
|
|
904
|
+
issue = ValidationIssue(
|
|
905
|
+
code="logging_file_component_placement_mismatch",
|
|
906
|
+
message=(
|
|
907
|
+
f"file source servers must be deployment targets of "
|
|
908
|
+
f"Component/{component_id}"
|
|
909
|
+
),
|
|
910
|
+
source=source,
|
|
911
|
+
field_path=("spec", "collectors", "files", position, "servers"),
|
|
912
|
+
)
|
|
913
|
+
raise ConfigValidationError(issue)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _validate_logging_uniqueness(
|
|
917
|
+
documents: Sequence[ResourceDocument],
|
|
918
|
+
) -> None:
|
|
919
|
+
environments: dict[ResourceId, ResourceDocument] = {}
|
|
920
|
+
backend_servers: dict[ResourceId, ResourceDocument] = {}
|
|
921
|
+
for document in documents:
|
|
922
|
+
if document.key.kind is not ResourceKind.LOGGING_STACK:
|
|
923
|
+
continue
|
|
924
|
+
spec = _required_mapping(document.content, "spec", document.source)
|
|
925
|
+
environment = _resource_id_value(spec, "environment", document.source)
|
|
926
|
+
backend = _required_mapping(spec, "backend", document.source)
|
|
927
|
+
backend_id = _resource_id_value(backend, "server", document.source)
|
|
928
|
+
for identity, seen, code, concept in (
|
|
929
|
+
(
|
|
930
|
+
environment,
|
|
931
|
+
environments,
|
|
932
|
+
"logging_environment_duplicate",
|
|
933
|
+
"environment",
|
|
934
|
+
),
|
|
935
|
+
(
|
|
936
|
+
backend_id,
|
|
937
|
+
backend_servers,
|
|
938
|
+
"logging_backend_duplicate",
|
|
939
|
+
"backend server",
|
|
940
|
+
),
|
|
941
|
+
):
|
|
942
|
+
previous = seen.get(identity)
|
|
943
|
+
if previous is not None:
|
|
944
|
+
issue = ValidationIssue(
|
|
945
|
+
code=code,
|
|
946
|
+
message=(
|
|
947
|
+
f"LoggingStack/{previous.key.resource_id} already owns "
|
|
948
|
+
f"{concept} {identity}"
|
|
949
|
+
),
|
|
950
|
+
source=document.source,
|
|
951
|
+
field_path=("spec",),
|
|
952
|
+
)
|
|
953
|
+
raise ConfigValidationError(issue)
|
|
954
|
+
seen[identity] = document
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _validate_server_type(spec: Mapping[str, object], source: SourceLocation) -> None:
|
|
958
|
+
storage = _required_mapping(spec, "storage", source)
|
|
959
|
+
packages = _required_mapping(spec, "packages", source)
|
|
960
|
+
services = _required_mapping(spec, "services", source)
|
|
961
|
+
configuration = _required_mapping(spec, "configuration", source)
|
|
962
|
+
_validate_unique_entry_keys(
|
|
963
|
+
storage,
|
|
964
|
+
"mounts",
|
|
965
|
+
"path",
|
|
966
|
+
source,
|
|
967
|
+
("spec", "storage", "mounts"),
|
|
968
|
+
)
|
|
969
|
+
_validate_unique_entry_keys(
|
|
970
|
+
packages,
|
|
971
|
+
"required",
|
|
972
|
+
"name",
|
|
973
|
+
source,
|
|
974
|
+
("spec", "packages", "required"),
|
|
975
|
+
)
|
|
976
|
+
_validate_unique_entry_keys(
|
|
977
|
+
services,
|
|
978
|
+
"required",
|
|
979
|
+
"name",
|
|
980
|
+
source,
|
|
981
|
+
("spec", "services", "required"),
|
|
982
|
+
)
|
|
983
|
+
configuration_items = _validate_unique_entry_keys(
|
|
984
|
+
configuration,
|
|
985
|
+
"files",
|
|
986
|
+
"path",
|
|
987
|
+
source,
|
|
988
|
+
("spec", "configuration", "files"),
|
|
989
|
+
)
|
|
990
|
+
for index, item in enumerate(configuration_items):
|
|
991
|
+
if item.get("sha256") is not None and item.get("capture") != "hash":
|
|
992
|
+
issue = ValidationIssue(
|
|
993
|
+
code="configuration_capture_invalid",
|
|
994
|
+
message="sha256 requires configuration capture mode 'hash'",
|
|
995
|
+
source=source,
|
|
996
|
+
field_path=("spec", "configuration", "files", index),
|
|
997
|
+
)
|
|
998
|
+
raise ConfigValidationError(issue)
|
|
999
|
+
_validate_server_type_firewall(spec, source)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _validate_server_type_firewall(
|
|
1003
|
+
spec: Mapping[str, object], source: SourceLocation
|
|
1004
|
+
) -> None:
|
|
1005
|
+
if spec.get("firewall") is None:
|
|
1006
|
+
return
|
|
1007
|
+
firewall = _required_mapping(spec, "firewall", source)
|
|
1008
|
+
raw_rules = firewall.get("allowedInbound")
|
|
1009
|
+
if not isinstance(raw_rules, list):
|
|
1010
|
+
issue = ValidationIssue(
|
|
1011
|
+
code="internal_state_shape_invalid",
|
|
1012
|
+
message="validated firewall allowedInbound is not a list",
|
|
1013
|
+
source=source,
|
|
1014
|
+
field_path=("spec", "firewall", "allowedInbound"),
|
|
1015
|
+
)
|
|
1016
|
+
raise ConfigValidationError(issue)
|
|
1017
|
+
seen: set[tuple[object, object]] = set()
|
|
1018
|
+
for index, raw_rule in enumerate(raw_rules):
|
|
1019
|
+
if not isinstance(raw_rule, dict) or not all(
|
|
1020
|
+
isinstance(key, str) for key in raw_rule
|
|
1021
|
+
):
|
|
1022
|
+
issue = ValidationIssue(
|
|
1023
|
+
code="internal_state_shape_invalid",
|
|
1024
|
+
message="validated firewall rule is not an object",
|
|
1025
|
+
source=source,
|
|
1026
|
+
field_path=("spec", "firewall", "allowedInbound", index),
|
|
1027
|
+
)
|
|
1028
|
+
raise ConfigValidationError(issue)
|
|
1029
|
+
rule = cast("Mapping[str, object]", raw_rule)
|
|
1030
|
+
identity = (rule.get("port"), rule.get("protocol"))
|
|
1031
|
+
if identity in seen:
|
|
1032
|
+
issue = ValidationIssue(
|
|
1033
|
+
code="firewall_rule_duplicate",
|
|
1034
|
+
message=(
|
|
1035
|
+
"duplicate firewall rule: "
|
|
1036
|
+
f"{rule.get('port')}/{rule.get('protocol')}"
|
|
1037
|
+
),
|
|
1038
|
+
source=source,
|
|
1039
|
+
field_path=("spec", "firewall", "allowedInbound", index),
|
|
1040
|
+
)
|
|
1041
|
+
raise ConfigValidationError(issue)
|
|
1042
|
+
seen.add(identity)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _validate_server_network(
|
|
1046
|
+
spec: Mapping[str, object], source: SourceLocation
|
|
1047
|
+
) -> None:
|
|
1048
|
+
raw_network = spec.get("network")
|
|
1049
|
+
if raw_network is None:
|
|
1050
|
+
return
|
|
1051
|
+
network = _required_mapping(spec, "network", source)
|
|
1052
|
+
raw_ipv4 = network.get("ipv4")
|
|
1053
|
+
raw_ipv6 = network.get("ipv6Cidr")
|
|
1054
|
+
if not isinstance(raw_ipv4, str) or not isinstance(raw_ipv6, str):
|
|
1055
|
+
issue = ValidationIssue(
|
|
1056
|
+
code="internal_state_shape_invalid",
|
|
1057
|
+
message="validated server network addresses are not strings",
|
|
1058
|
+
source=source,
|
|
1059
|
+
field_path=("spec", "network"),
|
|
1060
|
+
)
|
|
1061
|
+
raise ConfigValidationError(issue)
|
|
1062
|
+
try:
|
|
1063
|
+
ipv4 = IPv4Address(raw_ipv4)
|
|
1064
|
+
ipv6 = IPv6Network(raw_ipv6, strict=True)
|
|
1065
|
+
except ValueError as error:
|
|
1066
|
+
issue = ValidationIssue(
|
|
1067
|
+
code="server_network_invalid",
|
|
1068
|
+
message=str(error),
|
|
1069
|
+
source=source,
|
|
1070
|
+
field_path=("spec", "network"),
|
|
1071
|
+
)
|
|
1072
|
+
raise ConfigValidationError(issue) from error
|
|
1073
|
+
if str(ipv4) != raw_ipv4 or str(ipv6) != raw_ipv6:
|
|
1074
|
+
issue = ValidationIssue(
|
|
1075
|
+
code="server_network_not_canonical",
|
|
1076
|
+
message="server IPv4 and IPv6 network must use canonical notation",
|
|
1077
|
+
source=source,
|
|
1078
|
+
field_path=("spec", "network"),
|
|
1079
|
+
)
|
|
1080
|
+
raise ConfigValidationError(issue)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _validate_unique_entry_keys(
|
|
1084
|
+
content: Mapping[str, object],
|
|
1085
|
+
list_key: str,
|
|
1086
|
+
entry_key: str,
|
|
1087
|
+
source: SourceLocation,
|
|
1088
|
+
field_path: tuple[str | int, ...],
|
|
1089
|
+
) -> tuple[Mapping[str, object], ...]:
|
|
1090
|
+
value = content.get(list_key)
|
|
1091
|
+
if not isinstance(value, list):
|
|
1092
|
+
issue = ValidationIssue(
|
|
1093
|
+
code="internal_state_shape_invalid",
|
|
1094
|
+
message=f"validated field {list_key!r} is not a list",
|
|
1095
|
+
source=source,
|
|
1096
|
+
field_path=field_path,
|
|
1097
|
+
)
|
|
1098
|
+
raise ConfigValidationError(issue)
|
|
1099
|
+
items: list[Mapping[str, object]] = []
|
|
1100
|
+
seen: set[object] = set()
|
|
1101
|
+
for index, raw_item in enumerate(value):
|
|
1102
|
+
if not isinstance(raw_item, dict) or not all(
|
|
1103
|
+
isinstance(key, str) for key in raw_item
|
|
1104
|
+
):
|
|
1105
|
+
issue = ValidationIssue(
|
|
1106
|
+
code="internal_state_shape_invalid",
|
|
1107
|
+
message=f"validated {list_key!r} entry is not an object",
|
|
1108
|
+
source=source,
|
|
1109
|
+
field_path=(*field_path, index),
|
|
1110
|
+
)
|
|
1111
|
+
raise ConfigValidationError(issue)
|
|
1112
|
+
item = cast("Mapping[str, object]", raw_item)
|
|
1113
|
+
identity = item.get(entry_key)
|
|
1114
|
+
if identity in seen:
|
|
1115
|
+
issue = ValidationIssue(
|
|
1116
|
+
code="server_type_entry_duplicate",
|
|
1117
|
+
message=f"duplicate {entry_key} in server type: {identity}",
|
|
1118
|
+
source=source,
|
|
1119
|
+
field_path=(*field_path, index, entry_key),
|
|
1120
|
+
)
|
|
1121
|
+
raise ConfigValidationError(issue)
|
|
1122
|
+
seen.add(identity)
|
|
1123
|
+
items.append(item)
|
|
1124
|
+
return tuple(items)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _required_mapping(
|
|
1128
|
+
content: Mapping[str, object], key: str, source: SourceLocation
|
|
1129
|
+
) -> Mapping[str, object]:
|
|
1130
|
+
value = content.get(key)
|
|
1131
|
+
if not isinstance(value, dict) or not all(
|
|
1132
|
+
isinstance(child_key, str) for child_key in value
|
|
1133
|
+
):
|
|
1134
|
+
issue = ValidationIssue(
|
|
1135
|
+
code="internal_state_shape_invalid",
|
|
1136
|
+
message=f"validated field {key!r} is not an object",
|
|
1137
|
+
source=source,
|
|
1138
|
+
field_path=(key,),
|
|
1139
|
+
)
|
|
1140
|
+
raise ConfigValidationError(issue)
|
|
1141
|
+
return cast("Mapping[str, object]", value)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _resource_id_value(
|
|
1145
|
+
content: Mapping[str, object], key: str, source: SourceLocation
|
|
1146
|
+
) -> ResourceId:
|
|
1147
|
+
try:
|
|
1148
|
+
return ResourceId.from_boundary(content.get(key))
|
|
1149
|
+
except (TypeError, ValueError) as error:
|
|
1150
|
+
issue = ValidationIssue(
|
|
1151
|
+
code="internal_resource_id_invalid",
|
|
1152
|
+
message=str(error),
|
|
1153
|
+
source=source,
|
|
1154
|
+
field_path=(key,),
|
|
1155
|
+
)
|
|
1156
|
+
raise ConfigValidationError(issue) from error
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _resource_id_list(
|
|
1160
|
+
content: Mapping[str, object], key: str, source: SourceLocation
|
|
1161
|
+
) -> tuple[ResourceId, ...]:
|
|
1162
|
+
value = content.get(key)
|
|
1163
|
+
if not isinstance(value, list):
|
|
1164
|
+
issue = ValidationIssue(
|
|
1165
|
+
code="internal_state_shape_invalid",
|
|
1166
|
+
message=f"validated field {key!r} is not a list",
|
|
1167
|
+
source=source,
|
|
1168
|
+
field_path=(key,),
|
|
1169
|
+
)
|
|
1170
|
+
raise ConfigValidationError(issue)
|
|
1171
|
+
try:
|
|
1172
|
+
return tuple(ResourceId.from_boundary(item) for item in value)
|
|
1173
|
+
except (TypeError, ValueError) as error:
|
|
1174
|
+
issue = ValidationIssue(
|
|
1175
|
+
code="internal_resource_id_invalid",
|
|
1176
|
+
message=str(error),
|
|
1177
|
+
source=source,
|
|
1178
|
+
field_path=(key,),
|
|
1179
|
+
)
|
|
1180
|
+
raise ConfigValidationError(issue) from error
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _require_resource(
|
|
1184
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
1185
|
+
kind: ResourceKind,
|
|
1186
|
+
resource_id: ResourceId,
|
|
1187
|
+
source: SourceLocation,
|
|
1188
|
+
field_path: tuple[str | int, ...],
|
|
1189
|
+
) -> ResourceDocument:
|
|
1190
|
+
key = ResourceKey(kind=kind, resource_id=resource_id)
|
|
1191
|
+
try:
|
|
1192
|
+
return index[key]
|
|
1193
|
+
except KeyError as error:
|
|
1194
|
+
issue = ValidationIssue(
|
|
1195
|
+
code="resource_reference_missing",
|
|
1196
|
+
message=f"referenced resource does not exist: {kind.value}/{resource_id}",
|
|
1197
|
+
source=source,
|
|
1198
|
+
field_path=field_path,
|
|
1199
|
+
)
|
|
1200
|
+
raise ConfigValidationError(issue) from error
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _validate_install_root(
|
|
1204
|
+
deployment: Mapping[str, object],
|
|
1205
|
+
application_id: ResourceId,
|
|
1206
|
+
source: SourceLocation,
|
|
1207
|
+
) -> None:
|
|
1208
|
+
raw_install_root = deployment.get("installRoot")
|
|
1209
|
+
if not isinstance(raw_install_root, str):
|
|
1210
|
+
issue = ValidationIssue(
|
|
1211
|
+
code="internal_state_shape_invalid",
|
|
1212
|
+
message="validated installRoot is not a string",
|
|
1213
|
+
source=source,
|
|
1214
|
+
field_path=("spec", "deployment", "installRoot"),
|
|
1215
|
+
)
|
|
1216
|
+
raise ConfigValidationError(issue)
|
|
1217
|
+
install_root = PurePosixPath(raw_install_root)
|
|
1218
|
+
application_root = PurePosixPath("/srv/apps") / application_id.value
|
|
1219
|
+
if install_root == application_root or not install_root.is_relative_to(
|
|
1220
|
+
application_root
|
|
1221
|
+
):
|
|
1222
|
+
issue = ValidationIssue(
|
|
1223
|
+
code="component_install_root_invalid",
|
|
1224
|
+
message=(
|
|
1225
|
+
f"component installRoot must be below "
|
|
1226
|
+
f"{application_root}: {install_root}"
|
|
1227
|
+
),
|
|
1228
|
+
source=source,
|
|
1229
|
+
field_path=("spec", "deployment", "installRoot"),
|
|
1230
|
+
)
|
|
1231
|
+
raise ConfigValidationError(issue)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _validate_alert_rule_references(
|
|
1235
|
+
document: ResourceDocument,
|
|
1236
|
+
spec: Mapping[str, object],
|
|
1237
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
1238
|
+
) -> None:
|
|
1239
|
+
environment = _resource_id_value(spec, "environment", document.source)
|
|
1240
|
+
stack_environments = set()
|
|
1241
|
+
for key, stack in index.items():
|
|
1242
|
+
if key.kind is not ResourceKind.LOGGING_STACK:
|
|
1243
|
+
continue
|
|
1244
|
+
stack_spec = _required_mapping(stack.content, "spec", stack.source)
|
|
1245
|
+
stack_environments.add(
|
|
1246
|
+
_resource_id_value(stack_spec, "environment", stack.source)
|
|
1247
|
+
)
|
|
1248
|
+
if environment not in stack_environments:
|
|
1249
|
+
issue = ValidationIssue(
|
|
1250
|
+
code="alert_rule_environment_unmonitored",
|
|
1251
|
+
message=(
|
|
1252
|
+
f"no LoggingStack monitors environment {environment}; "
|
|
1253
|
+
"an alert rule without a metrics backend can never fire"
|
|
1254
|
+
),
|
|
1255
|
+
source=document.source,
|
|
1256
|
+
field_path=("spec", "environment"),
|
|
1257
|
+
)
|
|
1258
|
+
raise ConfigValidationError(issue)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _validate_operator_policy_references(
|
|
1262
|
+
document: ResourceDocument,
|
|
1263
|
+
spec: Mapping[str, object],
|
|
1264
|
+
index: Mapping[ResourceKey, ResourceDocument],
|
|
1265
|
+
) -> None:
|
|
1266
|
+
environment = _resource_id_value(spec, "environment", document.source)
|
|
1267
|
+
stack_environments = set()
|
|
1268
|
+
for key, stack in index.items():
|
|
1269
|
+
if key.kind is not ResourceKind.LOGGING_STACK:
|
|
1270
|
+
continue
|
|
1271
|
+
stack_spec = _required_mapping(stack.content, "spec", stack.source)
|
|
1272
|
+
stack_environments.add(
|
|
1273
|
+
_resource_id_value(stack_spec, "environment", stack.source)
|
|
1274
|
+
)
|
|
1275
|
+
if environment not in stack_environments:
|
|
1276
|
+
issue = ValidationIssue(
|
|
1277
|
+
code="operator_policy_environment_unmonitored",
|
|
1278
|
+
message=(
|
|
1279
|
+
f"no LoggingStack monitors environment {environment}; "
|
|
1280
|
+
"autonomy without alert evidence is not grantable"
|
|
1281
|
+
),
|
|
1282
|
+
source=document.source,
|
|
1283
|
+
field_path=("spec", "environment"),
|
|
1284
|
+
)
|
|
1285
|
+
raise ConfigValidationError(issue)
|
|
1286
|
+
duplicate = next(
|
|
1287
|
+
(
|
|
1288
|
+
other
|
|
1289
|
+
for key, other in index.items()
|
|
1290
|
+
if key.kind is ResourceKind.OPERATOR_POLICY
|
|
1291
|
+
and other is not document
|
|
1292
|
+
and _resource_id_value(
|
|
1293
|
+
_required_mapping(other.content, "spec", other.source),
|
|
1294
|
+
"environment",
|
|
1295
|
+
other.source,
|
|
1296
|
+
)
|
|
1297
|
+
== environment
|
|
1298
|
+
),
|
|
1299
|
+
None,
|
|
1300
|
+
)
|
|
1301
|
+
if duplicate is not None:
|
|
1302
|
+
issue = ValidationIssue(
|
|
1303
|
+
code="operator_policy_environment_duplicate",
|
|
1304
|
+
message=(
|
|
1305
|
+
f"environment {environment} already has an operator policy "
|
|
1306
|
+
f"at {duplicate.source.display()}"
|
|
1307
|
+
),
|
|
1308
|
+
source=document.source,
|
|
1309
|
+
field_path=("spec", "environment"),
|
|
1310
|
+
)
|
|
1311
|
+
raise ConfigValidationError(issue)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def validate_config(project_directory: Path, schema_directory: Path) -> ValidatedConfig:
|
|
1315
|
+
"""Load and validate a directory of Cloudfall YAML resources."""
|
|
1316
|
+
catalog = SchemaCatalog(schema_directory)
|
|
1317
|
+
return StateValidator(catalog).validate_directory(project_directory)
|
|
1318
|
+
|
|
1319
|
+
_REFERENCE_VALIDATORS: Mapping[
|
|
1320
|
+
ResourceKind,
|
|
1321
|
+
Callable[
|
|
1322
|
+
[
|
|
1323
|
+
ResourceDocument,
|
|
1324
|
+
Mapping[str, object],
|
|
1325
|
+
Mapping[ResourceKey, ResourceDocument],
|
|
1326
|
+
],
|
|
1327
|
+
None,
|
|
1328
|
+
],
|
|
1329
|
+
] = {
|
|
1330
|
+
ResourceKind.SERVER: _validate_server_references,
|
|
1331
|
+
ResourceKind.HOST_PROFILE: _validate_server_type_references,
|
|
1332
|
+
ResourceKind.APPLICATION: _validate_application_references,
|
|
1333
|
+
ResourceKind.COMPONENT: _validate_component_references,
|
|
1334
|
+
ResourceKind.DOMAIN: _validate_domain_references,
|
|
1335
|
+
ResourceKind.LOGGING_STACK: _validate_logging_references,
|
|
1336
|
+
ResourceKind.SSH_PUBLIC_KEY: _validate_ssh_public_key_references,
|
|
1337
|
+
ResourceKind.SERVICE: _validate_service_references,
|
|
1338
|
+
ResourceKind.ALERT_RULE: _validate_alert_rule_references,
|
|
1339
|
+
ResourceKind.OPERATOR_POLICY: _validate_operator_policy_references,
|
|
1340
|
+
}
|