@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- 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())
|