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/authoring.py ADDED
@@ -0,0 +1,341 @@
1
+ """Write fleet resources into a project.
2
+
3
+ ``cloudfall add`` turns the first minutes after ``cloudfall init`` into three
4
+ commands instead of three hand-written files: an SSH public key read from
5
+ disk, a server type from the bundled Debian baseline, and a server that
6
+ references it. Every document is schema-validated before it is written, the
7
+ whole project is validated afterwards, and a failed validation removes what
8
+ was written so the project never holds a resource it would reject.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from ipaddress import ip_address
15
+ from typing import TYPE_CHECKING
16
+
17
+ import yaml
18
+
19
+ from cloudfall.domain import (
20
+ ConnectionAddress,
21
+ Hostname,
22
+ LinuxUser,
23
+ OpenSshPublicKey,
24
+ ResourceId,
25
+ ResourceKind,
26
+ TcpPort,
27
+ )
28
+ from cloudfall.validation import ConfigValidationError, SchemaCatalog, validate_config
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Sequence
32
+ from pathlib import Path
33
+
34
+ API_VERSION = "cloudfall/v1"
35
+
36
+ BASELINE_PACKAGES: tuple[str, ...] = (
37
+ "acl",
38
+ "ca-certificates",
39
+ "curl",
40
+ "git",
41
+ "rsync",
42
+ "unzip",
43
+ )
44
+ """Packages the Cloudfall baseline installs on every managed host."""
45
+
46
+ BASELINE_ROOT_MINIMUM_BYTES = 20_000_000_000
47
+ """Smallest root filesystem the baseline server type accepts (20 GB)."""
48
+
49
+ _ERROR_KEY_FILE_MISSING = "ssh_key_file_missing"
50
+ _ERROR_KEY_FILE_INVALID = "ssh_key_file_invalid"
51
+ _ERROR_RESOURCE_EXISTS = "resource_exists"
52
+
53
+
54
+ class AuthoringError(RuntimeError):
55
+ """Fail-fast authoring error with a stable machine-readable code."""
56
+
57
+ def __init__(self, code: str, message: str) -> None:
58
+ """Record the failure code and human-readable detail."""
59
+ self.code = code
60
+ self.detail = message
61
+ super().__init__(f"{code}: {message}")
62
+
63
+ def as_dict(self) -> dict[str, object]:
64
+ """Serialize the error envelope for system boundaries."""
65
+ return {
66
+ "status": "error",
67
+ "error": {"code": self.code, "message": self.detail},
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class AddedResource:
73
+ """One resource file written into the project."""
74
+
75
+ kind: ResourceKind
76
+ resource_id: ResourceId
77
+ path: str
78
+
79
+ def as_dict(self) -> dict[str, object]:
80
+ """Serialize for the CLI and agent envelope."""
81
+ return {"kind": self.kind.value, "id": str(self.resource_id), "path": self.path}
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class AddResult:
86
+ """Outcome of one ``cloudfall add`` invocation."""
87
+
88
+ project: Path
89
+ added: tuple[AddedResource, ...]
90
+
91
+ def as_dict(self) -> dict[str, object]:
92
+ """Serialize for system boundaries."""
93
+ return {
94
+ "status": "ok",
95
+ "project": str(self.project),
96
+ "added": [resource.as_dict() for resource in self.added],
97
+ }
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class SshKeyOptions:
102
+ """Inputs for ``cloudfall add ssh-key``."""
103
+
104
+ key_path: Path
105
+ owner: ResourceId
106
+ environment: ResourceId
107
+ resource_id: ResourceId | None = None
108
+ description: str | None = None
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class ServerTypeOptions:
113
+ """Inputs for ``cloudfall add server-type``."""
114
+
115
+ resource_id: ResourceId
116
+ description: str | None = None
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ServerOptions:
121
+ """Inputs for ``cloudfall add server``."""
122
+
123
+ resource_id: ResourceId
124
+ address: ConnectionAddress
125
+ server_type: ResourceId
126
+ environment: ResourceId
127
+ ssh_user: LinuxUser
128
+ ssh_port: TcpPort
129
+ hostname: Hostname | None = None
130
+ description: str | None = None
131
+
132
+
133
+ def read_public_key(key_path: Path) -> OpenSshPublicKey:
134
+ """Read one OpenSSH public key from a file such as ``~/.ssh/id_ed25519.pub``."""
135
+ if not key_path.is_file():
136
+ message = f"SSH public key file does not exist: {key_path}"
137
+ raise AuthoringError(_ERROR_KEY_FILE_MISSING, message)
138
+ lines = [
139
+ line
140
+ for line in key_path.read_text(encoding="utf-8").splitlines()
141
+ if line.strip()
142
+ ]
143
+ if len(lines) != 1:
144
+ message = f"SSH public key file must hold exactly one key: {key_path}"
145
+ raise AuthoringError(_ERROR_KEY_FILE_INVALID, message)
146
+ try:
147
+ return OpenSshPublicKey.from_boundary(lines[0].strip())
148
+ except (TypeError, ValueError) as error:
149
+ message = f"{key_path}: {error}"
150
+ raise AuthoringError(_ERROR_KEY_FILE_INVALID, message) from error
151
+
152
+
153
+ def ssh_key_document(
154
+ options: SshKeyOptions, key: OpenSshPublicKey
155
+ ) -> dict[str, object]:
156
+ """Build the ``SshPublicKey`` resource for one key."""
157
+ resource_id = options.resource_id or options.owner
158
+ description = options.description or f"SSH public key of {options.owner}"
159
+ return {
160
+ "apiVersion": API_VERSION,
161
+ "kind": ResourceKind.SSH_PUBLIC_KEY.value,
162
+ "metadata": {"id": str(resource_id), "description": description},
163
+ "spec": {
164
+ "owner": str(options.owner),
165
+ "environment": str(options.environment),
166
+ "publicKey": key.value,
167
+ "lifecycle": "active",
168
+ },
169
+ }
170
+
171
+
172
+ def baseline_server_type_document(options: ServerTypeOptions) -> dict[str, object]:
173
+ """Build the bundled baseline: a plain Debian 13 host without software RAID."""
174
+ description = options.description or (
175
+ "Debian 13 host with the Cloudfall baseline and no software RAID"
176
+ )
177
+ return {
178
+ "apiVersion": API_VERSION,
179
+ "kind": ResourceKind.HOST_PROFILE.value,
180
+ "metadata": {"id": str(options.resource_id), "description": description},
181
+ "spec": {
182
+ "os": {
183
+ "distribution": "Debian",
184
+ "versions": ["13"],
185
+ "serviceManager": "systemd",
186
+ },
187
+ "storage": {
188
+ "mounts": [
189
+ {
190
+ "path": "/",
191
+ "filesystem": "ext4",
192
+ "minimumBytes": BASELINE_ROOT_MINIMUM_BYTES,
193
+ }
194
+ ]
195
+ },
196
+ "packages": {
197
+ "required": [{"name": name} for name in BASELINE_PACKAGES],
198
+ "forbidden": ["telnetd"],
199
+ },
200
+ "services": {
201
+ "required": [
202
+ {"name": "ssh.service", "state": "running", "status": "enabled"},
203
+ {
204
+ "name": "apt-daily.timer",
205
+ "state": "running",
206
+ "status": "enabled",
207
+ },
208
+ ]
209
+ },
210
+ "firewall": {
211
+ "policy": "default-deny",
212
+ "allowedInbound": [
213
+ {"port": 22, "protocol": "tcp", "description": "ssh"},
214
+ {"port": 80, "protocol": "tcp", "description": "http"},
215
+ {"port": 443, "protocol": "tcp", "description": "https"},
216
+ ],
217
+ },
218
+ "configuration": {
219
+ "files": [
220
+ {
221
+ "path": "/etc/ssh/sshd_config",
222
+ "capture": "hash",
223
+ "owner": "root",
224
+ "group": "root",
225
+ "mode": "0644",
226
+ }
227
+ ]
228
+ },
229
+ },
230
+ }
231
+
232
+
233
+ def _is_ip_address(value: str) -> bool:
234
+ try:
235
+ ip_address(value)
236
+ except ValueError:
237
+ return False
238
+ return True
239
+
240
+
241
+ def server_document(options: ServerOptions) -> dict[str, object]:
242
+ """Build the ``Server`` resource for one host."""
243
+ if options.hostname is not None:
244
+ hostname = options.hostname.value
245
+ elif _is_ip_address(options.address.value):
246
+ hostname = str(options.resource_id)
247
+ else:
248
+ hostname = Hostname(options.address.value).value
249
+ metadata: dict[str, object] = {"id": str(options.resource_id)}
250
+ if options.description is not None:
251
+ metadata["description"] = options.description
252
+ return {
253
+ "apiVersion": API_VERSION,
254
+ "kind": ResourceKind.SERVER.value,
255
+ "metadata": metadata,
256
+ "spec": {
257
+ "hostname": hostname,
258
+ "address": options.address.value,
259
+ "environment": str(options.environment),
260
+ "serverType": str(options.server_type),
261
+ "lifecycle": "active",
262
+ "ssh": {"user": options.ssh_user.value, "port": options.ssh_port.value},
263
+ },
264
+ }
265
+
266
+
267
+ def add_ssh_key(
268
+ project: Path, options: SshKeyOptions, schema_directory: Path
269
+ ) -> AddResult:
270
+ """Write one ``SshPublicKey`` read from a key file."""
271
+ key = read_public_key(options.key_path)
272
+ document = ssh_key_document(options, key)
273
+ return _commit(project, schema_directory, [(ResourceKind.SSH_PUBLIC_KEY, document)])
274
+
275
+
276
+ def add_server_type(
277
+ project: Path, options: ServerTypeOptions, schema_directory: Path
278
+ ) -> AddResult:
279
+ """Write one baseline ``ServerType``."""
280
+ document = baseline_server_type_document(options)
281
+ return _commit(project, schema_directory, [(ResourceKind.HOST_PROFILE, document)])
282
+
283
+
284
+ def add_server(
285
+ project: Path, options: ServerOptions, schema_directory: Path
286
+ ) -> AddResult:
287
+ """Write one ``Server``, and its ``ServerType`` when the project lacks it."""
288
+ documents: list[tuple[ResourceKind, dict[str, object]]] = []
289
+ if not _resource_path(
290
+ project, ResourceKind.HOST_PROFILE, options.server_type
291
+ ).exists():
292
+ server_type = ServerTypeOptions(resource_id=options.server_type)
293
+ documents.append(
294
+ (ResourceKind.HOST_PROFILE, baseline_server_type_document(server_type))
295
+ )
296
+ documents.append((ResourceKind.SERVER, server_document(options)))
297
+ return _commit(project, schema_directory, documents)
298
+
299
+
300
+ def _resource_path(project: Path, kind: ResourceKind, resource_id: ResourceId) -> Path:
301
+ return project / kind.directory / f"{resource_id}.yaml"
302
+
303
+
304
+ def _commit(
305
+ project: Path,
306
+ schema_directory: Path,
307
+ documents: Sequence[tuple[ResourceKind, dict[str, object]]],
308
+ ) -> AddResult:
309
+ catalog = SchemaCatalog(schema_directory)
310
+ planned: list[tuple[ResourceKind, ResourceId, Path, dict[str, object]]] = []
311
+ for kind, document in documents:
312
+ catalog.validate(kind, document)
313
+ metadata = document["metadata"]
314
+ assert isinstance(metadata, dict) # noqa: S101 - built above, schema-checked
315
+ resource_id = ResourceId.from_boundary(metadata["id"])
316
+ path = _resource_path(project, kind, resource_id)
317
+ if path.exists():
318
+ message = f"{kind.value}/{resource_id} already exists: {path}"
319
+ raise AuthoringError(_ERROR_RESOURCE_EXISTS, message)
320
+ planned.append((kind, resource_id, path, document))
321
+
322
+ written: list[Path] = []
323
+ try:
324
+ for _kind, _resource_id, path, document in planned:
325
+ path.parent.mkdir(parents=True, exist_ok=True)
326
+ path.write_text(
327
+ f"---\n{yaml.safe_dump(document, sort_keys=False)}", encoding="utf-8"
328
+ )
329
+ written.append(path)
330
+ validate_config(project, schema_directory)
331
+ except ConfigValidationError:
332
+ for path in written:
333
+ path.unlink()
334
+ raise
335
+ return AddResult(
336
+ project=project,
337
+ added=tuple(
338
+ AddedResource(kind, resource_id, path.relative_to(project).as_posix())
339
+ for kind, resource_id, path, _document in planned
340
+ ),
341
+ )