cloudfall 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudfall/__init__.py +19 -0
- cloudfall/__main__.py +5 -0
- cloudfall/_bundled/schemas/v1/alert-rule.schema.json +63 -0
- cloudfall/_bundled/schemas/v1/application.schema.json +60 -0
- cloudfall/_bundled/schemas/v1/artifact.schema.json +71 -0
- cloudfall/_bundled/schemas/v1/backup-receipt.schema.json +58 -0
- cloudfall/_bundled/schemas/v1/common.schema.json +115 -0
- cloudfall/_bundled/schemas/v1/component.schema.json +234 -0
- cloudfall/_bundled/schemas/v1/deployment-receipt.schema.json +53 -0
- cloudfall/_bundled/schemas/v1/domain.schema.json +208 -0
- cloudfall/_bundled/schemas/v1/environment-receipt.schema.json +55 -0
- cloudfall/_bundled/schemas/v1/logging-stack.schema.json +473 -0
- cloudfall/_bundled/schemas/v1/observed-domain.schema.json +158 -0
- cloudfall/_bundled/schemas/v1/observed-server.schema.json +627 -0
- cloudfall/_bundled/schemas/v1/operator-policy.schema.json +97 -0
- cloudfall/_bundled/schemas/v1/operator-proposal.schema.json +253 -0
- cloudfall/_bundled/schemas/v1/release-receipt.schema.json +66 -0
- cloudfall/_bundled/schemas/v1/server-type.schema.json +289 -0
- cloudfall/_bundled/schemas/v1/server.schema.json +146 -0
- cloudfall/_bundled/schemas/v1/service.schema.json +222 -0
- cloudfall/_bundled/schemas/v1/ssh-public-key.schema.json +55 -0
- cloudfall/agent_tools.py +868 -0
- cloudfall/arguments.py +92 -0
- cloudfall/audit.py +905 -0
- cloudfall/authoring.py +341 -0
- cloudfall/cli.py +1748 -0
- cloudfall/commands.py +257 -0
- cloudfall/cutover.py +370 -0
- cloudfall/dashboard.py +407 -0
- cloudfall/dashboard_server.py +201 -0
- cloudfall/domain.py +1157 -0
- cloudfall/importer.py +906 -0
- cloudfall/inventory.py +2116 -0
- cloudfall/lifecycle.py +874 -0
- cloudfall/mcp_server.py +704 -0
- cloudfall/migrate.py +730 -0
- cloudfall/observation.py +143 -0
- cloudfall/operations.py +1228 -0
- cloudfall/operator.py +1099 -0
- cloudfall/project.py +841 -0
- cloudfall/py.typed +0 -0
- cloudfall/render_api.py +338 -0
- cloudfall/resources.py +67 -0
- cloudfall/secrets.py +262 -0
- cloudfall/service_evidence.py +659 -0
- cloudfall/validation.py +1340 -0
- cloudfall-0.2.0.dist-info/METADATA +408 -0
- cloudfall-0.2.0.dist-info/RECORD +165 -0
- cloudfall-0.2.0.dist-info/WHEEL +4 -0
- cloudfall-0.2.0.dist-info/entry_points.txt +4 -0
- cloudfall-0.2.0.dist-info/licenses/LICENSE +661 -0
- cloudfall_engine/__init__.py +5 -0
- cloudfall_engine/__main__.py +5 -0
- cloudfall_engine/_bundled/ansible/ansible.cfg +7 -0
- cloudfall_engine/_bundled/ansible/playbooks/backup.yml +86 -0
- cloudfall_engine/_bundled/ansible/playbooks/baseline.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/bootstrap.yml +24 -0
- cloudfall_engine/_bundled/ansible/playbooks/data.yml +28 -0
- cloudfall_engine/_bundled/ansible/playbooks/deploy.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/domains.yml +21 -0
- cloudfall_engine/_bundled/ansible/playbooks/health.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/inspect.yml +15 -0
- cloudfall_engine/_bundled/ansible/playbooks/logging.yml +35 -0
- cloudfall_engine/_bundled/ansible/playbooks/restart.yml +31 -0
- cloudfall_engine/_bundled/ansible/playbooks/rollback.yml +33 -0
- cloudfall_engine/_bundled/ansible/playbooks/services.yml +37 -0
- cloudfall_engine/_bundled/ansible/playbooks/time.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/argument_specs.yml +34 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_access/tasks/main.yml +59 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/defaults/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/argument_specs.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_bootstrap/tasks/main.yml +99 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/defaults/main.yml +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/argument_specs.yml +36 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_data_migration/tasks/main.yml +201 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/defaults/main.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/argument_specs.yml +125 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/health_gate.yml +37 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/main.yml +285 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/restart.yml +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/tasks/rollback.yml +52 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_deploy/templates/component.service.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/tasks/main.yml +51 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +31 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/argument_specs.yml +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_inspect/tasks/main.yml +643 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/handlers/main.yml +29 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/argument_specs.yml +227 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/tasks/main.yml +487 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/alertmanager.yaml.j2 +33 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/grafana.ini.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/loki.yaml.j2 +49 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/nginx-loki-gateway.conf.j2 +45 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-datasource.yml.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-defaults.j2 +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus-rules.yaml.j2 +20 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_backend/templates/prometheus.yaml.j2 +24 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/defaults/main.yml +7 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/argument_specs.yml +122 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/meta/main.yml +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/tasks/main.yml +224 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/alloy-systemd-override.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_logging_collector/templates/config.alloy.j2 +127 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/defaults/main.yml +4 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/handlers/main.yml +5 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/argument_specs.yml +69 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/tasks/main.yml +212 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_nginx_site/templates/domain-site.conf.j2 +54 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/defaults/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/handlers/main.yml +9 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +91 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/tasks/main.yml +295 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.sh.j2 +17 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.sh.j2 +27 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_postgresql/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/handlers/main.yml +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/argument_specs.yml +68 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/tasks/main.yml +148 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.sh.j2 +14 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/backup.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/cloudfall.conf.j2 +6 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.service.j2 +11 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.sh.j2 +15 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_redis/templates/restore-check.timer.j2 +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/defaults/main.yml +2 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/argument_specs.yml +10 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_time/tasks/main.yml +119 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/defaults/main.yml +3 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/argument_specs.yml +8 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/meta/main.yml +13 -0
- cloudfall_engine/_bundled/ansible/roles/cloudfall_unattended_upgrades/tasks/main.yml +37 -0
- cloudfall_engine/ansible_inventory.py +253 -0
- cloudfall_engine/artifact.py +258 -0
- cloudfall_engine/cli.py +273 -0
- cloudfall_engine/playbook.py +175 -0
- cloudfall_engine/py.typed +0 -0
cloudfall/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
|