worktree-controller 1.30.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Reversible, explicit layout transition used only by governed activation.
|
|
2
|
+
|
|
3
|
+
The old layout is caller supplied. Core operations use only the current layout.
|
|
4
|
+
Repository locks and temporary refusal at the old entry points fence the switch;
|
|
5
|
+
allocation ownership and historical receipts are never rewritten.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from contextlib import ExitStack
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
|
|
18
|
+
from . import cleanup_evidence as evidence
|
|
19
|
+
from . import metadata_relocation as relocation
|
|
20
|
+
from . import preservation as p
|
|
21
|
+
from .models import ControllerError
|
|
22
|
+
from .store import MetadataStore, writer_lease_from_dict
|
|
23
|
+
|
|
24
|
+
REFUSAL = b"#!/bin/sh\necho 'Controller layout transition in progress; retry after activation.' >&2\nexit 75\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LayoutError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digest(raw: bytes) -> str:
|
|
32
|
+
return hashlib.sha256(raw).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def owned(path: Path, *, directory: bool = False) -> os.stat_result:
|
|
36
|
+
info = path.lstat()
|
|
37
|
+
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
|
38
|
+
if info.st_uid != os.geteuid() or not kind(info.st_mode):
|
|
39
|
+
raise LayoutError(
|
|
40
|
+
f"layout target is not an owned {'directory' if directory else 'file'}: {path}"
|
|
41
|
+
)
|
|
42
|
+
return info
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def relative(value: str) -> Path:
|
|
46
|
+
path = PurePosixPath(value)
|
|
47
|
+
if (
|
|
48
|
+
not path.parts
|
|
49
|
+
or path.is_absolute()
|
|
50
|
+
or str(path) != value
|
|
51
|
+
or any(x in {".", "..", ".git"} for x in path.parts)
|
|
52
|
+
or any(x in value for x in "\\*?[]\n\r\x00")
|
|
53
|
+
):
|
|
54
|
+
raise LayoutError("layout paths must be exact repository-relative paths")
|
|
55
|
+
return Path(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def replace_file(path: Path, raw: bytes, mode: int = 0o600) -> None:
|
|
59
|
+
pending = path.with_name(f".{path.name}.layout-pending")
|
|
60
|
+
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
|
|
61
|
+
try:
|
|
62
|
+
with os.fdopen(fd, "wb") as stream:
|
|
63
|
+
stream.write(raw)
|
|
64
|
+
stream.flush()
|
|
65
|
+
os.fchmod(stream.fileno(), mode)
|
|
66
|
+
os.fsync(stream.fileno())
|
|
67
|
+
os.replace(pending, path)
|
|
68
|
+
sync_directory(path.parent)
|
|
69
|
+
finally:
|
|
70
|
+
if pending.exists():
|
|
71
|
+
pending.unlink()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sync_directory(path: Path) -> None:
|
|
75
|
+
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
76
|
+
try:
|
|
77
|
+
os.fsync(fd)
|
|
78
|
+
finally:
|
|
79
|
+
os.close(fd)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _lease_bytes(raw: bytes, *, reverse: bool = False) -> bytes:
|
|
83
|
+
value = json.loads(raw)
|
|
84
|
+
if reverse:
|
|
85
|
+
writer_lease_from_dict(value)
|
|
86
|
+
value["schema_version"] = 1
|
|
87
|
+
value["holder"]["provider"] = value["holder"].pop("client_id")
|
|
88
|
+
else:
|
|
89
|
+
if (
|
|
90
|
+
type(value.get("schema_version")) is not int
|
|
91
|
+
or value["schema_version"] != 1
|
|
92
|
+
or "client_id" in value.get("holder", {})
|
|
93
|
+
):
|
|
94
|
+
raise LayoutError("previous writer lease does not match schema 1")
|
|
95
|
+
value["schema_version"] = 2
|
|
96
|
+
value["holder"]["client_id"] = value["holder"].pop("provider")
|
|
97
|
+
writer_lease_from_dict(value)
|
|
98
|
+
return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def lease_bytes(raw: bytes, *, reverse: bool = False) -> bytes:
|
|
102
|
+
try:
|
|
103
|
+
return _lease_bytes(raw, reverse=reverse)
|
|
104
|
+
except (ControllerError, ValueError, TypeError, KeyError, AttributeError) as exc:
|
|
105
|
+
raise LayoutError("writer lease does not match the exact migration schema") from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def quiet(root: Path, repository: Path) -> list[dict]:
|
|
109
|
+
if (root / "publication-lease.json").exists():
|
|
110
|
+
raise LayoutError(f"publication lease blocks layout migration: {root}")
|
|
111
|
+
compatible = []
|
|
112
|
+
try:
|
|
113
|
+
from .source_recovery import plan_reconciliation
|
|
114
|
+
from .transaction_scope import is_atomic_temp, pending, terminal_state
|
|
115
|
+
|
|
116
|
+
# The activation preflight already serializes on this namespace's
|
|
117
|
+
# lock; classifying through a fresh instance must inherit that
|
|
118
|
+
# serialization instead of taking a second, self-deadlocking flock.
|
|
119
|
+
store = MetadataStore(root.parent.parent, _migration_root=root)
|
|
120
|
+
journals = (
|
|
121
|
+
[
|
|
122
|
+
path
|
|
123
|
+
for path in (root / "transactions").iterdir()
|
|
124
|
+
if not is_atomic_temp(path.name)
|
|
125
|
+
]
|
|
126
|
+
if (root / "transactions").exists()
|
|
127
|
+
else []
|
|
128
|
+
)
|
|
129
|
+
if journals:
|
|
130
|
+
pending(store, assume_serialized=True) # Validate every identity, including retained terminal journals.
|
|
131
|
+
for path in sorted(journals):
|
|
132
|
+
state = terminal_state(
|
|
133
|
+
store, path.stem, verify_generations=True, assume_serialized=True
|
|
134
|
+
)
|
|
135
|
+
if state is not None:
|
|
136
|
+
compatible.append({"transaction_id": path.stem, "state": state})
|
|
137
|
+
continue
|
|
138
|
+
_, receipt_sha = evidence.read(store.completion_receipts_dir / path.name)
|
|
139
|
+
plan = plan_reconciliation(repository, path.stem, receipt_sha, _migration_store=store)
|
|
140
|
+
compatible.append(
|
|
141
|
+
{
|
|
142
|
+
"transaction_id": path.stem,
|
|
143
|
+
"state": plan["state"],
|
|
144
|
+
"plan_id": plan["plan_id"],
|
|
145
|
+
"completion_receipt_sha256": receipt_sha,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
except (ControllerError, OSError, ValueError, TypeError, KeyError, IndexError) as exc:
|
|
149
|
+
raise LayoutError(f"unfinished transaction blocks layout migration: {root}") from exc
|
|
150
|
+
live = set()
|
|
151
|
+
for path in (root / "records").glob("*.json"):
|
|
152
|
+
owned(path)
|
|
153
|
+
if json.loads(path.read_bytes()).get("lifecycle") != "RELEASED":
|
|
154
|
+
live.add(path.stem)
|
|
155
|
+
for path in (root / "completion-receipts").glob("*.json"):
|
|
156
|
+
owned(path)
|
|
157
|
+
raw = path.read_bytes()
|
|
158
|
+
value = json.loads(raw)
|
|
159
|
+
if value.get("allocation_id") in live and str(root).encode() in raw:
|
|
160
|
+
if any(row["transaction_id"] == path.stem for row in compatible):
|
|
161
|
+
continue # Exact supported evidence was verified above, not inferred from age.
|
|
162
|
+
raise LayoutError("live completion evidence still binds the previous layout")
|
|
163
|
+
return compatible
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def lease_record(path: Path) -> dict[str, str]:
|
|
167
|
+
owned(path)
|
|
168
|
+
raw = path.read_bytes()
|
|
169
|
+
converted = lease_bytes(raw)
|
|
170
|
+
if json.loads(converted)["allocation_id"] != path.stem:
|
|
171
|
+
raise LayoutError("writer lease filename does not match its allocation")
|
|
172
|
+
return {"before": raw.hex(), "after": converted.hex()}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def refuse_running_entries(names: set[str]) -> None:
|
|
176
|
+
for process in Path("/proc").iterdir():
|
|
177
|
+
if not process.name.isdecimal():
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
if (
|
|
181
|
+
process.stat().st_uid == os.geteuid()
|
|
182
|
+
and (process / "comm").read_text().strip() in names
|
|
183
|
+
):
|
|
184
|
+
raise LayoutError("Controller invocation is still running")
|
|
185
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class LayoutMigration:
|
|
190
|
+
"""One deployment receipt, not a new runtime authority or adapter."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, receipt: Path, document: dict):
|
|
193
|
+
self.receipt = receipt
|
|
194
|
+
self.document = document
|
|
195
|
+
|
|
196
|
+
def save(self) -> None:
|
|
197
|
+
replace_file(
|
|
198
|
+
self.receipt, (json.dumps(self.document, indent=2, sort_keys=True) + "\n").encode()
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def prepare(
|
|
203
|
+
cls,
|
|
204
|
+
receipt: Path,
|
|
205
|
+
repositories: dict[Path, Path],
|
|
206
|
+
*,
|
|
207
|
+
previous_namespace: str,
|
|
208
|
+
previous_policy: str,
|
|
209
|
+
previous_entries: dict[Path, str],
|
|
210
|
+
entries: dict[Path, bytes],
|
|
211
|
+
runtimes: dict[str, str],
|
|
212
|
+
) -> LayoutMigration:
|
|
213
|
+
namespace, policy = relative(previous_namespace), relative(previous_policy)
|
|
214
|
+
if len(namespace.parts) != 1 or not previous_entries:
|
|
215
|
+
raise LayoutError("one previous namespace and exact previous entry points are required")
|
|
216
|
+
if receipt.exists() or receipt.parent.exists():
|
|
217
|
+
raise LayoutError("layout recovery directory already exists")
|
|
218
|
+
rows = []
|
|
219
|
+
for canonical, common in repositories.items():
|
|
220
|
+
old = common / namespace / "v1"
|
|
221
|
+
new = MetadataStore(common).root
|
|
222
|
+
source_policy = canonical / policy
|
|
223
|
+
target_policy = canonical / ".agents/worktree-policy.toml"
|
|
224
|
+
identity = owned(old, directory=True)
|
|
225
|
+
owned(old.parent, directory=True)
|
|
226
|
+
if os.path.lexists(old / relocation.RECORD):
|
|
227
|
+
raise LayoutError("previous layout already has relocation evidence")
|
|
228
|
+
owned(source_policy)
|
|
229
|
+
owned(source_policy.parent, directory=True)
|
|
230
|
+
if (
|
|
231
|
+
new.exists()
|
|
232
|
+
or new.is_symlink()
|
|
233
|
+
or target_policy.exists()
|
|
234
|
+
or target_policy.is_symlink()
|
|
235
|
+
):
|
|
236
|
+
raise LayoutError("current layout already exists; refusing overlapping authorities")
|
|
237
|
+
with MetadataStore(common, _migration_root=old).lock(exclusive=False):
|
|
238
|
+
compatibility = quiet(old, canonical)
|
|
239
|
+
references = relocation.prepare(old, new)
|
|
240
|
+
for path in (old / "writer-leases").glob("*.json"):
|
|
241
|
+
lease_record(path)
|
|
242
|
+
rows.append(
|
|
243
|
+
{
|
|
244
|
+
"canonical": str(canonical),
|
|
245
|
+
"old": str(old),
|
|
246
|
+
"new": str(new),
|
|
247
|
+
"identity": [identity.st_dev, identity.st_ino],
|
|
248
|
+
"old_policy": str(source_policy),
|
|
249
|
+
"new_policy": str(target_policy),
|
|
250
|
+
"policy_sha256": digest(source_policy.read_bytes()),
|
|
251
|
+
"leases": {},
|
|
252
|
+
"transaction_compatibility": compatibility,
|
|
253
|
+
"references": references,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
files = []
|
|
257
|
+
for path in sorted(set(previous_entries) | set(entries)):
|
|
258
|
+
before = None
|
|
259
|
+
mode = 0o755
|
|
260
|
+
if path.exists() or path.is_symlink():
|
|
261
|
+
info = owned(path)
|
|
262
|
+
before = path.read_bytes()
|
|
263
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
264
|
+
if path in previous_entries:
|
|
265
|
+
if before is None or digest(before) != previous_entries[path]:
|
|
266
|
+
raise LayoutError(f"previous entry point drifted: {path}")
|
|
267
|
+
elif before is not None and before != entries[path]:
|
|
268
|
+
raise LayoutError(f"new entry point already has other content: {path}")
|
|
269
|
+
files.append(
|
|
270
|
+
{
|
|
271
|
+
"path": str(path),
|
|
272
|
+
"before": None if before is None else before.hex(),
|
|
273
|
+
"mode": mode,
|
|
274
|
+
"previous": path in previous_entries,
|
|
275
|
+
"after": entries[path].hex() if path in entries else REFUSAL.hex(),
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
receipt.parent.mkdir(mode=0o700)
|
|
279
|
+
migration = cls(
|
|
280
|
+
receipt,
|
|
281
|
+
{
|
|
282
|
+
"schema_version": 1,
|
|
283
|
+
"phase": "prepared",
|
|
284
|
+
"repositories": rows,
|
|
285
|
+
"entrypoints": files,
|
|
286
|
+
"runtimes": runtimes,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
migration.save()
|
|
290
|
+
return migration
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def load(cls, receipt: Path, expected_sha256: str) -> LayoutMigration:
|
|
294
|
+
owned(receipt)
|
|
295
|
+
raw = receipt.read_bytes()
|
|
296
|
+
if digest(raw) != expected_sha256:
|
|
297
|
+
raise LayoutError("layout receipt digest changed")
|
|
298
|
+
value = json.loads(raw)
|
|
299
|
+
if (
|
|
300
|
+
set(value) != {"schema_version", "phase", "repositories", "entrypoints", "runtimes"}
|
|
301
|
+
or value["schema_version"] != 1
|
|
302
|
+
):
|
|
303
|
+
raise LayoutError("unsupported layout receipt")
|
|
304
|
+
return cls(receipt, value)
|
|
305
|
+
|
|
306
|
+
def apply(self) -> None:
|
|
307
|
+
if self.document["phase"] != "prepared":
|
|
308
|
+
raise LayoutError("layout receipt is not prepared")
|
|
309
|
+
with ExitStack() as stack:
|
|
310
|
+
for row in self.document["repositories"]:
|
|
311
|
+
old = Path(row["old"])
|
|
312
|
+
info = owned(old, directory=True)
|
|
313
|
+
if [info.st_dev, info.st_ino] != row["identity"]:
|
|
314
|
+
raise LayoutError("previous namespace identity changed")
|
|
315
|
+
owned(old / "repo.lock")
|
|
316
|
+
handle = stack.enter_context((old / "repo.lock").open("rb"))
|
|
317
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
318
|
+
if quiet(old, Path(row["canonical"])) != row["transaction_compatibility"]:
|
|
319
|
+
raise LayoutError("transaction compatibility changed before layout move")
|
|
320
|
+
if relocation.prepare(old, Path(row["new"])) != row["references"]:
|
|
321
|
+
raise LayoutError("immutable evidence changed before layout move")
|
|
322
|
+
owned(Path(row["old_policy"]))
|
|
323
|
+
if digest(Path(row["old_policy"]).read_bytes()) != row["policy_sha256"]:
|
|
324
|
+
raise LayoutError("previous policy changed")
|
|
325
|
+
row["leases"] = {
|
|
326
|
+
p.name: lease_record(p) for p in (old / "writer-leases").glob("*.json")
|
|
327
|
+
}
|
|
328
|
+
self.document["phase"] = "applying"
|
|
329
|
+
self.save()
|
|
330
|
+
for entry in self.document["entrypoints"]:
|
|
331
|
+
path = Path(entry["path"])
|
|
332
|
+
observed = path.read_bytes().hex() if path.exists() else None
|
|
333
|
+
if observed != entry["before"]:
|
|
334
|
+
raise LayoutError("entry point changed after layout preflight")
|
|
335
|
+
if entry["previous"]:
|
|
336
|
+
replace_file(path, REFUSAL, entry["mode"])
|
|
337
|
+
previous_names = {
|
|
338
|
+
Path(x["path"]).name[:15] for x in self.document["entrypoints"] if x["previous"]
|
|
339
|
+
}
|
|
340
|
+
refuse_running_entries(previous_names)
|
|
341
|
+
for row in self.document["repositories"]:
|
|
342
|
+
old, new = Path(row["old"]), Path(row["new"])
|
|
343
|
+
source_policy, target_policy = Path(row["old_policy"]), Path(row["new_policy"])
|
|
344
|
+
new.parent.mkdir(mode=0o700, exist_ok=True)
|
|
345
|
+
target_policy.parent.mkdir(mode=0o755, exist_ok=True)
|
|
346
|
+
owned(new.parent, directory=True)
|
|
347
|
+
owned(target_policy.parent, directory=True)
|
|
348
|
+
if (
|
|
349
|
+
new.exists()
|
|
350
|
+
or new.is_symlink()
|
|
351
|
+
or target_policy.exists()
|
|
352
|
+
or target_policy.is_symlink()
|
|
353
|
+
):
|
|
354
|
+
raise LayoutError("current layout appeared after preflight")
|
|
355
|
+
os.rename(source_policy, target_policy)
|
|
356
|
+
sync_directory(source_policy.parent)
|
|
357
|
+
sync_directory(target_policy.parent)
|
|
358
|
+
# Travel with the directory rename: there is never a moved tree
|
|
359
|
+
# whose old immutable references lack their relocation evidence.
|
|
360
|
+
with p._absolute_directory(old) as chain:
|
|
361
|
+
p._write_once(chain[-1], relocation.RECORD, row["references"])
|
|
362
|
+
os.rename(old, new)
|
|
363
|
+
sync_directory(old.parent)
|
|
364
|
+
sync_directory(new.parent)
|
|
365
|
+
for name, content in row["leases"].items():
|
|
366
|
+
replace_file(new / "writer-leases" / name, bytes.fromhex(content["after"]))
|
|
367
|
+
for entry in self.document["entrypoints"]:
|
|
368
|
+
if entry["after"] != REFUSAL.hex():
|
|
369
|
+
replace_file(Path(entry["path"]), bytes.fromhex(entry["after"]), 0o755)
|
|
370
|
+
self.document["phase"] = "migrated"
|
|
371
|
+
self.save()
|
|
372
|
+
|
|
373
|
+
def rollback(self) -> None:
|
|
374
|
+
if self.document["phase"] not in {"prepared", "applying", "migrated", "activated"}:
|
|
375
|
+
raise LayoutError("layout receipt cannot be rolled back")
|
|
376
|
+
with ExitStack() as stack:
|
|
377
|
+
replacements = {}
|
|
378
|
+
for row in self.document["repositories"]:
|
|
379
|
+
old, new = Path(row["old"]), Path(row["new"])
|
|
380
|
+
if (old.exists() or old.is_symlink()) == (new.exists() or new.is_symlink()):
|
|
381
|
+
raise LayoutError("layout recovery has overlapping or missing namespaces")
|
|
382
|
+
current = new if new.exists() else old
|
|
383
|
+
info = owned(current, directory=True)
|
|
384
|
+
if [info.st_dev, info.st_ino] != row["identity"]:
|
|
385
|
+
raise LayoutError("namespace identity changed before rollback")
|
|
386
|
+
owned(current / "repo.lock")
|
|
387
|
+
handle = stack.enter_context((current / "repo.lock").open("rb"))
|
|
388
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
389
|
+
quiet(current, Path(row["canonical"]))
|
|
390
|
+
if relocation.inventory(current) != row["references"]["files"]:
|
|
391
|
+
raise LayoutError("new or changed cleanup evidence blocks rollback")
|
|
392
|
+
if (current / relocation.RECORD).exists():
|
|
393
|
+
value, _ = evidence.read(current / relocation.RECORD)
|
|
394
|
+
if value != row["references"]:
|
|
395
|
+
raise LayoutError("relocation evidence changed before rollback")
|
|
396
|
+
old_policy, new_policy = Path(row["old_policy"]), Path(row["new_policy"])
|
|
397
|
+
if (old_policy.exists() or old_policy.is_symlink()) == (
|
|
398
|
+
new_policy.exists() or new_policy.is_symlink()
|
|
399
|
+
):
|
|
400
|
+
raise LayoutError("layout recovery has overlapping or missing policies")
|
|
401
|
+
policy = new_policy if new_policy.exists() else old_policy
|
|
402
|
+
owned(policy)
|
|
403
|
+
if digest(policy.read_bytes()) != row["policy_sha256"]:
|
|
404
|
+
raise LayoutError("policy drift blocks rollback")
|
|
405
|
+
if current == new:
|
|
406
|
+
for path in (new / "writer-leases").glob("*.json"):
|
|
407
|
+
owned(path)
|
|
408
|
+
raw = path.read_bytes()
|
|
409
|
+
original = row["leases"].get(path.name)
|
|
410
|
+
if original and raw.hex() in {original["before"], original["after"]}:
|
|
411
|
+
replacements[path] = bytes.fromhex(original["before"])
|
|
412
|
+
else:
|
|
413
|
+
replacements[path] = lease_bytes(raw, reverse=True)
|
|
414
|
+
for entry in self.document["entrypoints"]:
|
|
415
|
+
path = Path(entry["path"])
|
|
416
|
+
if path.exists() or path.is_symlink():
|
|
417
|
+
owned(path)
|
|
418
|
+
raw = path.read_bytes().hex() if path.exists() else None
|
|
419
|
+
if raw not in {entry["before"], entry["after"], REFUSAL.hex()}:
|
|
420
|
+
raise LayoutError("entry point drift blocks rollback")
|
|
421
|
+
# New invocations must stop before their namespace moves back, too.
|
|
422
|
+
# A running command leaves the entries fenced for a later recovery retry.
|
|
423
|
+
current_entries = [
|
|
424
|
+
entry for entry in self.document["entrypoints"] if entry["after"] != REFUSAL.hex()
|
|
425
|
+
]
|
|
426
|
+
if any(Path(row["new"]).exists() for row in self.document["repositories"]):
|
|
427
|
+
for entry in current_entries:
|
|
428
|
+
replace_file(Path(entry["path"]), REFUSAL, 0o755)
|
|
429
|
+
refuse_running_entries({Path(entry["path"]).name[:15] for entry in current_entries})
|
|
430
|
+
for path, raw in replacements.items():
|
|
431
|
+
replace_file(path, raw)
|
|
432
|
+
for row in reversed(self.document["repositories"]):
|
|
433
|
+
old, new = Path(row["old"]), Path(row["new"])
|
|
434
|
+
# Keep the reference map with the namespace through the inverse
|
|
435
|
+
# rename too. A failed move must remain readable for a retry.
|
|
436
|
+
if new.exists():
|
|
437
|
+
os.rename(new, old)
|
|
438
|
+
sync_directory(new.parent)
|
|
439
|
+
sync_directory(old.parent)
|
|
440
|
+
# Only this activation's additive record is removed on rollback.
|
|
441
|
+
# Original journals/receipts are moved back byte for byte.
|
|
442
|
+
with p._absolute_directory(old) as chain:
|
|
443
|
+
if relocation.RECORD in os.listdir(chain[-1].fd):
|
|
444
|
+
value, _ = relocation._document(chain[-1], relocation.RECORD)
|
|
445
|
+
if value != row["references"]:
|
|
446
|
+
raise LayoutError("relocation evidence changed before rollback")
|
|
447
|
+
os.unlink(relocation.RECORD, dir_fd=chain[-1].fd)
|
|
448
|
+
os.fsync(chain[-1].fd)
|
|
449
|
+
if Path(row["new_policy"]).exists():
|
|
450
|
+
os.rename(row["new_policy"], row["old_policy"])
|
|
451
|
+
sync_directory(Path(row["old_policy"]).parent)
|
|
452
|
+
sync_directory(Path(row["new_policy"]).parent)
|
|
453
|
+
for entry in self.document["entrypoints"]:
|
|
454
|
+
path = Path(entry["path"])
|
|
455
|
+
if entry["before"] is None:
|
|
456
|
+
if path.exists():
|
|
457
|
+
path.unlink()
|
|
458
|
+
sync_directory(path.parent)
|
|
459
|
+
else:
|
|
460
|
+
replace_file(path, bytes.fromhex(entry["before"]), entry["mode"])
|
|
461
|
+
self.document["phase"] = "rolled-back"
|
|
462
|
+
self.save()
|
|
463
|
+
|
|
464
|
+
def finish(self) -> None:
|
|
465
|
+
if self.document["phase"] != "activated":
|
|
466
|
+
raise LayoutError("only a verified activation can retire previous entries")
|
|
467
|
+
for entry in self.document["entrypoints"]:
|
|
468
|
+
path = Path(entry["path"])
|
|
469
|
+
owned(path)
|
|
470
|
+
if not path.exists() or digest(path.read_bytes()) != digest(
|
|
471
|
+
bytes.fromhex(entry["after"])
|
|
472
|
+
):
|
|
473
|
+
raise LayoutError("entry point changed before retirement")
|
|
474
|
+
for entry in self.document["entrypoints"]:
|
|
475
|
+
if entry["previous"] and entry["after"] == REFUSAL.hex():
|
|
476
|
+
Path(entry["path"]).unlink()
|
|
477
|
+
sync_directory(Path(entry["path"]).parent)
|
|
478
|
+
for row in self.document["repositories"]:
|
|
479
|
+
for parent in (Path(row["old"]).parent, Path(row["old_policy"]).parent):
|
|
480
|
+
if parent.is_dir() and not any(parent.iterdir()):
|
|
481
|
+
parent.rmdir()
|
|
482
|
+
self.document["phase"] = "retired"
|
|
483
|
+
self.save()
|