cloudfall 0.2.0__py3-none-any.whl

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