@agentsdock/server 1.0.7-beta.5

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,2458 @@
1
+ """Crash-safe provenance for one AgentsServer release activation.
2
+
3
+ The installer deliberately keeps this small ledger outside any release tree.
4
+ It exists only from the last pre-takeover snapshot of links/configuration until
5
+ either candidate health or verified rollback. A later installer must recover
6
+ that exact transaction before it may choose a new rollback baseline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import ctypes
13
+ import hashlib
14
+ import ipaddress
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import secrets
20
+ import shutil
21
+ import stat
22
+ import sys
23
+ import uuid
24
+ from typing import Any
25
+
26
+
27
+ FORMAT = 2
28
+ EXECUTION_FORMAT = 3
29
+ MAX_CONTROL_BYTES = 256 * 1024
30
+ MAX_CONFIG_BYTES = 16 * 1024 * 1024
31
+ PHASES = {
32
+ "prepared",
33
+ "guarded",
34
+ "quiescing",
35
+ "quiesced",
36
+ "linking",
37
+ "linked",
38
+ "stopping",
39
+ "stopped",
40
+ "fencing",
41
+ "fenced",
42
+ "authorizing",
43
+ "authority",
44
+ "candidate-starting",
45
+ "candidate-healthy",
46
+ "committing",
47
+ "committed",
48
+ "rolling-back",
49
+ "rolled-back",
50
+ "rollback-healthy",
51
+ }
52
+ TRANSITIONS = {
53
+ "prepared": {"guarded", "quiescing", "linking", "rolling-back"},
54
+ "guarded": {"quiescing", "linking", "rolling-back"},
55
+ "quiescing": {"quiesced", "rolling-back"},
56
+ "quiesced": {"linking", "rolling-back"},
57
+ "linking": {"linked", "rolling-back"},
58
+ "linked": {"stopping", "candidate-starting", "rolling-back"},
59
+ "stopping": {"stopped", "rolling-back"},
60
+ "stopped": {"fencing", "fenced", "authorizing", "rolling-back"},
61
+ "fencing": {"fenced", "rolling-back"},
62
+ "fenced": {"authorizing", "rolling-back"},
63
+ "authorizing": {"authority", "rolling-back"},
64
+ "authority": {"candidate-starting", "rolling-back"},
65
+ "candidate-starting": {"candidate-healthy", "rolling-back"},
66
+ "candidate-healthy": {"committing", "rolling-back"},
67
+ "committing": {"committed"},
68
+ "committed": set(),
69
+ "rolling-back": {"rolled-back"},
70
+ "rolled-back": {"rollback-healthy"},
71
+ "rollback-healthy": set(),
72
+ }
73
+ MANIFEST_TEMP_RE = re.compile(r"^\.manifest\.json\.[0-9a-f]{24}\.tmp$")
74
+ GC_DIRECTORY_RE = re.compile(r"^\.activation-transaction-gc-activation-[0-9a-f]{24}$")
75
+ BEGIN_DIRECTORY_RE = re.compile(r"^\.activation-transaction\.[0-9a-f]{24}\.tmp$")
76
+
77
+
78
+ def _config_kinds(value: dict[str, Any]) -> tuple[str, ...]:
79
+ return (("env", "service", "gateway", "execution_layout")
80
+ if value.get("format") == EXECUTION_FORMAT else ("env", "service"))
81
+
82
+
83
+ def _config_modes(kind: str, *, publication: bool = False) -> set[int]:
84
+ if kind in {"env", "execution_layout"}:
85
+ return {0o600}
86
+ return {0o600, 0o644} if publication else {0o600, 0o640, 0o644}
87
+
88
+
89
+ def _execution_gateway_path(service: Path) -> Path:
90
+ names = {"agents-server.service": "agents-server-gateway.service",
91
+ "com.agentsdock.server.plist": "com.agentsdock.gateway.plist"}
92
+ if service.name not in names:
93
+ raise RuntimeError("execution activation requires the managed worker service")
94
+ return service.with_name(names[service.name])
95
+
96
+
97
+ def _begin_execution(args: argparse.Namespace, value: dict[str, Any], directory: Path, root: Path) -> None:
98
+ runtime = _absolute(args.execution_runtime_dir)
99
+ if str(runtime) != args.execution_runtime_dir or runtime.name != "execution":
100
+ raise RuntimeError("execution runtime path is invalid")
101
+ _volume_anchor(runtime.parent)
102
+ gateway = _execution_gateway_path(_absolute(args.service))
103
+ if str(gateway) != args.gateway_service:
104
+ raise RuntimeError("execution gateway service path is invalid")
105
+ operation = str(uuid.uuid5(uuid.NAMESPACE_URL, "agentsdock-activation:" + value["transaction_id"]))
106
+ handoff = None
107
+ if args.execution_handoff_file:
108
+ handoff = json.loads(_read_private(_absolute(args.execution_handoff_file)))
109
+ if not isinstance(handoff, dict) or set(handoff) != {
110
+ "schema", "operation_id", "worker_instance_id", "lease_id", "expected_server_identity",
111
+ } or type(handoff.get("schema")) is not int or handoff.get("schema") != 1:
112
+ raise RuntimeError("execution handoff is invalid")
113
+ operation = handoff["operation_id"]
114
+ prior_layout = root / "execution-layout.json"
115
+ old_worker = dict(value["old_release"])
116
+ if prior_layout.exists() or prior_layout.is_symlink():
117
+ from execution_install import active_worker_release
118
+ previous_worker = active_worker_release(root)
119
+ if previous_worker is None:
120
+ raise RuntimeError("previous execution worker cannot be identified")
121
+ old_worker = (dict(value["old_release"])
122
+ if value["old_release"] and _release_matches(previous_worker, value["old_release"])
123
+ else _release_identity(previous_worker, previous_worker))
124
+ value["format"] = EXECUTION_FORMAT
125
+ value["execution"] = {"runtime_dir": str(runtime), "operation_id": operation,
126
+ "gateway_state": args.gateway_state,
127
+ "gateway_enabled": args.gateway_enabled == "true",
128
+ "old_worker_release": old_worker, "handoff": handoff,
129
+ "api_contract": args.execution_api_contract}
130
+ for kind, path in (("gateway", gateway), ("execution_layout", prior_layout)):
131
+ value[f"{kind}_path"] = str(path)
132
+ value[kind] = _configuration(path, directory / f"{kind}.backup", allowed_modes=_config_modes(kind))
133
+ value[f"desired_{kind}"] = None
134
+ value[f"observed_{kind}_sha256"] = [value[kind]["sha256"]]
135
+ if args.gateway_state != "absent" and not value["gateway"]["existed"]:
136
+ raise RuntimeError("loaded gateway has no restorable service configuration")
137
+ if old_worker:
138
+ _capture_volume(value, _absolute(old_worker["source"]))
139
+ _capture_volume(value, runtime.parent)
140
+
141
+
142
+ def _validate_execution_metadata(root: Path, value: dict[str, Any], valid_release: Any) -> None:
143
+ if value["format"] != EXECUTION_FORMAT:
144
+ return
145
+ execution = value["execution"]
146
+ if not isinstance(execution, dict) or set(execution) != {
147
+ "runtime_dir", "operation_id", "gateway_state", "gateway_enabled", "old_worker_release", "handoff", "api_contract",
148
+ }:
149
+ raise RuntimeError("activation execution metadata is invalid")
150
+ runtime = _absolute(_safe_text(execution["runtime_dir"], "execution runtime"))
151
+ if (str(runtime) != execution["runtime_dir"] or runtime.name != "execution"
152
+ or root == runtime.parent or root in runtime.parents
153
+ or execution["gateway_state"] not in {"absent", "stopped", "running"}
154
+ or type(execution["gateway_enabled"]) is not bool
155
+ or type(execution["api_contract"]) is not int or execution["api_contract"] < 0
156
+ or value["gateway_path"] != str(_execution_gateway_path(_absolute(value["service_path"])))
157
+ or value["execution_layout_path"] != str(root / "execution-layout.json")):
158
+ raise RuntimeError("activation execution layout is invalid")
159
+ try:
160
+ if str(uuid.UUID(execution["operation_id"])) != execution["operation_id"]:
161
+ raise ValueError("noncanonical UUID")
162
+ except (TypeError, ValueError, AttributeError) as exc:
163
+ raise RuntimeError("activation execution operation is invalid") from exc
164
+ old = execution["old_worker_release"]
165
+ if old and (not valid_release(old) or _absolute(old["target"]).parent != root / "releases"):
166
+ raise RuntimeError("activation prior worker release is invalid")
167
+ if not old and old != {}:
168
+ raise RuntimeError("activation prior worker release is invalid")
169
+ handoff = execution["handoff"]
170
+ if handoff is not None:
171
+ if (not isinstance(handoff, dict) or set(handoff) != {
172
+ "schema", "operation_id", "worker_instance_id", "lease_id", "expected_server_identity",
173
+ } or type(handoff["schema"]) is not int or handoff["schema"] != 1
174
+ or handoff["operation_id"] != execution["operation_id"]):
175
+ raise RuntimeError("activation execution handoff is invalid")
176
+ try:
177
+ if str(uuid.UUID(handoff["lease_id"])) != handoff["lease_id"]:
178
+ raise ValueError("noncanonical lease UUID")
179
+ except (ValueError, TypeError, AttributeError) as exc:
180
+ raise RuntimeError("activation execution handoff lease is invalid") from exc
181
+ for field in ("worker_instance_id", "lease_id", "expected_server_identity"):
182
+ _safe_text(handoff[field], "execution " + field, maximum=240)
183
+
184
+
185
+ def execution_context(root: Path) -> dict[str, Any] | None:
186
+ """Read owned outer-transaction execution metadata with all file proofs."""
187
+ root = _absolute(root)
188
+ _directory, value = _read_manifest(root, cleanup=False)
189
+ if value["format"] != EXECUTION_FORMAT:
190
+ return None
191
+ args = argparse.Namespace(root=str(root), current=str(root / "current"), previous=str(root / "previous"),
192
+ env=value["env_path"], service=value["service_path"])
193
+ _validate_invocation(args, value, allow_missing_candidate=True)
194
+ return value
195
+
196
+
197
+ def pending_execution_worker_operation(root: Path, runtime_root: Path) -> str | None:
198
+ value = execution_context(root)
199
+ if value is None:
200
+ return None
201
+ runtime_root = _absolute(runtime_root)
202
+ retained = [value["candidate_release"], value["execution"]["old_worker_release"]]
203
+ if not any(release and _release_matches(runtime_root, release) for release in retained):
204
+ raise RuntimeError("worker runtime is not retained by the pending activation")
205
+ return value["execution"]["operation_id"]
206
+
207
+
208
+ def _absolute(path: str | Path) -> Path:
209
+ return Path(os.path.abspath(os.path.expanduser(os.fspath(path))))
210
+
211
+
212
+ def _volume_uuid(path: Path, identity: os.stat_result) -> str:
213
+ """Read Darwin's persistent volume UUID from the exact inspected inode."""
214
+ class Attributes(ctypes.Structure):
215
+ _fields_ = [
216
+ ("count", ctypes.c_uint16), ("reserved", ctypes.c_uint16),
217
+ ("common", ctypes.c_uint32), ("volume", ctypes.c_uint32),
218
+ ("directory", ctypes.c_uint32), ("file", ctypes.c_uint32),
219
+ ("fork", ctypes.c_uint32),
220
+ ]
221
+
222
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
223
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_DIRECTORY", 0)
224
+ descriptor = os.open(path, flags)
225
+ try:
226
+ opened = os.fstat(descriptor)
227
+ if (opened.st_dev, opened.st_ino) != (identity.st_dev, identity.st_ino):
228
+ raise RuntimeError("activation volume anchor changed")
229
+ library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
230
+ function = library.fgetattrlist
231
+ function.argtypes = [
232
+ ctypes.c_int, ctypes.POINTER(Attributes), ctypes.c_void_p,
233
+ ctypes.c_size_t, ctypes.c_ulong,
234
+ ]
235
+ function.restype = ctypes.c_int
236
+ # ATTR_BIT_MAP_COUNT, ATTR_VOL_INFO | ATTR_VOL_UUID, from sys/attr.h.
237
+ attributes = Attributes(5, 0, 0, 0x80040000, 0, 0, 0)
238
+ result = ctypes.create_string_buffer(20)
239
+ if function(descriptor, ctypes.byref(attributes), result, 20, 0) != 0:
240
+ raise OSError(ctypes.get_errno(), "cannot read activation volume UUID")
241
+ if int.from_bytes(result.raw[:4], sys.byteorder) != 20:
242
+ raise RuntimeError("activation volume UUID response is invalid")
243
+ identifier = uuid.UUID(bytes=result.raw[4:20])
244
+ if identifier.int == 0:
245
+ raise RuntimeError("activation filesystem has no persistent volume UUID")
246
+ linked = path.lstat()
247
+ if (linked.st_dev, linked.st_ino) != (opened.st_dev, opened.st_ino):
248
+ raise RuntimeError("activation volume anchor changed")
249
+ return str(identifier)
250
+ finally:
251
+ os.close(descriptor)
252
+
253
+
254
+ def _volume_anchor(path: Path) -> os.stat_result:
255
+ identity = path.lstat()
256
+ if (
257
+ not stat.S_ISDIR(identity.st_mode)
258
+ or identity.st_uid != os.getuid()
259
+ or stat.S_IMODE(identity.st_mode) & 0o022
260
+ ):
261
+ raise PermissionError("activation volume anchor is unsafe")
262
+ return identity
263
+
264
+
265
+ def _capture_volume(value: dict[str, Any], path: Path, *, allow_missing: bool = False) -> None:
266
+ if sys.platform != "darwin" or value["format"] == 1:
267
+ return
268
+ while True:
269
+ try:
270
+ identity = _volume_anchor(path)
271
+ break
272
+ except FileNotFoundError:
273
+ if not allow_missing or path.parent == path:
274
+ raise
275
+ # Fresh installs create configuration directories after begin().
276
+ # Bind their existing ancestor now; publication verifies the actual
277
+ # destination parent before adding any file identity to the journal.
278
+ path = path.parent
279
+ identifier = _volume_uuid(path, identity)
280
+ key = str(identity.st_dev)
281
+ bindings = value["volume_bindings"]
282
+ if key in bindings:
283
+ if bindings[key]["uuid"] != identifier:
284
+ raise RuntimeError("activation filesystem identity changed")
285
+ return
286
+ bindings[key] = {"anchor": str(path), "uuid": identifier}
287
+
288
+
289
+ def _rebase_volume_bindings(value: dict[str, Any]) -> None:
290
+ """Translate journal coordinates after remount; never relax inode checks."""
291
+ if value["format"] == 1:
292
+ return
293
+ bindings = value["volume_bindings"]
294
+ if not isinstance(bindings, dict) or len(bindings) > 8:
295
+ raise RuntimeError("activation volume bindings are invalid")
296
+ if sys.platform == "darwin" and not bindings:
297
+ raise RuntimeError("activation volume bindings are missing")
298
+ mapping: dict[int, int] = {}
299
+ rebound: dict[str, dict[str, str]] = {}
300
+ for device, binding in bindings.items():
301
+ if (
302
+ not isinstance(device, str)
303
+ or re.fullmatch(r"[1-9][0-9]*", device) is None
304
+ or not isinstance(binding, dict)
305
+ or set(binding) != {"anchor", "uuid"}
306
+ or not isinstance(binding.get("anchor"), str)
307
+ or not isinstance(binding.get("uuid"), str)
308
+ or re.fullmatch(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", binding["uuid"]) is None
309
+ ):
310
+ raise RuntimeError("activation volume binding is invalid")
311
+ path = _absolute(_safe_text(binding["anchor"], "volume anchor"))
312
+ if str(path) != binding["anchor"] or sys.platform != "darwin":
313
+ raise RuntimeError("activation volume binding is unsupported")
314
+ identity = _volume_anchor(path)
315
+ if _volume_uuid(path, identity) != binding["uuid"]:
316
+ raise RuntimeError("activation filesystem identity changed")
317
+ current_device = str(identity.st_dev)
318
+ if current_device in rebound:
319
+ raise RuntimeError("activation filesystems no longer have distinct identities")
320
+ mapping[int(device)] = identity.st_dev
321
+ rebound[current_device] = binding
322
+ if not mapping:
323
+ return
324
+ if any(old != new for old, new in mapping.items()) and value["intent"] in {
325
+ "host-reactivation", "failed-host-repair",
326
+ }:
327
+ raise RuntimeError(
328
+ "interrupted Team Hub reactivation crossed a filesystem remount; "
329
+ "its separate recovery journal requires support-assisted recovery"
330
+ )
331
+ coordinates = [
332
+ (value[name], "device")
333
+ for name in ("candidate_release", "old_release", "previous_release", "guard",
334
+ *(f"desired_{kind}" for kind in _config_kinds(value)))
335
+ if value[name]
336
+ ]
337
+ if value.get("execution", {}).get("old_worker_release"):
338
+ coordinates.append((value["execution"]["old_worker_release"], "device"))
339
+ if value["hub"]:
340
+ coordinates.append((value["hub"], "fence_device"))
341
+ # Map once from each original value: volumes may exchange device numbers.
342
+ for item, field in coordinates:
343
+ if item[field] not in mapping:
344
+ raise RuntimeError("activation filesystem coordinate lacks a volume binding")
345
+ for item, field in coordinates:
346
+ item[field] = mapping[item[field]]
347
+ value["volume_bindings"] = rebound
348
+
349
+
350
+ def _fsync_directory(path: Path) -> None:
351
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
352
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_DIRECTORY", 0)
353
+ descriptor = os.open(path, flags)
354
+ try:
355
+ os.fsync(descriptor)
356
+ finally:
357
+ os.close(descriptor)
358
+
359
+
360
+ def _safe_text(value: Any, name: str, *, maximum: int = 4096) -> str:
361
+ if not isinstance(value, str) or not value or len(value) > maximum:
362
+ raise RuntimeError(f"activation transaction {name} is invalid")
363
+ if "\x00" in value or "\n" in value or "\r" in value:
364
+ raise RuntimeError(f"activation transaction {name} is invalid")
365
+ return value
366
+
367
+
368
+ def _read_private(path: Path, *, maximum: int = MAX_CONTROL_BYTES) -> bytes:
369
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
370
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
371
+ descriptor = os.open(path, flags)
372
+ try:
373
+ before = os.fstat(descriptor)
374
+ if (
375
+ not stat.S_ISREG(before.st_mode)
376
+ or before.st_uid != os.getuid()
377
+ or before.st_nlink != 1
378
+ or stat.S_IMODE(before.st_mode) != 0o600
379
+ or not 0 <= before.st_size <= maximum
380
+ ):
381
+ raise PermissionError("activation transaction file is unsafe")
382
+ chunks: list[bytes] = []
383
+ remaining = maximum + 1
384
+ while remaining:
385
+ chunk = os.read(descriptor, min(remaining, 1024 * 1024))
386
+ if not chunk:
387
+ break
388
+ chunks.append(chunk)
389
+ remaining -= len(chunk)
390
+ value = b"".join(chunks)
391
+ after = os.fstat(descriptor)
392
+ linked = path.lstat()
393
+ if (
394
+ len(value) > maximum
395
+ or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
396
+ != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
397
+ or (before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
398
+ ):
399
+ raise RuntimeError("activation transaction file changed while reading")
400
+ return value
401
+ finally:
402
+ os.close(descriptor)
403
+
404
+
405
+ def _read_owned_regular(path: Path, *, maximum: int = MAX_CONFIG_BYTES) -> bytes:
406
+ """Read an installer-owned config, allowing 0600 or ordinary 0644 mode."""
407
+
408
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
409
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
410
+ descriptor = os.open(path, flags)
411
+ try:
412
+ before = os.fstat(descriptor)
413
+ if (
414
+ not stat.S_ISREG(before.st_mode)
415
+ or before.st_uid != os.getuid()
416
+ or before.st_nlink != 1
417
+ or stat.S_IMODE(before.st_mode) & 0o022
418
+ or not 0 <= before.st_size <= maximum
419
+ ):
420
+ raise PermissionError("activation configuration file is unsafe")
421
+ chunks: list[bytes] = []
422
+ remaining = maximum + 1
423
+ while remaining:
424
+ chunk = os.read(descriptor, min(remaining, 1024 * 1024))
425
+ if not chunk:
426
+ break
427
+ chunks.append(chunk)
428
+ remaining -= len(chunk)
429
+ value = b"".join(chunks)
430
+ after = os.fstat(descriptor)
431
+ linked = path.lstat()
432
+ if (
433
+ len(value) > maximum
434
+ or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
435
+ != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
436
+ or (before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
437
+ ):
438
+ raise RuntimeError("activation configuration changed while reading")
439
+ return value
440
+ finally:
441
+ os.close(descriptor)
442
+
443
+
444
+ def _write_new_private(path: Path, value: bytes) -> None:
445
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
446
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
447
+ descriptor = os.open(path, flags, 0o600)
448
+ try:
449
+ os.fchmod(descriptor, 0o600)
450
+ written = 0
451
+ while written < len(value):
452
+ written += os.write(descriptor, value[written:])
453
+ os.fsync(descriptor)
454
+ finally:
455
+ os.close(descriptor)
456
+
457
+
458
+ def _replace_private(path: Path, value: bytes) -> None:
459
+ temporary = path.parent / f".{path.name}.{secrets.token_hex(12)}.tmp"
460
+ try:
461
+ _write_new_private(temporary, value)
462
+ os.replace(temporary, path)
463
+ _fsync_directory(path.parent)
464
+ finally:
465
+ try:
466
+ temporary.unlink()
467
+ except FileNotFoundError:
468
+ pass
469
+
470
+
471
+ def _sha256(path: Path) -> str:
472
+ return hashlib.sha256(_read_private(path, maximum=MAX_CONFIG_BYTES)).hexdigest()
473
+
474
+
475
+ def _owned_regular_sha256(path: Path, *, maximum: int = 4096) -> str:
476
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
477
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
478
+ descriptor = os.open(path, flags)
479
+ try:
480
+ before = os.fstat(descriptor)
481
+ if (
482
+ not stat.S_ISREG(before.st_mode)
483
+ or before.st_uid != os.getuid()
484
+ or before.st_nlink != 1
485
+ or stat.S_IMODE(before.st_mode) & 0o022
486
+ or not 1 <= before.st_size <= maximum
487
+ ):
488
+ raise PermissionError("activation release identity is unsafe")
489
+ chunks: list[bytes] = []
490
+ remaining = maximum + 1
491
+ while remaining:
492
+ chunk = os.read(descriptor, min(remaining, 1024 * 1024))
493
+ if not chunk:
494
+ break
495
+ chunks.append(chunk)
496
+ remaining -= len(chunk)
497
+ value = b"".join(chunks)
498
+ after = os.fstat(descriptor)
499
+ linked = path.lstat()
500
+ if (
501
+ not 1 <= len(value) <= maximum
502
+ or len(value) != before.st_size
503
+ or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
504
+ != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
505
+ or (before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
506
+ ):
507
+ raise RuntimeError("activation release identity changed while reading")
508
+ return hashlib.sha256(value).hexdigest()
509
+ finally:
510
+ os.close(descriptor)
511
+
512
+
513
+ def _release_identity(source: Path | None, target: Path | None) -> dict[str, Any]:
514
+ if source is None or target is None:
515
+ return {}
516
+ info = source.lstat()
517
+ if (
518
+ not stat.S_ISDIR(info.st_mode)
519
+ or info.st_uid != os.getuid()
520
+ or stat.S_IMODE(info.st_mode) & 0o022
521
+ ):
522
+ raise PermissionError("activation rollback release is unsafe")
523
+ return {
524
+ "source": str(source),
525
+ "target": str(target),
526
+ "device": info.st_dev,
527
+ "inode": info.st_ino,
528
+ "version_sha256": _owned_regular_sha256(source / "VERSION"),
529
+ }
530
+
531
+
532
+ def _release_matches(path: Path, release: dict[str, Any]) -> bool:
533
+ try:
534
+ info = path.lstat()
535
+ except FileNotFoundError:
536
+ return False
537
+ return (
538
+ stat.S_ISDIR(info.st_mode)
539
+ and info.st_uid == os.getuid()
540
+ and not stat.S_IMODE(info.st_mode) & 0o022
541
+ and info.st_dev == release["device"]
542
+ and info.st_ino == release["inode"]
543
+ and _owned_regular_sha256(path / "VERSION")
544
+ == release["version_sha256"]
545
+ )
546
+
547
+
548
+ def _locate_release(
549
+ release: dict[str, Any],
550
+ *,
551
+ extras: tuple[Path, ...] = (),
552
+ ) -> Path | None:
553
+ candidates = [
554
+ _absolute(release["source"]),
555
+ _absolute(release["target"]),
556
+ *extras,
557
+ ]
558
+ seen: set[Path] = set()
559
+ for candidate in candidates:
560
+ if candidate in seen:
561
+ continue
562
+ seen.add(candidate)
563
+ if _release_matches(candidate, release):
564
+ return candidate
565
+ return None
566
+
567
+
568
+ def _copy_config(source: Path, destination: Path) -> tuple[str, int]:
569
+ source_info = source.lstat()
570
+ value = _read_owned_regular(source, maximum=MAX_CONFIG_BYTES)
571
+ linked_info = source.lstat()
572
+ if (source_info.st_dev, source_info.st_ino) != (
573
+ linked_info.st_dev,
574
+ linked_info.st_ino,
575
+ ):
576
+ raise RuntimeError("activation configuration changed while capturing it")
577
+ _write_new_private(destination, value)
578
+ return hashlib.sha256(value).hexdigest(), stat.S_IMODE(source_info.st_mode)
579
+
580
+
581
+ def _configuration(
582
+ path: Path,
583
+ backup: Path,
584
+ *,
585
+ allowed_modes: set[int],
586
+ ) -> dict[str, Any]:
587
+ try:
588
+ info = path.lstat()
589
+ except FileNotFoundError:
590
+ return {
591
+ "existed": False,
592
+ "backup": backup.name,
593
+ "sha256": None,
594
+ "mode": None,
595
+ }
596
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
597
+ raise PermissionError(f"configuration path is unsafe: {path}")
598
+ digest, mode = _copy_config(path, backup)
599
+ if mode not in allowed_modes:
600
+ raise PermissionError(f"configuration mode cannot be restored safely: {path}")
601
+ return {
602
+ "existed": True,
603
+ "backup": backup.name,
604
+ "sha256": digest,
605
+ "mode": mode,
606
+ }
607
+
608
+
609
+ def _link_state(path: Path, *, allow_directory: bool) -> dict[str, str]:
610
+ try:
611
+ before = path.lstat()
612
+ except FileNotFoundError:
613
+ return {"kind": "missing", "target": ""}
614
+ if stat.S_ISLNK(before.st_mode):
615
+ target = _safe_text(os.readlink(path), "link target")
616
+ after = path.lstat()
617
+ if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
618
+ raise RuntimeError("activation link changed while reading")
619
+ return {"kind": "symlink", "target": target}
620
+ if allow_directory and stat.S_ISDIR(before.st_mode) and before.st_uid == os.getuid():
621
+ return {"kind": "directory", "target": ""}
622
+ raise RuntimeError(f"activation path is not supported: {path}")
623
+
624
+
625
+ def _linked_release_identity(path: Path) -> dict[str, Any]:
626
+ state = _link_state(path, allow_directory=False)
627
+ if state["kind"] != "symlink":
628
+ return {}
629
+ target = Path(state["target"])
630
+ if not target.is_absolute():
631
+ target = path.parent / target
632
+ target = _absolute(target)
633
+ try:
634
+ return _release_identity(target, target)
635
+ except FileNotFoundError:
636
+ # A pre-existing dangling previous link remains restorable as an exact
637
+ # link value, but it carries no directory-identity claim.
638
+ return {}
639
+
640
+
641
+ def _validate_root(root: Path) -> None:
642
+ info = root.lstat()
643
+ if (
644
+ not stat.S_ISDIR(info.st_mode)
645
+ or info.st_uid != os.getuid()
646
+ or stat.S_IMODE(info.st_mode) & 0o022
647
+ ):
648
+ raise PermissionError("activation transaction root is unsafe")
649
+
650
+
651
+ def _validate_releases_root(root: Path) -> Path:
652
+ releases = root / "releases"
653
+ info = releases.lstat()
654
+ if (
655
+ not stat.S_ISDIR(info.st_mode)
656
+ or info.st_uid != os.getuid()
657
+ or stat.S_IMODE(info.st_mode) & 0o022
658
+ ):
659
+ raise PermissionError("activation releases root is unsafe")
660
+ return releases
661
+
662
+
663
+ def _validate_guard_metadata(guard: Any) -> None:
664
+ if guard == {}:
665
+ return
666
+ if (
667
+ not isinstance(guard, dict)
668
+ or set(guard) != {"id", "device", "inode"}
669
+ or not isinstance(guard.get("id"), str)
670
+ or re.fullmatch(r"cold-handoff-[0-9a-f]{24}", guard["id"]) is None
671
+ or isinstance(guard.get("device"), bool)
672
+ or not isinstance(guard.get("device"), int)
673
+ or guard["device"] <= 0
674
+ or isinstance(guard.get("inode"), bool)
675
+ or not isinstance(guard.get("inode"), int)
676
+ or guard["inode"] <= 0
677
+ ):
678
+ raise RuntimeError("activation transaction guard state is invalid")
679
+
680
+
681
+ def _validate_hub_metadata(hub: Any, *, intent: str) -> None:
682
+ if hub == {}:
683
+ return
684
+ if not isinstance(hub, dict) or set(hub) != {
685
+ "kind",
686
+ "data_dir",
687
+ "hub_id",
688
+ "host_identity",
689
+ "operation_id",
690
+ "snapshot",
691
+ "fence_device",
692
+ "fence_inode",
693
+ }:
694
+ raise RuntimeError("activation transaction Hub state is invalid")
695
+ data_dir_raw = hub.get("data_dir")
696
+ snapshot_raw = hub.get("snapshot")
697
+ if not isinstance(data_dir_raw, str) or not isinstance(snapshot_raw, str):
698
+ raise RuntimeError("activation transaction Hub state is invalid")
699
+ data_dir = _absolute(data_dir_raw)
700
+ snapshot = _absolute(snapshot_raw)
701
+ if (
702
+ data_dir_raw != str(data_dir)
703
+ or snapshot_raw != str(snapshot)
704
+ or snapshot.parent != data_dir / "maintenance-backups"
705
+ or re.fullmatch(r"snapshot_[A-Za-z0-9_]+", snapshot.name) is None
706
+ or hub.get("kind") not in {"server-update", "host-reactivation"}
707
+ or not isinstance(hub.get("hub_id"), str)
708
+ or re.fullmatch(r"[A-Za-z0-9_.:-]{8,240}", hub["hub_id"]) is None
709
+ or not isinstance(hub.get("host_identity"), str)
710
+ or re.fullmatch(
711
+ r"[A-Za-z0-9][A-Za-z0-9._:@/-]{7,239}",
712
+ hub["host_identity"],
713
+ )
714
+ is None
715
+ or not isinstance(hub.get("operation_id"), str)
716
+ or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", hub["operation_id"])
717
+ is None
718
+ or isinstance(hub.get("fence_device"), bool)
719
+ or not isinstance(hub.get("fence_device"), int)
720
+ or hub["fence_device"] <= 0
721
+ or isinstance(hub.get("fence_inode"), bool)
722
+ or not isinstance(hub.get("fence_inode"), int)
723
+ or hub["fence_inode"] <= 0
724
+ ):
725
+ raise RuntimeError("activation transaction Hub state is invalid")
726
+ if (
727
+ (hub["kind"] == "server-update" and intent != "server-update")
728
+ or (
729
+ hub["kind"] == "host-reactivation"
730
+ and intent not in {"host-reactivation", "failed-host-repair"}
731
+ )
732
+ ):
733
+ raise RuntimeError("activation transaction Hub intent changed")
734
+
735
+
736
+ def _manifest_path(root: Path) -> Path:
737
+ return root / ".activation-transaction" / "manifest.json"
738
+
739
+
740
+ def _cleanup_manifest_temps(directory: Path) -> None:
741
+ removed = False
742
+ for entry in list(os.scandir(directory)):
743
+ if MANIFEST_TEMP_RE.fullmatch(entry.name) is None:
744
+ continue
745
+ path = directory / entry.name
746
+ info = path.lstat()
747
+ if (
748
+ not stat.S_ISREG(info.st_mode)
749
+ or info.st_uid != os.getuid()
750
+ or info.st_nlink != 1
751
+ or stat.S_IMODE(info.st_mode) != 0o600
752
+ ):
753
+ raise PermissionError("activation transaction staging file is unsafe")
754
+ path.unlink()
755
+ removed = True
756
+ if removed:
757
+ _fsync_directory(directory)
758
+
759
+
760
+ def _remove_private_transaction_directory(directory: Path) -> None:
761
+ info = directory.lstat()
762
+ if (
763
+ not stat.S_ISDIR(info.st_mode)
764
+ or info.st_uid != os.getuid()
765
+ or stat.S_IMODE(info.st_mode) != 0o700
766
+ ):
767
+ raise PermissionError("retired activation transaction is unsafe")
768
+ for child in list(os.scandir(directory)):
769
+ child_path = directory / child.name
770
+ child_info = child_path.lstat()
771
+ if child.name == "candidate.retired":
772
+ if (
773
+ not stat.S_ISDIR(child_info.st_mode)
774
+ or child_info.st_uid != os.getuid()
775
+ or not shutil.rmtree.avoids_symlink_attacks
776
+ ):
777
+ raise PermissionError("retired activation candidate is unsafe")
778
+ shutil.rmtree(child_path)
779
+ continue
780
+ if (
781
+ child.name
782
+ not in {"manifest.json", "env.backup", "service.backup", "gateway.backup", "execution_layout.backup"}
783
+ and MANIFEST_TEMP_RE.fullmatch(child.name) is None
784
+ ) or (
785
+ not stat.S_ISREG(child_info.st_mode)
786
+ or child_info.st_uid != os.getuid()
787
+ or child_info.st_nlink != 1
788
+ or stat.S_IMODE(child_info.st_mode) != 0o600
789
+ ):
790
+ raise PermissionError("retired activation transaction is unsafe")
791
+ child_path.unlink()
792
+ directory.rmdir()
793
+
794
+
795
+ def _cleanup_retired_transactions(root: Path) -> None:
796
+ removed = False
797
+ for entry in list(os.scandir(root)):
798
+ if (
799
+ GC_DIRECTORY_RE.fullmatch(entry.name) is None
800
+ and BEGIN_DIRECTORY_RE.fullmatch(entry.name) is None
801
+ ):
802
+ continue
803
+ directory = root / entry.name
804
+ _remove_private_transaction_directory(directory)
805
+ removed = True
806
+ if removed:
807
+ _fsync_directory(root)
808
+
809
+
810
+ def _canonical(value: dict[str, Any]) -> bytes:
811
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n"
812
+
813
+
814
+ def _read_manifest(
815
+ root: Path,
816
+ *,
817
+ directory: Path | None = None,
818
+ cleanup: bool = True,
819
+ ) -> tuple[Path, dict[str, Any]]:
820
+ _validate_root(root)
821
+ releases = _validate_releases_root(root)
822
+ directory = directory or (root / ".activation-transaction")
823
+ info = directory.lstat()
824
+ if (
825
+ not stat.S_ISDIR(info.st_mode)
826
+ or info.st_uid != os.getuid()
827
+ or stat.S_IMODE(info.st_mode) != 0o700
828
+ ):
829
+ raise PermissionError("activation transaction directory is unsafe")
830
+ if cleanup:
831
+ _cleanup_manifest_temps(directory)
832
+ try:
833
+ value = json.loads(_read_private(directory / "manifest.json"))
834
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
835
+ raise RuntimeError("activation transaction manifest is invalid") from exc
836
+ expected = {
837
+ "format",
838
+ "transaction_id",
839
+ "release_version",
840
+ "release_dir",
841
+ "candidate_release",
842
+ "old_target",
843
+ "old_release",
844
+ "current",
845
+ "previous",
846
+ "previous_release",
847
+ "env_path",
848
+ "env",
849
+ "service_path",
850
+ "service",
851
+ "service_state",
852
+ "service_enabled",
853
+ "legacy_service_state",
854
+ "legacy_service_enabled",
855
+ "prior_port",
856
+ "prior_bind_address",
857
+ "intent",
858
+ "client_binding",
859
+ "desired_env",
860
+ "desired_service",
861
+ "phase",
862
+ "rollback_from",
863
+ "hub",
864
+ "guard",
865
+ "authority_pending",
866
+ "observed_env_sha256",
867
+ "observed_service_sha256",
868
+ }
869
+ if isinstance(value, dict) and value.get("format") in {FORMAT, EXECUTION_FORMAT}:
870
+ expected.add("volume_bindings")
871
+ if isinstance(value, dict) and value.get("format") == EXECUTION_FORMAT:
872
+ expected.add("execution")
873
+ for kind in ("gateway", "execution_layout"):
874
+ expected.update({kind, f"{kind}_path", f"desired_{kind}", f"observed_{kind}_sha256"})
875
+ if not isinstance(value, dict) or set(value) != expected or value.get("format") not in {1, FORMAT, EXECUTION_FORMAT}:
876
+ raise RuntimeError("activation transaction manifest is invalid")
877
+ if value.get("phase") not in PHASES:
878
+ raise RuntimeError("activation transaction phase is invalid")
879
+ rollback_from = value.get("rollback_from")
880
+ if rollback_from is not None and (
881
+ rollback_from not in PHASES
882
+ or rollback_from
883
+ in {
884
+ "rolling-back",
885
+ "rolled-back",
886
+ "rollback-healthy",
887
+ "committing",
888
+ "committed",
889
+ }
890
+ or value["phase"]
891
+ not in {"rolling-back", "rolled-back", "rollback-healthy"}
892
+ ):
893
+ raise RuntimeError("activation transaction rollback origin is invalid")
894
+ if value["phase"] in {"rolling-back", "rolled-back", "rollback-healthy"} and rollback_from is None:
895
+ raise RuntimeError("activation transaction rollback origin is missing")
896
+ transaction_id = _safe_text(value.get("transaction_id"), "id", maximum=64)
897
+ if re.fullmatch(r"activation-[0-9a-f]{24}", transaction_id) is None:
898
+ raise RuntimeError("activation transaction id is invalid")
899
+ _safe_text(value.get("release_version"), "version", maximum=128)
900
+ for field in ("release_dir", *(f"{kind}_path" for kind in _config_kinds(value))):
901
+ _safe_text(value.get(field), field)
902
+ if not isinstance(value.get("old_target"), str) or any(
903
+ character in value["old_target"] for character in "\x00\r\n"
904
+ ):
905
+ raise RuntimeError("activation transaction old target is invalid")
906
+ def valid_release(release: Any) -> bool:
907
+ return (
908
+ isinstance(release, dict)
909
+ and set(release)
910
+ == {"source", "target", "device", "inode", "version_sha256"}
911
+ and isinstance(release.get("source"), str)
912
+ and isinstance(release.get("target"), str)
913
+ and release["source"].startswith("/")
914
+ and release["target"].startswith("/")
915
+ and isinstance(release.get("device"), int)
916
+ and release["device"] >= 0
917
+ and isinstance(release.get("inode"), int)
918
+ and release["inode"] > 0
919
+ and isinstance(release.get("version_sha256"), str)
920
+ and re.fullmatch(r"[0-9a-f]{64}", release["version_sha256"])
921
+ is not None
922
+ )
923
+
924
+ old_release = value.get("old_release")
925
+ if old_release != {} and not valid_release(old_release):
926
+ raise RuntimeError("activation transaction rollback release is invalid")
927
+ previous_release = value.get("previous_release")
928
+ if previous_release != {} and not valid_release(previous_release):
929
+ raise RuntimeError("activation transaction previous release is invalid")
930
+ for field in ("current", "previous"):
931
+ item = value.get(field)
932
+ if (
933
+ not isinstance(item, dict)
934
+ or set(item) != {"kind", "target"}
935
+ or item.get("kind") not in {"missing", "symlink", "directory"}
936
+ or not isinstance(item.get("target"), str)
937
+ or "\n" in item["target"]
938
+ or "\r" in item["target"]
939
+ or "\x00" in item["target"]
940
+ or (field == "previous" and item["kind"] == "directory")
941
+ ):
942
+ raise RuntimeError("activation transaction link state is invalid")
943
+ for field in _config_kinds(value):
944
+ item = value.get(field)
945
+ if (
946
+ not isinstance(item, dict)
947
+ or set(item) != {"existed", "backup", "sha256", "mode"}
948
+ or not isinstance(item.get("existed"), bool)
949
+ or item.get("backup") != f"{field}.backup"
950
+ or (
951
+ item["existed"]
952
+ and (
953
+ not isinstance(item.get("sha256"), str)
954
+ or re.fullmatch(r"[0-9a-f]{64}", item["sha256"])
955
+ is None
956
+ )
957
+ )
958
+ or (
959
+ item["existed"]
960
+ and (
961
+ not isinstance(item.get("mode"), int)
962
+ or item["mode"] not in {0o600, 0o640, 0o644}
963
+ )
964
+ )
965
+ or (
966
+ not item["existed"]
967
+ and (item.get("sha256") is not None or item.get("mode") is not None)
968
+ )
969
+ ):
970
+ raise RuntimeError("activation transaction backup metadata is invalid")
971
+ if item["existed"] and _sha256(directory / item["backup"]) != item["sha256"]:
972
+ raise RuntimeError("activation transaction backup changed")
973
+ if value.get("service_state") not in {"absent", "stopped", "running"}:
974
+ raise RuntimeError("activation transaction service state is invalid")
975
+ if not isinstance(value.get("service_enabled"), bool):
976
+ raise RuntimeError("activation transaction service enablement is invalid")
977
+ if value.get("legacy_service_state") not in {"absent", "stopped", "running"}:
978
+ raise RuntimeError("activation transaction legacy service state is invalid")
979
+ if not isinstance(value.get("legacy_service_enabled"), bool):
980
+ raise RuntimeError("activation transaction legacy enablement is invalid")
981
+ prior_port = value.get("prior_port")
982
+ if not isinstance(prior_port, int) or not 1 <= prior_port <= 65535:
983
+ raise RuntimeError("activation transaction prior port is invalid")
984
+ prior_bind = _safe_text(
985
+ value.get("prior_bind_address"), "prior bind", maximum=64
986
+ )
987
+ if prior_bind != "localhost":
988
+ try:
989
+ parsed_bind = ipaddress.ip_address(prior_bind)
990
+ except ValueError as exc:
991
+ raise RuntimeError(
992
+ "activation transaction prior bind is invalid"
993
+ ) from exc
994
+ if str(parsed_bind) != prior_bind:
995
+ raise RuntimeError("activation transaction prior bind is noncanonical")
996
+ if value.get("intent") not in {
997
+ "ordinary",
998
+ "server-update",
999
+ "host-reactivation",
1000
+ "failed-host-repair",
1001
+ }:
1002
+ raise RuntimeError("activation transaction intent is invalid")
1003
+ client_binding = value.get("client_binding")
1004
+ if (
1005
+ not isinstance(client_binding, str)
1006
+ or len(client_binding) > 64 * 1024
1007
+ or any(character in client_binding for character in "\x00\r\n")
1008
+ ):
1009
+ raise RuntimeError("activation transaction client binding is invalid")
1010
+ for field in (f"desired_{kind}" for kind in _config_kinds(value)):
1011
+ item = value.get(field)
1012
+ kind = field.removeprefix("desired_")
1013
+ destination = _absolute(
1014
+ value[f"{kind}_path"]
1015
+ )
1016
+ expected_source, _expected_secured = _config_staging_paths(
1017
+ destination,
1018
+ value["transaction_id"],
1019
+ kind,
1020
+ )
1021
+ if item is not None and (
1022
+ not isinstance(item, dict)
1023
+ or set(item) != {"sha256", "mode", "source", "device", "inode"}
1024
+ or not isinstance(item.get("sha256"), str)
1025
+ or re.fullmatch(r"[0-9a-f]{64}", item["sha256"]) is None
1026
+ or not isinstance(item.get("mode"), int)
1027
+ or item["mode"] not in (
1028
+ _config_modes(kind, publication=True)
1029
+ )
1030
+ or item.get("source") != str(expected_source)
1031
+ or isinstance(item.get("device"), bool)
1032
+ or not isinstance(item.get("device"), int)
1033
+ or item["device"] <= 0
1034
+ or isinstance(item.get("inode"), bool)
1035
+ or not isinstance(item.get("inode"), int)
1036
+ or item["inode"] <= 0
1037
+ ):
1038
+ raise RuntimeError("activation desired configuration is invalid")
1039
+ if not isinstance(value.get("authority_pending"), bool):
1040
+ raise RuntimeError("activation transaction authority state is invalid")
1041
+ for field in (f"observed_{kind}_sha256" for kind in _config_kinds(value)):
1042
+ items = value.get(field)
1043
+ if not isinstance(items, list) or any(
1044
+ item is not None
1045
+ and (
1046
+ not isinstance(item, str)
1047
+ or re.fullmatch(r"[0-9a-f]{64}", item) is None
1048
+ )
1049
+ for item in items
1050
+ ):
1051
+ raise RuntimeError("activation transaction configuration history is invalid")
1052
+ candidate_release = value.get("candidate_release")
1053
+ if not valid_release(candidate_release):
1054
+ raise RuntimeError("activation transaction candidate release is invalid")
1055
+ release_version = value["release_version"]
1056
+ release_dir = _absolute(value["release_dir"])
1057
+ candidate_source = _absolute(candidate_release["source"])
1058
+ candidate_target = _absolute(candidate_release["target"])
1059
+ if (
1060
+ value["release_dir"] != str(release_dir)
1061
+ or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", release_version)
1062
+ is None
1063
+ or release_dir != releases / release_version
1064
+ or candidate_release["source"] != str(candidate_source)
1065
+ or candidate_release["target"] != str(candidate_target)
1066
+ or candidate_source.parent != releases
1067
+ or candidate_source == candidate_target
1068
+ or candidate_target != release_dir
1069
+ ):
1070
+ raise RuntimeError("activation transaction release path is invalid")
1071
+ if old_release:
1072
+ old_source = _absolute(old_release["source"])
1073
+ old_target = _absolute(old_release["target"])
1074
+ if (
1075
+ old_release["source"] != str(old_source)
1076
+ or old_release["target"] != str(old_target)
1077
+ or old_target.parent != releases
1078
+ or (old_source.parent != releases and old_source != root / "current")
1079
+ or value["old_target"] != str(old_target)
1080
+ ):
1081
+ raise RuntimeError("activation transaction rollback release path is invalid")
1082
+ elif value["old_target"]:
1083
+ raise RuntimeError("activation transaction rollback release path is invalid")
1084
+ if previous_release:
1085
+ previous_source = _absolute(previous_release["source"])
1086
+ previous_target = _absolute(previous_release["target"])
1087
+ if (
1088
+ previous_release["source"] != str(previous_source)
1089
+ or previous_release["target"] != str(previous_target)
1090
+ or previous_source.parent != releases
1091
+ or previous_target.parent != releases
1092
+ ):
1093
+ raise RuntimeError("activation transaction previous release path is invalid")
1094
+ _validate_execution_metadata(root, value, valid_release)
1095
+ hub = value.get("hub")
1096
+ _validate_hub_metadata(hub, intent=value["intent"])
1097
+ guard = value.get("guard")
1098
+ _validate_guard_metadata(guard)
1099
+ _rebase_volume_bindings(value)
1100
+ return directory, value
1101
+
1102
+
1103
+ def _current_config_metadata(path: Path) -> dict[str, Any] | None:
1104
+ try:
1105
+ info = path.lstat()
1106
+ except FileNotFoundError:
1107
+ return None
1108
+ payload = _read_owned_regular(path, maximum=MAX_CONFIG_BYTES)
1109
+ linked_info = path.lstat()
1110
+ if (info.st_dev, info.st_ino) != (linked_info.st_dev, linked_info.st_ino):
1111
+ raise RuntimeError("activation configuration changed while verifying it")
1112
+ return {
1113
+ "sha256": hashlib.sha256(payload).hexdigest(),
1114
+ "mode": stat.S_IMODE(info.st_mode),
1115
+ }
1116
+
1117
+
1118
+ def _current_config_digest(path: Path) -> str | None:
1119
+ metadata = _current_config_metadata(path)
1120
+ return None if metadata is None else str(metadata["sha256"])
1121
+
1122
+
1123
+ def _config_staging_paths(
1124
+ destination: Path,
1125
+ transaction_id: str,
1126
+ kind: str,
1127
+ ) -> tuple[Path, Path]:
1128
+ prefix = f".{destination.name}.activation-{transaction_id}-{kind}"
1129
+ return (
1130
+ destination.parent / f"{prefix}.source",
1131
+ destination.parent / f"{prefix}.publication",
1132
+ )
1133
+
1134
+
1135
+ def _desired_content_metadata(desired: dict[str, Any]) -> dict[str, Any]:
1136
+ return {"sha256": desired["sha256"], "mode": desired["mode"]}
1137
+
1138
+
1139
+ def _config_identity_matches(info: os.stat_result, desired: dict[str, Any]) -> bool:
1140
+ return (info.st_dev, info.st_ino) == (desired["device"], desired["inode"])
1141
+
1142
+
1143
+ def _config_path_matches_desired(path: Path, desired: dict[str, Any]) -> bool:
1144
+ try:
1145
+ before = path.lstat()
1146
+ except FileNotFoundError:
1147
+ return False
1148
+ if not _config_identity_matches(before, desired):
1149
+ return False
1150
+ if _current_config_metadata(path) != _desired_content_metadata(desired):
1151
+ return False
1152
+ after = path.lstat()
1153
+ return (before.st_dev, before.st_ino) == (after.st_dev, after.st_ino)
1154
+
1155
+
1156
+ def _open_config_staging_source(path: Path) -> tuple[int, os.stat_result, bytes]:
1157
+ flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0)
1158
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1159
+ descriptor = os.open(path, flags)
1160
+ try:
1161
+ before = os.fstat(descriptor)
1162
+ if (
1163
+ not stat.S_ISREG(before.st_mode)
1164
+ or before.st_uid != os.getuid()
1165
+ or before.st_nlink != 1
1166
+ or stat.S_IMODE(before.st_mode) & 0o022
1167
+ or not 0 <= before.st_size <= MAX_CONFIG_BYTES
1168
+ ):
1169
+ raise PermissionError("activation configuration staging file is unsafe")
1170
+ chunks: list[bytes] = []
1171
+ remaining = MAX_CONFIG_BYTES + 1
1172
+ while remaining:
1173
+ chunk = os.read(descriptor, min(remaining, 1024 * 1024))
1174
+ if not chunk:
1175
+ break
1176
+ chunks.append(chunk)
1177
+ remaining -= len(chunk)
1178
+ payload = b"".join(chunks)
1179
+ after = os.fstat(descriptor)
1180
+ linked = path.lstat()
1181
+ if (
1182
+ len(payload) > MAX_CONFIG_BYTES
1183
+ or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
1184
+ != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
1185
+ or (before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
1186
+ ):
1187
+ raise RuntimeError("activation configuration staging file changed")
1188
+ return descriptor, before, payload
1189
+ except BaseException:
1190
+ os.close(descriptor)
1191
+ raise
1192
+
1193
+
1194
+ def _staged_config_metadata(path: Path) -> tuple[dict[str, Any], os.stat_result]:
1195
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
1196
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
1197
+ descriptor = os.open(path, flags)
1198
+ try:
1199
+ before = os.fstat(descriptor)
1200
+ if (
1201
+ not stat.S_ISREG(before.st_mode)
1202
+ or before.st_uid != os.getuid()
1203
+ or before.st_nlink not in {1, 2}
1204
+ or stat.S_IMODE(before.st_mode) & 0o022
1205
+ or not 0 <= before.st_size <= MAX_CONFIG_BYTES
1206
+ ):
1207
+ raise PermissionError("activation configuration publication is unsafe")
1208
+ chunks: list[bytes] = []
1209
+ remaining = MAX_CONFIG_BYTES + 1
1210
+ while remaining:
1211
+ chunk = os.read(descriptor, min(remaining, 1024 * 1024))
1212
+ if not chunk:
1213
+ break
1214
+ chunks.append(chunk)
1215
+ remaining -= len(chunk)
1216
+ payload = b"".join(chunks)
1217
+ after = os.fstat(descriptor)
1218
+ linked = path.lstat()
1219
+ if (
1220
+ len(payload) > MAX_CONFIG_BYTES
1221
+ or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
1222
+ != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
1223
+ or (before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
1224
+ ):
1225
+ raise RuntimeError("activation configuration publication changed")
1226
+ return {
1227
+ "sha256": hashlib.sha256(payload).hexdigest(),
1228
+ "mode": stat.S_IMODE(before.st_mode),
1229
+ }, before
1230
+ finally:
1231
+ os.close(descriptor)
1232
+
1233
+
1234
+ def _unlink_if_same_file(path: Path, identity: os.stat_result) -> None:
1235
+ try:
1236
+ linked = path.lstat()
1237
+ except FileNotFoundError:
1238
+ return
1239
+ if (linked.st_dev, linked.st_ino) == (identity.st_dev, identity.st_ino):
1240
+ path.unlink()
1241
+
1242
+
1243
+ def _fsync_staging_directory(path: Path) -> None:
1244
+ """Persist the private staging link before the final publish rename."""
1245
+
1246
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
1247
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_DIRECTORY", 0)
1248
+ descriptor = os.open(path, flags)
1249
+ try:
1250
+ os.fsync(descriptor)
1251
+ finally:
1252
+ os.close(descriptor)
1253
+
1254
+
1255
+ def _publish_staged_config(
1256
+ *,
1257
+ staged: Path,
1258
+ source: Path,
1259
+ destination: Path,
1260
+ desired: dict[str, Any],
1261
+ ) -> None:
1262
+ metadata, identity = _staged_config_metadata(staged)
1263
+ if (
1264
+ metadata != _desired_content_metadata(desired)
1265
+ or not _config_identity_matches(identity, desired)
1266
+ ):
1267
+ raise RuntimeError("activation staged configuration does not match its journal")
1268
+ _unlink_if_same_file(source, identity)
1269
+ _fsync_staging_directory(source.parent)
1270
+ if staged.parent != source.parent:
1271
+ _fsync_staging_directory(staged.parent)
1272
+ try:
1273
+ os.replace(staged, destination)
1274
+ except BaseException:
1275
+ # A normal rename failure leaves the exact staged inode available for
1276
+ # retry. Restore the caller's source name when possible so an in-process
1277
+ # retry retains the original interface; a process crash instead leaves
1278
+ # the fsynced deterministic staging name for recovery.
1279
+ try:
1280
+ staged.lstat()
1281
+ source.lstat()
1282
+ except FileNotFoundError:
1283
+ try:
1284
+ staged.lstat()
1285
+ except FileNotFoundError:
1286
+ pass
1287
+ else:
1288
+ os.rename(staged, source)
1289
+ _fsync_staging_directory(source.parent)
1290
+ raise
1291
+ _fsync_directory(destination.parent)
1292
+ if not _config_path_matches_desired(destination, desired):
1293
+ raise RuntimeError("activation configuration changed after publication")
1294
+
1295
+
1296
+ def _cleanup_config_staging(
1297
+ directory: Path,
1298
+ value: dict[str, Any],
1299
+ ) -> None:
1300
+ """Remove only schema-closed staging inodes owned by this transaction."""
1301
+
1302
+ for kind in _config_kinds(value):
1303
+ desired = value[f"desired_{kind}"]
1304
+ destination = _absolute(
1305
+ value[f"{kind}_path"]
1306
+ )
1307
+ source, secured = _config_staging_paths(
1308
+ destination,
1309
+ value["transaction_id"],
1310
+ kind,
1311
+ )
1312
+
1313
+ try:
1314
+ secured_metadata, secured_info = _staged_config_metadata(secured)
1315
+ except FileNotFoundError:
1316
+ secured_metadata = None
1317
+ secured_info = None
1318
+
1319
+ try:
1320
+ source_metadata, source_info = _staged_config_metadata(source)
1321
+ except FileNotFoundError:
1322
+ pass
1323
+ else:
1324
+ if desired is None:
1325
+ if (
1326
+ source_info.st_nlink != 1
1327
+ or stat.S_IMODE(source_info.st_mode) != 0o600
1328
+ ):
1329
+ raise PermissionError(
1330
+ "activation unjournaled configuration staging is unsafe"
1331
+ )
1332
+ elif (
1333
+ source_metadata != _desired_content_metadata(desired)
1334
+ or not _config_identity_matches(source_info, desired)
1335
+ ):
1336
+ # A later rewrite may have populated this private reserved
1337
+ # source and crashed before its new intent was journaled. No
1338
+ # matching publication inode can exist before that
1339
+ # write-ahead update, so an independent nlink-1 draft is safe
1340
+ # to discard even while the prior exact publication is being
1341
+ # resumed.
1342
+ if (
1343
+ source_info.st_nlink != 1
1344
+ or stat.S_IMODE(source_info.st_mode) != 0o600
1345
+ or (
1346
+ secured_info is not None
1347
+ and (
1348
+ desired is None
1349
+ or secured_metadata
1350
+ != _desired_content_metadata(desired)
1351
+ or not _config_identity_matches(
1352
+ secured_info,
1353
+ desired,
1354
+ )
1355
+ )
1356
+ )
1357
+ ):
1358
+ raise RuntimeError(
1359
+ "activation configuration staging ownership changed"
1360
+ )
1361
+ source.unlink()
1362
+ _fsync_directory(source.parent)
1363
+
1364
+ if secured_info is None:
1365
+ continue
1366
+ if desired is None or (
1367
+ secured_metadata != _desired_content_metadata(desired)
1368
+ or not _config_identity_matches(secured_info, desired)
1369
+ ):
1370
+ raise RuntimeError(
1371
+ "activation configuration publication ownership changed"
1372
+ )
1373
+ secured.unlink()
1374
+ _fsync_directory(destination.parent)
1375
+
1376
+
1377
+ def begin(args: argparse.Namespace) -> None:
1378
+ root = _absolute(args.root)
1379
+ _validate_root(root)
1380
+ releases = _validate_releases_root(root)
1381
+ _cleanup_retired_transactions(root)
1382
+ current = _absolute(args.current)
1383
+ previous = _absolute(args.previous)
1384
+ env_path = _absolute(args.env)
1385
+ service_path = _absolute(args.service)
1386
+ release_dir = _absolute(args.release_dir)
1387
+ candidate_source = _absolute(args.candidate_source)
1388
+ old_source = _absolute(args.old_source) if args.old_source else None
1389
+ old_target = _absolute(args.old_target) if args.old_target else None
1390
+ release_version = _safe_text(args.release_version, "version", maximum=128)
1391
+ if (
1392
+ current != root / "current"
1393
+ or previous != root / "previous"
1394
+ or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", release_version)
1395
+ is None
1396
+ or release_dir != releases / release_version
1397
+ or candidate_source.parent != releases
1398
+ or candidate_source == release_dir
1399
+ or (
1400
+ old_target is not None and old_target.parent != releases
1401
+ )
1402
+ or (
1403
+ old_source is not None
1404
+ and old_source.parent != releases
1405
+ and old_source != current
1406
+ )
1407
+ or ((old_source is None) != (old_target is None))
1408
+ ):
1409
+ raise RuntimeError("activation transaction paths are invalid")
1410
+ final = root / ".activation-transaction"
1411
+ try:
1412
+ final.lstat()
1413
+ except FileNotFoundError:
1414
+ pass
1415
+ else:
1416
+ raise RuntimeError("an activation transaction is already pending")
1417
+ temporary = root / f".activation-transaction.{secrets.token_hex(12)}.tmp"
1418
+ os.mkdir(temporary, 0o700)
1419
+ os.chmod(temporary, 0o700)
1420
+ try:
1421
+ env = _configuration(
1422
+ env_path,
1423
+ temporary / "env.backup",
1424
+ allowed_modes={0o600},
1425
+ )
1426
+ service = _configuration(
1427
+ service_path,
1428
+ temporary / "service.backup",
1429
+ allowed_modes={0o600, 0o640, 0o644},
1430
+ )
1431
+ current_state = _link_state(current, allow_directory=True)
1432
+ old_release = _release_identity(old_source, old_target)
1433
+ if current_state["kind"] == "missing":
1434
+ if old_release:
1435
+ raise RuntimeError(
1436
+ "activation rollback identity exists without a current release"
1437
+ )
1438
+ elif not old_release:
1439
+ raise RuntimeError("activation current release has no rollback identity")
1440
+ elif current_state["kind"] == "directory":
1441
+ if old_source != current or not _release_matches(current, old_release):
1442
+ raise RuntimeError("activation legacy current rollback identity changed")
1443
+ else:
1444
+ current_target = Path(current_state["target"])
1445
+ if not current_target.is_absolute():
1446
+ current_target = current.parent / current_target
1447
+ if not _release_matches(_absolute(current_target), old_release):
1448
+ raise RuntimeError("activation current rollback identity changed")
1449
+ candidate_release = _release_identity(candidate_source, release_dir)
1450
+ version_payload = _read_owned_regular(
1451
+ candidate_source / "VERSION",
1452
+ maximum=4096,
1453
+ )
1454
+ try:
1455
+ candidate_version = version_payload.decode("utf-8").strip()
1456
+ except UnicodeDecodeError as exc:
1457
+ raise RuntimeError("activation candidate version is invalid") from exc
1458
+ if candidate_version != release_version:
1459
+ raise RuntimeError("activation candidate release version changed")
1460
+ value: dict[str, Any] = {
1461
+ "format": FORMAT,
1462
+ "volume_bindings": {},
1463
+ "transaction_id": f"activation-{secrets.token_hex(12)}",
1464
+ "release_version": release_version,
1465
+ "release_dir": str(release_dir),
1466
+ "candidate_release": candidate_release,
1467
+ "old_target": str(old_target) if old_target is not None else "",
1468
+ "old_release": old_release,
1469
+ "current": current_state,
1470
+ "previous": _link_state(previous, allow_directory=False),
1471
+ "previous_release": _linked_release_identity(previous),
1472
+ "env_path": str(env_path),
1473
+ "env": env,
1474
+ "service_path": str(service_path),
1475
+ "service": service,
1476
+ "service_state": args.service_state,
1477
+ "service_enabled": args.service_enabled == "true",
1478
+ "legacy_service_state": args.legacy_service_state,
1479
+ "legacy_service_enabled": args.legacy_service_enabled == "true",
1480
+ "prior_port": args.prior_port,
1481
+ "prior_bind_address": args.prior_bind_address,
1482
+ "intent": args.intent,
1483
+ "client_binding": args.client_binding,
1484
+ "desired_env": None,
1485
+ "desired_service": None,
1486
+ "phase": "prepared",
1487
+ "rollback_from": None,
1488
+ "hub": {},
1489
+ "guard": {},
1490
+ "authority_pending": False,
1491
+ "observed_env_sha256": [env["sha256"]],
1492
+ "observed_service_sha256": [service["sha256"]],
1493
+ }
1494
+ if args.execution_runtime_dir:
1495
+ _begin_execution(args, value, temporary, root)
1496
+ elif args.gateway_service or args.execution_handoff_file:
1497
+ raise RuntimeError("execution metadata requires an execution runtime directory")
1498
+ for anchor in (root, releases):
1499
+ _capture_volume(value, anchor)
1500
+ for anchor in (_absolute(value[f"{kind}_path"]).parent for kind in _config_kinds(value)):
1501
+ _capture_volume(value, anchor, allow_missing=True)
1502
+ _write_new_private(temporary / "manifest.json", _canonical(value))
1503
+ _fsync_directory(temporary)
1504
+ _verified_directory, verified_value = _read_manifest(
1505
+ root,
1506
+ directory=temporary,
1507
+ )
1508
+ _validate_invocation(
1509
+ args,
1510
+ verified_value,
1511
+ transaction_directory=temporary,
1512
+ )
1513
+ os.rename(temporary, final)
1514
+ _fsync_directory(root)
1515
+ sys.stdout.write(str(value["transaction_id"]) + "\n")
1516
+ except BaseException:
1517
+ shutil.rmtree(temporary, ignore_errors=True)
1518
+ raise
1519
+
1520
+
1521
+ def _validate_invocation(
1522
+ args: argparse.Namespace,
1523
+ value: dict[str, Any],
1524
+ *,
1525
+ allow_missing_candidate: bool = False,
1526
+ transaction_directory: Path | None = None,
1527
+ ) -> None:
1528
+ root = _absolute(args.root)
1529
+ if (
1530
+ _absolute(value["env_path"]) != _absolute(args.env)
1531
+ or _absolute(value["service_path"]) != _absolute(args.service)
1532
+ or _absolute(args.current) != root / "current"
1533
+ or _absolute(args.previous) != root / "previous"
1534
+ ):
1535
+ raise RuntimeError("pending activation belongs to a different layout")
1536
+ if getattr(args, "transaction_id", None) not in {None, ""} and (
1537
+ args.transaction_id != value["transaction_id"]
1538
+ ):
1539
+ raise RuntimeError("activation transaction ownership changed")
1540
+ if getattr(args, "release_version", None) not in {None, ""} and (
1541
+ value["release_version"] != args.release_version
1542
+ ):
1543
+ raise RuntimeError("activation transaction release version changed")
1544
+ if getattr(args, "release_dir", None) not in {None, ""} and (
1545
+ _absolute(value["release_dir"]) != _absolute(args.release_dir)
1546
+ ):
1547
+ raise RuntimeError("activation transaction release path changed")
1548
+ candidate_retired = root / "releases" / (
1549
+ ".activation-candidate-retired-" + str(value["transaction_id"])
1550
+ )
1551
+ candidate_parked = (
1552
+ transaction_directory or (root / ".activation-transaction")
1553
+ ) / "candidate.retired"
1554
+ old_release = value["old_release"]
1555
+ if old_release and _locate_release(old_release) is None:
1556
+ suffix = (
1557
+ "; this legacy journal has no persistent volume proof, so a "
1558
+ "filesystem remount requires support-assisted recovery"
1559
+ if value["format"] == 1 else ""
1560
+ )
1561
+ raise RuntimeError("activation rollback release identity changed" + suffix)
1562
+ old_worker = value.get("execution", {}).get("old_worker_release")
1563
+ if old_worker and _locate_release(old_worker) is None:
1564
+ raise RuntimeError("activation pinned worker release identity changed")
1565
+ candidate_location = _locate_release(
1566
+ value["candidate_release"],
1567
+ extras=(candidate_retired, candidate_parked),
1568
+ )
1569
+ if candidate_location is None and not allow_missing_candidate:
1570
+ raise RuntimeError("activation candidate release identity changed")
1571
+
1572
+ def link_points_to(path: Path, release: dict[str, Any]) -> bool:
1573
+ try:
1574
+ info = path.lstat()
1575
+ except FileNotFoundError:
1576
+ return False
1577
+ if not stat.S_ISLNK(info.st_mode):
1578
+ return False
1579
+ raw_target = os.readlink(path)
1580
+ target = Path(raw_target)
1581
+ if not target.is_absolute():
1582
+ target = path.parent / target
1583
+ return _release_matches(_absolute(target), release)
1584
+
1585
+ current_path = _absolute(args.current)
1586
+ previous_path = _absolute(args.previous)
1587
+ current_state = _link_state(current_path, allow_directory=True)
1588
+ original_current = value["current"]
1589
+ candidate_current = {"kind": "symlink", "target": value["release_dir"]}
1590
+ phase = value["phase"]
1591
+ candidate_link_target_missing = False
1592
+ if current_state == candidate_current:
1593
+ raw_target = Path(current_state["target"])
1594
+ if not raw_target.is_absolute():
1595
+ raw_target = current_path.parent / raw_target
1596
+ try:
1597
+ raw_target.lstat()
1598
+ except FileNotFoundError:
1599
+ candidate_link_target_missing = True
1600
+ points_to_candidate = (
1601
+ current_state == candidate_current
1602
+ and link_points_to(current_path, value["candidate_release"])
1603
+ )
1604
+ points_to_old = (
1605
+ bool(old_release)
1606
+ and current_state == original_current
1607
+ and (
1608
+ (
1609
+ original_current["kind"] == "symlink"
1610
+ and link_points_to(current_path, old_release)
1611
+ )
1612
+ or (
1613
+ original_current["kind"] == "directory"
1614
+ and _release_matches(current_path, old_release)
1615
+ )
1616
+ )
1617
+ )
1618
+ original_current_valid = current_state == original_current and (
1619
+ original_current["kind"] == "missing" or points_to_old
1620
+ )
1621
+ candidate_current_valid = points_to_candidate or (
1622
+ allow_missing_candidate
1623
+ and current_state == candidate_current
1624
+ and candidate_link_target_missing
1625
+ )
1626
+ current_missing = current_state == {"kind": "missing", "target": ""}
1627
+ if phase in {"prepared", "guarded", "quiescing", "quiesced"}:
1628
+ if not original_current_valid:
1629
+ raise RuntimeError("current release changed before link takeover")
1630
+ elif phase in {"linking", "rolling-back"}:
1631
+ if not (
1632
+ original_current_valid
1633
+ or candidate_current_valid
1634
+ or current_missing
1635
+ or (
1636
+ current_state == candidate_current
1637
+ and candidate_link_target_missing
1638
+ )
1639
+ ):
1640
+ raise RuntimeError("current release target changed during activation")
1641
+ elif phase in {"rolled-back", "rollback-healthy"}:
1642
+ if not original_current_valid:
1643
+ raise RuntimeError("current release was not restored")
1644
+ elif not candidate_current_valid:
1645
+ raise RuntimeError("current release does not resolve to the candidate")
1646
+
1647
+ previous_state = _link_state(previous_path, allow_directory=False)
1648
+ candidate_previous = (
1649
+ {"kind": "symlink", "target": value["old_target"]}
1650
+ if value["old_target"]
1651
+ else None
1652
+ )
1653
+ previous_release = value["previous_release"]
1654
+ original_previous_valid = previous_state == value["previous"] and (
1655
+ not previous_release or link_points_to(previous_path, previous_release)
1656
+ )
1657
+ candidate_previous_valid = (
1658
+ candidate_previous is None
1659
+ and original_previous_valid
1660
+ ) or (
1661
+ candidate_previous is not None
1662
+ and previous_state == candidate_previous
1663
+ and bool(old_release)
1664
+ and link_points_to(previous_path, old_release)
1665
+ )
1666
+ dangling_candidate_previous = (
1667
+ phase == "rolling-back"
1668
+ and candidate_previous is not None
1669
+ and previous_state == candidate_previous
1670
+ and bool(old_release)
1671
+ and _release_matches(_absolute(old_release["source"]), old_release)
1672
+ )
1673
+ previous_missing = previous_state == {"kind": "missing", "target": ""}
1674
+ if phase in {"prepared", "guarded", "quiescing", "quiesced"}:
1675
+ if not original_previous_valid:
1676
+ raise RuntimeError("previous release changed before link takeover")
1677
+ elif phase in {"linking", "rolling-back"}:
1678
+ if not (
1679
+ original_previous_valid
1680
+ or candidate_previous_valid
1681
+ or dangling_candidate_previous
1682
+ or previous_missing
1683
+ ):
1684
+ raise RuntimeError("previous release changed during activation")
1685
+ elif phase in {"rolled-back", "rollback-healthy"}:
1686
+ if not original_previous_valid:
1687
+ raise RuntimeError("previous release link was not restored")
1688
+ elif not candidate_previous_valid:
1689
+ raise RuntimeError("previous release does not resolve to the rollback release")
1690
+
1691
+ for kind in _config_kinds(value):
1692
+ destination = _absolute(value[f"{kind}_path"])
1693
+ metadata = _current_config_metadata(destination)
1694
+ original = value[kind]
1695
+ original_metadata = ({"sha256": original["sha256"], "mode": original["mode"]}
1696
+ if original["existed"] else None)
1697
+ desired = value[f"desired_{kind}"]
1698
+ if phase in {"prepared", "guarded", "quiescing", "quiesced", "linking", "stopping", "stopped", "fencing",
1699
+ "rolled-back", "rollback-healthy"}:
1700
+ valid = metadata == original_metadata
1701
+ elif phase in {"linked", "fenced", "authorizing", "authority", "rolling-back"}:
1702
+ history = value[f"observed_{kind}_sha256"]
1703
+ valid = (None in history if metadata is None else
1704
+ metadata["sha256"] in history and metadata["mode"] in _config_modes(kind))
1705
+ else:
1706
+ valid = desired is not None and _config_path_matches_desired(destination, desired)
1707
+ if not valid:
1708
+ raise RuntimeError("activation configuration does not match its phase")
1709
+
1710
+
1711
+ def load(args: argparse.Namespace) -> None:
1712
+ root = _absolute(args.root)
1713
+ _directory, value = _read_manifest(root)
1714
+ _validate_invocation(args, value, allow_missing_candidate=True)
1715
+ hub = value["hub"]
1716
+ guard = value["guard"]
1717
+ candidate_retired = root / "releases" / (
1718
+ ".activation-candidate-retired-" + str(value["transaction_id"])
1719
+ )
1720
+ candidate_parked = root / ".activation-transaction" / "candidate.retired"
1721
+ candidate_path = _locate_release(
1722
+ value["candidate_release"],
1723
+ extras=(candidate_retired, candidate_parked),
1724
+ )
1725
+ fields = [
1726
+ value["transaction_id"],
1727
+ value["release_version"],
1728
+ value["release_dir"],
1729
+ value["phase"],
1730
+ str(value.get("rollback_from") or ""),
1731
+ value["old_target"],
1732
+ str(value["old_release"].get("source", "")),
1733
+ value["current"]["kind"],
1734
+ value["current"]["target"],
1735
+ value["previous"]["kind"],
1736
+ value["previous"]["target"],
1737
+ "true" if value["env"]["existed"] else "false",
1738
+ str(root / ".activation-transaction" / value["env"]["backup"]),
1739
+ "true" if value["service"]["existed"] else "false",
1740
+ str(root / ".activation-transaction" / value["service"]["backup"]),
1741
+ value["service_state"],
1742
+ "true" if value["service_enabled"] else "false",
1743
+ value["legacy_service_state"],
1744
+ "true" if value["legacy_service_enabled"] else "false",
1745
+ value["intent"],
1746
+ value["client_binding"],
1747
+ str(hub.get("kind", "")),
1748
+ str(hub.get("data_dir", "")),
1749
+ str(hub.get("hub_id", "")),
1750
+ str(hub.get("host_identity", "")),
1751
+ str(hub.get("operation_id", "")),
1752
+ str(hub.get("snapshot", "")),
1753
+ str(hub.get("fence_device", "")),
1754
+ str(hub.get("fence_inode", "")),
1755
+ str(guard.get("id", "")),
1756
+ str(guard.get("device", "")),
1757
+ str(guard.get("inode", "")),
1758
+ "true" if value["authority_pending"] else "false",
1759
+ (
1760
+ "true"
1761
+ if (
1762
+ value["rollback_from"]
1763
+ if value["phase"]
1764
+ in {"rolling-back", "rolled-back", "rollback-healthy"}
1765
+ else value["phase"]
1766
+ )
1767
+ in {
1768
+ "linking",
1769
+ "linked",
1770
+ "stopping",
1771
+ "stopped",
1772
+ "fencing",
1773
+ "fenced",
1774
+ "authorizing",
1775
+ "authority",
1776
+ "candidate-starting",
1777
+ "candidate-healthy",
1778
+ "committing",
1779
+ "committed",
1780
+ "rolling-back",
1781
+ }
1782
+ else "false"
1783
+ ),
1784
+ str(candidate_path) if candidate_path is not None else "",
1785
+ str(value["prior_port"]),
1786
+ value["prior_bind_address"],
1787
+ "activation-end",
1788
+ ]
1789
+ for index, field in enumerate(fields):
1790
+ if "\n" in field or "\r" in field or "\x00" in field:
1791
+ raise RuntimeError(f"activation transaction output field {index} is invalid")
1792
+ sys.stdout.write("\n".join(fields) + "\n")
1793
+
1794
+
1795
+ def record(args: argparse.Namespace) -> None:
1796
+ root = _absolute(args.root)
1797
+ directory, value = _read_manifest(root)
1798
+ _validate_invocation(
1799
+ args,
1800
+ value,
1801
+ allow_missing_candidate=args.phase
1802
+ in {"rolling-back", "rolled-back", "rollback-healthy"},
1803
+ )
1804
+ if args.phase not in PHASES:
1805
+ raise RuntimeError("activation transaction phase is invalid")
1806
+ original_value = json.loads(json.dumps(value))
1807
+ previous_phase = value["phase"]
1808
+ if args.phase != value["phase"]:
1809
+ if args.phase in {"quiescing", "quiesced"} and value.get("format") != EXECUTION_FORMAT:
1810
+ raise RuntimeError("execution quiescence requires a split transaction")
1811
+ if args.phase not in TRANSITIONS[value["phase"]]:
1812
+ raise RuntimeError("activation transaction phase transition is invalid")
1813
+ value["phase"] = args.phase
1814
+ if args.phase == "rolling-back":
1815
+ value["rollback_from"] = previous_phase
1816
+ if args.hub_kind:
1817
+ try:
1818
+ fence_device = int(args.fence_device or 0)
1819
+ fence_inode = int(args.fence_inode or 0)
1820
+ except ValueError as exc:
1821
+ raise RuntimeError("activation transaction Hub coordinates are invalid") from exc
1822
+ data_dir = _absolute(args.hub_data_dir)
1823
+ snapshot = _absolute(args.snapshot)
1824
+ hub = {
1825
+ "kind": args.hub_kind,
1826
+ "data_dir": str(data_dir),
1827
+ "hub_id": _safe_text(args.hub_id, "Hub id", maximum=240),
1828
+ "host_identity": _safe_text(args.host_identity, "host identity", maximum=240),
1829
+ "operation_id": _safe_text(args.operation_id, "operation id", maximum=128),
1830
+ "snapshot": str(snapshot),
1831
+ "fence_device": fence_device or 1,
1832
+ "fence_inode": fence_inode or 1,
1833
+ }
1834
+ _validate_hub_metadata(hub, intent=value["intent"])
1835
+ if args.hub_data_dir != str(data_dir) or args.snapshot != str(snapshot):
1836
+ raise RuntimeError("activation transaction Hub path is invalid")
1837
+ for hub_directory in (data_dir, data_dir / "maintenance-backups"):
1838
+ hub_directory_info = hub_directory.lstat()
1839
+ if (
1840
+ not stat.S_ISDIR(hub_directory_info.st_mode)
1841
+ or hub_directory_info.st_uid != os.getuid()
1842
+ ):
1843
+ raise PermissionError("activation Team Hub directory is unsafe")
1844
+ fence_path = data_dir / "maintenance-fence.json"
1845
+ fence_info = fence_path.lstat()
1846
+ if (
1847
+ not stat.S_ISREG(fence_info.st_mode)
1848
+ or fence_info.st_uid != os.getuid()
1849
+ or fence_info.st_nlink != 1
1850
+ or stat.S_IMODE(fence_info.st_mode) != 0o600
1851
+ ):
1852
+ raise PermissionError("activation Team Hub fence is unsafe")
1853
+ if fence_device == 0 and fence_inode == 0:
1854
+ fence_device = fence_info.st_dev
1855
+ fence_inode = fence_info.st_ino
1856
+ elif (
1857
+ fence_device != fence_info.st_dev
1858
+ or fence_inode != fence_info.st_ino
1859
+ ):
1860
+ raise RuntimeError("activation Team Hub fence ownership changed")
1861
+ hub["fence_device"] = fence_device
1862
+ hub["fence_inode"] = fence_inode
1863
+ _capture_volume(value, data_dir)
1864
+ _validate_hub_metadata(hub, intent=value["intent"])
1865
+ if value["hub"] and value["hub"] != hub:
1866
+ existing_without_inode = {
1867
+ key: item
1868
+ for key, item in value["hub"].items()
1869
+ if key not in {"fence_device", "fence_inode"}
1870
+ }
1871
+ new_without_inode = {
1872
+ key: item
1873
+ for key, item in hub.items()
1874
+ if key not in {"fence_device", "fence_inode"}
1875
+ }
1876
+ if not (
1877
+ previous_phase == "fencing"
1878
+ and args.phase in {"fenced", "rolling-back"}
1879
+ and existing_without_inode == new_without_inode
1880
+ ):
1881
+ raise RuntimeError("activation Team Hub ownership changed")
1882
+ value["hub"] = hub
1883
+ elif any(
1884
+ (
1885
+ args.hub_data_dir,
1886
+ args.hub_id,
1887
+ args.host_identity,
1888
+ args.operation_id,
1889
+ args.snapshot,
1890
+ args.fence_device,
1891
+ args.fence_inode,
1892
+ )
1893
+ ):
1894
+ raise RuntimeError("activation transaction Hub metadata has no kind")
1895
+ if args.guard_id:
1896
+ try:
1897
+ guard = {
1898
+ "id": _safe_text(args.guard_id, "guard id", maximum=64),
1899
+ "device": int(args.guard_device),
1900
+ "inode": int(args.guard_inode),
1901
+ }
1902
+ except ValueError as exc:
1903
+ raise RuntimeError("activation transaction guard coordinates are invalid") from exc
1904
+ _validate_guard_metadata(guard)
1905
+ if sys.platform == "darwin" and value["format"] in {FORMAT, EXECUTION_FORMAT}:
1906
+ if args.guard_path and not value["guard"]:
1907
+ guard_path = _absolute(args.guard_path)
1908
+ guard_info = guard_path.lstat()
1909
+ if (
1910
+ str(guard_path) != args.guard_path
1911
+ or guard_path.name != ".managed-startup-guard.json"
1912
+ or not stat.S_ISREG(guard_info.st_mode)
1913
+ or guard_info.st_uid != os.getuid()
1914
+ or guard_info.st_nlink != 1
1915
+ or stat.S_IMODE(guard_info.st_mode) != 0o600
1916
+ or (guard_info.st_dev, guard_info.st_ino)
1917
+ != (guard["device"], guard["inode"])
1918
+ ):
1919
+ raise RuntimeError("activation Team Hub guard path changed")
1920
+ guard_payload = json.loads(_read_private(guard_path))
1921
+ if not isinstance(guard_payload, dict) or guard_payload.get("guard_id") != guard["id"]:
1922
+ raise RuntimeError("activation Team Hub guard identity changed")
1923
+ _capture_volume(value, guard_path.parent)
1924
+ if str(guard["device"]) not in value["volume_bindings"]:
1925
+ raise RuntimeError("activation Team Hub guard lacks a volume binding")
1926
+ if value["guard"] and value["guard"] != guard:
1927
+ raise RuntimeError("activation Team Hub guard ownership changed")
1928
+ value["guard"] = guard
1929
+ elif args.guard_device or args.guard_inode or args.guard_path:
1930
+ raise RuntimeError("activation transaction guard coordinates have no id")
1931
+ if args.authority_pending != "unchanged":
1932
+ value["authority_pending"] = args.authority_pending == "true"
1933
+ _validate_hub_metadata(value["hub"], intent=value["intent"])
1934
+ _validate_guard_metadata(value["guard"])
1935
+ if previous_phase in {"committed", "rollback-healthy"} and value != original_value:
1936
+ raise RuntimeError("terminal activation transaction metadata is immutable")
1937
+ _validate_invocation(
1938
+ args,
1939
+ value,
1940
+ allow_missing_candidate=args.phase
1941
+ in {"rolling-back", "rolled-back", "rollback-healthy"},
1942
+ )
1943
+ _replace_private(directory / "manifest.json", _canonical(value))
1944
+ if args.hub_kind:
1945
+ sys.stdout.write(
1946
+ f"{value['hub']['fence_device']}\n{value['hub']['fence_inode']}\n"
1947
+ )
1948
+
1949
+
1950
+ def replace_config(args: argparse.Namespace) -> None:
1951
+ """Write-ahead and atomically publish one installer-generated config."""
1952
+
1953
+ root = _absolute(args.root)
1954
+ directory, value = _read_manifest(root)
1955
+ _validate_invocation(args, value)
1956
+ if value["phase"] in {
1957
+ "committing",
1958
+ "committed",
1959
+ "rolling-back",
1960
+ "rolled-back",
1961
+ "rollback-healthy",
1962
+ }:
1963
+ raise RuntimeError("activation configuration cannot change in this phase")
1964
+ source = _absolute(args.source)
1965
+ if args.kind not in _config_kinds(value):
1966
+ raise RuntimeError("activation configuration kind is not owned by this transaction")
1967
+ field = f"{args.kind}_path"
1968
+ history_field = f"observed_{args.kind}_sha256"
1969
+ destination = _absolute(value[field])
1970
+ expected_source, secured = _config_staging_paths(
1971
+ destination,
1972
+ value["transaction_id"],
1973
+ args.kind,
1974
+ )
1975
+ if source != expected_source:
1976
+ raise RuntimeError("activation configuration staging path is invalid")
1977
+ mode = int(args.mode, 8)
1978
+ expected_modes = _config_modes(args.kind, publication=True)
1979
+ if mode not in expected_modes:
1980
+ raise RuntimeError("activation configuration mode is invalid")
1981
+ desired_field = f"desired_{args.kind}"
1982
+ journaled_desired = value[desired_field]
1983
+ try:
1984
+ source.lstat()
1985
+ except FileNotFoundError:
1986
+ if journaled_desired is None or journaled_desired.get("mode") != mode:
1987
+ raise RuntimeError("activation configuration staging file is missing")
1988
+ try:
1989
+ secured.lstat()
1990
+ except FileNotFoundError:
1991
+ if not _config_path_matches_desired(destination, journaled_desired):
1992
+ raise RuntimeError(
1993
+ "activation configuration publication is incomplete"
1994
+ )
1995
+ _fsync_directory(destination.parent)
1996
+ return
1997
+ _publish_staged_config(
1998
+ staged=secured,
1999
+ source=source,
2000
+ destination=destination,
2001
+ desired=journaled_desired,
2002
+ )
2003
+ return
2004
+ try:
2005
+ secured.lstat()
2006
+ except FileNotFoundError:
2007
+ pass
2008
+ else:
2009
+ if journaled_desired is None or journaled_desired.get("mode") != mode:
2010
+ raise RuntimeError("activation configuration publication path exists")
2011
+ _publish_staged_config(
2012
+ staged=secured,
2013
+ source=source,
2014
+ destination=destination,
2015
+ desired=journaled_desired,
2016
+ )
2017
+ return
2018
+
2019
+ _capture_volume(value, source.parent)
2020
+ descriptor, source_identity, payload = _open_config_staging_source(source)
2021
+ digest = hashlib.sha256(payload).hexdigest()
2022
+ desired = {
2023
+ "sha256": digest,
2024
+ "mode": mode,
2025
+ "source": str(source),
2026
+ "device": source_identity.st_dev,
2027
+ "inode": source_identity.st_ino,
2028
+ }
2029
+ value[desired_field] = desired
2030
+ if digest not in value[history_field]:
2031
+ value[history_field].append(digest)
2032
+ try:
2033
+ _replace_private(directory / "manifest.json", _canonical(value))
2034
+ os.fchmod(descriptor, mode)
2035
+ os.fsync(descriptor)
2036
+ os.link(source, secured, follow_symlinks=False)
2037
+ _fsync_staging_directory(source.parent)
2038
+ secured_info = secured.lstat()
2039
+ source_after = os.fstat(descriptor)
2040
+ if (
2041
+ (source_identity.st_dev, source_identity.st_ino)
2042
+ != (source_after.st_dev, source_after.st_ino)
2043
+ or (source_identity.st_dev, source_identity.st_ino)
2044
+ != (secured_info.st_dev, secured_info.st_ino)
2045
+ or hashlib.sha256(payload).hexdigest() != digest
2046
+ ):
2047
+ raise RuntimeError("activation configuration staging identity changed")
2048
+ finally:
2049
+ os.close(descriptor)
2050
+ _publish_staged_config(
2051
+ staged=secured,
2052
+ source=source,
2053
+ destination=destination,
2054
+ desired=desired,
2055
+ )
2056
+
2057
+
2058
+ def _replace_symlink(path: Path, target: str) -> None:
2059
+ temporary = path.parent / f".{path.name}.{secrets.token_hex(12)}.tmp"
2060
+ try:
2061
+ os.symlink(target, temporary)
2062
+ os.replace(temporary, path)
2063
+ _fsync_directory(path.parent)
2064
+ finally:
2065
+ try:
2066
+ temporary.unlink()
2067
+ except FileNotFoundError:
2068
+ pass
2069
+
2070
+
2071
+ def _rename_and_fsync(source: Path, destination: Path) -> None:
2072
+ """Rename one entry and durably publish both affected directories."""
2073
+
2074
+ os.rename(source, destination)
2075
+ _fsync_directory(source.parent)
2076
+ if destination.parent != source.parent:
2077
+ _fsync_directory(destination.parent)
2078
+
2079
+
2080
+ def activate_files(args: argparse.Namespace) -> None:
2081
+ """Idempotently perform the release moves and exact link takeover."""
2082
+
2083
+ root = _absolute(args.root)
2084
+ _directory, value = _read_manifest(root)
2085
+ _validate_invocation(args, value)
2086
+ if value["phase"] != "linking":
2087
+ raise RuntimeError("activation transaction is not linking")
2088
+
2089
+ old_release = value["old_release"]
2090
+ if old_release and old_release["source"] != old_release["target"]:
2091
+ old_source = _absolute(old_release["source"])
2092
+ old_target = _absolute(old_release["target"])
2093
+ if _release_matches(old_source, old_release):
2094
+ try:
2095
+ old_target.lstat()
2096
+ except FileNotFoundError:
2097
+ _rename_and_fsync(old_source, old_target)
2098
+ else:
2099
+ raise RuntimeError("activation rollback quarantine already exists")
2100
+ elif not _release_matches(old_target, old_release):
2101
+ raise RuntimeError("activation rollback release is missing")
2102
+
2103
+ candidate = value["candidate_release"]
2104
+ candidate_source = _absolute(candidate["source"])
2105
+ candidate_target = _absolute(candidate["target"])
2106
+ if not _release_matches(candidate_target, candidate):
2107
+ if not _release_matches(candidate_source, candidate):
2108
+ raise RuntimeError("activation candidate release is missing")
2109
+ try:
2110
+ candidate_target.lstat()
2111
+ except FileNotFoundError:
2112
+ _rename_and_fsync(candidate_source, candidate_target)
2113
+ else:
2114
+ raise RuntimeError("activation candidate destination changed")
2115
+
2116
+ old_target = value["old_target"]
2117
+ if old_target:
2118
+ _replace_symlink(_absolute(args.previous), old_target)
2119
+ _replace_symlink(_absolute(args.current), value["release_dir"])
2120
+ if not _release_matches(candidate_target, candidate):
2121
+ raise RuntimeError("activation candidate release changed after link takeover")
2122
+
2123
+
2124
+ def _restore_configuration(
2125
+ destination: Path,
2126
+ directory: Path,
2127
+ metadata: dict[str, Any],
2128
+ ) -> None:
2129
+ if not metadata["existed"]:
2130
+ try:
2131
+ info = destination.lstat()
2132
+ except FileNotFoundError:
2133
+ return
2134
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
2135
+ raise PermissionError("activation configuration destination is unsafe")
2136
+ destination.unlink()
2137
+ _fsync_directory(destination.parent)
2138
+ return
2139
+ value = _read_private(
2140
+ directory / metadata["backup"],
2141
+ maximum=MAX_CONFIG_BYTES,
2142
+ )
2143
+ temporary = destination.parent / f".{destination.name}.{secrets.token_hex(12)}.tmp"
2144
+ descriptor: int | None = None
2145
+ try:
2146
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
2147
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
2148
+ descriptor = os.open(temporary, flags, int(metadata["mode"]))
2149
+ os.fchmod(descriptor, int(metadata["mode"]))
2150
+ written = 0
2151
+ while written < len(value):
2152
+ written += os.write(descriptor, value[written:])
2153
+ os.fsync(descriptor)
2154
+ os.close(descriptor)
2155
+ descriptor = None
2156
+ os.replace(temporary, destination)
2157
+ _fsync_directory(destination.parent)
2158
+ finally:
2159
+ if descriptor is not None:
2160
+ os.close(descriptor)
2161
+ try:
2162
+ temporary.unlink()
2163
+ except FileNotFoundError:
2164
+ pass
2165
+
2166
+
2167
+ def restore_files(args: argparse.Namespace) -> None:
2168
+ """Restore exact pre-activation links/configuration under the journal."""
2169
+
2170
+ root = _absolute(args.root)
2171
+ directory, value = _read_manifest(root)
2172
+ _validate_invocation(args, value, allow_missing_candidate=True)
2173
+ if value["phase"] != "rolling-back":
2174
+ raise RuntimeError("activation transaction is not rolling back")
2175
+ current = _absolute(args.current)
2176
+ previous = _absolute(args.previous)
2177
+ old_release = value["old_release"]
2178
+ if old_release and old_release["source"] != old_release["target"]:
2179
+ old_source = _absolute(old_release["source"])
2180
+ old_target = _absolute(old_release["target"])
2181
+
2182
+ if not _release_matches(old_source, old_release):
2183
+ if not _release_matches(old_target, old_release):
2184
+ raise RuntimeError("activation rollback release is missing")
2185
+ try:
2186
+ old_source_info = old_source.lstat()
2187
+ except FileNotFoundError:
2188
+ pass
2189
+ else:
2190
+ candidate = value["candidate_release"]
2191
+ if stat.S_ISLNK(old_source_info.st_mode) and old_source == current:
2192
+ old_source.unlink()
2193
+ _fsync_directory(old_source.parent)
2194
+ else:
2195
+ if not _release_matches(old_source, candidate):
2196
+ raise RuntimeError("activation candidate release changed")
2197
+ retired_candidate = directory / "candidate.retired"
2198
+ if not _release_matches(retired_candidate, candidate):
2199
+ try:
2200
+ retired_candidate.lstat()
2201
+ except FileNotFoundError:
2202
+ _rename_and_fsync(old_source, retired_candidate)
2203
+ else:
2204
+ raise RuntimeError(
2205
+ "activation candidate retirement path exists"
2206
+ )
2207
+ _rename_and_fsync(old_target, old_source)
2208
+ original_current = value["current"]
2209
+ if original_current["kind"] == "symlink":
2210
+ _replace_symlink(current, original_current["target"])
2211
+ elif original_current["kind"] == "missing":
2212
+ try:
2213
+ current_info = current.lstat()
2214
+ except FileNotFoundError:
2215
+ pass
2216
+ else:
2217
+ if not stat.S_ISLNK(current_info.st_mode):
2218
+ raise RuntimeError("current release path changed during rollback")
2219
+ current.unlink()
2220
+ _fsync_directory(current.parent)
2221
+ else:
2222
+ old_target = _absolute(value["old_target"])
2223
+ try:
2224
+ current_info = current.lstat()
2225
+ except FileNotFoundError:
2226
+ pass
2227
+ else:
2228
+ if stat.S_ISLNK(current_info.st_mode):
2229
+ current.unlink()
2230
+ _fsync_directory(current.parent)
2231
+ elif stat.S_ISDIR(current_info.st_mode):
2232
+ # The legacy directory was never moved.
2233
+ old_target = current
2234
+ else:
2235
+ raise RuntimeError("legacy current path changed during rollback")
2236
+ if old_target != current:
2237
+ _rename_and_fsync(old_target, current)
2238
+
2239
+ original_previous = value["previous"]
2240
+ if original_previous["kind"] == "symlink":
2241
+ _replace_symlink(previous, original_previous["target"])
2242
+ else:
2243
+ try:
2244
+ previous_info = previous.lstat()
2245
+ except FileNotFoundError:
2246
+ pass
2247
+ else:
2248
+ if not stat.S_ISLNK(previous_info.st_mode):
2249
+ raise RuntimeError("previous release path changed during rollback")
2250
+ previous.unlink()
2251
+ _fsync_directory(previous.parent)
2252
+
2253
+ _cleanup_config_staging(directory, value)
2254
+ for kind in _config_kinds(value):
2255
+ _restore_configuration(_absolute(value[f"{kind}_path"]), directory, value[kind])
2256
+
2257
+
2258
+ def finish(args: argparse.Namespace) -> None:
2259
+ root = _absolute(args.root)
2260
+ _validate_root(root)
2261
+ _validate_releases_root(root)
2262
+ if re.fullmatch(r"activation-[0-9a-f]{24}", args.transaction_id) is None:
2263
+ raise RuntimeError("activation transaction id is invalid")
2264
+ active = root / ".activation-transaction"
2265
+ retired = root / (
2266
+ ".activation-transaction-gc-" + str(args.transaction_id)
2267
+ )
2268
+ try:
2269
+ active.lstat()
2270
+ except FileNotFoundError:
2271
+ try:
2272
+ retired.lstat()
2273
+ except FileNotFoundError:
2274
+ # A prior exact finish may have retired and removed the directory
2275
+ # before its caller observed success. The unguessable transaction
2276
+ # id is the idempotency key; absence is already the terminal state.
2277
+ _fsync_directory(root)
2278
+ return
2279
+ try:
2280
+ directory, value = _read_manifest(root, directory=retired)
2281
+ except FileNotFoundError:
2282
+ # Retired-directory cleanup can itself be interrupted after the
2283
+ # manifest unlink. Only this exact transaction-id directory is
2284
+ # eligible for bounded, schema-closed cleanup.
2285
+ _remove_private_transaction_directory(retired)
2286
+ _fsync_directory(root)
2287
+ return
2288
+ else:
2289
+ # Any missing or malformed control in the live transaction is an
2290
+ # unsettled activation, never evidence that finish already succeeded.
2291
+ directory, value = _read_manifest(root, directory=active)
2292
+ _validate_invocation(
2293
+ args,
2294
+ value,
2295
+ allow_missing_candidate=value["phase"] == "rollback-healthy",
2296
+ transaction_directory=directory,
2297
+ )
2298
+ if value["phase"] not in {"committed", "rollback-healthy"}:
2299
+ raise RuntimeError("activation transaction is not terminal")
2300
+ if value["phase"] == "rollback-healthy":
2301
+ candidate = value["candidate_release"]
2302
+ retired_candidate = root / "releases" / (
2303
+ ".activation-candidate-retired-" + value["transaction_id"]
2304
+ )
2305
+ parked_candidate = directory / "candidate.retired"
2306
+ candidate_path = _locate_release(
2307
+ candidate,
2308
+ extras=(retired_candidate, parked_candidate),
2309
+ )
2310
+ if candidate_path is not None and candidate_path != parked_candidate:
2311
+ try:
2312
+ parked_candidate.lstat()
2313
+ except FileNotFoundError:
2314
+ _rename_and_fsync(candidate_path, parked_candidate)
2315
+ else:
2316
+ raise RuntimeError("activation candidate retirement path exists")
2317
+ _cleanup_config_staging(directory, value)
2318
+ allowed = {"manifest.json"}
2319
+ for kind in _config_kinds(value):
2320
+ if value[kind]["existed"]:
2321
+ allowed.add(f"{kind}.backup")
2322
+ if value["phase"] == "rollback-healthy" and (directory / "candidate.retired").exists():
2323
+ allowed.add("candidate.retired")
2324
+ if {entry.name for entry in directory.iterdir()} != allowed:
2325
+ raise RuntimeError("activation transaction directory contains unexpected files")
2326
+ if directory != retired:
2327
+ try:
2328
+ retired.lstat()
2329
+ except FileNotFoundError:
2330
+ pass
2331
+ else:
2332
+ raise RuntimeError("activation transaction retirement path exists")
2333
+ os.rename(directory, retired)
2334
+ _fsync_directory(root)
2335
+ _cleanup_retired_transactions(root)
2336
+
2337
+
2338
+ def parser() -> argparse.ArgumentParser:
2339
+ result = argparse.ArgumentParser()
2340
+ subparsers = result.add_subparsers(dest="command", required=True)
2341
+
2342
+ def layout(command: argparse.ArgumentParser) -> None:
2343
+ command.add_argument("--root", required=True)
2344
+ command.add_argument("--current", required=True)
2345
+ command.add_argument("--previous", required=True)
2346
+ command.add_argument("--env", required=True)
2347
+ command.add_argument("--service", required=True)
2348
+
2349
+ def owned(command: argparse.ArgumentParser) -> None:
2350
+ layout(command)
2351
+ command.add_argument("--release-dir", required=True)
2352
+ command.add_argument("--release-version", required=True)
2353
+ command.add_argument("--transaction-id", required=True)
2354
+
2355
+ begin_parser = subparsers.add_parser("begin")
2356
+ layout(begin_parser)
2357
+ begin_parser.add_argument("--release-dir", required=True)
2358
+ begin_parser.add_argument("--release-version", required=True)
2359
+ begin_parser.add_argument("--old-target", required=True)
2360
+ begin_parser.add_argument("--old-source", required=True)
2361
+ begin_parser.add_argument("--candidate-source", required=True)
2362
+ begin_parser.add_argument(
2363
+ "--service-state",
2364
+ choices=("absent", "stopped", "running"),
2365
+ required=True,
2366
+ )
2367
+ begin_parser.add_argument(
2368
+ "--service-enabled",
2369
+ choices=("true", "false"),
2370
+ required=True,
2371
+ )
2372
+ begin_parser.add_argument(
2373
+ "--legacy-service-state",
2374
+ choices=("absent", "stopped", "running"),
2375
+ required=True,
2376
+ )
2377
+ begin_parser.add_argument(
2378
+ "--legacy-service-enabled",
2379
+ choices=("true", "false"),
2380
+ required=True,
2381
+ )
2382
+ begin_parser.add_argument("--prior-port", type=int, required=True)
2383
+ begin_parser.add_argument("--prior-bind-address", required=True)
2384
+ begin_parser.add_argument(
2385
+ "--intent",
2386
+ choices=(
2387
+ "ordinary",
2388
+ "server-update",
2389
+ "host-reactivation",
2390
+ "failed-host-repair",
2391
+ ),
2392
+ required=True,
2393
+ )
2394
+ begin_parser.add_argument("--client-binding", default="")
2395
+ begin_parser.add_argument("--execution-runtime-dir", default="")
2396
+ begin_parser.add_argument("--execution-api-contract", type=int, default=0)
2397
+ begin_parser.add_argument("--gateway-service", default="")
2398
+ begin_parser.add_argument("--gateway-state", choices=("absent", "stopped", "running"), default="absent")
2399
+ begin_parser.add_argument("--gateway-enabled", choices=("true", "false"), default="false")
2400
+ begin_parser.add_argument("--execution-handoff-file", default="")
2401
+
2402
+ load_parser = subparsers.add_parser("load")
2403
+ layout(load_parser)
2404
+
2405
+ record_parser = subparsers.add_parser("record")
2406
+ owned(record_parser)
2407
+ record_parser.add_argument("--phase", required=True)
2408
+ record_parser.add_argument("--hub-kind", default="")
2409
+ record_parser.add_argument("--hub-data-dir", default="")
2410
+ record_parser.add_argument("--hub-id", default="")
2411
+ record_parser.add_argument("--host-identity", default="")
2412
+ record_parser.add_argument("--operation-id", default="")
2413
+ record_parser.add_argument("--snapshot", default="")
2414
+ record_parser.add_argument("--fence-device", default="")
2415
+ record_parser.add_argument("--fence-inode", default="")
2416
+ record_parser.add_argument("--guard-id", default="")
2417
+ record_parser.add_argument("--guard-device", default="")
2418
+ record_parser.add_argument("--guard-inode", default="")
2419
+ record_parser.add_argument("--guard-path", default="")
2420
+ record_parser.add_argument(
2421
+ "--authority-pending",
2422
+ choices=("true", "false", "unchanged"),
2423
+ default="unchanged",
2424
+ )
2425
+
2426
+ replace_parser = subparsers.add_parser("replace-config")
2427
+ owned(replace_parser)
2428
+ replace_parser.add_argument("--kind", choices=("env", "service", "gateway", "execution_layout"), required=True)
2429
+ replace_parser.add_argument("--source", required=True)
2430
+ replace_parser.add_argument("--mode", required=True)
2431
+
2432
+ activate_parser = subparsers.add_parser("activate-files")
2433
+ owned(activate_parser)
2434
+
2435
+ restore_parser = subparsers.add_parser("restore-files")
2436
+ owned(restore_parser)
2437
+
2438
+ finish_parser = subparsers.add_parser("finish")
2439
+ owned(finish_parser)
2440
+ return result
2441
+
2442
+
2443
+ def main(argv: list[str] | None = None) -> int:
2444
+ args = parser().parse_args(argv)
2445
+ {
2446
+ "begin": begin,
2447
+ "load": load,
2448
+ "record": record,
2449
+ "replace-config": replace_config,
2450
+ "activate-files": activate_files,
2451
+ "restore-files": restore_files,
2452
+ "finish": finish,
2453
+ }[args.command](args)
2454
+ return 0
2455
+
2456
+
2457
+ if __name__ == "__main__":
2458
+ raise SystemExit(main())