@delorenj/pjangler 1.4.2 → 1.4.3
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/dist/index.js +3336 -1359
- package/dist/index.js.map +7 -0
- package/dist/mcp-server.js +1520 -838
- package/dist/mcp-server.js.map +7 -0
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +7 -0
- package/package.json +8 -4
- package/templates/hermes-agent/copier.yml +16 -3
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
- package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
- package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
- package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
- package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
- package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
- package/templates/hermes-agent/template/hermes.jinja +1 -1
- package/templates/hermes-agent/template/role.yaml.jinja +11 -4
|
@@ -0,0 +1,2340 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Commit one verified channel wiring as a crash-consistent transaction.
|
|
3
|
+
|
|
4
|
+
Raw credentials are never accepted. This helper owns the canonical lock
|
|
5
|
+
ordering (registry, then profile), journals every candidate before mutation,
|
|
6
|
+
and compare-and-swaps each target against the exact state it observed. A
|
|
7
|
+
crash is recovered on the next invocation; an unrelated edit is never erased.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import copy
|
|
14
|
+
import ctypes
|
|
15
|
+
import errno
|
|
16
|
+
import fcntl
|
|
17
|
+
import grp
|
|
18
|
+
import hashlib
|
|
19
|
+
import importlib.util
|
|
20
|
+
import json
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import pwd
|
|
25
|
+
import re
|
|
26
|
+
import stat
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import yaml
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_profile_lock_module():
|
|
39
|
+
source = pathlib.Path(__file__).parent / "lib" / "profile-config-lock.py"
|
|
40
|
+
if source.is_symlink() or not source.is_file():
|
|
41
|
+
raise RuntimeError(f"trusted profile config lock helper is unavailable: {source}")
|
|
42
|
+
spec = importlib.util.spec_from_file_location(
|
|
43
|
+
"pjangler_profile_config_lock", source
|
|
44
|
+
)
|
|
45
|
+
if spec is None or spec.loader is None:
|
|
46
|
+
raise RuntimeError(f"cannot load profile config lock helper: {source}")
|
|
47
|
+
module = importlib.util.module_from_spec(spec)
|
|
48
|
+
spec.loader.exec_module(module)
|
|
49
|
+
return module
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
PROFILE_LOCK = load_profile_lock_module()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
LIST_PATCH_KEY = "x-pjangler-merge"
|
|
56
|
+
CHANNEL_FIELDS = {
|
|
57
|
+
"telegram": ("provisioning_status", "bot_username", "bot_id"),
|
|
58
|
+
"slack": (
|
|
59
|
+
"provisioning_status",
|
|
60
|
+
"team_id",
|
|
61
|
+
"team_name",
|
|
62
|
+
"bot_user_id",
|
|
63
|
+
"bot_id",
|
|
64
|
+
"bot_username",
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
CHANNEL_REFERENCE_KEYS = {
|
|
68
|
+
"telegram": ("TELEGRAM_BOT_TOKEN",),
|
|
69
|
+
"slack": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
|
|
70
|
+
}
|
|
71
|
+
CHANNEL_ALLOWED_KEYS = {
|
|
72
|
+
"telegram": "TELEGRAM_ALLOWED_USERS",
|
|
73
|
+
"slack": "SLACK_ALLOWED_USERS",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class FileState:
|
|
79
|
+
existed: bool
|
|
80
|
+
mode: int
|
|
81
|
+
dev: int = 0
|
|
82
|
+
ino: int = 0
|
|
83
|
+
size: int = 0
|
|
84
|
+
mtime_ns: int = 0
|
|
85
|
+
nlink: int = 0
|
|
86
|
+
sha256: str = ""
|
|
87
|
+
|
|
88
|
+
def to_json(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"existed": self.existed,
|
|
91
|
+
"mode": self.mode,
|
|
92
|
+
"dev": self.dev,
|
|
93
|
+
"ino": self.ino,
|
|
94
|
+
"size": self.size,
|
|
95
|
+
"mtime_ns": self.mtime_ns,
|
|
96
|
+
"nlink": self.nlink,
|
|
97
|
+
"sha256": self.sha256,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_json(cls, value: object) -> "FileState":
|
|
102
|
+
if not isinstance(value, dict):
|
|
103
|
+
raise TransactionRecoveryError("journal file state is not a mapping")
|
|
104
|
+
expected = {
|
|
105
|
+
"existed",
|
|
106
|
+
"mode",
|
|
107
|
+
"dev",
|
|
108
|
+
"ino",
|
|
109
|
+
"size",
|
|
110
|
+
"mtime_ns",
|
|
111
|
+
"nlink",
|
|
112
|
+
"sha256",
|
|
113
|
+
}
|
|
114
|
+
if set(value) != expected:
|
|
115
|
+
raise TransactionRecoveryError("journal file state has invalid fields")
|
|
116
|
+
if not isinstance(value["existed"], bool):
|
|
117
|
+
raise TransactionRecoveryError("journal file state existence is invalid")
|
|
118
|
+
numeric = ("mode", "dev", "ino", "size", "mtime_ns", "nlink")
|
|
119
|
+
if any(not isinstance(value[key], int) or isinstance(value[key], bool) for key in numeric):
|
|
120
|
+
raise TransactionRecoveryError("journal file state numeric field is invalid")
|
|
121
|
+
digest = value["sha256"]
|
|
122
|
+
if not isinstance(digest, str) or (digest and not re.fullmatch(r"[0-9a-f]{64}", digest)):
|
|
123
|
+
raise TransactionRecoveryError("journal file state digest is invalid")
|
|
124
|
+
return cls(**value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Snapshot:
|
|
129
|
+
state: FileState
|
|
130
|
+
content: bytes
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def existed(self) -> bool:
|
|
134
|
+
return self.state.existed
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def mode(self) -> int:
|
|
138
|
+
return self.state.mode
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TransactionConflict(RuntimeError):
|
|
142
|
+
"""A target changed outside this transaction."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TransactionRecoveryError(RuntimeError):
|
|
146
|
+
"""Protected recovery state is malformed or cannot be reconciled."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ExistingWiringUnavailable(RuntimeError):
|
|
150
|
+
"""The role has no verified durable wiring to reconcile."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ExistingWiringValidationUnavailable(RuntimeError):
|
|
154
|
+
"""Verified references exist but cannot currently be validated."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ExistingWiringAlreadyVerified(RuntimeError):
|
|
158
|
+
"""Preparation found verified wiring and deliberately made no change."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fail(message: str) -> "None":
|
|
162
|
+
raise SystemExit(f"channel transaction failed: {message}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fsync_parent(path: pathlib.Path) -> None:
|
|
166
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
167
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
168
|
+
try:
|
|
169
|
+
directory_fd = os.open(path.parent, flags)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
if exc.errno in unsupported:
|
|
172
|
+
return
|
|
173
|
+
raise
|
|
174
|
+
try:
|
|
175
|
+
try:
|
|
176
|
+
os.fsync(directory_fd)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
if exc.errno not in unsupported:
|
|
179
|
+
raise
|
|
180
|
+
finally:
|
|
181
|
+
os.close(directory_fd)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fsync_directory(path: pathlib.Path) -> None:
|
|
185
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
186
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
187
|
+
directory_fd = os.open(path, flags)
|
|
188
|
+
try:
|
|
189
|
+
try:
|
|
190
|
+
os.fsync(directory_fd)
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
if exc.errno not in unsupported:
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
os.close(directory_fd)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def finite_timeout(name: str, fallback: str) -> float:
|
|
199
|
+
raw = os.environ.get(name, fallback)
|
|
200
|
+
try:
|
|
201
|
+
value = float(raw)
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
raise TransactionRecoveryError(f"{name} must be a finite non-negative number") from exc
|
|
204
|
+
if not math.isfinite(value) or value < 0:
|
|
205
|
+
raise TransactionRecoveryError(f"{name} must be a finite non-negative number")
|
|
206
|
+
return value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def directory_is_private_to_user(info: os.stat_result) -> bool:
|
|
210
|
+
"""Accept an owner-only directory or the account's private primary group."""
|
|
211
|
+
|
|
212
|
+
if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o002:
|
|
213
|
+
return False
|
|
214
|
+
if not stat.S_IMODE(info.st_mode) & 0o020:
|
|
215
|
+
return True
|
|
216
|
+
try:
|
|
217
|
+
username = pwd.getpwuid(os.geteuid()).pw_name
|
|
218
|
+
group = grp.getgrgid(info.st_gid)
|
|
219
|
+
primary_members = {
|
|
220
|
+
account.pw_name for account in pwd.getpwall() if account.pw_gid == info.st_gid
|
|
221
|
+
}
|
|
222
|
+
except (KeyError, OSError):
|
|
223
|
+
return False
|
|
224
|
+
return (set(group.gr_mem) | primary_members) <= {username}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class RegistryLock:
|
|
228
|
+
"""Symlink-safe canonical registry lock, acquired before the profile lock.
|
|
229
|
+
|
|
230
|
+
During the caller migration this also recognizes the exact lock inode on an
|
|
231
|
+
inherited descriptor. It still calls ``flock`` itself, so direct helper
|
|
232
|
+
invocation cannot bypass locking and an inherited *unlocked* descriptor is
|
|
233
|
+
not trusted.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, registry: pathlib.Path):
|
|
237
|
+
self.registry = registry
|
|
238
|
+
self.path = registry.with_name(registry.name + ".lock")
|
|
239
|
+
self.fd: int | None = None
|
|
240
|
+
self.borrowed = False
|
|
241
|
+
self.timeout = finite_timeout(
|
|
242
|
+
"HERMES_REGISTRY_LOCK_TIMEOUT_SECONDS",
|
|
243
|
+
os.environ.get("FLEET_LOCK_TIMEOUT_SECONDS", "30"),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _inherited_matching_fd(self, opened: os.stat_result) -> int | None:
|
|
247
|
+
try:
|
|
248
|
+
names = os.listdir("/proc/self/fd")
|
|
249
|
+
except OSError:
|
|
250
|
+
names = [str(number) for number in range(3, 256)]
|
|
251
|
+
for raw in names:
|
|
252
|
+
if not raw.isdigit():
|
|
253
|
+
continue
|
|
254
|
+
candidate = int(raw)
|
|
255
|
+
if candidate == self.fd:
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
current = os.fstat(candidate)
|
|
259
|
+
except OSError:
|
|
260
|
+
continue
|
|
261
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
262
|
+
continue
|
|
263
|
+
if not stat.S_ISREG(current.st_mode):
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
fcntl.flock(candidate, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
267
|
+
except BlockingIOError:
|
|
268
|
+
continue
|
|
269
|
+
return candidate
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def __enter__(self) -> "RegistryLock":
|
|
273
|
+
parent = self.path.parent
|
|
274
|
+
if parent.is_symlink() or not parent.is_dir():
|
|
275
|
+
raise TransactionRecoveryError(
|
|
276
|
+
f"registry parent must be a real directory before locking: {parent}"
|
|
277
|
+
)
|
|
278
|
+
if self.path.is_symlink():
|
|
279
|
+
raise TransactionRecoveryError(f"refusing registry lock symlink: {self.path}")
|
|
280
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
|
281
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
282
|
+
fd = os.open(self.path, flags, 0o600)
|
|
283
|
+
self.fd = fd
|
|
284
|
+
try:
|
|
285
|
+
opened = os.fstat(fd)
|
|
286
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
287
|
+
raise TransactionRecoveryError(
|
|
288
|
+
f"registry lock is not a regular file: {self.path}"
|
|
289
|
+
)
|
|
290
|
+
os.fchmod(fd, 0o600)
|
|
291
|
+
deadline = time.monotonic() + self.timeout
|
|
292
|
+
while True:
|
|
293
|
+
try:
|
|
294
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
295
|
+
break
|
|
296
|
+
except BlockingIOError as exc:
|
|
297
|
+
inherited = self._inherited_matching_fd(opened)
|
|
298
|
+
if inherited is not None:
|
|
299
|
+
os.close(fd)
|
|
300
|
+
self.fd = inherited
|
|
301
|
+
self.borrowed = True
|
|
302
|
+
break
|
|
303
|
+
if time.monotonic() >= deadline:
|
|
304
|
+
raise TransactionRecoveryError(
|
|
305
|
+
f"timed out waiting for registry lock: {self.path}"
|
|
306
|
+
) from exc
|
|
307
|
+
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
308
|
+
locked = os.lstat(self.path)
|
|
309
|
+
held = os.fstat(self.fd)
|
|
310
|
+
if (locked.st_dev, locked.st_ino) != (held.st_dev, held.st_ino):
|
|
311
|
+
raise TransactionRecoveryError(
|
|
312
|
+
f"registry lock identity changed while acquiring: {self.path}"
|
|
313
|
+
)
|
|
314
|
+
if self.registry.is_symlink():
|
|
315
|
+
raise TransactionRecoveryError(
|
|
316
|
+
f"refusing registry symlink: {self.registry}"
|
|
317
|
+
)
|
|
318
|
+
if self.registry.exists() and not self.registry.is_file():
|
|
319
|
+
raise TransactionRecoveryError(
|
|
320
|
+
f"registry is not a regular file: {self.registry}"
|
|
321
|
+
)
|
|
322
|
+
return self
|
|
323
|
+
except BaseException:
|
|
324
|
+
if self.fd is not None and not self.borrowed:
|
|
325
|
+
os.close(self.fd)
|
|
326
|
+
self.fd = None
|
|
327
|
+
raise
|
|
328
|
+
|
|
329
|
+
def __exit__(self, *_args: object) -> None:
|
|
330
|
+
if self.fd is None:
|
|
331
|
+
return
|
|
332
|
+
fd, borrowed = self.fd, self.borrowed
|
|
333
|
+
self.fd = None
|
|
334
|
+
self.borrowed = False
|
|
335
|
+
if borrowed:
|
|
336
|
+
return
|
|
337
|
+
try:
|
|
338
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
339
|
+
finally:
|
|
340
|
+
os.close(fd)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def snapshot(path: pathlib.Path, default_mode: int) -> Snapshot:
|
|
344
|
+
try:
|
|
345
|
+
listed = os.lstat(path)
|
|
346
|
+
except FileNotFoundError:
|
|
347
|
+
return Snapshot(FileState(False, default_mode), b"")
|
|
348
|
+
if stat.S_ISLNK(listed.st_mode):
|
|
349
|
+
fail(f"refusing symlinked transaction path: {path}")
|
|
350
|
+
if not stat.S_ISREG(listed.st_mode):
|
|
351
|
+
fail(f"transaction path is not a regular file: {path}")
|
|
352
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
353
|
+
fd = os.open(path, flags)
|
|
354
|
+
try:
|
|
355
|
+
opened = os.fstat(fd)
|
|
356
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
357
|
+
fail(f"transaction path is not a regular file: {path}")
|
|
358
|
+
chunks: list[bytes] = []
|
|
359
|
+
while True:
|
|
360
|
+
chunk = os.read(fd, 1024 * 1024)
|
|
361
|
+
if not chunk:
|
|
362
|
+
break
|
|
363
|
+
chunks.append(chunk)
|
|
364
|
+
content = b"".join(chunks)
|
|
365
|
+
finished = os.fstat(fd)
|
|
366
|
+
finally:
|
|
367
|
+
os.close(fd)
|
|
368
|
+
relisted = os.lstat(path)
|
|
369
|
+
identities = {
|
|
370
|
+
(listed.st_dev, listed.st_ino),
|
|
371
|
+
(opened.st_dev, opened.st_ino),
|
|
372
|
+
(finished.st_dev, finished.st_ino),
|
|
373
|
+
(relisted.st_dev, relisted.st_ino),
|
|
374
|
+
}
|
|
375
|
+
signatures = {
|
|
376
|
+
(
|
|
377
|
+
value.st_size,
|
|
378
|
+
value.st_mtime_ns,
|
|
379
|
+
value.st_ctime_ns,
|
|
380
|
+
value.st_nlink,
|
|
381
|
+
stat.S_IMODE(value.st_mode),
|
|
382
|
+
)
|
|
383
|
+
for value in (listed, opened, finished, relisted)
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
len(identities) != 1
|
|
387
|
+
or len(signatures) != 1
|
|
388
|
+
or finished.st_size != len(content)
|
|
389
|
+
):
|
|
390
|
+
raise TransactionConflict(f"transaction path changed while snapshotting: {path}")
|
|
391
|
+
state = FileState(
|
|
392
|
+
True,
|
|
393
|
+
stat.S_IMODE(finished.st_mode),
|
|
394
|
+
finished.st_dev,
|
|
395
|
+
finished.st_ino,
|
|
396
|
+
finished.st_size,
|
|
397
|
+
finished.st_mtime_ns,
|
|
398
|
+
finished.st_nlink,
|
|
399
|
+
hashlib.sha256(content).hexdigest(),
|
|
400
|
+
)
|
|
401
|
+
return Snapshot(state, content)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def current_state(path: pathlib.Path, default_mode: int) -> FileState:
|
|
405
|
+
return snapshot(path, default_mode).state
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def state_matches(path: pathlib.Path, expected: FileState) -> bool:
|
|
409
|
+
try:
|
|
410
|
+
return current_state(path, expected.mode) == expected
|
|
411
|
+
except (OSError, SystemExit, TransactionConflict):
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def state_same_inode_and_bytes(current: FileState, expected: FileState) -> bool:
|
|
416
|
+
"""Compare durable identity/content while allowing known hard-link counts."""
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
current.existed == expected.existed
|
|
420
|
+
and current.mode == expected.mode
|
|
421
|
+
and current.dev == expected.dev
|
|
422
|
+
and current.ino == expected.ino
|
|
423
|
+
and current.size == expected.size
|
|
424
|
+
and current.mtime_ns == expected.mtime_ns
|
|
425
|
+
and current.sha256 == expected.sha256
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
AT_FDCWD = -100
|
|
430
|
+
RENAME_NOREPLACE = 1
|
|
431
|
+
RENAME_EXCHANGE = 2
|
|
432
|
+
_LIBC = ctypes.CDLL(None, use_errno=True)
|
|
433
|
+
_RENAMEAT2 = getattr(_LIBC, "renameat2", None)
|
|
434
|
+
if _RENAMEAT2 is not None:
|
|
435
|
+
_RENAMEAT2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
|
436
|
+
_RENAMEAT2.restype = ctypes.c_int
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def renameat2(source: pathlib.Path, target: pathlib.Path, flags: int) -> None:
|
|
440
|
+
"""Linux atomic rename primitive used as the file-level CAS operation."""
|
|
441
|
+
|
|
442
|
+
if _RENAMEAT2 is None:
|
|
443
|
+
raise TransactionRecoveryError(
|
|
444
|
+
"renameat2 is required for crash-safe channel compare-and-swap"
|
|
445
|
+
)
|
|
446
|
+
result = _RENAMEAT2(
|
|
447
|
+
AT_FDCWD,
|
|
448
|
+
os.fsencode(source),
|
|
449
|
+
AT_FDCWD,
|
|
450
|
+
os.fsencode(target),
|
|
451
|
+
flags,
|
|
452
|
+
)
|
|
453
|
+
if result != 0:
|
|
454
|
+
error = ctypes.get_errno()
|
|
455
|
+
raise OSError(error, os.strerror(error), str(source), str(target))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def atomic_exchange(source: pathlib.Path, target: pathlib.Path) -> None:
|
|
459
|
+
renameat2(source, target, RENAME_EXCHANGE)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def atomic_move_noreplace(source: pathlib.Path, target: pathlib.Path) -> None:
|
|
463
|
+
renameat2(source, target, RENAME_NOREPLACE)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def fault_boundary(_label: str) -> None:
|
|
467
|
+
"""No-op seam replaced only in copied test fixtures.
|
|
468
|
+
|
|
469
|
+
Production code has no path-writing pause hook. Process tests instrument a
|
|
470
|
+
private copy of this function to stop or kill the helper at exact durable
|
|
471
|
+
boundaries without widening the deployed interface.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
# TEST_FIXTURE_FAULT_BOUNDARY
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def strict_json_loads(content: str) -> dict[str, Any]:
|
|
478
|
+
def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
479
|
+
result: dict[str, Any] = {}
|
|
480
|
+
for key, value in pairs:
|
|
481
|
+
if key in result:
|
|
482
|
+
raise TransactionRecoveryError(f"duplicate journal key: {key}")
|
|
483
|
+
result[key] = value
|
|
484
|
+
return result
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
value = json.loads(content, object_pairs_hook=unique_pairs)
|
|
488
|
+
except (json.JSONDecodeError, UnicodeError) as exc:
|
|
489
|
+
raise TransactionRecoveryError(
|
|
490
|
+
f"transaction journal is malformed: {type(exc).__name__}"
|
|
491
|
+
) from exc
|
|
492
|
+
if not isinstance(value, dict):
|
|
493
|
+
raise TransactionRecoveryError("transaction journal root is not a mapping")
|
|
494
|
+
return value
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class CrashConsistentTransaction:
|
|
498
|
+
"""Ordered, journaled compare-and-swap across channel-owned files."""
|
|
499
|
+
|
|
500
|
+
SCHEMA_VERSION = 1
|
|
501
|
+
JOURNAL_NAME = "journal.json"
|
|
502
|
+
NEXT_JOURNAL_NAME = ".journal.next"
|
|
503
|
+
|
|
504
|
+
def __init__(
|
|
505
|
+
self,
|
|
506
|
+
*,
|
|
507
|
+
profile: pathlib.Path,
|
|
508
|
+
registry: pathlib.Path,
|
|
509
|
+
channel: str,
|
|
510
|
+
agent_id: str,
|
|
511
|
+
targets: dict[str, pathlib.Path],
|
|
512
|
+
modes: dict[str, int],
|
|
513
|
+
):
|
|
514
|
+
self.profile = profile
|
|
515
|
+
self.registry = registry
|
|
516
|
+
self.channel = channel
|
|
517
|
+
self.agent_id = agent_id
|
|
518
|
+
self.targets = targets
|
|
519
|
+
self.modes = modes
|
|
520
|
+
self.directory = profile.parent / f".{profile.name}.channel-transaction"
|
|
521
|
+
self.journal_path = self.directory / self.JOURNAL_NAME
|
|
522
|
+
self.journal: dict[str, Any] = {}
|
|
523
|
+
|
|
524
|
+
def _validate_directory(self) -> None:
|
|
525
|
+
if self.directory.is_symlink() or not self.directory.is_dir():
|
|
526
|
+
raise TransactionRecoveryError(
|
|
527
|
+
f"protected transaction path is not a real directory: {self.directory}"
|
|
528
|
+
)
|
|
529
|
+
info = os.lstat(self.directory)
|
|
530
|
+
if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700:
|
|
531
|
+
raise TransactionRecoveryError(
|
|
532
|
+
f"protected transaction directory ownership/mode is unsafe: {self.directory}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
def _write_journal(self) -> None:
|
|
536
|
+
self._validate_directory()
|
|
537
|
+
payload = (json.dumps(self.journal, sort_keys=True, separators=(",", ":")) + "\n").encode(
|
|
538
|
+
"utf-8"
|
|
539
|
+
)
|
|
540
|
+
temporary = self.directory / self.NEXT_JOURNAL_NAME
|
|
541
|
+
if temporary.is_symlink():
|
|
542
|
+
raise TransactionRecoveryError(
|
|
543
|
+
f"refusing transaction journal symlink: {temporary}"
|
|
544
|
+
)
|
|
545
|
+
if temporary.exists():
|
|
546
|
+
if not temporary.is_file():
|
|
547
|
+
raise TransactionRecoveryError(
|
|
548
|
+
f"transaction journal staging path is unsafe: {temporary}"
|
|
549
|
+
)
|
|
550
|
+
temporary.unlink()
|
|
551
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
552
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
553
|
+
fd = os.open(temporary, flags, 0o600)
|
|
554
|
+
try:
|
|
555
|
+
os.fchmod(fd, 0o600)
|
|
556
|
+
view = memoryview(payload)
|
|
557
|
+
while view:
|
|
558
|
+
written = os.write(fd, view)
|
|
559
|
+
view = view[written:]
|
|
560
|
+
os.fsync(fd)
|
|
561
|
+
finally:
|
|
562
|
+
os.close(fd)
|
|
563
|
+
os.replace(temporary, self.journal_path)
|
|
564
|
+
fsync_directory(self.directory)
|
|
565
|
+
|
|
566
|
+
def _load_journal(self) -> dict[str, Any]:
|
|
567
|
+
self._validate_directory()
|
|
568
|
+
if self.journal_path.is_symlink() or not self.journal_path.is_file():
|
|
569
|
+
raise TransactionRecoveryError(
|
|
570
|
+
f"protected transaction journal is unavailable: {self.journal_path}"
|
|
571
|
+
)
|
|
572
|
+
if stat.S_IMODE(os.lstat(self.journal_path).st_mode) != 0o600:
|
|
573
|
+
raise TransactionRecoveryError(
|
|
574
|
+
f"protected transaction journal mode is unsafe: {self.journal_path}"
|
|
575
|
+
)
|
|
576
|
+
return strict_json_loads(self.journal_path.read_text(encoding="utf-8"))
|
|
577
|
+
|
|
578
|
+
def _validate_journal_identity(self, journal: dict[str, Any]) -> None:
|
|
579
|
+
if journal.get("schema_version") != self.SCHEMA_VERSION:
|
|
580
|
+
raise TransactionRecoveryError("unsupported transaction journal schema")
|
|
581
|
+
identity = journal.get("identity")
|
|
582
|
+
if not isinstance(identity, dict):
|
|
583
|
+
raise TransactionRecoveryError("transaction journal identity is invalid")
|
|
584
|
+
expected = {
|
|
585
|
+
"profile": str(self.profile),
|
|
586
|
+
"registry": str(self.registry),
|
|
587
|
+
"agent_id": self.agent_id,
|
|
588
|
+
}
|
|
589
|
+
for key, value in expected.items():
|
|
590
|
+
if identity.get(key) != value:
|
|
591
|
+
raise TransactionRecoveryError(
|
|
592
|
+
f"transaction journal {key} does not match this invocation"
|
|
593
|
+
)
|
|
594
|
+
targets = journal.get("targets")
|
|
595
|
+
if not isinstance(targets, dict) or set(targets) != set(self.targets):
|
|
596
|
+
raise TransactionRecoveryError("transaction journal target set is invalid")
|
|
597
|
+
for key, path in self.targets.items():
|
|
598
|
+
entry = targets[key]
|
|
599
|
+
if not isinstance(entry, dict) or entry.get("path") != str(path):
|
|
600
|
+
raise TransactionRecoveryError(
|
|
601
|
+
f"transaction journal path does not match for {key}"
|
|
602
|
+
)
|
|
603
|
+
FileState.from_json(entry.get("original"))
|
|
604
|
+
|
|
605
|
+
def _artifact_inventory(
|
|
606
|
+
self,
|
|
607
|
+
journal: dict[str, Any],
|
|
608
|
+
*,
|
|
609
|
+
directory: pathlib.Path | None = None,
|
|
610
|
+
) -> tuple[dict[pathlib.Path, list[FileState]], dict[tuple[int, int], int]]:
|
|
611
|
+
"""Return allowed artifact states and actual known link counts."""
|
|
612
|
+
|
|
613
|
+
artifact_directory = self.directory if directory is None else directory
|
|
614
|
+
allowed: dict[pathlib.Path, list[FileState]] = {}
|
|
615
|
+
per_target: dict[str, list[FileState]] = {}
|
|
616
|
+
for key, entry in journal["targets"].items():
|
|
617
|
+
states = [FileState.from_json(entry["original"])]
|
|
618
|
+
if entry.get("protected") is not None:
|
|
619
|
+
states.append(FileState.from_json(entry["protected"]))
|
|
620
|
+
per_target[key] = states
|
|
621
|
+
for operation in journal.get("operations", []):
|
|
622
|
+
key = operation["target"]
|
|
623
|
+
per_target[key].extend(
|
|
624
|
+
[
|
|
625
|
+
FileState.from_json(operation["expected"]),
|
|
626
|
+
FileState.from_json(operation["desired"]),
|
|
627
|
+
]
|
|
628
|
+
)
|
|
629
|
+
staged = operation.get("staged")
|
|
630
|
+
if isinstance(staged, str):
|
|
631
|
+
allowed[artifact_directory / staged] = per_target[key]
|
|
632
|
+
for key, entry in journal["targets"].items():
|
|
633
|
+
recovery = entry.get("recovery")
|
|
634
|
+
if isinstance(recovery, str):
|
|
635
|
+
allowed[artifact_directory / recovery] = per_target[key]
|
|
636
|
+
rollback_capture = entry.get("rollback_capture")
|
|
637
|
+
if isinstance(rollback_capture, str):
|
|
638
|
+
allowed[artifact_directory / rollback_capture] = per_target[key]
|
|
639
|
+
|
|
640
|
+
counts: dict[tuple[int, int], int] = {}
|
|
641
|
+
candidates = [*self.targets.values(), *allowed]
|
|
642
|
+
for path in candidates:
|
|
643
|
+
try:
|
|
644
|
+
info = os.lstat(path)
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
continue
|
|
647
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
648
|
+
raise TransactionConflict(f"unsafe transaction path appeared: {path}")
|
|
649
|
+
identity = (info.st_dev, info.st_ino)
|
|
650
|
+
counts[identity] = counts.get(identity, 0) + 1
|
|
651
|
+
return allowed, counts
|
|
652
|
+
|
|
653
|
+
def _validate_artifacts_no_discard(
|
|
654
|
+
self,
|
|
655
|
+
journal: dict[str, Any],
|
|
656
|
+
*,
|
|
657
|
+
directory: pathlib.Path | None = None,
|
|
658
|
+
) -> None:
|
|
659
|
+
allowed, counts = self._artifact_inventory(journal, directory=directory)
|
|
660
|
+
for path, states in allowed.items():
|
|
661
|
+
if not path.exists():
|
|
662
|
+
if path.is_symlink():
|
|
663
|
+
raise TransactionConflict(f"transaction artifact became a symlink: {path}")
|
|
664
|
+
continue
|
|
665
|
+
current = current_state(path, 0o600)
|
|
666
|
+
if not any(state_same_inode_and_bytes(current, state) for state in states):
|
|
667
|
+
raise TransactionConflict(
|
|
668
|
+
f"unknown inode in protected transaction artifact; retained: {path}"
|
|
669
|
+
)
|
|
670
|
+
if current.nlink != counts[(current.dev, current.ino)]:
|
|
671
|
+
raise TransactionConflict(
|
|
672
|
+
f"unexpected hard-link alias for protected transaction artifact: {path}"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def _cleanup_directory(self, journal: dict[str, Any] | None = None) -> None:
|
|
676
|
+
"""Atomically detach, validate, and remove only known artifacts.
|
|
677
|
+
|
|
678
|
+
A pathname writer racing cleanup is redirected into the empty directory
|
|
679
|
+
exchanged into the canonical location. The captured transaction tree
|
|
680
|
+
is validated only after that exchange, so cleanup never performs a
|
|
681
|
+
check-then-unlink against the live canonical pathname.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
self._validate_directory()
|
|
685
|
+
allowed = {self.JOURNAL_NAME, self.NEXT_JOURNAL_NAME}
|
|
686
|
+
if journal is not None:
|
|
687
|
+
self._validate_artifacts_no_discard(journal)
|
|
688
|
+
targets = journal.get("targets", {})
|
|
689
|
+
if isinstance(targets, dict):
|
|
690
|
+
for entry in targets.values():
|
|
691
|
+
if isinstance(entry, dict) and isinstance(entry.get("recovery"), str):
|
|
692
|
+
allowed.add(entry["recovery"])
|
|
693
|
+
if isinstance(entry, dict) and isinstance(
|
|
694
|
+
entry.get("rollback_capture"), str
|
|
695
|
+
):
|
|
696
|
+
allowed.add(entry["rollback_capture"])
|
|
697
|
+
operations = journal.get("operations", [])
|
|
698
|
+
if isinstance(operations, list):
|
|
699
|
+
for operation in operations:
|
|
700
|
+
if isinstance(operation, dict) and isinstance(operation.get("staged"), str):
|
|
701
|
+
allowed.add(operation["staged"])
|
|
702
|
+
for entry in tuple(self.directory.iterdir()):
|
|
703
|
+
if entry.name not in allowed:
|
|
704
|
+
raise TransactionRecoveryError(
|
|
705
|
+
f"unknown protected transaction artifact requires manual review: {entry}"
|
|
706
|
+
)
|
|
707
|
+
if entry.is_symlink() or (entry.exists() and not entry.is_file()):
|
|
708
|
+
raise TransactionRecoveryError(
|
|
709
|
+
f"unsafe protected transaction artifact requires manual review: {entry}"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
captured_info = os.lstat(self.directory)
|
|
713
|
+
quarantine = self.directory.with_name(
|
|
714
|
+
f"{self.directory.name}.cleanup-{uuid.uuid4().hex}"
|
|
715
|
+
)
|
|
716
|
+
os.mkdir(quarantine, 0o700)
|
|
717
|
+
os.chmod(quarantine, 0o700)
|
|
718
|
+
placeholder_info = os.lstat(quarantine)
|
|
719
|
+
fsync_parent(quarantine)
|
|
720
|
+
fault_boundary("cleanup-before-exchange")
|
|
721
|
+
atomic_exchange(self.directory, quarantine)
|
|
722
|
+
fsync_parent(self.directory)
|
|
723
|
+
captured_after = os.lstat(quarantine)
|
|
724
|
+
placeholder_after = os.lstat(self.directory)
|
|
725
|
+
identities_ok = (
|
|
726
|
+
(captured_after.st_dev, captured_after.st_ino)
|
|
727
|
+
== (captured_info.st_dev, captured_info.st_ino)
|
|
728
|
+
and (placeholder_after.st_dev, placeholder_after.st_ino)
|
|
729
|
+
== (placeholder_info.st_dev, placeholder_info.st_ino)
|
|
730
|
+
)
|
|
731
|
+
try:
|
|
732
|
+
if not identities_ok:
|
|
733
|
+
raise TransactionConflict(
|
|
734
|
+
"transaction directory identity changed during atomic cleanup capture"
|
|
735
|
+
)
|
|
736
|
+
if journal is not None:
|
|
737
|
+
self._validate_artifacts_no_discard(journal, directory=quarantine)
|
|
738
|
+
for entry in tuple(quarantine.iterdir()):
|
|
739
|
+
if entry.name not in allowed:
|
|
740
|
+
raise TransactionConflict(
|
|
741
|
+
f"unknown cleanup artifact retained: {entry}"
|
|
742
|
+
)
|
|
743
|
+
if entry.is_symlink() or (entry.exists() and not entry.is_file()):
|
|
744
|
+
raise TransactionConflict(
|
|
745
|
+
f"unsafe cleanup artifact retained: {entry}"
|
|
746
|
+
)
|
|
747
|
+
except BaseException:
|
|
748
|
+
# Restore the captured tree with another atomic exchange. If a
|
|
749
|
+
# pathname writer populated the placeholder meanwhile, its files
|
|
750
|
+
# move to the quarantine name and are deliberately retained.
|
|
751
|
+
atomic_exchange(quarantine, self.directory)
|
|
752
|
+
fsync_parent(self.directory)
|
|
753
|
+
try:
|
|
754
|
+
quarantine.rmdir()
|
|
755
|
+
except OSError:
|
|
756
|
+
pass
|
|
757
|
+
raise
|
|
758
|
+
|
|
759
|
+
# The captured directory has an unguessable sibling name and the
|
|
760
|
+
# canonical pathname now designates a distinct empty directory. Check
|
|
761
|
+
# each inode again immediately before removal and remove the journal
|
|
762
|
+
# last, leaving recovery evidence intact on any discrepancy.
|
|
763
|
+
try:
|
|
764
|
+
artifact_states, _counts = (
|
|
765
|
+
self._artifact_inventory(journal, directory=quarantine)
|
|
766
|
+
if journal is not None
|
|
767
|
+
else ({}, {})
|
|
768
|
+
)
|
|
769
|
+
names = sorted(allowed - {self.JOURNAL_NAME, self.NEXT_JOURNAL_NAME})
|
|
770
|
+
names.extend([self.NEXT_JOURNAL_NAME, self.JOURNAL_NAME])
|
|
771
|
+
for name in names:
|
|
772
|
+
artifact = quarantine / name
|
|
773
|
+
try:
|
|
774
|
+
info = os.lstat(artifact)
|
|
775
|
+
except FileNotFoundError:
|
|
776
|
+
continue
|
|
777
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
778
|
+
raise TransactionConflict(
|
|
779
|
+
f"cleanup artifact changed and was retained: {artifact}"
|
|
780
|
+
)
|
|
781
|
+
states = artifact_states.get(artifact)
|
|
782
|
+
if states is not None:
|
|
783
|
+
current = current_state(artifact, 0o600)
|
|
784
|
+
if not any(
|
|
785
|
+
state_same_inode_and_bytes(current, expected)
|
|
786
|
+
for expected in states
|
|
787
|
+
):
|
|
788
|
+
raise TransactionConflict(
|
|
789
|
+
f"cleanup artifact inode changed and was retained: {artifact}"
|
|
790
|
+
)
|
|
791
|
+
artifact.unlink()
|
|
792
|
+
fsync_directory(quarantine)
|
|
793
|
+
quarantine.rmdir()
|
|
794
|
+
except BaseException:
|
|
795
|
+
# Keep the journal (removed last above) and any remaining recovery
|
|
796
|
+
# artifacts at the canonical location if cleanup is interrupted.
|
|
797
|
+
if quarantine.exists() and self.directory.exists():
|
|
798
|
+
atomic_exchange(quarantine, self.directory)
|
|
799
|
+
fsync_parent(self.directory)
|
|
800
|
+
try:
|
|
801
|
+
quarantine.rmdir()
|
|
802
|
+
except OSError:
|
|
803
|
+
pass
|
|
804
|
+
raise
|
|
805
|
+
try:
|
|
806
|
+
self.directory.rmdir()
|
|
807
|
+
except OSError as exc:
|
|
808
|
+
raise TransactionConflict(
|
|
809
|
+
f"new state appeared during cleanup and was retained: {self.directory}"
|
|
810
|
+
) from exc
|
|
811
|
+
fsync_parent(self.directory)
|
|
812
|
+
|
|
813
|
+
def _cleanup_incomplete_preparation(self) -> None:
|
|
814
|
+
"""Remove only an owned 0700 directory created before journal publish.
|
|
815
|
+
|
|
816
|
+
Target mutation starts only after a complete ``ready`` journal exists,
|
|
817
|
+
so a journal-less directory cannot represent a committed file write.
|
|
818
|
+
"""
|
|
819
|
+
|
|
820
|
+
self._validate_directory()
|
|
821
|
+
for entry in tuple(self.directory.iterdir()):
|
|
822
|
+
if entry.name != self.NEXT_JOURNAL_NAME:
|
|
823
|
+
raise TransactionRecoveryError(
|
|
824
|
+
f"journal-less transaction artifact requires manual review: {entry}"
|
|
825
|
+
)
|
|
826
|
+
if entry.is_symlink() or not entry.is_file():
|
|
827
|
+
raise TransactionRecoveryError(
|
|
828
|
+
f"unsafe journal-less transaction artifact: {entry}"
|
|
829
|
+
)
|
|
830
|
+
for entry in tuple(self.directory.iterdir()):
|
|
831
|
+
entry.unlink()
|
|
832
|
+
fsync_directory(self.directory)
|
|
833
|
+
self.directory.rmdir()
|
|
834
|
+
fsync_parent(self.directory)
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _operation_states(journal: dict[str, Any]) -> dict[str, list[tuple[int, FileState]]]:
|
|
838
|
+
result: dict[str, list[tuple[int, FileState]]] = {}
|
|
839
|
+
operations = journal.get("operations", [])
|
|
840
|
+
if not isinstance(operations, list):
|
|
841
|
+
raise TransactionRecoveryError("transaction journal operations are invalid")
|
|
842
|
+
for index, operation in enumerate(operations):
|
|
843
|
+
if not isinstance(operation, dict) or operation.get("index") != index:
|
|
844
|
+
raise TransactionRecoveryError("transaction journal operation order is invalid")
|
|
845
|
+
key = operation.get("target")
|
|
846
|
+
if not isinstance(key, str):
|
|
847
|
+
raise TransactionRecoveryError("transaction journal operation target is invalid")
|
|
848
|
+
result.setdefault(key, []).append((index, FileState.from_json(operation.get("desired"))))
|
|
849
|
+
return result
|
|
850
|
+
|
|
851
|
+
def _record_conflicts(self, journal: dict[str, Any], conflicts: list[str]) -> None:
|
|
852
|
+
journal["status"] = "conflict"
|
|
853
|
+
journal["conflicts"] = sorted(set(conflicts))
|
|
854
|
+
self.journal = journal
|
|
855
|
+
self._write_journal()
|
|
856
|
+
|
|
857
|
+
@staticmethod
|
|
858
|
+
def _parse_intent(
|
|
859
|
+
journal: dict[str, Any], operations: list[dict[str, Any]]
|
|
860
|
+
) -> tuple[int, str] | None:
|
|
861
|
+
value = journal.get("intent")
|
|
862
|
+
if value is None:
|
|
863
|
+
return None
|
|
864
|
+
if not isinstance(value, dict) or set(value) != {"index", "phase"}:
|
|
865
|
+
raise TransactionRecoveryError("transaction journal intent is invalid")
|
|
866
|
+
index = value.get("index")
|
|
867
|
+
phase = value.get("phase")
|
|
868
|
+
if (
|
|
869
|
+
not isinstance(index, int)
|
|
870
|
+
or isinstance(index, bool)
|
|
871
|
+
or not 0 <= index < len(operations)
|
|
872
|
+
or phase
|
|
873
|
+
not in {"before-syscall", "captured", "reversing", "reversed"}
|
|
874
|
+
):
|
|
875
|
+
raise TransactionRecoveryError("transaction journal intent is invalid")
|
|
876
|
+
return index, phase
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _same(current: FileState, expected: FileState) -> bool:
|
|
880
|
+
return current == expected or state_same_inode_and_bytes(current, expected)
|
|
881
|
+
|
|
882
|
+
def _operation_slot(
|
|
883
|
+
self, operation: dict[str, Any], *, required: bool = True
|
|
884
|
+
) -> pathlib.Path | None:
|
|
885
|
+
raw = operation.get("staged")
|
|
886
|
+
if raw is None and not required:
|
|
887
|
+
return None
|
|
888
|
+
if not isinstance(raw, str) or pathlib.Path(raw).name != raw:
|
|
889
|
+
raise TransactionRecoveryError("transaction operation slot is invalid")
|
|
890
|
+
return self.directory / raw
|
|
891
|
+
|
|
892
|
+
def _target_artifacts(
|
|
893
|
+
self, journal: dict[str, Any], key: str
|
|
894
|
+
) -> list[pathlib.Path]:
|
|
895
|
+
entry = journal["targets"][key]
|
|
896
|
+
result: list[pathlib.Path] = []
|
|
897
|
+
for name_key in ("recovery", "rollback_capture"):
|
|
898
|
+
raw = entry.get(name_key)
|
|
899
|
+
if isinstance(raw, str):
|
|
900
|
+
result.append(self.directory / raw)
|
|
901
|
+
for operation in journal.get("operations", []):
|
|
902
|
+
if operation.get("target") != key:
|
|
903
|
+
continue
|
|
904
|
+
slot = self._operation_slot(operation, required=False)
|
|
905
|
+
if slot is not None:
|
|
906
|
+
result.append(slot)
|
|
907
|
+
return list(dict.fromkeys(result))
|
|
908
|
+
|
|
909
|
+
def _find_original_artifact(
|
|
910
|
+
self, journal: dict[str, Any], key: str, original: FileState
|
|
911
|
+
) -> tuple[pathlib.Path, FileState] | None:
|
|
912
|
+
for path in self._target_artifacts(journal, key):
|
|
913
|
+
state = current_state(path, original.mode)
|
|
914
|
+
if state.existed and state_same_inode_and_bytes(state, original):
|
|
915
|
+
return path, state
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
def _forward_possible_states(
|
|
919
|
+
self,
|
|
920
|
+
journal: dict[str, Any],
|
|
921
|
+
operations: list[dict[str, Any]],
|
|
922
|
+
cursor: int,
|
|
923
|
+
intent: tuple[int, str] | None,
|
|
924
|
+
) -> dict[str, list[FileState]]:
|
|
925
|
+
"""States this transaction may legitimately have installed at targets."""
|
|
926
|
+
|
|
927
|
+
possible: dict[str, list[FileState]] = {
|
|
928
|
+
key: [FileState.from_json(entry["original"])]
|
|
929
|
+
for key, entry in journal["targets"].items()
|
|
930
|
+
}
|
|
931
|
+
for operation in operations[:cursor]:
|
|
932
|
+
possible[operation["target"]].append(
|
|
933
|
+
FileState.from_json(operation["desired"])
|
|
934
|
+
)
|
|
935
|
+
if intent is not None:
|
|
936
|
+
index, _phase = intent
|
|
937
|
+
operation = operations[index]
|
|
938
|
+
possible[operation["target"]].extend(
|
|
939
|
+
[
|
|
940
|
+
FileState.from_json(operation["expected"]),
|
|
941
|
+
FileState.from_json(operation["desired"]),
|
|
942
|
+
]
|
|
943
|
+
)
|
|
944
|
+
return possible
|
|
945
|
+
|
|
946
|
+
def _reverse_external_forward_capture(
|
|
947
|
+
self,
|
|
948
|
+
journal: dict[str, Any],
|
|
949
|
+
operation: dict[str, Any],
|
|
950
|
+
target_state: FileState,
|
|
951
|
+
slot_state: FileState,
|
|
952
|
+
) -> None:
|
|
953
|
+
"""Atomically return a displaced external inode to the target."""
|
|
954
|
+
|
|
955
|
+
index = operation["index"]
|
|
956
|
+
label = operation["label"]
|
|
957
|
+
key = operation["target"]
|
|
958
|
+
path = self.targets[key]
|
|
959
|
+
kind = operation["kind"]
|
|
960
|
+
slot = self._operation_slot(operation)
|
|
961
|
+
assert slot is not None
|
|
962
|
+
journal["intent"] = {"index": index, "phase": "reversing"}
|
|
963
|
+
self.journal = journal
|
|
964
|
+
self._write_journal()
|
|
965
|
+
fault_boundary(f"reverse-intent:{index}:{label}")
|
|
966
|
+
reversed_ok = False
|
|
967
|
+
if kind == "replace":
|
|
968
|
+
atomic_exchange(slot, path)
|
|
969
|
+
fsync_parent(path)
|
|
970
|
+
fsync_directory(self.directory)
|
|
971
|
+
after_target = current_state(path, slot_state.mode)
|
|
972
|
+
after_slot = current_state(slot, target_state.mode)
|
|
973
|
+
reversed_ok = self._same(after_target, slot_state) and self._same(
|
|
974
|
+
after_slot, target_state
|
|
975
|
+
)
|
|
976
|
+
elif kind == "delete":
|
|
977
|
+
try:
|
|
978
|
+
atomic_move_noreplace(slot, path)
|
|
979
|
+
except OSError as exc:
|
|
980
|
+
if exc.errno != errno.EEXIST:
|
|
981
|
+
raise
|
|
982
|
+
else:
|
|
983
|
+
fsync_parent(path)
|
|
984
|
+
fsync_directory(self.directory)
|
|
985
|
+
after_target = current_state(path, slot_state.mode)
|
|
986
|
+
after_slot = current_state(slot, target_state.mode)
|
|
987
|
+
reversed_ok = self._same(after_target, slot_state) and not after_slot.existed
|
|
988
|
+
else:
|
|
989
|
+
raise TransactionRecoveryError(
|
|
990
|
+
f"cannot reverse transaction operation kind: {kind}"
|
|
991
|
+
)
|
|
992
|
+
journal["intent"] = {"index": index, "phase": "reversed"}
|
|
993
|
+
self._write_journal()
|
|
994
|
+
fault_boundary(f"reverse:{index}:{label}")
|
|
995
|
+
detail = (
|
|
996
|
+
f"{key}:external-capture-reversed"
|
|
997
|
+
if reversed_ok
|
|
998
|
+
else f"{key}:external-capture-raced"
|
|
999
|
+
)
|
|
1000
|
+
self._record_conflicts(journal, [detail])
|
|
1001
|
+
raise TransactionConflict(
|
|
1002
|
+
"external state preserved after atomic capture conflict; protected "
|
|
1003
|
+
f"journal retained at {self.journal_path}"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
def _resolve_inflight_capture(
|
|
1007
|
+
self,
|
|
1008
|
+
journal: dict[str, Any],
|
|
1009
|
+
operations: list[dict[str, Any]],
|
|
1010
|
+
intent: tuple[int, str] | None,
|
|
1011
|
+
) -> None:
|
|
1012
|
+
"""Resolve an interrupted or mismatched forward atomic syscall."""
|
|
1013
|
+
|
|
1014
|
+
if intent is None:
|
|
1015
|
+
return
|
|
1016
|
+
index, phase = intent
|
|
1017
|
+
operation = operations[index]
|
|
1018
|
+
kind = operation["kind"]
|
|
1019
|
+
if kind not in {"replace", "delete"}:
|
|
1020
|
+
return
|
|
1021
|
+
expected = FileState.from_json(operation["expected"])
|
|
1022
|
+
desired = FileState.from_json(operation["desired"])
|
|
1023
|
+
path = self.targets[operation["target"]]
|
|
1024
|
+
slot = self._operation_slot(operation)
|
|
1025
|
+
assert slot is not None
|
|
1026
|
+
target_state = current_state(path, desired.mode)
|
|
1027
|
+
slot_state = current_state(slot, expected.mode)
|
|
1028
|
+
|
|
1029
|
+
if kind == "replace":
|
|
1030
|
+
pre_syscall = self._same(target_state, expected) and self._same(
|
|
1031
|
+
slot_state, desired
|
|
1032
|
+
)
|
|
1033
|
+
captured_expected = self._same(target_state, desired) and self._same(
|
|
1034
|
+
slot_state, expected
|
|
1035
|
+
)
|
|
1036
|
+
reversed_external = self._same(slot_state, desired) and not (
|
|
1037
|
+
self._same(target_state, expected)
|
|
1038
|
+
or self._same(target_state, desired)
|
|
1039
|
+
)
|
|
1040
|
+
captured_external = self._same(target_state, desired) and not (
|
|
1041
|
+
self._same(slot_state, expected) or self._same(slot_state, desired)
|
|
1042
|
+
)
|
|
1043
|
+
if pre_syscall or captured_expected:
|
|
1044
|
+
return
|
|
1045
|
+
if captured_external:
|
|
1046
|
+
self._reverse_external_forward_capture(
|
|
1047
|
+
journal, operation, target_state, slot_state
|
|
1048
|
+
)
|
|
1049
|
+
if reversed_external or phase == "reversed":
|
|
1050
|
+
self._record_conflicts(
|
|
1051
|
+
journal, [f"{operation['target']}:external-capture-reversed"]
|
|
1052
|
+
)
|
|
1053
|
+
raise TransactionConflict(
|
|
1054
|
+
"external state preserved after interrupted atomic reversal; "
|
|
1055
|
+
f"journal retained at {self.journal_path}"
|
|
1056
|
+
)
|
|
1057
|
+
else:
|
|
1058
|
+
pre_syscall = self._same(target_state, expected) and not slot_state.existed
|
|
1059
|
+
captured_expected = not target_state.existed and self._same(
|
|
1060
|
+
slot_state, expected
|
|
1061
|
+
)
|
|
1062
|
+
reversed_external = (
|
|
1063
|
+
not slot_state.existed
|
|
1064
|
+
and target_state.existed
|
|
1065
|
+
and not self._same(target_state, expected)
|
|
1066
|
+
)
|
|
1067
|
+
captured_external = (
|
|
1068
|
+
not target_state.existed
|
|
1069
|
+
and slot_state.existed
|
|
1070
|
+
and not self._same(slot_state, expected)
|
|
1071
|
+
)
|
|
1072
|
+
if pre_syscall or captured_expected:
|
|
1073
|
+
return
|
|
1074
|
+
if captured_external:
|
|
1075
|
+
self._reverse_external_forward_capture(
|
|
1076
|
+
journal, operation, target_state, slot_state
|
|
1077
|
+
)
|
|
1078
|
+
if reversed_external or phase == "reversed":
|
|
1079
|
+
self._record_conflicts(
|
|
1080
|
+
journal, [f"{operation['target']}:external-capture-reversed"]
|
|
1081
|
+
)
|
|
1082
|
+
raise TransactionConflict(
|
|
1083
|
+
"external state preserved after interrupted atomic reversal; "
|
|
1084
|
+
f"journal retained at {self.journal_path}"
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
self._record_conflicts(
|
|
1088
|
+
journal, [f"{operation['target']}:ambiguous-inflight-topology"]
|
|
1089
|
+
)
|
|
1090
|
+
raise TransactionConflict(
|
|
1091
|
+
"ambiguous atomic transaction topology retained for recovery at "
|
|
1092
|
+
f"{self.journal_path}"
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
def _build_rollback(
|
|
1096
|
+
self,
|
|
1097
|
+
journal: dict[str, Any],
|
|
1098
|
+
possible: dict[str, list[FileState]],
|
|
1099
|
+
) -> dict[str, Any]:
|
|
1100
|
+
"""Classify all paths before the first rollback mutation."""
|
|
1101
|
+
|
|
1102
|
+
self._validate_artifacts_no_discard(journal)
|
|
1103
|
+
actions: list[dict[str, Any]] = []
|
|
1104
|
+
conflicts: list[str] = []
|
|
1105
|
+
for key, entry in journal["targets"].items():
|
|
1106
|
+
path = pathlib.Path(entry["path"])
|
|
1107
|
+
original = FileState.from_json(entry["original"])
|
|
1108
|
+
current = current_state(path, original.mode)
|
|
1109
|
+
if self._same(current, original):
|
|
1110
|
+
continue
|
|
1111
|
+
known_current = any(self._same(current, state) for state in possible[key])
|
|
1112
|
+
if original.existed:
|
|
1113
|
+
source = self._find_original_artifact(journal, key, original)
|
|
1114
|
+
if source is None:
|
|
1115
|
+
conflicts.append(f"{key}:missing-original-recovery-inode")
|
|
1116
|
+
continue
|
|
1117
|
+
source_path, source_state = source
|
|
1118
|
+
if current.existed and known_current:
|
|
1119
|
+
actions.append(
|
|
1120
|
+
{
|
|
1121
|
+
"key": key,
|
|
1122
|
+
"kind": "exchange",
|
|
1123
|
+
"target": str(path),
|
|
1124
|
+
"source": str(source_path),
|
|
1125
|
+
"expected_target": current.to_json(),
|
|
1126
|
+
"expected_source": source_state.to_json(),
|
|
1127
|
+
}
|
|
1128
|
+
)
|
|
1129
|
+
elif not current.existed and any(
|
|
1130
|
+
not state.existed for state in possible[key]
|
|
1131
|
+
):
|
|
1132
|
+
actions.append(
|
|
1133
|
+
{
|
|
1134
|
+
"key": key,
|
|
1135
|
+
"kind": "restore-missing",
|
|
1136
|
+
"target": str(path),
|
|
1137
|
+
"source": str(source_path),
|
|
1138
|
+
"expected_target": current.to_json(),
|
|
1139
|
+
"expected_source": source_state.to_json(),
|
|
1140
|
+
}
|
|
1141
|
+
)
|
|
1142
|
+
else:
|
|
1143
|
+
conflicts.append(f"{key}:external-state")
|
|
1144
|
+
elif not current.existed:
|
|
1145
|
+
continue
|
|
1146
|
+
elif known_current:
|
|
1147
|
+
capture_name = entry.get("rollback_capture")
|
|
1148
|
+
if not isinstance(capture_name, str):
|
|
1149
|
+
conflicts.append(f"{key}:missing-rollback-capture")
|
|
1150
|
+
continue
|
|
1151
|
+
capture = self.directory / capture_name
|
|
1152
|
+
capture_state = current_state(capture, current.mode)
|
|
1153
|
+
if capture_state.existed:
|
|
1154
|
+
conflicts.append(f"{key}:occupied-rollback-capture")
|
|
1155
|
+
continue
|
|
1156
|
+
actions.append(
|
|
1157
|
+
{
|
|
1158
|
+
"key": key,
|
|
1159
|
+
"kind": "capture-created",
|
|
1160
|
+
"target": str(path),
|
|
1161
|
+
"source": str(capture),
|
|
1162
|
+
"expected_target": current.to_json(),
|
|
1163
|
+
"expected_source": capture_state.to_json(),
|
|
1164
|
+
}
|
|
1165
|
+
)
|
|
1166
|
+
else:
|
|
1167
|
+
conflicts.append(f"{key}:external-state")
|
|
1168
|
+
if conflicts:
|
|
1169
|
+
self._record_conflicts(journal, conflicts)
|
|
1170
|
+
raise TransactionConflict(
|
|
1171
|
+
"external state preserved; protected transaction journal retained at "
|
|
1172
|
+
f"{self.journal_path}"
|
|
1173
|
+
)
|
|
1174
|
+
rollback = {"cursor": 0, "intent": None, "actions": actions}
|
|
1175
|
+
journal["rollback"] = rollback
|
|
1176
|
+
journal["status"] = "rolling-back"
|
|
1177
|
+
journal["intent"] = None
|
|
1178
|
+
self.journal = journal
|
|
1179
|
+
self._write_journal()
|
|
1180
|
+
return rollback
|
|
1181
|
+
|
|
1182
|
+
def _parse_rollback(self, journal: dict[str, Any]) -> dict[str, Any]:
|
|
1183
|
+
rollback = journal.get("rollback")
|
|
1184
|
+
if not isinstance(rollback, dict) or set(rollback) != {
|
|
1185
|
+
"cursor",
|
|
1186
|
+
"intent",
|
|
1187
|
+
"actions",
|
|
1188
|
+
}:
|
|
1189
|
+
raise TransactionRecoveryError("transaction rollback journal is invalid")
|
|
1190
|
+
actions = rollback.get("actions")
|
|
1191
|
+
cursor = rollback.get("cursor")
|
|
1192
|
+
intent = rollback.get("intent")
|
|
1193
|
+
if (
|
|
1194
|
+
not isinstance(actions, list)
|
|
1195
|
+
or not isinstance(cursor, int)
|
|
1196
|
+
or isinstance(cursor, bool)
|
|
1197
|
+
or not 0 <= cursor <= len(actions)
|
|
1198
|
+
):
|
|
1199
|
+
raise TransactionRecoveryError("transaction rollback cursor is invalid")
|
|
1200
|
+
if intent is not None and (
|
|
1201
|
+
not isinstance(intent, dict)
|
|
1202
|
+
or set(intent) != {"index", "phase"}
|
|
1203
|
+
or intent.get("index") != cursor
|
|
1204
|
+
or intent.get("phase")
|
|
1205
|
+
not in {"before-syscall", "captured", "reversing", "reversed"}
|
|
1206
|
+
):
|
|
1207
|
+
raise TransactionRecoveryError("transaction rollback intent is invalid")
|
|
1208
|
+
for action in actions:
|
|
1209
|
+
if not isinstance(action, dict) or set(action) != {
|
|
1210
|
+
"key",
|
|
1211
|
+
"kind",
|
|
1212
|
+
"target",
|
|
1213
|
+
"source",
|
|
1214
|
+
"expected_target",
|
|
1215
|
+
"expected_source",
|
|
1216
|
+
}:
|
|
1217
|
+
raise TransactionRecoveryError("transaction rollback action is invalid")
|
|
1218
|
+
if action["kind"] not in {"exchange", "restore-missing", "capture-created"}:
|
|
1219
|
+
raise TransactionRecoveryError("transaction rollback action kind is invalid")
|
|
1220
|
+
FileState.from_json(action["expected_target"])
|
|
1221
|
+
FileState.from_json(action["expected_source"])
|
|
1222
|
+
return rollback
|
|
1223
|
+
|
|
1224
|
+
def _rollback_action_conflict(
|
|
1225
|
+
self,
|
|
1226
|
+
journal: dict[str, Any],
|
|
1227
|
+
action: dict[str, Any],
|
|
1228
|
+
detail: str,
|
|
1229
|
+
) -> "None":
|
|
1230
|
+
self._record_conflicts(journal, [f"{action['key']}:{detail}"])
|
|
1231
|
+
raise TransactionConflict(
|
|
1232
|
+
"newer state appeared during atomic rollback and was preserved; "
|
|
1233
|
+
f"journal retained at {self.journal_path}"
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
def _run_rollback_action(
|
|
1237
|
+
self,
|
|
1238
|
+
journal: dict[str, Any],
|
|
1239
|
+
rollback: dict[str, Any],
|
|
1240
|
+
index: int,
|
|
1241
|
+
) -> None:
|
|
1242
|
+
action = rollback["actions"][index]
|
|
1243
|
+
key = action["key"]
|
|
1244
|
+
kind = action["kind"]
|
|
1245
|
+
target = pathlib.Path(action["target"])
|
|
1246
|
+
source = pathlib.Path(action["source"])
|
|
1247
|
+
expected_target = FileState.from_json(action["expected_target"])
|
|
1248
|
+
expected_source = FileState.from_json(action["expected_source"])
|
|
1249
|
+
target_state = current_state(target, expected_target.mode)
|
|
1250
|
+
source_state = current_state(source, expected_source.mode)
|
|
1251
|
+
|
|
1252
|
+
if kind == "exchange":
|
|
1253
|
+
post = self._same(target_state, expected_source) and self._same(
|
|
1254
|
+
source_state, expected_target
|
|
1255
|
+
)
|
|
1256
|
+
pre = self._same(target_state, expected_target) and self._same(
|
|
1257
|
+
source_state, expected_source
|
|
1258
|
+
)
|
|
1259
|
+
elif kind == "restore-missing":
|
|
1260
|
+
post = self._same(target_state, expected_source) and not source_state.existed
|
|
1261
|
+
pre = not target_state.existed and self._same(source_state, expected_source)
|
|
1262
|
+
else:
|
|
1263
|
+
post = not target_state.existed and self._same(source_state, expected_target)
|
|
1264
|
+
pre = self._same(target_state, expected_target) and not source_state.existed
|
|
1265
|
+
|
|
1266
|
+
if post:
|
|
1267
|
+
rollback["cursor"] = index + 1
|
|
1268
|
+
rollback["intent"] = None
|
|
1269
|
+
self._write_journal()
|
|
1270
|
+
fault_boundary(f"rollback-commit:{index}:{key}")
|
|
1271
|
+
return
|
|
1272
|
+
if not pre:
|
|
1273
|
+
self._rollback_action_conflict(journal, action, "rollback-topology-conflict")
|
|
1274
|
+
|
|
1275
|
+
rollback["intent"] = {"index": index, "phase": "before-syscall"}
|
|
1276
|
+
self._write_journal()
|
|
1277
|
+
fault_boundary(f"rollback-intent:{index}:{key}")
|
|
1278
|
+
if kind == "exchange":
|
|
1279
|
+
atomic_exchange(source, target)
|
|
1280
|
+
elif kind == "restore-missing":
|
|
1281
|
+
try:
|
|
1282
|
+
atomic_move_noreplace(source, target)
|
|
1283
|
+
except OSError as exc:
|
|
1284
|
+
if exc.errno == errno.EEXIST:
|
|
1285
|
+
self._rollback_action_conflict(
|
|
1286
|
+
journal, action, "rollback-target-created"
|
|
1287
|
+
)
|
|
1288
|
+
raise
|
|
1289
|
+
else:
|
|
1290
|
+
try:
|
|
1291
|
+
atomic_move_noreplace(target, source)
|
|
1292
|
+
except OSError as exc:
|
|
1293
|
+
if exc.errno == errno.EEXIST:
|
|
1294
|
+
self._rollback_action_conflict(
|
|
1295
|
+
journal, action, "rollback-capture-occupied"
|
|
1296
|
+
)
|
|
1297
|
+
raise
|
|
1298
|
+
fsync_parent(target)
|
|
1299
|
+
fsync_directory(self.directory)
|
|
1300
|
+
rollback["intent"] = {"index": index, "phase": "captured"}
|
|
1301
|
+
self._write_journal()
|
|
1302
|
+
fault_boundary(f"rollback-capture:{index}:{key}")
|
|
1303
|
+
|
|
1304
|
+
after_target = current_state(target, expected_target.mode)
|
|
1305
|
+
after_source = current_state(source, expected_source.mode)
|
|
1306
|
+
if kind == "exchange":
|
|
1307
|
+
valid = self._same(after_target, expected_source) and self._same(
|
|
1308
|
+
after_source, expected_target
|
|
1309
|
+
)
|
|
1310
|
+
elif kind == "restore-missing":
|
|
1311
|
+
valid = self._same(after_target, expected_source) and not after_source.existed
|
|
1312
|
+
else:
|
|
1313
|
+
valid = not after_target.existed and self._same(
|
|
1314
|
+
after_source, expected_target
|
|
1315
|
+
)
|
|
1316
|
+
if not valid:
|
|
1317
|
+
rollback["intent"] = {"index": index, "phase": "reversing"}
|
|
1318
|
+
self._write_journal()
|
|
1319
|
+
fault_boundary(f"rollback-reverse-intent:{index}:{key}")
|
|
1320
|
+
reverse_ok = False
|
|
1321
|
+
if kind == "exchange":
|
|
1322
|
+
atomic_exchange(source, target)
|
|
1323
|
+
reverse_target = current_state(target, after_source.mode)
|
|
1324
|
+
reverse_source = current_state(source, after_target.mode)
|
|
1325
|
+
reverse_ok = self._same(reverse_target, after_source) and self._same(
|
|
1326
|
+
reverse_source, after_target
|
|
1327
|
+
)
|
|
1328
|
+
elif kind == "restore-missing":
|
|
1329
|
+
try:
|
|
1330
|
+
atomic_move_noreplace(target, source)
|
|
1331
|
+
except OSError as exc:
|
|
1332
|
+
if exc.errno != errno.EEXIST:
|
|
1333
|
+
raise
|
|
1334
|
+
else:
|
|
1335
|
+
reverse_ok = not target.exists() and self._same(
|
|
1336
|
+
current_state(source, after_target.mode), after_target
|
|
1337
|
+
)
|
|
1338
|
+
else:
|
|
1339
|
+
try:
|
|
1340
|
+
atomic_move_noreplace(source, target)
|
|
1341
|
+
except OSError as exc:
|
|
1342
|
+
if exc.errno != errno.EEXIST:
|
|
1343
|
+
raise
|
|
1344
|
+
else:
|
|
1345
|
+
reverse_ok = not source.exists() and self._same(
|
|
1346
|
+
current_state(target, after_source.mode), after_source
|
|
1347
|
+
)
|
|
1348
|
+
fsync_parent(target)
|
|
1349
|
+
fsync_directory(self.directory)
|
|
1350
|
+
rollback["intent"] = {"index": index, "phase": "reversed"}
|
|
1351
|
+
self._write_journal()
|
|
1352
|
+
fault_boundary(f"rollback-reverse:{index}:{key}")
|
|
1353
|
+
self._rollback_action_conflict(
|
|
1354
|
+
journal,
|
|
1355
|
+
action,
|
|
1356
|
+
"rollback-cas-reversed" if reverse_ok else "rollback-cas-raced",
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
rollback["cursor"] = index + 1
|
|
1360
|
+
rollback["intent"] = None
|
|
1361
|
+
self._write_journal()
|
|
1362
|
+
fault_boundary(f"rollback-commit:{index}:{key}")
|
|
1363
|
+
|
|
1364
|
+
def _rollback(self, journal: dict[str, Any]) -> None:
|
|
1365
|
+
cursor = journal.get("cursor")
|
|
1366
|
+
operations = journal.get("operations", [])
|
|
1367
|
+
if (
|
|
1368
|
+
not isinstance(operations, list)
|
|
1369
|
+
or not isinstance(cursor, int)
|
|
1370
|
+
or isinstance(cursor, bool)
|
|
1371
|
+
or not 0 <= cursor <= len(operations)
|
|
1372
|
+
):
|
|
1373
|
+
raise TransactionRecoveryError("transaction journal cursor is invalid")
|
|
1374
|
+
intent = self._parse_intent(journal, operations)
|
|
1375
|
+
if journal.get("status") != "rolling-back":
|
|
1376
|
+
self._resolve_inflight_capture(journal, operations, intent)
|
|
1377
|
+
possible = self._forward_possible_states(
|
|
1378
|
+
journal, operations, cursor, intent
|
|
1379
|
+
)
|
|
1380
|
+
rollback = self._build_rollback(journal, possible)
|
|
1381
|
+
else:
|
|
1382
|
+
rollback = self._parse_rollback(journal)
|
|
1383
|
+
|
|
1384
|
+
self._validate_artifacts_no_discard(journal)
|
|
1385
|
+
for index in range(rollback["cursor"], len(rollback["actions"])):
|
|
1386
|
+
self._run_rollback_action(journal, rollback, index)
|
|
1387
|
+
|
|
1388
|
+
originals = {
|
|
1389
|
+
key: FileState.from_json(entry["original"])
|
|
1390
|
+
for key, entry in journal["targets"].items()
|
|
1391
|
+
}
|
|
1392
|
+
for key, original in originals.items():
|
|
1393
|
+
if not self._same(current_state(self.targets[key], original.mode), original):
|
|
1394
|
+
self._record_conflicts(journal, [f"{key}:restore-verification-failed"])
|
|
1395
|
+
raise TransactionConflict(
|
|
1396
|
+
f"rollback verification failed; journal retained at {self.journal_path}"
|
|
1397
|
+
)
|
|
1398
|
+
self._cleanup_directory(journal)
|
|
1399
|
+
for key, original in originals.items():
|
|
1400
|
+
if current_state(self.targets[key], original.mode) != original:
|
|
1401
|
+
raise TransactionRecoveryError(
|
|
1402
|
+
f"rollback did not restore exact inode/link state for {key}"
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
def recover_if_needed(self) -> None:
|
|
1406
|
+
try:
|
|
1407
|
+
exists = self.directory.exists()
|
|
1408
|
+
except OSError as exc:
|
|
1409
|
+
raise TransactionRecoveryError(
|
|
1410
|
+
f"cannot inspect protected transaction directory: {type(exc).__name__}"
|
|
1411
|
+
) from exc
|
|
1412
|
+
if not exists:
|
|
1413
|
+
if self.directory.is_symlink():
|
|
1414
|
+
raise TransactionRecoveryError(
|
|
1415
|
+
f"refusing protected transaction symlink: {self.directory}"
|
|
1416
|
+
)
|
|
1417
|
+
return
|
|
1418
|
+
if self.directory.is_symlink():
|
|
1419
|
+
raise TransactionRecoveryError(
|
|
1420
|
+
f"refusing protected transaction symlink: {self.directory}"
|
|
1421
|
+
)
|
|
1422
|
+
if not self.journal_path.exists():
|
|
1423
|
+
self._cleanup_incomplete_preparation()
|
|
1424
|
+
return
|
|
1425
|
+
journal = self._load_journal()
|
|
1426
|
+
self._validate_journal_identity(journal)
|
|
1427
|
+
status = journal.get("status")
|
|
1428
|
+
if status == "preparing":
|
|
1429
|
+
conflicts = []
|
|
1430
|
+
for key, entry in journal["targets"].items():
|
|
1431
|
+
original = FileState.from_json(entry["original"])
|
|
1432
|
+
current = current_state(pathlib.Path(entry["path"]), original.mode)
|
|
1433
|
+
if not (
|
|
1434
|
+
current == original
|
|
1435
|
+
or (
|
|
1436
|
+
original.existed
|
|
1437
|
+
and state_same_inode_and_bytes(current, original)
|
|
1438
|
+
)
|
|
1439
|
+
):
|
|
1440
|
+
conflicts.append(f"{key}:changed-during-preparation")
|
|
1441
|
+
if conflicts:
|
|
1442
|
+
self._record_conflicts(journal, conflicts)
|
|
1443
|
+
raise TransactionConflict(
|
|
1444
|
+
f"incomplete preparation conflicts with live state; journal retained at {self.journal_path}"
|
|
1445
|
+
)
|
|
1446
|
+
try:
|
|
1447
|
+
self._cleanup_directory(journal)
|
|
1448
|
+
except TransactionConflict as exc:
|
|
1449
|
+
self._record_conflicts(journal, ["preparing:unsafe-recovery-artifact"])
|
|
1450
|
+
raise TransactionConflict(
|
|
1451
|
+
f"incomplete preparation recovery artifact was retained: {exc}"
|
|
1452
|
+
) from exc
|
|
1453
|
+
return
|
|
1454
|
+
if status == "committed":
|
|
1455
|
+
final = {
|
|
1456
|
+
key: FileState.from_json(entry["original"])
|
|
1457
|
+
for key, entry in journal["targets"].items()
|
|
1458
|
+
}
|
|
1459
|
+
for operation in journal.get("operations", []):
|
|
1460
|
+
final[operation["target"]] = FileState.from_json(operation["desired"])
|
|
1461
|
+
conflicts = [
|
|
1462
|
+
f"{key}:post-commit-divergence"
|
|
1463
|
+
for key, state in final.items()
|
|
1464
|
+
if not state_matches(self.targets[key], state)
|
|
1465
|
+
]
|
|
1466
|
+
if conflicts:
|
|
1467
|
+
self._record_conflicts(journal, conflicts)
|
|
1468
|
+
raise TransactionConflict(
|
|
1469
|
+
f"committed generation diverged; journal retained at {self.journal_path}"
|
|
1470
|
+
)
|
|
1471
|
+
self._cleanup_directory(journal)
|
|
1472
|
+
return
|
|
1473
|
+
if status not in {"ready", "running", "rolling-back", "conflict"}:
|
|
1474
|
+
raise TransactionRecoveryError(f"transaction journal status is invalid: {status}")
|
|
1475
|
+
self._operation_states(journal)
|
|
1476
|
+
self._rollback(journal)
|
|
1477
|
+
|
|
1478
|
+
def prepare(
|
|
1479
|
+
self,
|
|
1480
|
+
originals: dict[str, Snapshot],
|
|
1481
|
+
specifications: list[tuple[str, str, bytes | None, int]],
|
|
1482
|
+
) -> None:
|
|
1483
|
+
if self.directory.exists() or self.directory.is_symlink():
|
|
1484
|
+
raise TransactionRecoveryError(
|
|
1485
|
+
f"protected transaction directory was not recovered: {self.directory}"
|
|
1486
|
+
)
|
|
1487
|
+
parent = self.directory.parent
|
|
1488
|
+
parent_info = os.lstat(parent)
|
|
1489
|
+
if not stat.S_ISDIR(parent_info.st_mode) or stat.S_ISLNK(parent_info.st_mode):
|
|
1490
|
+
raise TransactionRecoveryError(
|
|
1491
|
+
f"transaction parent must be a real directory: {parent}"
|
|
1492
|
+
)
|
|
1493
|
+
if not directory_is_private_to_user(parent_info):
|
|
1494
|
+
raise TransactionRecoveryError(
|
|
1495
|
+
f"transaction parent ownership/mode is unsafe: {parent}"
|
|
1496
|
+
)
|
|
1497
|
+
os.mkdir(self.directory, 0o700)
|
|
1498
|
+
os.chmod(self.directory, 0o700)
|
|
1499
|
+
fsync_parent(self.directory)
|
|
1500
|
+
transaction_id = uuid.uuid4().hex
|
|
1501
|
+
targets: dict[str, Any] = {}
|
|
1502
|
+
for key, path in self.targets.items():
|
|
1503
|
+
original = originals[key].state
|
|
1504
|
+
targets[key] = {
|
|
1505
|
+
"path": str(path),
|
|
1506
|
+
"original": original.to_json(),
|
|
1507
|
+
"recovery": f"recovery-{key}" if original.existed else None,
|
|
1508
|
+
"rollback_capture": f"rollback-{key}",
|
|
1509
|
+
"protected": None,
|
|
1510
|
+
}
|
|
1511
|
+
self.journal = {
|
|
1512
|
+
"schema_version": self.SCHEMA_VERSION,
|
|
1513
|
+
"transaction_id": transaction_id,
|
|
1514
|
+
"status": "preparing",
|
|
1515
|
+
"identity": {
|
|
1516
|
+
"profile": str(self.profile),
|
|
1517
|
+
"registry": str(self.registry),
|
|
1518
|
+
"agent_id": self.agent_id,
|
|
1519
|
+
"channel": self.channel,
|
|
1520
|
+
},
|
|
1521
|
+
"targets": targets,
|
|
1522
|
+
"operations": [],
|
|
1523
|
+
"cursor": 0,
|
|
1524
|
+
"intent": None,
|
|
1525
|
+
"conflicts": [],
|
|
1526
|
+
}
|
|
1527
|
+
self._write_journal()
|
|
1528
|
+
|
|
1529
|
+
transaction_dev = os.lstat(self.directory).st_dev
|
|
1530
|
+
for key, path in self.targets.items():
|
|
1531
|
+
parent_info = os.lstat(path.parent)
|
|
1532
|
+
if not stat.S_ISDIR(parent_info.st_mode) or stat.S_ISLNK(parent_info.st_mode):
|
|
1533
|
+
raise TransactionRecoveryError(
|
|
1534
|
+
f"transaction target parent is unsafe for {key}: {path.parent}"
|
|
1535
|
+
)
|
|
1536
|
+
if parent_info.st_dev != transaction_dev or (
|
|
1537
|
+
originals[key].existed and originals[key].state.dev != transaction_dev
|
|
1538
|
+
):
|
|
1539
|
+
raise TransactionRecoveryError(
|
|
1540
|
+
f"transaction target is on a different filesystem: {path}"
|
|
1541
|
+
)
|
|
1542
|
+
|
|
1543
|
+
# Every original recovery inode is created and validated before any
|
|
1544
|
+
# target mutation. Recovery links live only in the protected profile
|
|
1545
|
+
# journal directory, never beside tracked role files.
|
|
1546
|
+
for key, path in self.targets.items():
|
|
1547
|
+
original = originals[key]
|
|
1548
|
+
if not original.existed:
|
|
1549
|
+
continue
|
|
1550
|
+
if original.state.nlink != 1:
|
|
1551
|
+
raise TransactionRecoveryError(
|
|
1552
|
+
f"transaction target already has a hard-link alias: {path}"
|
|
1553
|
+
)
|
|
1554
|
+
recovery = self.directory / targets[key]["recovery"]
|
|
1555
|
+
os.link(path, recovery, follow_symlinks=False)
|
|
1556
|
+
fault_boundary(f"recovery-link:{key}")
|
|
1557
|
+
protected_target = current_state(path, original.mode)
|
|
1558
|
+
protected_recovery = current_state(recovery, original.mode)
|
|
1559
|
+
if (
|
|
1560
|
+
not state_same_inode_and_bytes(protected_target, original.state)
|
|
1561
|
+
or protected_target.nlink != 2
|
|
1562
|
+
or protected_recovery != protected_target
|
|
1563
|
+
):
|
|
1564
|
+
raise TransactionConflict(
|
|
1565
|
+
f"transaction target changed while preparing recovery: {path}"
|
|
1566
|
+
)
|
|
1567
|
+
targets[key]["protected"] = protected_target.to_json()
|
|
1568
|
+
fsync_directory(self.directory)
|
|
1569
|
+
self._write_journal()
|
|
1570
|
+
|
|
1571
|
+
expected = {
|
|
1572
|
+
key: (
|
|
1573
|
+
FileState.from_json(targets[key]["protected"])
|
|
1574
|
+
if original.existed
|
|
1575
|
+
else original.state
|
|
1576
|
+
)
|
|
1577
|
+
for key, original in originals.items()
|
|
1578
|
+
}
|
|
1579
|
+
operations: list[dict[str, Any]] = []
|
|
1580
|
+
for index, (label, key, content, mode) in enumerate(specifications):
|
|
1581
|
+
if key not in self.targets:
|
|
1582
|
+
raise TransactionRecoveryError(f"unknown transaction target: {key}")
|
|
1583
|
+
prior = expected[key]
|
|
1584
|
+
staged_name: str | None = None
|
|
1585
|
+
noop = False
|
|
1586
|
+
if content is None:
|
|
1587
|
+
desired = FileState(False, mode)
|
|
1588
|
+
noop = not prior.existed
|
|
1589
|
+
kind = "noop" if noop else "delete"
|
|
1590
|
+
if noop:
|
|
1591
|
+
desired = prior
|
|
1592
|
+
else:
|
|
1593
|
+
staged_name = f"capture-{index:02d}"
|
|
1594
|
+
else:
|
|
1595
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
1596
|
+
if (
|
|
1597
|
+
prior.existed
|
|
1598
|
+
and prior.mode == mode
|
|
1599
|
+
and prior.size == len(content)
|
|
1600
|
+
and prior.sha256 == digest
|
|
1601
|
+
):
|
|
1602
|
+
desired = prior
|
|
1603
|
+
noop = True
|
|
1604
|
+
kind = "noop"
|
|
1605
|
+
else:
|
|
1606
|
+
staged_name = f"candidate-{index:02d}"
|
|
1607
|
+
staged = self.directory / staged_name
|
|
1608
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
1609
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
1610
|
+
fd = os.open(staged, flags, mode)
|
|
1611
|
+
try:
|
|
1612
|
+
os.fchmod(fd, mode)
|
|
1613
|
+
view = memoryview(content)
|
|
1614
|
+
while view:
|
|
1615
|
+
written = os.write(fd, view)
|
|
1616
|
+
view = view[written:]
|
|
1617
|
+
os.fsync(fd)
|
|
1618
|
+
finally:
|
|
1619
|
+
os.close(fd)
|
|
1620
|
+
desired = current_state(staged, mode)
|
|
1621
|
+
if desired.dev != transaction_dev or desired.sha256 != digest:
|
|
1622
|
+
raise TransactionRecoveryError(
|
|
1623
|
+
f"staged transaction candidate validation failed: {label}"
|
|
1624
|
+
)
|
|
1625
|
+
kind = "create" if not prior.existed else "replace"
|
|
1626
|
+
operation = {
|
|
1627
|
+
"index": index,
|
|
1628
|
+
"label": label,
|
|
1629
|
+
"target": key,
|
|
1630
|
+
"expected": prior.to_json(),
|
|
1631
|
+
"desired": desired.to_json(),
|
|
1632
|
+
"staged": staged_name,
|
|
1633
|
+
"noop": noop,
|
|
1634
|
+
"kind": kind,
|
|
1635
|
+
}
|
|
1636
|
+
operations.append(operation)
|
|
1637
|
+
expected[key] = desired
|
|
1638
|
+
self.journal["operations"] = operations
|
|
1639
|
+
self.journal["status"] = "ready"
|
|
1640
|
+
self._write_journal()
|
|
1641
|
+
fault_boundary("prepared")
|
|
1642
|
+
|
|
1643
|
+
def execute(self) -> None:
|
|
1644
|
+
journal = self.journal
|
|
1645
|
+
operations = journal["operations"]
|
|
1646
|
+
try:
|
|
1647
|
+
journal["status"] = "running"
|
|
1648
|
+
self._write_journal()
|
|
1649
|
+
for index, operation in enumerate(operations):
|
|
1650
|
+
key = operation["target"]
|
|
1651
|
+
path = self.targets[key]
|
|
1652
|
+
expected = FileState.from_json(operation["expected"])
|
|
1653
|
+
desired = FileState.from_json(operation["desired"])
|
|
1654
|
+
kind = operation["kind"]
|
|
1655
|
+
journal["intent"] = {"index": index, "phase": "before-syscall"}
|
|
1656
|
+
self._write_journal()
|
|
1657
|
+
fault_boundary(f"intent:{index}:{operation['label']}")
|
|
1658
|
+
staged_name = operation.get("staged")
|
|
1659
|
+
staged = self.directory / staged_name if isinstance(staged_name, str) else None
|
|
1660
|
+
displaced: FileState | None = None
|
|
1661
|
+
if kind == "noop":
|
|
1662
|
+
if not state_matches(path, expected):
|
|
1663
|
+
raise TransactionConflict(
|
|
1664
|
+
f"compare-and-swap conflict at no-op {operation['label']}: {path}"
|
|
1665
|
+
)
|
|
1666
|
+
elif kind == "create":
|
|
1667
|
+
assert staged is not None
|
|
1668
|
+
if not state_matches(staged, desired):
|
|
1669
|
+
raise TransactionConflict(
|
|
1670
|
+
f"staged candidate changed before {operation['label']}"
|
|
1671
|
+
)
|
|
1672
|
+
try:
|
|
1673
|
+
atomic_move_noreplace(staged, path)
|
|
1674
|
+
except OSError as exc:
|
|
1675
|
+
if exc.errno == errno.EEXIST:
|
|
1676
|
+
raise TransactionConflict(
|
|
1677
|
+
f"compare-and-swap found a new external path at {operation['label']}: {path}"
|
|
1678
|
+
) from exc
|
|
1679
|
+
raise
|
|
1680
|
+
elif kind == "replace":
|
|
1681
|
+
assert staged is not None
|
|
1682
|
+
if not state_matches(staged, desired):
|
|
1683
|
+
raise TransactionConflict(
|
|
1684
|
+
f"staged candidate changed before {operation['label']}"
|
|
1685
|
+
)
|
|
1686
|
+
atomic_exchange(staged, path)
|
|
1687
|
+
displaced = current_state(staged, expected.mode)
|
|
1688
|
+
elif kind == "delete":
|
|
1689
|
+
assert staged is not None
|
|
1690
|
+
try:
|
|
1691
|
+
atomic_move_noreplace(path, staged)
|
|
1692
|
+
except OSError as exc:
|
|
1693
|
+
if exc.errno == errno.ENOENT:
|
|
1694
|
+
raise TransactionConflict(
|
|
1695
|
+
f"compare-and-swap found a missing external path at {operation['label']}: {path}"
|
|
1696
|
+
) from exc
|
|
1697
|
+
if exc.errno == errno.EEXIST:
|
|
1698
|
+
raise TransactionRecoveryError(
|
|
1699
|
+
f"protected capture slot already exists: {staged}"
|
|
1700
|
+
) from exc
|
|
1701
|
+
raise
|
|
1702
|
+
displaced = current_state(staged, expected.mode)
|
|
1703
|
+
else:
|
|
1704
|
+
raise TransactionRecoveryError(
|
|
1705
|
+
f"transaction operation kind is invalid: {kind}"
|
|
1706
|
+
)
|
|
1707
|
+
if kind != "noop":
|
|
1708
|
+
fsync_parent(path)
|
|
1709
|
+
fsync_directory(self.directory)
|
|
1710
|
+
journal["intent"] = {"index": index, "phase": "captured"}
|
|
1711
|
+
self._write_journal()
|
|
1712
|
+
fault_boundary(f"capture:{index}:{operation['label']}")
|
|
1713
|
+
|
|
1714
|
+
if displaced is not None and displaced != expected:
|
|
1715
|
+
# The atomic syscall captured an out-of-band inode. Reverse
|
|
1716
|
+
# only through another atomic primitive; if a newer path
|
|
1717
|
+
# appeared meanwhile, retain both artifacts and fail.
|
|
1718
|
+
journal["intent"] = {"index": index, "phase": "reversing"}
|
|
1719
|
+
self._write_journal()
|
|
1720
|
+
fault_boundary(f"reverse-intent:{index}:{operation['label']}")
|
|
1721
|
+
if kind == "replace":
|
|
1722
|
+
atomic_exchange(staged, path)
|
|
1723
|
+
reversed_slot = current_state(staged, desired.mode)
|
|
1724
|
+
reversed_target = current_state(path, displaced.mode)
|
|
1725
|
+
reversed_ok = (
|
|
1726
|
+
reversed_slot == desired and reversed_target == displaced
|
|
1727
|
+
)
|
|
1728
|
+
else:
|
|
1729
|
+
try:
|
|
1730
|
+
atomic_move_noreplace(staged, path)
|
|
1731
|
+
except OSError as exc:
|
|
1732
|
+
if exc.errno == errno.EEXIST:
|
|
1733
|
+
reversed_ok = False
|
|
1734
|
+
else:
|
|
1735
|
+
raise
|
|
1736
|
+
else:
|
|
1737
|
+
reversed_ok = state_matches(path, displaced) and not staged.exists()
|
|
1738
|
+
fsync_parent(path)
|
|
1739
|
+
fsync_directory(self.directory)
|
|
1740
|
+
journal["intent"] = {"index": index, "phase": "reversed"}
|
|
1741
|
+
self._write_journal()
|
|
1742
|
+
fault_boundary(f"reverse:{index}:{operation['label']}")
|
|
1743
|
+
if not reversed_ok:
|
|
1744
|
+
raise TransactionConflict(
|
|
1745
|
+
f"external state changed during atomic CAS reversal at {operation['label']}; protected artifacts retained"
|
|
1746
|
+
)
|
|
1747
|
+
raise TransactionConflict(
|
|
1748
|
+
f"atomic compare-and-swap preserved external state at {operation['label']}: {path}"
|
|
1749
|
+
)
|
|
1750
|
+
fault_boundary(f"write:{index}:{operation['label']}")
|
|
1751
|
+
if not state_matches(path, desired):
|
|
1752
|
+
raise TransactionConflict(
|
|
1753
|
+
f"transaction write verification failed for {operation['label']}: {path}"
|
|
1754
|
+
)
|
|
1755
|
+
journal["cursor"] = index + 1
|
|
1756
|
+
journal["intent"] = None
|
|
1757
|
+
self._write_journal()
|
|
1758
|
+
fault_boundary(f"commit:{index}:{operation['label']}")
|
|
1759
|
+
journal["status"] = "committed"
|
|
1760
|
+
self._write_journal()
|
|
1761
|
+
fault_boundary("transaction-committed")
|
|
1762
|
+
self._cleanup_directory(journal)
|
|
1763
|
+
except BaseException:
|
|
1764
|
+
if journal.get("status") != "committed":
|
|
1765
|
+
self._rollback(journal)
|
|
1766
|
+
raise
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def load_mapping(path: pathlib.Path, *, required: bool = True) -> dict:
|
|
1770
|
+
if path.is_symlink() or (required and not path.is_file()):
|
|
1771
|
+
fail(f"required mapping is unavailable: {path}")
|
|
1772
|
+
if not path.exists():
|
|
1773
|
+
return {}
|
|
1774
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
1775
|
+
if not isinstance(data, dict):
|
|
1776
|
+
fail(f"mapping root required: {path}")
|
|
1777
|
+
return data
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def load_snapshot_mapping(
|
|
1781
|
+
path: pathlib.Path, original: Snapshot, *, required: bool = True
|
|
1782
|
+
) -> dict:
|
|
1783
|
+
if not original.existed:
|
|
1784
|
+
if required:
|
|
1785
|
+
fail(f"required mapping is unavailable: {path}")
|
|
1786
|
+
return {}
|
|
1787
|
+
try:
|
|
1788
|
+
data = yaml.safe_load(original.content.decode("utf-8")) or {}
|
|
1789
|
+
except (UnicodeError, yaml.YAMLError) as exc:
|
|
1790
|
+
fail(f"invalid mapping snapshot {path}: {type(exc).__name__}")
|
|
1791
|
+
if not isinstance(data, dict):
|
|
1792
|
+
fail(f"mapping root required: {path}")
|
|
1793
|
+
return data
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def plain_merge(base: dict, override: dict) -> dict:
|
|
1797
|
+
result = copy.deepcopy(base)
|
|
1798
|
+
for key, value in override.items():
|
|
1799
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
1800
|
+
result[key] = plain_merge(result[key], value)
|
|
1801
|
+
elif key in result and isinstance(result[key], dict) and value is None:
|
|
1802
|
+
continue
|
|
1803
|
+
else:
|
|
1804
|
+
result[key] = copy.deepcopy(value)
|
|
1805
|
+
return result
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def apply_list_patches(result: dict, directive: object) -> None:
|
|
1809
|
+
if directive is None:
|
|
1810
|
+
return
|
|
1811
|
+
if not isinstance(directive, dict) or not isinstance(directive.get("list_patches", {}), dict):
|
|
1812
|
+
fail(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
|
|
1813
|
+
for dotted, rule in directive.get("list_patches", {}).items():
|
|
1814
|
+
if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
|
|
1815
|
+
fail("invalid list patch")
|
|
1816
|
+
additions = rule.get("add", []) or []
|
|
1817
|
+
removals = rule.get("remove", []) or []
|
|
1818
|
+
if not isinstance(additions, list) or not isinstance(removals, list) or not all(
|
|
1819
|
+
isinstance(item, str) for item in [*additions, *removals]
|
|
1820
|
+
):
|
|
1821
|
+
fail(f"list patch for {dotted} must contain string lists")
|
|
1822
|
+
cursor = result
|
|
1823
|
+
parts = dotted.split(".")
|
|
1824
|
+
for part in parts[:-1]:
|
|
1825
|
+
child = cursor.setdefault(part, {})
|
|
1826
|
+
if not isinstance(child, dict):
|
|
1827
|
+
fail(f"list patch parent for {dotted} is not a mapping")
|
|
1828
|
+
cursor = child
|
|
1829
|
+
current = cursor.get(parts[-1], []) or []
|
|
1830
|
+
if not isinstance(current, list):
|
|
1831
|
+
fail(f"list patch target {dotted} is not a list")
|
|
1832
|
+
removed = set(removals)
|
|
1833
|
+
merged = [item for item in current if item not in removed]
|
|
1834
|
+
for item in additions:
|
|
1835
|
+
if item not in merged:
|
|
1836
|
+
merged.append(item)
|
|
1837
|
+
cursor[parts[-1]] = merged
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def merge(base: dict, delta: dict) -> dict:
|
|
1841
|
+
ordinary = {key: value for key, value in delta.items() if key != LIST_PATCH_KEY}
|
|
1842
|
+
result = plain_merge(base, ordinary)
|
|
1843
|
+
apply_list_patches(result, delta.get(LIST_PATCH_KEY))
|
|
1844
|
+
return result
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def delta_comments(original: bytes) -> list[str]:
|
|
1848
|
+
comments: list[str] = []
|
|
1849
|
+
for line in original.decode("utf-8").splitlines() if original else []:
|
|
1850
|
+
if line.lstrip().startswith("#") and line not in comments:
|
|
1851
|
+
comments.append(line)
|
|
1852
|
+
standard = [
|
|
1853
|
+
"# Override-only delta for this Hermes profile.",
|
|
1854
|
+
"# Contains configuration and secret references only; secret values remain in 1Password.",
|
|
1855
|
+
]
|
|
1856
|
+
return [*standard, *(line for line in comments if line not in standard)]
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def render_delta(delta: dict, original: bytes) -> bytes:
|
|
1860
|
+
return (
|
|
1861
|
+
"\n".join(delta_comments(original))
|
|
1862
|
+
+ "\n"
|
|
1863
|
+
+ yaml.safe_dump(delta, sort_keys=False)
|
|
1864
|
+
).encode("utf-8")
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def render_generated(base: dict, delta: dict) -> bytes:
|
|
1868
|
+
header = (
|
|
1869
|
+
"# GENERATED FILE -- DO NOT EDIT.\n"
|
|
1870
|
+
"# source: fleet config.yaml + profile config.delta.yaml\n"
|
|
1871
|
+
)
|
|
1872
|
+
return (header + yaml.safe_dump(merge(base, delta), sort_keys=False)).encode("utf-8")
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def update_role(original: bytes, channel: str, metadata: dict[str, str]) -> bytes:
|
|
1876
|
+
text = original.decode("utf-8")
|
|
1877
|
+
match = re.search(
|
|
1878
|
+
rf"(?ms)^{re.escape(channel)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
|
|
1879
|
+
)
|
|
1880
|
+
if not match:
|
|
1881
|
+
fail(f"{channel} metadata block missing from role.yaml")
|
|
1882
|
+
body = match.group("body")
|
|
1883
|
+
for key in CHANNEL_FIELDS[channel]:
|
|
1884
|
+
value = metadata[key]
|
|
1885
|
+
replacement = f" {key}: {json.dumps(value)}"
|
|
1886
|
+
body, count = re.subn(
|
|
1887
|
+
rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
|
|
1888
|
+
)
|
|
1889
|
+
if count == 0:
|
|
1890
|
+
if body and not body.endswith("\n"):
|
|
1891
|
+
body += "\n"
|
|
1892
|
+
body += replacement + "\n"
|
|
1893
|
+
return (text[: match.start("body")] + body + text[match.end("body") :]).encode(
|
|
1894
|
+
"utf-8"
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def update_role_status(original: bytes, channel: str, status_value: str) -> bytes:
|
|
1899
|
+
text = original.decode("utf-8")
|
|
1900
|
+
match = re.search(
|
|
1901
|
+
rf"(?ms)^{re.escape(channel)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
|
|
1902
|
+
)
|
|
1903
|
+
if not match:
|
|
1904
|
+
fail(f"{channel} metadata block missing from role.yaml")
|
|
1905
|
+
body = match.group("body")
|
|
1906
|
+
replacement = f" provisioning_status: {json.dumps(status_value)}"
|
|
1907
|
+
body, count = re.subn(
|
|
1908
|
+
r"(?m)^\s+provisioning_status:\s*.*$", lambda _: replacement, body, count=1
|
|
1909
|
+
)
|
|
1910
|
+
if count == 0:
|
|
1911
|
+
if body and not body.endswith("\n"):
|
|
1912
|
+
body += "\n"
|
|
1913
|
+
body += replacement + "\n"
|
|
1914
|
+
return (text[: match.start("body")] + body + text[match.end("body") :]).encode(
|
|
1915
|
+
"utf-8"
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def update_runtime_env(original: bytes, channel: str, allowed_value: str) -> bytes:
|
|
1920
|
+
text = original.decode("utf-8") if original else ""
|
|
1921
|
+
keys = [*CHANNEL_REFERENCE_KEYS[channel], CHANNEL_ALLOWED_KEYS[channel]]
|
|
1922
|
+
for key in keys:
|
|
1923
|
+
text = re.sub(
|
|
1924
|
+
rf"(?m)^\s*(?:export\s+)?#?\s*{re.escape(key)}\s*=.*(?:\n|$)",
|
|
1925
|
+
"",
|
|
1926
|
+
text,
|
|
1927
|
+
)
|
|
1928
|
+
text = text.rstrip("\n")
|
|
1929
|
+
if text:
|
|
1930
|
+
text += "\n"
|
|
1931
|
+
text += f"{CHANNEL_ALLOWED_KEYS[channel]}={json.dumps(allowed_value)}\n"
|
|
1932
|
+
return text.encode("utf-8")
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def snapshot_allowed_value(original: Snapshot, channel: str) -> str:
|
|
1936
|
+
"""Read the active nonsecret policy from the same locked transaction snapshot."""
|
|
1937
|
+
|
|
1938
|
+
if not original.existed:
|
|
1939
|
+
return ""
|
|
1940
|
+
try:
|
|
1941
|
+
text = original.content.decode("utf-8")
|
|
1942
|
+
except UnicodeError as exc:
|
|
1943
|
+
fail(f"invalid {channel} runtime policy encoding: {type(exc).__name__}")
|
|
1944
|
+
key = CHANNEL_ALLOWED_KEYS[channel]
|
|
1945
|
+
match = re.search(
|
|
1946
|
+
rf"(?m)^\s*(?:export\s+)?{re.escape(key)}\s*=\s*(.*)$", text
|
|
1947
|
+
)
|
|
1948
|
+
if not match:
|
|
1949
|
+
return ""
|
|
1950
|
+
serialized = match.group(1).strip()
|
|
1951
|
+
try:
|
|
1952
|
+
value = json.loads(serialized)
|
|
1953
|
+
except json.JSONDecodeError:
|
|
1954
|
+
if len(serialized) >= 2 and serialized[0] == serialized[-1] == "'":
|
|
1955
|
+
value = serialized[1:-1]
|
|
1956
|
+
else:
|
|
1957
|
+
value = serialized
|
|
1958
|
+
if not isinstance(value, str) or any(character in value for character in "\r\n\0"):
|
|
1959
|
+
fail(f"invalid {channel} runtime allow-list policy")
|
|
1960
|
+
return value
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def merge_managed(current: dict, update: dict) -> dict:
|
|
1964
|
+
result = copy.deepcopy(current)
|
|
1965
|
+
for key, value in update.items():
|
|
1966
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
1967
|
+
result[key] = merge_managed(result[key], value)
|
|
1968
|
+
else:
|
|
1969
|
+
result[key] = copy.deepcopy(value)
|
|
1970
|
+
return result
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def update_registry(
|
|
1974
|
+
original: bytes,
|
|
1975
|
+
channel: str,
|
|
1976
|
+
agent_id: str,
|
|
1977
|
+
role_dir: str,
|
|
1978
|
+
profile_name: str,
|
|
1979
|
+
metadata: dict[str, str],
|
|
1980
|
+
) -> bytes:
|
|
1981
|
+
try:
|
|
1982
|
+
data = yaml.safe_load(original.decode("utf-8")) if original else None
|
|
1983
|
+
except (UnicodeError, yaml.YAMLError) as exc:
|
|
1984
|
+
fail(f"cannot safely inspect {channel} ownership registry: {type(exc).__name__}")
|
|
1985
|
+
data = data or {"schema_version": 1, "agents": {}}
|
|
1986
|
+
if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
|
|
1987
|
+
fail(f"cannot safely inspect {channel} ownership registry: invalid agents mapping")
|
|
1988
|
+
agents = data.setdefault("agents", {})
|
|
1989
|
+
for other_id, entry in agents.items():
|
|
1990
|
+
if other_id == agent_id or not isinstance(entry, dict):
|
|
1991
|
+
continue
|
|
1992
|
+
claim = entry.get(channel) or {}
|
|
1993
|
+
if not isinstance(claim, dict):
|
|
1994
|
+
continue
|
|
1995
|
+
if channel == "telegram" and str(claim.get("bot_id") or "") == metadata["bot_id"]:
|
|
1996
|
+
fail(f"Telegram bot identity is already assigned to agent {other_id}")
|
|
1997
|
+
if channel == "slack":
|
|
1998
|
+
same_bot = claim.get("bot_id") == metadata["bot_id"]
|
|
1999
|
+
same_team_user = (
|
|
2000
|
+
claim.get("team_id") == metadata["team_id"]
|
|
2001
|
+
and claim.get("bot_user_id") == metadata["bot_user_id"]
|
|
2002
|
+
)
|
|
2003
|
+
if same_bot or same_team_user:
|
|
2004
|
+
fail(f"Slack bot identity is already assigned to agent {other_id}")
|
|
2005
|
+
existing = agents.get(agent_id, {})
|
|
2006
|
+
if not isinstance(existing, dict):
|
|
2007
|
+
fail(f"registry entry for {agent_id} is not a mapping")
|
|
2008
|
+
managed = {
|
|
2009
|
+
"role_dir": role_dir,
|
|
2010
|
+
"profile_name": profile_name,
|
|
2011
|
+
channel: metadata,
|
|
2012
|
+
}
|
|
2013
|
+
updated = merge_managed(existing, managed)
|
|
2014
|
+
if updated == existing:
|
|
2015
|
+
return original
|
|
2016
|
+
agents[agent_id] = updated
|
|
2017
|
+
return yaml.safe_dump(data, sort_keys=False).encode("utf-8")
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def parse_pairs(values: list[list[str]], expected: tuple[str, ...], label: str) -> dict[str, str]:
|
|
2021
|
+
pairs: dict[str, str] = {}
|
|
2022
|
+
for key, value in values:
|
|
2023
|
+
if key in pairs:
|
|
2024
|
+
fail(f"duplicate {label} key: {key}")
|
|
2025
|
+
pairs[key] = value
|
|
2026
|
+
if tuple(pairs) != expected:
|
|
2027
|
+
fail(f"{label} keys must be exactly: {', '.join(expected)}")
|
|
2028
|
+
return pairs
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def existing_wiring(
|
|
2032
|
+
args: argparse.Namespace,
|
|
2033
|
+
originals: dict[pathlib.Path, Snapshot],
|
|
2034
|
+
delta_path: pathlib.Path,
|
|
2035
|
+
role_path: pathlib.Path,
|
|
2036
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
2037
|
+
channel = args.channel
|
|
2038
|
+
role = load_snapshot_mapping(role_path, originals[role_path])
|
|
2039
|
+
role_channel = role.get(channel)
|
|
2040
|
+
if not isinstance(role_channel, dict) or role_channel.get("provisioning_status") != "verified":
|
|
2041
|
+
raise ExistingWiringUnavailable
|
|
2042
|
+
|
|
2043
|
+
metadata: dict[str, str] = {}
|
|
2044
|
+
for key in CHANNEL_FIELDS[channel]:
|
|
2045
|
+
value = role_channel.get(key, "")
|
|
2046
|
+
if not isinstance(value, (str, int)) or isinstance(value, bool):
|
|
2047
|
+
fail(f"verified {channel} metadata field {key} is invalid")
|
|
2048
|
+
rendered = str(value)
|
|
2049
|
+
if key != "team_name" and not rendered:
|
|
2050
|
+
fail(f"verified {channel} metadata field {key} is missing")
|
|
2051
|
+
metadata[key] = rendered
|
|
2052
|
+
|
|
2053
|
+
delta = load_snapshot_mapping(delta_path, originals[delta_path])
|
|
2054
|
+
try:
|
|
2055
|
+
secret_env = delta["secrets"]["onepassword"]["env"]
|
|
2056
|
+
except (KeyError, TypeError):
|
|
2057
|
+
fail(f"verified {channel} wiring has no 1Password environment mapping")
|
|
2058
|
+
if not isinstance(secret_env, dict):
|
|
2059
|
+
fail(f"verified {channel} 1Password environment mapping is invalid")
|
|
2060
|
+
references: dict[str, str] = {}
|
|
2061
|
+
for name in CHANNEL_REFERENCE_KEYS[channel]:
|
|
2062
|
+
reference = secret_env.get(name, "")
|
|
2063
|
+
if (
|
|
2064
|
+
not isinstance(reference, str)
|
|
2065
|
+
or not reference.startswith("op://")
|
|
2066
|
+
or any(character in reference for character in "\r\n\0")
|
|
2067
|
+
):
|
|
2068
|
+
fail(f"verified {channel} reference {name} is missing or invalid")
|
|
2069
|
+
references[name] = reference
|
|
2070
|
+
|
|
2071
|
+
validator = pathlib.Path(args.reference_validator or "")
|
|
2072
|
+
if validator.is_symlink() or not validator.is_file():
|
|
2073
|
+
raise ExistingWiringValidationUnavailable
|
|
2074
|
+
for reference in references.values():
|
|
2075
|
+
try:
|
|
2076
|
+
result = subprocess.run(
|
|
2077
|
+
[sys.executable, "-I", str(validator), "--validate-reference", reference],
|
|
2078
|
+
stdin=subprocess.DEVNULL,
|
|
2079
|
+
stdout=subprocess.DEVNULL,
|
|
2080
|
+
stderr=subprocess.DEVNULL,
|
|
2081
|
+
check=False,
|
|
2082
|
+
timeout=30,
|
|
2083
|
+
)
|
|
2084
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
2085
|
+
raise ExistingWiringValidationUnavailable from exc
|
|
2086
|
+
if result.returncode != 0:
|
|
2087
|
+
raise ExistingWiringValidationUnavailable
|
|
2088
|
+
return references, metadata
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def prepare_unconfigured(
|
|
2092
|
+
args: argparse.Namespace,
|
|
2093
|
+
originals: dict[str, Snapshot],
|
|
2094
|
+
delta_path: pathlib.Path,
|
|
2095
|
+
generated_path: pathlib.Path,
|
|
2096
|
+
base_path: pathlib.Path,
|
|
2097
|
+
role_path: pathlib.Path,
|
|
2098
|
+
marker_key: str,
|
|
2099
|
+
transaction: CrashConsistentTransaction,
|
|
2100
|
+
modes: dict[str, int],
|
|
2101
|
+
) -> None:
|
|
2102
|
+
"""Durably disable a never-verified channel without touching valid wiring."""
|
|
2103
|
+
|
|
2104
|
+
role = load_snapshot_mapping(role_path, originals["role"])
|
|
2105
|
+
role_channel = role.get(args.channel)
|
|
2106
|
+
if isinstance(role_channel, dict) and role_channel.get("provisioning_status") == "verified":
|
|
2107
|
+
raise ExistingWiringAlreadyVerified
|
|
2108
|
+
|
|
2109
|
+
base = load_mapping(base_path)
|
|
2110
|
+
delta = load_snapshot_mapping(delta_path, originals["delta"], required=False)
|
|
2111
|
+
platforms = delta.setdefault("platforms", {})
|
|
2112
|
+
if not isinstance(platforms, dict):
|
|
2113
|
+
fail("platforms delta must be a mapping")
|
|
2114
|
+
platform = platforms.setdefault(args.channel, {})
|
|
2115
|
+
if not isinstance(platform, dict):
|
|
2116
|
+
fail(f"platforms.{args.channel} delta must be a mapping")
|
|
2117
|
+
platform["enabled"] = False
|
|
2118
|
+
delta_content = render_delta(delta, originals["delta"].content)
|
|
2119
|
+
generated_content = render_generated(base, delta)
|
|
2120
|
+
role_content = update_role_status(
|
|
2121
|
+
originals["role"].content, args.channel, "deferred"
|
|
2122
|
+
)
|
|
2123
|
+
run_transaction(
|
|
2124
|
+
transaction,
|
|
2125
|
+
originals,
|
|
2126
|
+
[
|
|
2127
|
+
("deferred-delta", "delta", delta_content, modes["delta"]),
|
|
2128
|
+
("deferred-generated", "generated", generated_content, modes["generated"]),
|
|
2129
|
+
("deferred-role", "role", role_content, originals["role"].mode),
|
|
2130
|
+
("deferred-marker", marker_key, None, modes[marker_key]),
|
|
2131
|
+
],
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def run_transaction(
|
|
2136
|
+
transaction: CrashConsistentTransaction,
|
|
2137
|
+
originals: dict[str, Snapshot],
|
|
2138
|
+
specifications: list[tuple[str, str, bytes | None, int]],
|
|
2139
|
+
) -> None:
|
|
2140
|
+
try:
|
|
2141
|
+
transaction.prepare(originals, specifications)
|
|
2142
|
+
transaction.execute()
|
|
2143
|
+
except BaseException as exc:
|
|
2144
|
+
if transaction.directory.exists() and not isinstance(
|
|
2145
|
+
exc, (KeyboardInterrupt, SystemExit, TransactionConflict)
|
|
2146
|
+
):
|
|
2147
|
+
try:
|
|
2148
|
+
transaction.recover_if_needed()
|
|
2149
|
+
except TransactionConflict:
|
|
2150
|
+
raise
|
|
2151
|
+
if (
|
|
2152
|
+
not transaction.directory.exists()
|
|
2153
|
+
and not isinstance(exc, (KeyboardInterrupt, SystemExit, TransactionConflict))
|
|
2154
|
+
):
|
|
2155
|
+
raise TransactionRecoveryError(
|
|
2156
|
+
f"{exc}; all local channel files restored"
|
|
2157
|
+
) from exc
|
|
2158
|
+
raise
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def commit_locked(args: argparse.Namespace) -> None:
|
|
2162
|
+
channel = args.channel
|
|
2163
|
+
profile = pathlib.Path(args.profile)
|
|
2164
|
+
if profile.is_symlink() or not profile.is_dir():
|
|
2165
|
+
fail(f"profile root must be a real directory: {profile}")
|
|
2166
|
+
delta_path = profile / "config.delta.yaml"
|
|
2167
|
+
generated_path = profile / "config.yaml"
|
|
2168
|
+
base_path = profile.parent.parent / "config.yaml"
|
|
2169
|
+
role_path = pathlib.Path(args.role_yaml)
|
|
2170
|
+
registry_path = pathlib.Path(args.registry)
|
|
2171
|
+
env_path = pathlib.Path(args.runtime_env)
|
|
2172
|
+
marker_path = pathlib.Path(args.done_marker)
|
|
2173
|
+
expected_marker = role_path.parent / ".scripts" / f".done-{'30-telegram' if channel == 'telegram' else '31-slack'}"
|
|
2174
|
+
if marker_path != expected_marker:
|
|
2175
|
+
fail(f"done marker does not match the {channel} role contract")
|
|
2176
|
+
if (args.reconcile_existing or args.prepare_unconfigured) and (
|
|
2177
|
+
args.reference or args.metadata
|
|
2178
|
+
):
|
|
2179
|
+
fail("snapshot-derived transaction modes do not accept reference inputs")
|
|
2180
|
+
if not args.reconcile_existing and not args.prepare_unconfigured:
|
|
2181
|
+
references = parse_pairs(
|
|
2182
|
+
args.reference, CHANNEL_REFERENCE_KEYS[channel], "reference"
|
|
2183
|
+
)
|
|
2184
|
+
metadata = parse_pairs(args.metadata, CHANNEL_FIELDS[channel], "metadata")
|
|
2185
|
+
if metadata["provisioning_status"] != "verified" or any(
|
|
2186
|
+
not metadata[key] for key in CHANNEL_FIELDS[channel] if key != "team_name"
|
|
2187
|
+
):
|
|
2188
|
+
fail(f"{channel} verified metadata is incomplete")
|
|
2189
|
+
for reference in references.values():
|
|
2190
|
+
if not reference.startswith("op://") or any(
|
|
2191
|
+
ch in reference for ch in "\r\n\0"
|
|
2192
|
+
):
|
|
2193
|
+
fail("invalid 1Password reference")
|
|
2194
|
+
|
|
2195
|
+
targets = {
|
|
2196
|
+
"delta": delta_path,
|
|
2197
|
+
"generated": generated_path,
|
|
2198
|
+
"role": role_path,
|
|
2199
|
+
"registry": registry_path,
|
|
2200
|
+
"runtime_env": env_path,
|
|
2201
|
+
"telegram_marker": role_path.parent / ".scripts" / ".done-30-telegram",
|
|
2202
|
+
"slack_marker": role_path.parent / ".scripts" / ".done-31-slack",
|
|
2203
|
+
}
|
|
2204
|
+
modes = {
|
|
2205
|
+
"delta": 0o600,
|
|
2206
|
+
"generated": 0o600,
|
|
2207
|
+
"role": 0o644,
|
|
2208
|
+
"registry": 0o600,
|
|
2209
|
+
"runtime_env": 0o600,
|
|
2210
|
+
"telegram_marker": 0o600,
|
|
2211
|
+
"slack_marker": 0o600,
|
|
2212
|
+
}
|
|
2213
|
+
transaction = CrashConsistentTransaction(
|
|
2214
|
+
profile=profile,
|
|
2215
|
+
registry=registry_path,
|
|
2216
|
+
channel=channel,
|
|
2217
|
+
agent_id=args.agent_id,
|
|
2218
|
+
targets=targets,
|
|
2219
|
+
modes=modes,
|
|
2220
|
+
)
|
|
2221
|
+
transaction.recover_if_needed()
|
|
2222
|
+
originals = {key: snapshot(path, modes[key]) for key, path in targets.items()}
|
|
2223
|
+
originals_by_path = {path: originals[key] for key, path in targets.items()}
|
|
2224
|
+
marker_key = f"{channel}_marker"
|
|
2225
|
+
if args.prepare_unconfigured:
|
|
2226
|
+
PROFILE_LOCK.test_snapshot_barrier(f"channel-prepare:{channel}")
|
|
2227
|
+
prepare_unconfigured(
|
|
2228
|
+
args,
|
|
2229
|
+
originals,
|
|
2230
|
+
delta_path,
|
|
2231
|
+
generated_path,
|
|
2232
|
+
base_path,
|
|
2233
|
+
role_path,
|
|
2234
|
+
marker_key,
|
|
2235
|
+
transaction,
|
|
2236
|
+
modes,
|
|
2237
|
+
)
|
|
2238
|
+
return
|
|
2239
|
+
PROFILE_LOCK.test_snapshot_barrier(f"channel:{channel}")
|
|
2240
|
+
if args.reconcile_existing:
|
|
2241
|
+
references, metadata = existing_wiring(
|
|
2242
|
+
args, originals_by_path, delta_path, role_path
|
|
2243
|
+
)
|
|
2244
|
+
allowed_value = snapshot_allowed_value(originals["runtime_env"], channel)
|
|
2245
|
+
else:
|
|
2246
|
+
allowed_value = args.allowed_value
|
|
2247
|
+
base = load_mapping(base_path)
|
|
2248
|
+
delta = load_snapshot_mapping(delta_path, originals["delta"], required=False)
|
|
2249
|
+
onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
|
|
2250
|
+
if not isinstance(onepassword, dict):
|
|
2251
|
+
fail("secrets.onepassword delta must be a mapping")
|
|
2252
|
+
onepassword["enabled"] = True
|
|
2253
|
+
secret_env = onepassword.setdefault("env", {})
|
|
2254
|
+
if not isinstance(secret_env, dict):
|
|
2255
|
+
fail("secrets.onepassword.env delta must be a mapping")
|
|
2256
|
+
secret_env.update(references)
|
|
2257
|
+
platforms = delta.setdefault("platforms", {})
|
|
2258
|
+
if not isinstance(platforms, dict):
|
|
2259
|
+
fail("platforms delta must be a mapping")
|
|
2260
|
+
platform = platforms.setdefault(channel, {})
|
|
2261
|
+
if not isinstance(platform, dict):
|
|
2262
|
+
fail(f"platforms.{channel} delta must be a mapping")
|
|
2263
|
+
platform["enabled"] = False
|
|
2264
|
+
|
|
2265
|
+
disabled_delta = render_delta(delta, originals["delta"].content)
|
|
2266
|
+
disabled_generated = render_generated(base, delta)
|
|
2267
|
+
role_content = update_role(originals["role"].content, channel, metadata)
|
|
2268
|
+
env_content = update_runtime_env(originals["runtime_env"].content, channel, allowed_value)
|
|
2269
|
+
registry_content = update_registry(
|
|
2270
|
+
originals["registry"].content,
|
|
2271
|
+
channel,
|
|
2272
|
+
args.agent_id,
|
|
2273
|
+
args.role_dir,
|
|
2274
|
+
args.profile_name,
|
|
2275
|
+
metadata,
|
|
2276
|
+
)
|
|
2277
|
+
platform["enabled"] = True
|
|
2278
|
+
enabled_delta = render_delta(delta, originals["delta"].content)
|
|
2279
|
+
enabled_generated = render_generated(base, delta)
|
|
2280
|
+
run_transaction(
|
|
2281
|
+
transaction,
|
|
2282
|
+
originals,
|
|
2283
|
+
[
|
|
2284
|
+
("disabled-delta", "delta", disabled_delta, modes["delta"]),
|
|
2285
|
+
("disabled-generated", "generated", disabled_generated, modes["generated"]),
|
|
2286
|
+
("runtime-policy", "runtime_env", env_content, modes["runtime_env"]),
|
|
2287
|
+
("role-identity", "role", role_content, originals["role"].mode),
|
|
2288
|
+
("registry-identity", "registry", registry_content, modes["registry"]),
|
|
2289
|
+
("enabled-delta", "delta", enabled_delta, modes["delta"]),
|
|
2290
|
+
("enabled-generated", "generated", enabled_generated, modes["generated"]),
|
|
2291
|
+
("completion-marker", marker_key, b"", originals[marker_key].mode),
|
|
2292
|
+
],
|
|
2293
|
+
)
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
def commit(args: argparse.Namespace) -> None:
|
|
2297
|
+
profile = pathlib.Path(args.profile)
|
|
2298
|
+
registry = pathlib.Path(args.registry)
|
|
2299
|
+
try:
|
|
2300
|
+
# The helper owns this order. During migration, RegistryLock can adopt
|
|
2301
|
+
# the exact inherited lock description held by an unchanged shell
|
|
2302
|
+
# caller; direct invocation still acquires and validates it itself.
|
|
2303
|
+
with RegistryLock(registry):
|
|
2304
|
+
with PROFILE_LOCK.ProfileConfigLock(profile):
|
|
2305
|
+
commit_locked(args)
|
|
2306
|
+
except ExistingWiringUnavailable as exc:
|
|
2307
|
+
raise SystemExit(2) from exc
|
|
2308
|
+
except ExistingWiringValidationUnavailable as exc:
|
|
2309
|
+
raise SystemExit(75) from exc
|
|
2310
|
+
except ExistingWiringAlreadyVerified as exc:
|
|
2311
|
+
raise SystemExit(3) from exc
|
|
2312
|
+
except PROFILE_LOCK.ProfileConfigLockError as exc:
|
|
2313
|
+
fail(str(exc))
|
|
2314
|
+
except (OSError, TransactionConflict, TransactionRecoveryError) as exc:
|
|
2315
|
+
fail(str(exc))
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
def parser() -> argparse.ArgumentParser:
|
|
2319
|
+
result = argparse.ArgumentParser()
|
|
2320
|
+
result.add_argument("--channel", choices=tuple(CHANNEL_FIELDS), required=True)
|
|
2321
|
+
result.add_argument("--profile", required=True)
|
|
2322
|
+
result.add_argument("--role-yaml", required=True)
|
|
2323
|
+
result.add_argument("--registry", required=True)
|
|
2324
|
+
result.add_argument("--runtime-env", required=True)
|
|
2325
|
+
result.add_argument("--done-marker", required=True)
|
|
2326
|
+
result.add_argument("--agent-id", required=True)
|
|
2327
|
+
result.add_argument("--role-dir", required=True)
|
|
2328
|
+
result.add_argument("--profile-name", required=True)
|
|
2329
|
+
result.add_argument("--allowed-value", default="")
|
|
2330
|
+
mode = result.add_mutually_exclusive_group()
|
|
2331
|
+
mode.add_argument("--reconcile-existing", action="store_true")
|
|
2332
|
+
mode.add_argument("--prepare-unconfigured", action="store_true")
|
|
2333
|
+
result.add_argument("--reference-validator")
|
|
2334
|
+
result.add_argument("--reference", nargs=2, action="append", default=[])
|
|
2335
|
+
result.add_argument("--metadata", nargs=2, action="append", default=[])
|
|
2336
|
+
return result
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
if __name__ == "__main__":
|
|
2340
|
+
commit(parser().parse_args())
|