cloudfall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. cloudfall/__init__.py +19 -0
  2. cloudfall/__main__.py +5 -0
  3. cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
  4. cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
  5. cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
  6. cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
  7. cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
  8. cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
  9. cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
  10. cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
  11. cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
  12. cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
  13. cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
  14. cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
  15. cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
  16. cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
  17. cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
  18. cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
  19. cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
  20. cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
  21. cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
  22. cloudfall/agent_tools.py +868 -0
  23. cloudfall/arguments.py +92 -0
  24. cloudfall/audit.py +905 -0
  25. cloudfall/authoring.py +341 -0
  26. cloudfall/cli.py +1748 -0
  27. cloudfall/commands.py +257 -0
  28. cloudfall/cutover.py +370 -0
  29. cloudfall/dashboard.py +407 -0
  30. cloudfall/dashboard_server.py +201 -0
  31. cloudfall/domain.py +1157 -0
  32. cloudfall/importer.py +906 -0
  33. cloudfall/inventory.py +2116 -0
  34. cloudfall/lifecycle.py +874 -0
  35. cloudfall/mcp_server.py +704 -0
  36. cloudfall/migrate.py +730 -0
  37. cloudfall/observation.py +143 -0
  38. cloudfall/operations.py +1228 -0
  39. cloudfall/operator.py +1099 -0
  40. cloudfall/project.py +841 -0
  41. cloudfall/py.typed +0 -0
  42. cloudfall/render_api.py +338 -0
  43. cloudfall/resources.py +67 -0
  44. cloudfall/secrets.py +262 -0
  45. cloudfall/service_evidence.py +659 -0
  46. cloudfall/validation.py +1340 -0
  47. cloudfall-0.2.0.dist-info/METADATA +408 -0
  48. cloudfall-0.2.0.dist-info/RECORD +165 -0
  49. cloudfall-0.2.0.dist-info/WHEEL +4 -0
  50. cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
  51. cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
  52. cloudfall_engine/__init__.py +5 -0
  53. cloudfall_engine/__main__.py +5 -0
  54. cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
  55. cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
  56. cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
  57. cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
  58. cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
  59. cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
  60. cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
  61. cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
  62. cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
  63. cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
  64. cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
  65. cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
  66. cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
  67. cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
  68. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
  69. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
  70. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
  71. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
  72. cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
  73. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
  74. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
  75. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
  76. cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
  77. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
  78. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
  79. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
  80. cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
  81. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
  82. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
  83. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
  84. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
  85. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
  86. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
  87. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
  88. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
  89. cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
  90. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
  91. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
  92. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
  93. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
  94. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
  95. cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
  96. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
  97. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
  98. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
  99. cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
  100. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
  101. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
  102. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
  103. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
  104. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
  105. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
  106. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
  107. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
  108. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
  109. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
  110. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
  111. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
  112. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
  113. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
  114. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
  115. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
  116. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
  117. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
  118. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
  119. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
  120. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
  121. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
  122. cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
  123. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
  124. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
  125. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
  126. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
  127. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
  128. cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
  129. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
  130. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
  131. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
  132. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
  133. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
  134. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
  135. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
  136. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
  137. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
  138. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
  139. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
  140. cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
  141. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
  142. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
  143. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
  144. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
  145. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
  146. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
  147. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
  148. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
  149. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
  150. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
  151. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
  152. cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
  153. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
  154. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
  155. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
  156. cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
  157. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
  158. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
  159. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
  160. cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
  161. cloudfall_engine/ansible_inventory.py +253 -0
  162. cloudfall_engine/artifact.py +258 -0
  163. cloudfall_engine/cli.py +273 -0
  164. cloudfall_engine/playbook.py +175 -0
  165. cloudfall_engine/py.typed +0 -0
cloudfall/domain.py ADDED
@@ -0,0 +1,1157 @@
1
+ """Strict domain values used by the Cloudfall state reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import re
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from ipaddress import IPv4Address as StandardIPv4Address
11
+ from ipaddress import IPv6Network as StandardIPv6Network
12
+ from ipaddress import ip_address
13
+ from pathlib import PurePosixPath
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+
20
+ _RESOURCE_ID_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
21
+ _RESOURCE_ID_MAX_LENGTH = 63
22
+ _HOST_LABEL_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
23
+ _LINUX_USER_PATTERN = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
24
+ _PACKAGE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9+.-]{0,199}$")
25
+ _SERVICE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.service$")
26
+ _SYSTEMD_UNIT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.(?:service|timer)$")
27
+ _FILE_MODE_PATTERN = re.compile(r"^0[0-7]{3}$")
28
+ _SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
29
+ _MAX_HOSTNAME_LENGTH = 253
30
+ _MAX_TCP_PORT = 65535
31
+ _MIN_HTTP_STATUS = 100
32
+ _MAX_HTTP_STATUS = 599
33
+ _SSH_PUBLIC_KEY_ALGORITHMS = frozenset({"ssh-ed25519", "ssh-rsa"})
34
+ _SSH_PUBLIC_KEY_MIN_PARTS = 2
35
+ _SSH_WIRE_PREFIX_BYTES = 4
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ResourceId:
40
+ """Stable identifier for a platform resource."""
41
+
42
+ value: str
43
+
44
+ def __post_init__(self) -> None:
45
+ """Enforce the identifier invariant at construction time."""
46
+ if not _RESOURCE_ID_PATTERN.fullmatch(self.value):
47
+ message = f"invalid resource id: {self.value!r}"
48
+ raise ValueError(message)
49
+ if len(self.value) > _RESOURCE_ID_MAX_LENGTH:
50
+ message = f"resource id exceeds 63 characters: {self.value!r}"
51
+ raise ValueError(message)
52
+
53
+ @classmethod
54
+ def from_boundary(cls, value: object) -> ResourceId:
55
+ """Coerce a boundary value while keeping internal APIs strictly typed."""
56
+ if not isinstance(value, str):
57
+ message = f"resource id must be a string, got {type(value).__name__}"
58
+ raise TypeError(message)
59
+ return cls(value)
60
+
61
+ def __str__(self) -> str:
62
+ """Return the serialized identifier."""
63
+ return self.value
64
+
65
+
66
+ class ResourceKind(StrEnum):
67
+ """Resource kinds supported by the v1 state contract."""
68
+
69
+ SERVER = "Server"
70
+ HOST_PROFILE = "ServerType"
71
+ APPLICATION = "Application"
72
+ COMPONENT = "Component"
73
+ DOMAIN = "Domain"
74
+ SSH_PUBLIC_KEY = "SshPublicKey"
75
+ LOGGING_STACK = "LoggingStack"
76
+ SERVICE = "Service"
77
+ ALERT_RULE = "AlertRule"
78
+ OPERATOR_POLICY = "OperatorPolicy"
79
+
80
+ @classmethod
81
+ def from_boundary(cls, value: object) -> ResourceKind:
82
+ """Coerce a boundary value into a supported resource kind."""
83
+ if not isinstance(value, str):
84
+ message = f"resource kind must be a string, got {type(value).__name__}"
85
+ raise TypeError(message)
86
+ return cls(value)
87
+
88
+ @property
89
+ def directory(self) -> str:
90
+ """Return the project directory that holds resources of this kind."""
91
+ return _RESOURCE_DIRECTORIES[self]
92
+
93
+
94
+ _RESOURCE_DIRECTORIES: Mapping[ResourceKind, str] = {
95
+ ResourceKind.SSH_PUBLIC_KEY: "ssh-public-keys",
96
+ ResourceKind.HOST_PROFILE: "server-types",
97
+ ResourceKind.SERVER: "servers",
98
+ ResourceKind.APPLICATION: "applications",
99
+ ResourceKind.COMPONENT: "components",
100
+ ResourceKind.SERVICE: "services",
101
+ ResourceKind.DOMAIN: "domains",
102
+ ResourceKind.LOGGING_STACK: "logging-stacks",
103
+ ResourceKind.ALERT_RULE: "alert-rules",
104
+ ResourceKind.OPERATOR_POLICY: "operator-policies",
105
+ }
106
+
107
+
108
+ class SshPublicKeyLifecycle(StrEnum):
109
+ """Lifecycle of a declarative SSH public key."""
110
+
111
+ ACTIVE = "active"
112
+ REVOKED = "revoked"
113
+
114
+ @classmethod
115
+ def from_boundary(cls, value: object) -> SshPublicKeyLifecycle:
116
+ """Coerce a boundary value into an SSH-key lifecycle."""
117
+ return cls(_required_string(value, "SSH public-key lifecycle"))
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class OpenSshPublicKey:
122
+ """Validated, single-line OpenSSH public key."""
123
+
124
+ value: str
125
+
126
+ def __post_init__(self) -> None:
127
+ """Validate syntax, base64 encoding, and embedded algorithm."""
128
+ if "\n" in self.value or "\r" in self.value:
129
+ message = "OpenSSH public key must be a single line"
130
+ raise ValueError(message)
131
+ parts = self.value.split(maxsplit=2)
132
+ if len(parts) < _SSH_PUBLIC_KEY_MIN_PARTS:
133
+ message = "OpenSSH public key must include algorithm and key data"
134
+ raise ValueError(message)
135
+ algorithm, encoded = parts[0], parts[1]
136
+ if algorithm not in _SSH_PUBLIC_KEY_ALGORITHMS:
137
+ message = f"unsupported OpenSSH public-key algorithm: {algorithm!r}"
138
+ raise ValueError(message)
139
+ padded = encoded + "=" * (-len(encoded) % 4)
140
+ try:
141
+ decoded = base64.b64decode(padded, validate=True)
142
+ except binascii.Error as error:
143
+ message = "OpenSSH public-key data is not valid base64"
144
+ raise ValueError(message) from error
145
+ if len(decoded) < _SSH_WIRE_PREFIX_BYTES:
146
+ message = "OpenSSH public-key payload is truncated"
147
+ raise ValueError(message)
148
+ algorithm_length = int.from_bytes(
149
+ decoded[:_SSH_WIRE_PREFIX_BYTES], byteorder="big"
150
+ )
151
+ algorithm_end = _SSH_WIRE_PREFIX_BYTES + algorithm_length
152
+ try:
153
+ embedded_algorithm = decoded[_SSH_WIRE_PREFIX_BYTES:algorithm_end].decode(
154
+ "ascii"
155
+ )
156
+ except UnicodeDecodeError as error:
157
+ message = "OpenSSH public-key payload has an invalid algorithm"
158
+ raise ValueError(message) from error
159
+ if algorithm_end > len(decoded) or embedded_algorithm != algorithm:
160
+ message = "OpenSSH public-key algorithm does not match its payload"
161
+ raise ValueError(message)
162
+
163
+ @classmethod
164
+ def from_boundary(cls, value: object) -> OpenSshPublicKey:
165
+ """Coerce a boundary value into an OpenSSH public key."""
166
+ return cls(_required_string(value, "OpenSSH public key"))
167
+
168
+ @property
169
+ def algorithm(self) -> str:
170
+ """Return the validated OpenSSH algorithm name."""
171
+ return self.value.split(maxsplit=1)[0]
172
+
173
+
174
+ @dataclass(frozen=True, slots=True)
175
+ class Hostname:
176
+ """Validated DNS hostname."""
177
+
178
+ value: str
179
+
180
+ def __post_init__(self) -> None:
181
+ """Enforce DNS hostname syntax."""
182
+ candidate = self.value.removesuffix(".")
183
+ labels = candidate.split(".")
184
+ if (
185
+ not candidate
186
+ or len(candidate) > _MAX_HOSTNAME_LENGTH
187
+ or any(not _HOST_LABEL_PATTERN.fullmatch(label) for label in labels)
188
+ ):
189
+ message = f"invalid hostname: {self.value!r}"
190
+ raise ValueError(message)
191
+
192
+ @classmethod
193
+ def from_boundary(cls, value: object) -> Hostname:
194
+ """Coerce a boundary value into a hostname."""
195
+ return cls(_required_string(value, "hostname"))
196
+
197
+
198
+ @dataclass(frozen=True, slots=True)
199
+ class ConnectionAddress:
200
+ """Validated hostname or IP address used to connect to a server."""
201
+
202
+ value: str
203
+
204
+ def __post_init__(self) -> None:
205
+ """Enforce hostname-or-IP syntax."""
206
+ try:
207
+ ip_address(self.value)
208
+ except ValueError:
209
+ Hostname(self.value)
210
+
211
+ @classmethod
212
+ def from_boundary(cls, value: object) -> ConnectionAddress:
213
+ """Coerce a boundary value into a connection address."""
214
+ return cls(_required_string(value, "connection address"))
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class Ipv4Address:
219
+ """Validated IPv4 address assigned to a server."""
220
+
221
+ value: str
222
+
223
+ def __post_init__(self) -> None:
224
+ """Enforce canonical IPv4 syntax."""
225
+ parsed = StandardIPv4Address(self.value)
226
+ if str(parsed) != self.value:
227
+ message = f"IPv4 address is not canonical: {self.value!r}"
228
+ raise ValueError(message)
229
+
230
+ @classmethod
231
+ def from_boundary(cls, value: object) -> Ipv4Address:
232
+ """Coerce a boundary value into an IPv4 address."""
233
+ return cls(_required_string(value, "IPv4 address"))
234
+
235
+
236
+ @dataclass(frozen=True, slots=True)
237
+ class IpAddress:
238
+ """Validated canonical IPv4 or IPv6 address."""
239
+
240
+ value: str
241
+
242
+ def __post_init__(self) -> None:
243
+ """Enforce canonical IP address notation."""
244
+ parsed = ip_address(self.value)
245
+ if str(parsed) != self.value:
246
+ message = f"IP address is not canonical: {self.value!r}"
247
+ raise ValueError(message)
248
+
249
+ @classmethod
250
+ def from_boundary(cls, value: object) -> IpAddress:
251
+ """Coerce a boundary value into an IP address."""
252
+ return cls(_required_string(value, "IP address"))
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class Ipv6NetworkCidr:
257
+ """Validated canonical IPv6 network allocation."""
258
+
259
+ value: str
260
+
261
+ def __post_init__(self) -> None:
262
+ """Enforce a canonical IPv6 network and prefix."""
263
+ parsed = StandardIPv6Network(self.value, strict=True)
264
+ if str(parsed) != self.value:
265
+ message = f"IPv6 network is not canonical: {self.value!r}"
266
+ raise ValueError(message)
267
+
268
+ @classmethod
269
+ def from_boundary(cls, value: object) -> Ipv6NetworkCidr:
270
+ """Coerce a boundary value into an IPv6 network allocation."""
271
+ return cls(_required_string(value, "IPv6 network"))
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class ProviderServerId:
276
+ """Opaque server identifier assigned by an infrastructure provider."""
277
+
278
+ value: str
279
+
280
+ def __post_init__(self) -> None:
281
+ """Reject empty and unsafe provider identifiers."""
282
+ if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", self.value):
283
+ message = f"invalid provider server id: {self.value!r}"
284
+ raise ValueError(message)
285
+
286
+ @classmethod
287
+ def from_boundary(cls, value: object) -> ProviderServerId:
288
+ """Coerce a boundary value into a provider server identifier."""
289
+ return cls(_required_string(value, "provider server id"))
290
+
291
+
292
+ @dataclass(frozen=True, slots=True)
293
+ class DatacenterCode:
294
+ """Validated provider datacenter code."""
295
+
296
+ value: str
297
+
298
+ def __post_init__(self) -> None:
299
+ """Enforce a short provider location-code syntax."""
300
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", self.value):
301
+ message = f"invalid datacenter code: {self.value!r}"
302
+ raise ValueError(message)
303
+
304
+ @classmethod
305
+ def from_boundary(cls, value: object) -> DatacenterCode:
306
+ """Coerce a boundary value into a datacenter code."""
307
+ return cls(_required_string(value, "datacenter code"))
308
+
309
+
310
+ @dataclass(frozen=True, slots=True)
311
+ class LinuxUser:
312
+ """Validated Linux account name."""
313
+
314
+ value: str
315
+
316
+ def __post_init__(self) -> None:
317
+ """Enforce Linux account naming rules used by Cloudfall."""
318
+ if not _LINUX_USER_PATTERN.fullmatch(self.value):
319
+ message = f"invalid Linux user: {self.value!r}"
320
+ raise ValueError(message)
321
+
322
+ @classmethod
323
+ def from_boundary(cls, value: object) -> LinuxUser:
324
+ """Coerce a boundary value into a Linux account name."""
325
+ return cls(_required_string(value, "Linux user"))
326
+
327
+
328
+ @dataclass(frozen=True, slots=True)
329
+ class TcpPort:
330
+ """Validated TCP port."""
331
+
332
+ value: int
333
+
334
+ def __post_init__(self) -> None:
335
+ """Enforce the usable TCP port range."""
336
+ if isinstance(self.value, bool) or not 1 <= self.value <= _MAX_TCP_PORT:
337
+ message = f"invalid TCP port: {self.value!r}"
338
+ raise ValueError(message)
339
+
340
+ @classmethod
341
+ def from_boundary(cls, value: object) -> TcpPort:
342
+ """Coerce a boundary value into a TCP port."""
343
+ if isinstance(value, bool) or not isinstance(value, int):
344
+ message = f"TCP port must be an integer, got {type(value).__name__}"
345
+ raise TypeError(message)
346
+ return cls(value)
347
+
348
+
349
+ @dataclass(frozen=True, slots=True)
350
+ class AbsolutePath:
351
+ """Validated absolute POSIX path."""
352
+
353
+ value: str
354
+
355
+ def __post_init__(self) -> None:
356
+ """Reject relative, parent-traversing, and NUL-containing paths."""
357
+ path = PurePosixPath(self.value)
358
+ if (
359
+ not self.value.startswith("/")
360
+ or "\x00" in self.value
361
+ or "//" in self.value
362
+ or ".." in path.parts
363
+ ):
364
+ message = f"invalid absolute path: {self.value!r}"
365
+ raise ValueError(message)
366
+
367
+ @classmethod
368
+ def from_boundary(cls, value: object) -> AbsolutePath:
369
+ """Coerce a boundary value into an absolute path."""
370
+ return cls(_required_string(value, "absolute path"))
371
+
372
+
373
+ class ServerLifecycle(StrEnum):
374
+ """Operational lifecycle of a managed server."""
375
+
376
+ ACTIVE = "active"
377
+ MAINTENANCE = "maintenance"
378
+ RETIRED = "retired"
379
+
380
+ @classmethod
381
+ def from_boundary(cls, value: object) -> ServerLifecycle:
382
+ """Coerce a boundary value into a server lifecycle."""
383
+ return cls(_required_string(value, "server lifecycle"))
384
+
385
+
386
+ class DeploymentApproval(StrEnum):
387
+ """Deployment approval policy owned by an application."""
388
+
389
+ AUTOMATIC = "automatic"
390
+ MANUAL = "manual"
391
+
392
+ @classmethod
393
+ def from_boundary(cls, value: object) -> DeploymentApproval:
394
+ """Coerce a boundary value into an approval policy."""
395
+ return cls(_required_string(value, "deployment approval"))
396
+
397
+
398
+ class HttpScheme(StrEnum):
399
+ """Supported HTTP transport schemes."""
400
+
401
+ HTTP = "http"
402
+ HTTPS = "https"
403
+
404
+ @classmethod
405
+ def from_boundary(cls, value: object) -> HttpScheme:
406
+ """Coerce a boundary value into an HTTP scheme."""
407
+ return cls(_required_string(value, "HTTP scheme"))
408
+
409
+
410
+ class DnsMode(StrEnum):
411
+ """How public DNS exposes an Cloudfall domain."""
412
+
413
+ DNS_ONLY = "dns-only"
414
+ PROXIED = "proxied"
415
+
416
+ @classmethod
417
+ def from_boundary(cls, value: object) -> DnsMode:
418
+ """Coerce a boundary value into a DNS exposure mode."""
419
+ return cls(_required_string(value, "DNS mode"))
420
+
421
+
422
+ class TlsMode(StrEnum):
423
+ """Desired TLS behavior for a public domain."""
424
+
425
+ DISABLED = "disabled"
426
+ REQUIRED = "required"
427
+
428
+ @classmethod
429
+ def from_boundary(cls, value: object) -> TlsMode:
430
+ """Coerce a boundary value into a TLS mode."""
431
+ return cls(_required_string(value, "TLS mode"))
432
+
433
+
434
+ class LoggingMigrationMode(StrEnum):
435
+ """Supported transition mode for the replacement logging path."""
436
+
437
+ PARALLEL = "parallel"
438
+
439
+ @classmethod
440
+ def from_boundary(cls, value: object) -> LoggingMigrationMode:
441
+ """Coerce a boundary value into a logging migration mode."""
442
+ return cls(_required_string(value, "logging migration mode"))
443
+
444
+
445
+ class LoggingStorageType(StrEnum):
446
+ """Storage backend supported by the first logging implementation slice."""
447
+
448
+ FILESYSTEM = "filesystem"
449
+
450
+ @classmethod
451
+ def from_boundary(cls, value: object) -> LoggingStorageType:
452
+ """Coerce a boundary value into a logging storage type."""
453
+ return cls(_required_string(value, "logging storage type"))
454
+
455
+
456
+ @dataclass(frozen=True, slots=True)
457
+ class HttpStatusCode:
458
+ """Validated HTTP response status code."""
459
+
460
+ value: int
461
+
462
+ def __post_init__(self) -> None:
463
+ """Require the standard three-digit HTTP status range."""
464
+ if (
465
+ isinstance(self.value, bool)
466
+ or not _MIN_HTTP_STATUS <= self.value <= _MAX_HTTP_STATUS
467
+ ):
468
+ message = f"invalid HTTP status code: {self.value!r}"
469
+ raise ValueError(message)
470
+
471
+ @classmethod
472
+ def from_boundary(cls, value: object) -> HttpStatusCode:
473
+ """Coerce a boundary value into an HTTP status code."""
474
+ if isinstance(value, bool) or not isinstance(value, int):
475
+ message = f"HTTP status must be an integer, got {type(value).__name__}"
476
+ raise TypeError(message)
477
+ return cls(value)
478
+
479
+
480
+ @dataclass(frozen=True, slots=True)
481
+ class PackageName:
482
+ """Validated Debian package name."""
483
+
484
+ value: str
485
+
486
+ def __post_init__(self) -> None:
487
+ """Enforce Debian package naming syntax used by Cloudfall."""
488
+ if not _PACKAGE_NAME_PATTERN.fullmatch(self.value):
489
+ message = f"invalid package name: {self.value!r}"
490
+ raise ValueError(message)
491
+
492
+ @classmethod
493
+ def from_boundary(cls, value: object) -> PackageName:
494
+ """Coerce a boundary value into a package name."""
495
+ return cls(_required_string(value, "package name"))
496
+
497
+
498
+ @dataclass(frozen=True, slots=True)
499
+ class ServiceName:
500
+ """Validated systemd service-unit name."""
501
+
502
+ value: str
503
+
504
+ def __post_init__(self) -> None:
505
+ """Enforce the service-unit naming syntax accepted by state."""
506
+ if not _SERVICE_NAME_PATTERN.fullmatch(self.value):
507
+ message = f"invalid service name: {self.value!r}"
508
+ raise ValueError(message)
509
+
510
+ @classmethod
511
+ def from_boundary(cls, value: object) -> ServiceName:
512
+ """Coerce a boundary value into a service name."""
513
+ return cls(_required_string(value, "service name"))
514
+
515
+
516
+ @dataclass(frozen=True, slots=True)
517
+ class SystemdUnitName:
518
+ """Validated systemd service- or timer-unit name."""
519
+
520
+ value: str
521
+
522
+ def __post_init__(self) -> None:
523
+ """Enforce the unit naming syntax accepted by state."""
524
+ if not _SYSTEMD_UNIT_NAME_PATTERN.fullmatch(self.value):
525
+ message = f"invalid systemd unit name: {self.value!r}"
526
+ raise ValueError(message)
527
+
528
+ @classmethod
529
+ def from_boundary(cls, value: object) -> SystemdUnitName:
530
+ """Coerce a boundary value into a systemd unit name."""
531
+ return cls(_required_string(value, "systemd unit name"))
532
+
533
+ @property
534
+ def is_timer(self) -> bool:
535
+ """Return whether the unit is a timer."""
536
+ return self.value.endswith(".timer")
537
+
538
+
539
+ class NetworkProtocol(StrEnum):
540
+ """Transport protocols supported by firewall rules."""
541
+
542
+ TCP = "tcp"
543
+ UDP = "udp"
544
+
545
+ @classmethod
546
+ def from_boundary(cls, value: object) -> NetworkProtocol:
547
+ """Coerce a boundary value into a network protocol."""
548
+ return cls(_required_string(value, "network protocol"))
549
+
550
+
551
+ class FirewallPolicy(StrEnum):
552
+ """Inbound firewall policies supported by Cloudfall v1."""
553
+
554
+ DEFAULT_DENY = "default-deny"
555
+
556
+ @classmethod
557
+ def from_boundary(cls, value: object) -> FirewallPolicy:
558
+ """Coerce a boundary value into a firewall policy."""
559
+ return cls(_required_string(value, "firewall policy"))
560
+
561
+
562
+ class ServiceKind(StrEnum):
563
+ """Infrastructure service kinds supported by Cloudfall v1."""
564
+
565
+ POSTGRESQL = "postgresql"
566
+ REDIS = "redis"
567
+
568
+ @classmethod
569
+ def from_boundary(cls, value: object) -> ServiceKind:
570
+ """Coerce a boundary value into a service kind."""
571
+ return cls(_required_string(value, "service kind"))
572
+
573
+
574
+ class HealthCheckType(StrEnum):
575
+ """Component health-check kinds supported by the v1 contract."""
576
+
577
+ HTTP = "http"
578
+ NONE = "none"
579
+
580
+ @classmethod
581
+ def from_boundary(cls, value: object) -> HealthCheckType:
582
+ """Coerce a boundary value into a health-check kind."""
583
+ return cls(_required_string(value, "health check type"))
584
+
585
+
586
+ class RuntimeType(StrEnum):
587
+ """Component runtimes supported by the v1 contract."""
588
+
589
+ PYTHON = "python"
590
+ NODE = "node"
591
+ PM2 = "pm2"
592
+
593
+ @classmethod
594
+ def from_boundary(cls, value: object) -> RuntimeType:
595
+ """Coerce a boundary value into a runtime type."""
596
+ return cls(_required_string(value, "runtime type"))
597
+
598
+
599
+ class RuntimePackageManager(StrEnum):
600
+ """Package managers supported by the v1 contract."""
601
+
602
+ UV = "uv"
603
+ NPM = "npm"
604
+ PNPM = "pnpm"
605
+ YARN = "yarn"
606
+
607
+ @classmethod
608
+ def from_boundary(cls, value: object) -> RuntimePackageManager:
609
+ """Coerce a boundary value into a package manager."""
610
+ return cls(_required_string(value, "runtime package manager"))
611
+
612
+
613
+ @dataclass(frozen=True, slots=True)
614
+ class RuntimeVersion:
615
+ """Opaque runtime version requested by a component."""
616
+
617
+ value: str
618
+
619
+ def __post_init__(self) -> None:
620
+ """Reject empty and multi-line versions."""
621
+ if not self.value or "\n" in self.value or "\r" in self.value:
622
+ message = f"invalid runtime version: {self.value!r}"
623
+ raise ValueError(message)
624
+
625
+ @classmethod
626
+ def from_boundary(cls, value: object) -> RuntimeVersion:
627
+ """Coerce a boundary value into a runtime version."""
628
+ return cls(_required_string(value, "runtime version"))
629
+
630
+
631
+ @dataclass(frozen=True, slots=True)
632
+ class RepositoryUrl:
633
+ """Validated HTTPS, SSH, or local-file git repository URL."""
634
+
635
+ value: str
636
+
637
+ def __post_init__(self) -> None:
638
+ """Enforce the transport allowlist and reject control characters."""
639
+ if not re.fullmatch(r"(?:https|ssh|file)://[^\s\x00]+", self.value):
640
+ message = f"invalid repository URL: {self.value!r}"
641
+ raise ValueError(message)
642
+
643
+ @classmethod
644
+ def from_boundary(cls, value: object) -> RepositoryUrl:
645
+ """Coerce a boundary value into a repository URL."""
646
+ return cls(_required_string(value, "repository URL"))
647
+
648
+
649
+ @dataclass(frozen=True, slots=True)
650
+ class ServiceCommand:
651
+ """Validated argv-style service command."""
652
+
653
+ value: tuple[str, ...]
654
+
655
+ def __post_init__(self) -> None:
656
+ """Reject empty commands and unit-file control characters."""
657
+ if not self.value:
658
+ message = "service command must not be empty"
659
+ raise ValueError(message)
660
+ for argument in self.value:
661
+ if not argument or any(
662
+ character in argument for character in ("\n", "\r", "\x00", '"')
663
+ ):
664
+ message = f"invalid service command argument: {argument!r}"
665
+ raise ValueError(message)
666
+
667
+ @classmethod
668
+ def from_boundary(cls, value: object) -> ServiceCommand:
669
+ """Coerce a boundary value into a service command."""
670
+ if not isinstance(value, list) or not all(
671
+ isinstance(item, str) for item in value
672
+ ):
673
+ message = "service command must be a list of strings"
674
+ raise TypeError(message)
675
+ return cls(tuple(value))
676
+
677
+
678
+ @dataclass(frozen=True, slots=True)
679
+ class PostgresDatabaseName:
680
+ """Validated PostgreSQL database name safe for quoted identifiers."""
681
+
682
+ value: str
683
+
684
+ def __post_init__(self) -> None:
685
+ """Enforce the conservative database naming accepted by state."""
686
+ if not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", self.value):
687
+ message = f"invalid PostgreSQL database name: {self.value!r}"
688
+ raise ValueError(message)
689
+
690
+ @classmethod
691
+ def from_boundary(cls, value: object) -> PostgresDatabaseName:
692
+ """Coerce a boundary value into a database name."""
693
+ return cls(_required_string(value, "PostgreSQL database name"))
694
+
695
+
696
+ @dataclass(frozen=True, slots=True)
697
+ class ReleaseId:
698
+ """Validated component release identifier."""
699
+
700
+ value: str
701
+
702
+ def __post_init__(self) -> None:
703
+ """Enforce the timestamp-and-commit release naming contract."""
704
+ if not re.fullmatch(
705
+ r"[0-9]{8}T[0-9]{6}Z-[0-9a-f]{7,40}", self.value
706
+ ):
707
+ message = f"invalid release id: {self.value!r}"
708
+ raise ValueError(message)
709
+
710
+ @classmethod
711
+ def from_boundary(cls, value: object) -> ReleaseId:
712
+ """Coerce a boundary value into a release identifier."""
713
+ return cls(_required_string(value, "release id"))
714
+
715
+
716
+ @dataclass(frozen=True, slots=True)
717
+ class PostgresMajorVersion:
718
+ """Validated PostgreSQL major version."""
719
+
720
+ value: str
721
+
722
+ def __post_init__(self) -> None:
723
+ """Reject non-numeric major versions."""
724
+ if not re.fullmatch(r"[0-9]{2}", self.value):
725
+ message = f"invalid PostgreSQL major version: {self.value!r}"
726
+ raise ValueError(message)
727
+
728
+ @classmethod
729
+ def from_boundary(cls, value: object) -> PostgresMajorVersion:
730
+ """Coerce a boundary value into a PostgreSQL major version."""
731
+ return cls(_required_string(value, "PostgreSQL major version"))
732
+
733
+
734
+ @dataclass(frozen=True, slots=True)
735
+ class SystemdCalendar:
736
+ """Validated systemd OnCalendar expression safe for unit rendering."""
737
+
738
+ value: str
739
+
740
+ def __post_init__(self) -> None:
741
+ """Reject expressions that could escape a unit-file directive."""
742
+ if not re.fullmatch(r"[A-Za-z0-9 *:,.^~/-]{1,100}", self.value):
743
+ message = f"invalid systemd calendar expression: {self.value!r}"
744
+ raise ValueError(message)
745
+
746
+ @classmethod
747
+ def from_boundary(cls, value: object) -> SystemdCalendar:
748
+ """Coerce a boundary value into a calendar expression."""
749
+ return cls(_required_string(value, "systemd calendar expression"))
750
+
751
+
752
+ class SecretScope(StrEnum):
753
+ """Ownership scope of one declared secret reference."""
754
+
755
+ PLATFORM = "platform"
756
+ APPLICATION = "application"
757
+ COMPONENT = "component"
758
+
759
+ @classmethod
760
+ def from_boundary(cls, value: object) -> SecretScope:
761
+ """Coerce a boundary value into a secret scope."""
762
+ return cls(_required_string(value, "secret scope"))
763
+
764
+
765
+ class AlertSeverity(StrEnum):
766
+ """Severity level attached to a declared alert rule."""
767
+
768
+ WARNING = "warning"
769
+ CRITICAL = "critical"
770
+
771
+ @classmethod
772
+ def from_boundary(cls, value: object) -> AlertSeverity:
773
+ """Coerce a boundary value into an alert severity."""
774
+ return cls(_required_string(value, "alert severity"))
775
+
776
+
777
+ @dataclass(frozen=True, slots=True)
778
+ class PromqlExpression:
779
+ """Validated single-line PromQL expression safe for rule rendering."""
780
+
781
+ value: str
782
+
783
+ def __post_init__(self) -> None:
784
+ """Reject control characters and unbounded expressions."""
785
+ if not re.fullmatch(r"[^\x00-\x1f\x7f]{1,1000}", self.value):
786
+ message = f"invalid promql expression: {self.value!r}"
787
+ raise ValueError(message)
788
+
789
+ @classmethod
790
+ def from_boundary(cls, value: object) -> PromqlExpression:
791
+ """Coerce a boundary value into a PromQL expression."""
792
+ return cls(_required_string(value, "promql expression"))
793
+
794
+
795
+ @dataclass(frozen=True, slots=True)
796
+ class AlertDuration:
797
+ """Validated Prometheus for-duration guarding alert flappiness."""
798
+
799
+ value: str
800
+
801
+ def __post_init__(self) -> None:
802
+ """Reject durations outside the simple seconds/minutes/hours form."""
803
+ if not re.fullmatch(r"[0-9]{1,5}[smh]", self.value):
804
+ message = f"invalid alert duration: {self.value!r}"
805
+ raise ValueError(message)
806
+
807
+ @classmethod
808
+ def from_boundary(cls, value: object) -> AlertDuration:
809
+ """Coerce a boundary value into an alert duration."""
810
+ return cls(_required_string(value, "alert duration"))
811
+
812
+
813
+ @dataclass(frozen=True, slots=True)
814
+ class AlertSummary:
815
+ """Validated single-line human summary rendered into annotations."""
816
+
817
+ value: str
818
+
819
+ def __post_init__(self) -> None:
820
+ """Reject control characters and unbounded summaries."""
821
+ if not re.fullmatch(r"[^\x00-\x1f\x7f]{1,200}", self.value):
822
+ message = f"invalid alert summary: {self.value!r}"
823
+ raise ValueError(message)
824
+
825
+ @classmethod
826
+ def from_boundary(cls, value: object) -> AlertSummary:
827
+ """Coerce a boundary value into an alert summary."""
828
+ return cls(_required_string(value, "alert summary"))
829
+
830
+
831
+ @dataclass(frozen=True, slots=True)
832
+ class DayTime:
833
+ """Validated HH:MM wall-clock time for policy windows."""
834
+
835
+ value: str
836
+
837
+ def __post_init__(self) -> None:
838
+ """Reject values outside the strict 24-hour HH:MM form."""
839
+ if not re.fullmatch(r"([01][0-9]|2[0-3]):[0-5][0-9]", self.value):
840
+ message = f"invalid day time: {self.value!r}"
841
+ raise ValueError(message)
842
+
843
+ @property
844
+ def minutes(self) -> int:
845
+ """Return the minutes since midnight this time represents."""
846
+ hours, minutes = self.value.split(":")
847
+ return int(hours) * 60 + int(minutes)
848
+
849
+ @classmethod
850
+ def from_boundary(cls, value: object) -> DayTime:
851
+ """Coerce a boundary value into a wall-clock time."""
852
+ return cls(_required_string(value, "day time"))
853
+
854
+
855
+ @dataclass(frozen=True, slots=True)
856
+ class WebhookUrl:
857
+ """Validated HTTP(S) webhook destination for alert delivery."""
858
+
859
+ value: str
860
+
861
+ def __post_init__(self) -> None:
862
+ """Reject non-HTTP schemes, spaces, and control characters."""
863
+ if not re.fullmatch(
864
+ r"https?://[^\x00-\x1f\x7f ]{1,490}", self.value
865
+ ):
866
+ message = f"invalid webhook url: {self.value!r}"
867
+ raise ValueError(message)
868
+
869
+ @classmethod
870
+ def from_boundary(cls, value: object) -> WebhookUrl:
871
+ """Coerce a boundary value into a webhook URL."""
872
+ return cls(_required_string(value, "webhook url"))
873
+
874
+
875
+ @dataclass(frozen=True, slots=True)
876
+ class SmtpSmarthost:
877
+ """Validated host:port SMTP relay address."""
878
+
879
+ value: str
880
+
881
+ def __post_init__(self) -> None:
882
+ """Reject smarthosts outside the strict host:port form."""
883
+ if not re.fullmatch(r"[a-z0-9.-]{1,253}:[0-9]{1,5}", self.value):
884
+ message = f"invalid smtp smarthost: {self.value!r}"
885
+ raise ValueError(message)
886
+
887
+ @classmethod
888
+ def from_boundary(cls, value: object) -> SmtpSmarthost:
889
+ """Coerce a boundary value into an SMTP smarthost."""
890
+ return cls(_required_string(value, "smtp smarthost"))
891
+
892
+
893
+ @dataclass(frozen=True, slots=True)
894
+ class EmailAddress:
895
+ """Validated single email address for alert delivery."""
896
+
897
+ value: str
898
+
899
+ def __post_init__(self) -> None:
900
+ """Reject addresses without a local part and domain."""
901
+ if not re.fullmatch(r"[^@\s]{1,100}@[^@\s]{1,100}", self.value):
902
+ message = f"invalid email address: {self.value!r}"
903
+ raise ValueError(message)
904
+
905
+ @classmethod
906
+ def from_boundary(cls, value: object) -> EmailAddress:
907
+ """Coerce a boundary value into an email address."""
908
+ return cls(_required_string(value, "email address"))
909
+
910
+
911
+ @dataclass(frozen=True, slots=True)
912
+ class SmtpUsername:
913
+ """Validated SMTP authentication username."""
914
+
915
+ value: str
916
+
917
+ def __post_init__(self) -> None:
918
+ """Reject control characters and unbounded usernames."""
919
+ if not re.fullmatch(r"[^\x00-\x1f\x7f]{1,200}", self.value):
920
+ message = f"invalid smtp username: {self.value!r}"
921
+ raise ValueError(message)
922
+
923
+ @classmethod
924
+ def from_boundary(cls, value: object) -> SmtpUsername:
925
+ """Coerce a boundary value into an SMTP username."""
926
+ return cls(_required_string(value, "smtp username"))
927
+
928
+
929
+ @dataclass(frozen=True, slots=True)
930
+ class FileMode:
931
+ """Validated four-digit POSIX file mode."""
932
+
933
+ value: str
934
+
935
+ def __post_init__(self) -> None:
936
+ """Enforce an explicit octal file mode."""
937
+ if not _FILE_MODE_PATTERN.fullmatch(self.value):
938
+ message = f"invalid file mode: {self.value!r}"
939
+ raise ValueError(message)
940
+
941
+ @classmethod
942
+ def from_boundary(cls, value: object) -> FileMode:
943
+ """Coerce a boundary value into a file mode."""
944
+ return cls(_required_string(value, "file mode"))
945
+
946
+
947
+ @dataclass(frozen=True, slots=True)
948
+ class Sha256Digest:
949
+ """Validated lowercase SHA-256 digest."""
950
+
951
+ value: str
952
+
953
+ def __post_init__(self) -> None:
954
+ """Enforce SHA-256 hexadecimal length and alphabet."""
955
+ if not _SHA256_PATTERN.fullmatch(self.value):
956
+ message = f"invalid SHA-256 digest: {self.value!r}"
957
+ raise ValueError(message)
958
+
959
+ @classmethod
960
+ def from_boundary(cls, value: object) -> Sha256Digest:
961
+ """Coerce a boundary value into a SHA-256 digest."""
962
+ return cls(_required_string(value, "SHA-256 digest"))
963
+
964
+
965
+ @dataclass(frozen=True, slots=True)
966
+ class ByteSize:
967
+ """Strictly positive byte-size requirement."""
968
+
969
+ value: int
970
+
971
+ def __post_init__(self) -> None:
972
+ """Reject booleans, zero, and negative sizes."""
973
+ if isinstance(self.value, bool) or self.value < 1:
974
+ message = f"invalid positive byte size: {self.value!r}"
975
+ raise ValueError(message)
976
+
977
+ @classmethod
978
+ def from_boundary(cls, value: object) -> ByteSize:
979
+ """Coerce a boundary value into a positive byte size."""
980
+ if isinstance(value, bool) or not isinstance(value, int):
981
+ message = f"byte size must be an integer, got {type(value).__name__}"
982
+ raise TypeError(message)
983
+ return cls(value)
984
+
985
+
986
+ @dataclass(frozen=True, slots=True)
987
+ class PositiveCount:
988
+ """Strictly positive count value."""
989
+
990
+ value: int
991
+
992
+ def __post_init__(self) -> None:
993
+ """Reject booleans, zero, and negative counts."""
994
+ if isinstance(self.value, bool) or self.value < 1:
995
+ message = f"invalid positive count: {self.value!r}"
996
+ raise ValueError(message)
997
+
998
+ @classmethod
999
+ def from_boundary(cls, value: object) -> PositiveCount:
1000
+ """Coerce a boundary value into a positive count."""
1001
+ if isinstance(value, bool) or not isinstance(value, int):
1002
+ message = f"count must be an integer, got {type(value).__name__}"
1003
+ raise TypeError(message)
1004
+ return cls(value)
1005
+
1006
+
1007
+ class RaidLevel(StrEnum):
1008
+ """Supported Linux software RAID levels."""
1009
+
1010
+ RAID0 = "raid0"
1011
+ RAID1 = "raid1"
1012
+ RAID5 = "raid5"
1013
+ RAID6 = "raid6"
1014
+ RAID10 = "raid10"
1015
+
1016
+ @classmethod
1017
+ def from_boundary(cls, value: object) -> RaidLevel:
1018
+ """Coerce a boundary value into a RAID level."""
1019
+ return cls(_required_string(value, "RAID level"))
1020
+
1021
+
1022
+ class ConfigCapture(StrEnum):
1023
+ """Permitted non-secret configuration evidence levels."""
1024
+
1025
+ METADATA = "metadata"
1026
+ HASH = "hash"
1027
+
1028
+ @classmethod
1029
+ def from_boundary(cls, value: object) -> ConfigCapture:
1030
+ """Coerce a boundary value into a capture level."""
1031
+ return cls(_required_string(value, "configuration capture"))
1032
+
1033
+
1034
+ class RequiredServiceState(StrEnum):
1035
+ """Desired runtime state for a systemd service."""
1036
+
1037
+ RUNNING = "running"
1038
+ STOPPED = "stopped"
1039
+
1040
+ @classmethod
1041
+ def from_boundary(cls, value: object) -> RequiredServiceState:
1042
+ """Coerce a boundary value into a service state."""
1043
+ return cls(_required_string(value, "service state"))
1044
+
1045
+
1046
+ class RequiredServiceStatus(StrEnum):
1047
+ """Desired enablement status for a systemd service."""
1048
+
1049
+ ENABLED = "enabled"
1050
+ DISABLED = "disabled"
1051
+ STATIC = "static"
1052
+ INDIRECT = "indirect"
1053
+
1054
+ @classmethod
1055
+ def from_boundary(cls, value: object) -> RequiredServiceStatus:
1056
+ """Coerce a boundary value into a service status."""
1057
+ return cls(_required_string(value, "service status"))
1058
+
1059
+
1060
+ class OperatingSystemDistribution(StrEnum):
1061
+ """Operating-system distributions supported by Cloudfall v1."""
1062
+
1063
+ DEBIAN = "Debian"
1064
+
1065
+ @classmethod
1066
+ def from_boundary(cls, value: object) -> OperatingSystemDistribution:
1067
+ """Coerce a boundary value into a supported distribution."""
1068
+ return cls(_required_string(value, "operating-system distribution"))
1069
+
1070
+
1071
+ class ServiceManager(StrEnum):
1072
+ """Service managers supported by Cloudfall v1."""
1073
+
1074
+ SYSTEMD = "systemd"
1075
+
1076
+ @classmethod
1077
+ def from_boundary(cls, value: object) -> ServiceManager:
1078
+ """Coerce a boundary value into a supported service manager."""
1079
+ return cls(_required_string(value, "service manager"))
1080
+
1081
+
1082
+ @dataclass(frozen=True, slots=True)
1083
+ class OperatingSystemMajorVersion:
1084
+ """Validated numeric operating-system major version."""
1085
+
1086
+ value: str
1087
+
1088
+ def __post_init__(self) -> None:
1089
+ """Reject non-numeric major versions."""
1090
+ if not self.value.isdigit():
1091
+ message = f"invalid operating-system major version: {self.value!r}"
1092
+ raise ValueError(message)
1093
+
1094
+ @classmethod
1095
+ def from_boundary(cls, value: object) -> OperatingSystemMajorVersion:
1096
+ """Coerce a boundary value into an OS major version."""
1097
+ return cls(_required_string(value, "operating-system major version"))
1098
+
1099
+
1100
+ @dataclass(frozen=True, slots=True)
1101
+ class FilesystemName:
1102
+ """Validated filesystem type name."""
1103
+
1104
+ value: str
1105
+
1106
+ @classmethod
1107
+ def from_boundary(cls, value: object) -> FilesystemName:
1108
+ """Coerce a boundary value into a filesystem name."""
1109
+ return cls(_required_string(value, "filesystem name"))
1110
+
1111
+
1112
+ @dataclass(frozen=True, slots=True)
1113
+ class PackageVersion:
1114
+ """Opaque package-manager version string."""
1115
+
1116
+ value: str
1117
+
1118
+ @classmethod
1119
+ def from_boundary(cls, value: object) -> PackageVersion:
1120
+ """Coerce a boundary value into a package version."""
1121
+ return cls(_required_string(value, "package version"))
1122
+
1123
+
1124
+ @dataclass(frozen=True, slots=True)
1125
+ class ResourceKey:
1126
+ """Typed lookup key for the state index."""
1127
+
1128
+ kind: ResourceKind
1129
+ resource_id: ResourceId
1130
+
1131
+
1132
+ @dataclass(frozen=True, slots=True)
1133
+ class SourceLocation:
1134
+ """Location of a YAML document on disk."""
1135
+
1136
+ path: Path
1137
+ document_number: int
1138
+
1139
+ def display(self) -> str:
1140
+ """Return a stable human-readable source reference."""
1141
+ return f"{self.path}#{self.document_number}"
1142
+
1143
+
1144
+ @dataclass(frozen=True, slots=True)
1145
+ class ResourceDocument:
1146
+ """A schema-validated resource document."""
1147
+
1148
+ key: ResourceKey
1149
+ content: Mapping[str, object]
1150
+ source: SourceLocation
1151
+
1152
+
1153
+ def _required_string(value: object, concept: str) -> str:
1154
+ if not isinstance(value, str) or not value:
1155
+ message = f"{concept} must be a non-empty string"
1156
+ raise TypeError(message)
1157
+ return value