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,1412 @@
|
|
|
1
|
+
"""Idempotent, per-repository migration of the legacy metadata namespace.
|
|
2
|
+
|
|
3
|
+
The historical namespace was ``<git-common-dir>/codex-worktree/v1``; authority today is
|
|
4
|
+
``<git-common-dir>/worktree-controller/v1``. Nothing in the runtime discovers a
|
|
5
|
+
namespace, so a repository left holding only the legacy tree reads as unmanaged and its
|
|
6
|
+
real state stays invisible. This module is the only supported way to move it: a caller
|
|
7
|
+
never copies, edits or deletes namespace JSON by hand.
|
|
8
|
+
|
|
9
|
+
Two properties make the transition safe to start, stop and repeat:
|
|
10
|
+
|
|
11
|
+
* the critical section fences on the *destination* repository lock, which is the lock
|
|
12
|
+
every bootstrapping verb (``adopt``, ``acquire``, ``integration-enqueue``) already
|
|
13
|
+
takes, so a concurrent verb queues behind the migration instead of minting a second,
|
|
14
|
+
unrelated repository identity beside it;
|
|
15
|
+
* the plan identity is derived from the **merged** content of both namespaces, which a
|
|
16
|
+
move does not change. The same plan id therefore survives an interrupt, so a replay
|
|
17
|
+
re-presents the original receipt instead of a new migration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import fcntl
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import stat
|
|
28
|
+
import tempfile
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextlib import contextmanager, suppress
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from . import metadata_relocation as relocation
|
|
35
|
+
from . import preservation as p
|
|
36
|
+
from .activation_layout import LayoutError, lease_bytes, quiet
|
|
37
|
+
from .git_backend import inventory
|
|
38
|
+
from .models import ControllerError
|
|
39
|
+
from .store import ROOT_NAME, VERSION_NAME, MetadataStore, now_iso
|
|
40
|
+
|
|
41
|
+
LEGACY_ROOT_NAME = "codex-worktree"
|
|
42
|
+
CONTRACT = "namespace-migration/v1"
|
|
43
|
+
SCHEMA_VERSION = 1
|
|
44
|
+
RECEIPTS_DIRECTORY = "namespace-migrations"
|
|
45
|
+
LOCK_NAME = "repo.lock"
|
|
46
|
+
AUTHORITY_FILE = "repo.json"
|
|
47
|
+
LEASE_DIRECTORY = "writer-leases"
|
|
48
|
+
POLICY_PARTS = (".agents", "worktree-policy.toml")
|
|
49
|
+
LEGACY_POLICY_PARTS = (".codex", "worktree-policy.toml")
|
|
50
|
+
|
|
51
|
+
CURRENT = "NAMESPACE_ALREADY_CURRENT"
|
|
52
|
+
MIGRATED = "NAMESPACE_MIGRATED"
|
|
53
|
+
CONVERGED = "NAMESPACE_CONVERGED"
|
|
54
|
+
REQUIRED = "NAMESPACE_MIGRATION_REQUIRED"
|
|
55
|
+
CONVERGENCE = "NAMESPACE_CONVERGENCE_REQUIRED"
|
|
56
|
+
UNMANAGED = "NAMESPACE_UNMANAGED"
|
|
57
|
+
|
|
58
|
+
MOVED = "moved"
|
|
59
|
+
REDUNDANT = "redundant"
|
|
60
|
+
DESTINATION_ONLY = "destination-only"
|
|
61
|
+
REWRITE = "rewrite"
|
|
62
|
+
MUTABLE_AFTER_SWITCH = frozenset(
|
|
63
|
+
{"records", "bindings", "writer-leases", "integration-intents", "release-intents"}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
IDENTITY_FIELDS = (
|
|
67
|
+
"contract",
|
|
68
|
+
"schema_version",
|
|
69
|
+
"repository_id",
|
|
70
|
+
"repository_path",
|
|
71
|
+
"common_dir",
|
|
72
|
+
"legacy_root",
|
|
73
|
+
"current_root",
|
|
74
|
+
"merged_digest",
|
|
75
|
+
"policy_target",
|
|
76
|
+
"policy_sha256",
|
|
77
|
+
)
|
|
78
|
+
FAULT_POINTS = (
|
|
79
|
+
"namespace_receipt_prepared",
|
|
80
|
+
"namespace_conversion_temp_partial",
|
|
81
|
+
"namespace_converted_lease_published",
|
|
82
|
+
"namespace_rewrite_temp_fsynced",
|
|
83
|
+
"namespace_destination_rewritten",
|
|
84
|
+
"namespace_entries_moved",
|
|
85
|
+
"namespace_switched",
|
|
86
|
+
"namespace_record_written",
|
|
87
|
+
"namespace_policy_moved",
|
|
88
|
+
"namespace_receipt_settled",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _blocked(code: str, message: str) -> ControllerError:
|
|
93
|
+
return ControllerError(code, message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _crash_point(name: str) -> None:
|
|
97
|
+
"""The shared fault-injection hook, so a torn migration is reproducible."""
|
|
98
|
+
|
|
99
|
+
from .lifecycle import _crash_point as hook
|
|
100
|
+
|
|
101
|
+
hook(name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sha256(raw: bytes) -> str:
|
|
105
|
+
return hashlib.sha256(raw).hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_temp(name: str) -> bool:
|
|
109
|
+
"""An interrupted ``store._atomic_json`` write: ``.<target>.<8>.tmp``.
|
|
110
|
+
|
|
111
|
+
The Controller itself declares these ignored residue, so a migration carries them
|
|
112
|
+
with the namespace instead of refusing to move a repository over a dead scratch file.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
return re.fullmatch(r"\..+\.json\.[A-Za-z0-9_-]{8}\.tmp", name) is not None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _relative(value: str) -> str:
|
|
119
|
+
if (
|
|
120
|
+
not value
|
|
121
|
+
or value.startswith("/")
|
|
122
|
+
or "\\" in value
|
|
123
|
+
or any(part in {"", ".", ".."} for part in value.split("/"))
|
|
124
|
+
):
|
|
125
|
+
raise _blocked("NAMESPACE_PATH_INVALID", f"unsafe namespace-relative path: {value!r}")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _namespace(value: str) -> str:
|
|
130
|
+
if (
|
|
131
|
+
value in {ROOT_NAME, LEGACY_ROOT_NAME}
|
|
132
|
+
and value.isascii()
|
|
133
|
+
and not value.startswith(".")
|
|
134
|
+
and "/" not in value
|
|
135
|
+
):
|
|
136
|
+
return value
|
|
137
|
+
raise _blocked(
|
|
138
|
+
"NAMESPACE_UNSUPPORTED",
|
|
139
|
+
f"only the released namespace names are migratable, got {value!r}",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _tree(
|
|
144
|
+
root: Path, *, skip_names: frozenset[str] = frozenset(), skip_prefixes: tuple[str, ...] = ()
|
|
145
|
+
) -> dict[str, bytes]:
|
|
146
|
+
"""Every metadata document in one namespace, keyed by relative POSIX path.
|
|
147
|
+
|
|
148
|
+
Anything the Controller would never write - a symlink, a socket, an unparseable
|
|
149
|
+
document, a file with no understood role - fails the migration closed: moving it
|
|
150
|
+
would silently change what the historical evidence means.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
if root.is_symlink():
|
|
154
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"namespace root is a symlink: {root}")
|
|
155
|
+
if not root.is_dir():
|
|
156
|
+
return {}
|
|
157
|
+
found: dict[str, bytes] = {}
|
|
158
|
+
for directory, names, files in os.walk(root, followlinks=False):
|
|
159
|
+
base = Path(directory)
|
|
160
|
+
for name in sorted(names):
|
|
161
|
+
path = base / name
|
|
162
|
+
if path.is_symlink():
|
|
163
|
+
raise _blocked(
|
|
164
|
+
"NAMESPACE_EVIDENCE_INVALID",
|
|
165
|
+
f"namespace contains a symlinked directory: {path}",
|
|
166
|
+
)
|
|
167
|
+
for name in sorted(files):
|
|
168
|
+
path = base / name
|
|
169
|
+
relative = _relative(path.relative_to(root).as_posix())
|
|
170
|
+
if relative in skip_names or relative.startswith(skip_prefixes):
|
|
171
|
+
continue
|
|
172
|
+
info = os.lstat(path)
|
|
173
|
+
if stat.S_ISLNK(info.st_mode):
|
|
174
|
+
raise _blocked(
|
|
175
|
+
"NAMESPACE_EVIDENCE_INVALID", f"namespace contains a symlink: {path}"
|
|
176
|
+
)
|
|
177
|
+
if not stat.S_ISREG(info.st_mode):
|
|
178
|
+
raise _blocked(
|
|
179
|
+
"NAMESPACE_EVIDENCE_INVALID", f"namespace contains a non-regular entry: {path}"
|
|
180
|
+
)
|
|
181
|
+
if _is_temp(name):
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
raw = path.read_bytes()
|
|
185
|
+
except OSError as exc:
|
|
186
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"cannot read {path}: {exc}") from exc
|
|
187
|
+
if not name.endswith(".json"):
|
|
188
|
+
raise _blocked(
|
|
189
|
+
"NAMESPACE_EVIDENCE_INVALID", f"namespace holds an unknown document: {path}"
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
document = json.loads(raw)
|
|
193
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
194
|
+
raise _blocked(
|
|
195
|
+
"NAMESPACE_EVIDENCE_INVALID", f"corrupt document {path}: {exc}"
|
|
196
|
+
) from exc
|
|
197
|
+
if not isinstance(document, dict):
|
|
198
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"document is not an object: {path}")
|
|
199
|
+
found[relative] = raw
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _converted(relative: str, raw: bytes) -> bytes:
|
|
204
|
+
"""The bytes a legacy document must land as, converting schema-1 writer leases."""
|
|
205
|
+
|
|
206
|
+
if not relative.startswith(f"{LEASE_DIRECTORY}/") or not relative.endswith(".json"):
|
|
207
|
+
return raw
|
|
208
|
+
try:
|
|
209
|
+
document = json.loads(raw)
|
|
210
|
+
except (UnicodeError, json.JSONDecodeError):
|
|
211
|
+
return raw
|
|
212
|
+
if document.get("schema_version") != 1:
|
|
213
|
+
return raw
|
|
214
|
+
try:
|
|
215
|
+
converted = lease_bytes(raw)
|
|
216
|
+
except LayoutError as exc:
|
|
217
|
+
raise _blocked("NAMESPACE_LEASE_INVALID", f"{relative}: {exc}") from exc
|
|
218
|
+
if json.loads(converted).get("allocation_id") != Path(relative).stem:
|
|
219
|
+
raise _blocked("NAMESPACE_LEASE_INVALID", f"writer lease filename differs: {relative}")
|
|
220
|
+
return converted
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _merge(
|
|
224
|
+
legacy: dict[str, bytes], current: dict[str, bytes]
|
|
225
|
+
) -> tuple[dict[str, tuple[str, bytes]], list[str]]:
|
|
226
|
+
"""Prove one authoritative content set, or report every conflict.
|
|
227
|
+
|
|
228
|
+
The authoritative bytes for a path are its legacy document in the form the runtime can
|
|
229
|
+
read - for a schema-1 writer lease, that means its converted form. A destination that
|
|
230
|
+
already holds them makes the source copy redundant debris; a destination holding the
|
|
231
|
+
unconverted twin is rewritten, because a converged namespace nobody can parse is not a
|
|
232
|
+
migration. Anything else that differs is a conflict: the plan refuses, nothing moves.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
merged: dict[str, tuple[str, bytes]] = {}
|
|
236
|
+
conflicts: list[str] = []
|
|
237
|
+
for relative in sorted(set(legacy) | set(current)):
|
|
238
|
+
left, right = legacy.get(relative), current.get(relative)
|
|
239
|
+
target = _converted(relative, left if left is not None else right)
|
|
240
|
+
if left is not None and right is not None:
|
|
241
|
+
if right == target:
|
|
242
|
+
merged[relative] = (REDUNDANT, target)
|
|
243
|
+
elif left == right:
|
|
244
|
+
merged[relative] = (REWRITE, target)
|
|
245
|
+
else:
|
|
246
|
+
conflicts.append(relative)
|
|
247
|
+
continue
|
|
248
|
+
if right is not None:
|
|
249
|
+
merged[relative] = (REWRITE if right != target else DESTINATION_ONLY, target)
|
|
250
|
+
continue
|
|
251
|
+
merged[relative] = (MOVED, target)
|
|
252
|
+
return merged, conflicts
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _digest(content: dict[str, bytes]) -> str:
|
|
256
|
+
return p._sha({relative: _sha256(raw) for relative, raw in sorted(content.items())})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _authority_id(content: dict[str, bytes]) -> str:
|
|
260
|
+
"""The one repository identity a migration may carry forward."""
|
|
261
|
+
|
|
262
|
+
raw = content.get(AUTHORITY_FILE)
|
|
263
|
+
if raw is None:
|
|
264
|
+
raise _blocked(
|
|
265
|
+
"NAMESPACE_IDENTITY_MISSING",
|
|
266
|
+
f"no {AUTHORITY_FILE} in either namespace; refusing to guess a repository identity",
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
document = json.loads(raw)
|
|
270
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
271
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"corrupt {AUTHORITY_FILE}: {exc}") from exc
|
|
272
|
+
repository_id = document.get("repository_id")
|
|
273
|
+
if not isinstance(repository_id, str) or not repository_id:
|
|
274
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"{AUTHORITY_FILE} carries no repository_id")
|
|
275
|
+
return repository_id
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _unfinished(root: Path, canonical: Path, *, serialized: bool) -> list[dict[str, str]]:
|
|
279
|
+
"""Judge unfinished work only from inside the fence, never from a waiting reader.
|
|
280
|
+
|
|
281
|
+
Classifying journals correctly requires the namespace's own serialization. A reader
|
|
282
|
+
that is not inside the critical section would have to take a shared flock and wait an
|
|
283
|
+
unbounded time behind whatever writer holds it - and the plan it produced would still
|
|
284
|
+
be superseded by the re-derivation the migration performs under its exclusive lock.
|
|
285
|
+
So the advisory plan defers this one question to the fence instead of blocking on it.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
if not serialized or not root.is_dir() or not (root / AUTHORITY_FILE).is_file():
|
|
289
|
+
return []
|
|
290
|
+
return _quiet(root, canonical)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _quiet(root: Path, canonical: Path) -> list[dict[str, str]]:
|
|
294
|
+
try:
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
"transaction_id": str(row.get("transaction_id", "")),
|
|
298
|
+
"state": str(row.get("state", "")),
|
|
299
|
+
}
|
|
300
|
+
for row in quiet(root, canonical)
|
|
301
|
+
]
|
|
302
|
+
except LayoutError as exc:
|
|
303
|
+
raise _blocked("NAMESPACE_UNSAFE", f"{root}: {exc}") from exc
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _policy_state(repository: Path, requested: Path | None) -> dict[str, Any]:
|
|
307
|
+
"""Where the single policy authority must live, and the bytes it must hold.
|
|
308
|
+
|
|
309
|
+
Computed from whichever side exists so that moving a policy never changes the plan
|
|
310
|
+
identity: the migration must stay replayable across its own write. A destination
|
|
311
|
+
that already exists with different content is reported by the conflict check, not
|
|
312
|
+
silently overwritten, because policy is authority and not namespace plumbing.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
destination = repository.joinpath(*POLICY_PARTS)
|
|
316
|
+
source = requested if requested is not None else repository.joinpath(*LEGACY_POLICY_PARTS)
|
|
317
|
+
if destination.parent.is_symlink():
|
|
318
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "policy destination parent is a symlink")
|
|
319
|
+
if destination.exists() or destination.is_symlink():
|
|
320
|
+
if destination.is_symlink():
|
|
321
|
+
raise _blocked(
|
|
322
|
+
"NAMESPACE_EVIDENCE_INVALID", f"policy authority is a symlink: {destination}"
|
|
323
|
+
)
|
|
324
|
+
return {
|
|
325
|
+
"target": str(destination),
|
|
326
|
+
"sha256": _sha256(destination.read_bytes()),
|
|
327
|
+
"move": None,
|
|
328
|
+
}
|
|
329
|
+
if source.parent.is_symlink():
|
|
330
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "policy source parent is a symlink")
|
|
331
|
+
if source.is_symlink():
|
|
332
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"legacy policy is a symlink: {source}")
|
|
333
|
+
if not source.is_file():
|
|
334
|
+
return {"target": None, "sha256": None, "move": None}
|
|
335
|
+
return {
|
|
336
|
+
"target": str(destination),
|
|
337
|
+
"sha256": _sha256(source.read_bytes()),
|
|
338
|
+
"move": str(source),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _roots(repository_path: Path, legacy_namespace: str) -> tuple[Path, Path, Path]:
|
|
343
|
+
repository = inventory(repository_path)
|
|
344
|
+
namespace = _namespace(legacy_namespace)
|
|
345
|
+
common = repository.common_dir
|
|
346
|
+
legacy_root = common / namespace / VERSION_NAME
|
|
347
|
+
current_root = common / ROOT_NAME / VERSION_NAME
|
|
348
|
+
if legacy_root == current_root:
|
|
349
|
+
raise _blocked("INVALID_REQUEST", "the legacy namespace is already the current namespace")
|
|
350
|
+
for path in (legacy_root.parent, legacy_root, current_root.parent, current_root):
|
|
351
|
+
if os.path.lexists(path) and (path.is_symlink() or not path.is_dir()):
|
|
352
|
+
raise _blocked(
|
|
353
|
+
"NAMESPACE_EVIDENCE_INVALID", f"namespace path is not a directory: {path}"
|
|
354
|
+
)
|
|
355
|
+
return repository.canonical_path, legacy_root, current_root
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _plan(
|
|
359
|
+
repository_path: Path,
|
|
360
|
+
legacy_namespace: str,
|
|
361
|
+
policy_from: Path | None,
|
|
362
|
+
*,
|
|
363
|
+
serialized: bool = False,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
canonical, legacy_root, current_root = _roots(repository_path, legacy_namespace)
|
|
366
|
+
legacy_present = legacy_root.is_dir()
|
|
367
|
+
current_present = current_root.is_dir()
|
|
368
|
+
if not legacy_present and not current_present:
|
|
369
|
+
raise _blocked(
|
|
370
|
+
UNMANAGED,
|
|
371
|
+
f"neither {legacy_root} nor {current_root} exists; this repository has no metadata to migrate",
|
|
372
|
+
)
|
|
373
|
+
if legacy_present and (legacy_root / relocation.RECORD).is_file():
|
|
374
|
+
raise _blocked(
|
|
375
|
+
"NAMESPACE_RELOCATION_PRESENT",
|
|
376
|
+
f"{legacy_root} already carries relocation evidence; it is a migration target, "
|
|
377
|
+
"not a source, and must be reconciled by its operator",
|
|
378
|
+
)
|
|
379
|
+
legacy_tree = _tree(legacy_root, skip_names=frozenset({LOCK_NAME})) if legacy_present else {}
|
|
380
|
+
current_tree = (
|
|
381
|
+
_tree(
|
|
382
|
+
current_root,
|
|
383
|
+
skip_names=frozenset({LOCK_NAME, relocation.RECORD}),
|
|
384
|
+
skip_prefixes=(f"{RECEIPTS_DIRECTORY}/",),
|
|
385
|
+
)
|
|
386
|
+
if current_present
|
|
387
|
+
else {}
|
|
388
|
+
)
|
|
389
|
+
merged, conflicts = _merge(legacy_tree, current_tree)
|
|
390
|
+
if conflicts:
|
|
391
|
+
raise _blocked(
|
|
392
|
+
"NAMESPACE_CONFLICT",
|
|
393
|
+
"both namespaces hold differing content for: " + ", ".join(sorted(conflicts)[:12]),
|
|
394
|
+
)
|
|
395
|
+
repository_id = _authority_id({relative: raw for relative, (_, raw) in merged.items()})
|
|
396
|
+
if legacy_present and not (legacy_root / LOCK_NAME).is_file():
|
|
397
|
+
raise _blocked(
|
|
398
|
+
"NAMESPACE_LOCK_MISSING",
|
|
399
|
+
f"{legacy_root / LOCK_NAME} is missing or not a regular file; the legacy namespace "
|
|
400
|
+
"is not a serialized authority and must be repaired by its operator first",
|
|
401
|
+
)
|
|
402
|
+
for root in (legacy_root, current_root):
|
|
403
|
+
for row in _unfinished(root, canonical, serialized=serialized):
|
|
404
|
+
raise _blocked(
|
|
405
|
+
"NAMESPACE_UNSAFE",
|
|
406
|
+
f"unfinished transaction {row['transaction_id']} in {root} blocks migration",
|
|
407
|
+
)
|
|
408
|
+
policy = _policy_state(canonical, policy_from)
|
|
409
|
+
moves = sorted(relative for relative, (kind, _) in merged.items() if kind == MOVED)
|
|
410
|
+
redundant = sorted(relative for relative, (kind, _) in merged.items() if kind == REDUNDANT)
|
|
411
|
+
rewrites = sorted(relative for relative, (kind, _) in merged.items() if kind == REWRITE)
|
|
412
|
+
converted = sorted(
|
|
413
|
+
relative
|
|
414
|
+
for relative in set(moves) | set(redundant) | set(rewrites)
|
|
415
|
+
if relative in legacy_tree and merged[relative][1] != legacy_tree[relative]
|
|
416
|
+
)
|
|
417
|
+
merged_digest = _digest({relative: raw for relative, (_, raw) in merged.items()})
|
|
418
|
+
manifest = {
|
|
419
|
+
relative: {
|
|
420
|
+
"kind": kind,
|
|
421
|
+
"source_sha256": _sha256(legacy_tree[relative]) if relative in legacy_tree else None,
|
|
422
|
+
"current_sha256": _sha256(current_tree[relative]) if relative in current_tree else None,
|
|
423
|
+
"target_sha256": _sha256(raw),
|
|
424
|
+
}
|
|
425
|
+
for relative, (kind, raw) in sorted(merged.items())
|
|
426
|
+
}
|
|
427
|
+
identity = {
|
|
428
|
+
"contract": CONTRACT,
|
|
429
|
+
"schema_version": SCHEMA_VERSION,
|
|
430
|
+
"repository_id": repository_id,
|
|
431
|
+
"repository_path": str(canonical),
|
|
432
|
+
"common_dir": str(legacy_root.parent.parent),
|
|
433
|
+
"legacy_root": str(legacy_root),
|
|
434
|
+
"current_root": str(current_root),
|
|
435
|
+
"merged_digest": merged_digest,
|
|
436
|
+
"policy_target": policy["target"],
|
|
437
|
+
"policy_sha256": policy["sha256"],
|
|
438
|
+
}
|
|
439
|
+
plan_id = p._sha(identity)
|
|
440
|
+
return {
|
|
441
|
+
**identity,
|
|
442
|
+
"plan_id": plan_id,
|
|
443
|
+
"scenario": CONVERGENCE if redundant or rewrites else REQUIRED if moves else CURRENT,
|
|
444
|
+
"moves": moves,
|
|
445
|
+
"redundant": redundant,
|
|
446
|
+
"rewrites": rewrites,
|
|
447
|
+
"legacy_digest": _digest(legacy_tree),
|
|
448
|
+
"current_digest": _digest(current_tree),
|
|
449
|
+
"lease_conversions": converted,
|
|
450
|
+
"preflight": "under_fence" if not serialized else "classified",
|
|
451
|
+
"policy": policy,
|
|
452
|
+
"manifest": manifest,
|
|
453
|
+
"files": {relative: raw for relative, (_, raw) in merged.items()},
|
|
454
|
+
"sources": dict(legacy_tree),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _scenario_report(plan: dict[str, Any]) -> dict[str, Any]:
|
|
459
|
+
return {
|
|
460
|
+
"code": plan["scenario"],
|
|
461
|
+
"dry_run": True,
|
|
462
|
+
"plan": {key: value for key, value in plan.items() if key not in {"files", "sources"}},
|
|
463
|
+
"receipts": receipts_of(Path(plan["current_root"]), plan["plan_id"]),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _pending_receipt(current_root: Path) -> dict[str, Any] | None:
|
|
468
|
+
directory = current_root / RECEIPTS_DIRECTORY
|
|
469
|
+
if directory.is_symlink():
|
|
470
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "migration receipt directory is a symlink")
|
|
471
|
+
if not directory.is_dir():
|
|
472
|
+
return None
|
|
473
|
+
pending: list[dict[str, Any]] = []
|
|
474
|
+
for path in sorted(directory.glob("*.prepared.json")):
|
|
475
|
+
plan_id = path.name.removesuffix(".prepared.json")
|
|
476
|
+
if re.fullmatch(r"[0-9a-f]{64}", plan_id) is None:
|
|
477
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"invalid prepared receipt name: {path}")
|
|
478
|
+
found = receipts_of(current_root, plan_id)
|
|
479
|
+
if found["settled"] is None and found["prepared"] is not None:
|
|
480
|
+
pending.append(found["prepared"])
|
|
481
|
+
if len(pending) > 1:
|
|
482
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "more than one unsettled migration exists")
|
|
483
|
+
return pending[0] if pending else None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _recovery_report(witness: dict[str, Any], current_root: Path) -> dict[str, Any]:
|
|
487
|
+
plan_id = witness.get("plan_id")
|
|
488
|
+
if (
|
|
489
|
+
not isinstance(plan_id, str)
|
|
490
|
+
or re.fullmatch(r"[0-9a-f]{64}", plan_id) is None
|
|
491
|
+
or any(key not in witness for key in IDENTITY_FIELDS)
|
|
492
|
+
or witness.get("phase") != "PREPARED"
|
|
493
|
+
or witness.get("contract") != CONTRACT
|
|
494
|
+
or witness.get("schema_version") != SCHEMA_VERSION
|
|
495
|
+
or witness.get("current_root") != str(current_root)
|
|
496
|
+
or witness.get("scenario") not in {REQUIRED, CONVERGENCE}
|
|
497
|
+
):
|
|
498
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "unsettled migration receipt is invalid")
|
|
499
|
+
identity = {key: witness[key] for key in IDENTITY_FIELDS}
|
|
500
|
+
if p._sha(identity) != plan_id:
|
|
501
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "unsettled migration identity differs")
|
|
502
|
+
manifest = witness.get("manifest")
|
|
503
|
+
if manifest is not None and (
|
|
504
|
+
not isinstance(manifest, dict)
|
|
505
|
+
or AUTHORITY_FILE not in manifest
|
|
506
|
+
or any(
|
|
507
|
+
not isinstance(relative, str)
|
|
508
|
+
or not isinstance(entry, dict)
|
|
509
|
+
or entry.get("kind") not in {MOVED, REDUNDANT, DESTINATION_ONLY, REWRITE}
|
|
510
|
+
for relative, entry in manifest.items()
|
|
511
|
+
)
|
|
512
|
+
):
|
|
513
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "unsettled migration manifest is invalid")
|
|
514
|
+
manifest = manifest or {}
|
|
515
|
+
return {
|
|
516
|
+
"code": witness["scenario"],
|
|
517
|
+
"dry_run": True,
|
|
518
|
+
"recovery_required": True,
|
|
519
|
+
"plan": {
|
|
520
|
+
**{key: witness.get(key) for key in IDENTITY_FIELDS},
|
|
521
|
+
"plan_id": plan_id,
|
|
522
|
+
"scenario": witness["scenario"],
|
|
523
|
+
"moves": sorted(r for r, e in manifest.items() if e.get("kind") == MOVED),
|
|
524
|
+
"redundant": sorted(r for r, e in manifest.items() if e.get("kind") == REDUNDANT),
|
|
525
|
+
"rewrites": sorted(r for r, e in manifest.items() if e.get("kind") == REWRITE),
|
|
526
|
+
"lease_conversions": witness.get("lease_conversions", []),
|
|
527
|
+
"policy": witness.get("policy"),
|
|
528
|
+
"preflight": "under_fence",
|
|
529
|
+
},
|
|
530
|
+
"receipts": receipts_of(current_root, plan_id),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def plan_namespace_migration(
|
|
535
|
+
repository_path: Path,
|
|
536
|
+
*,
|
|
537
|
+
legacy_namespace: str = LEGACY_ROOT_NAME,
|
|
538
|
+
policy_from: Path | None = None,
|
|
539
|
+
) -> dict[str, Any]:
|
|
540
|
+
"""Report what one migration would do, without writing anything."""
|
|
541
|
+
|
|
542
|
+
_, _, current_root = _roots(repository_path, legacy_namespace)
|
|
543
|
+
pending = _pending_receipt(current_root)
|
|
544
|
+
decision = (
|
|
545
|
+
_recovery_report(pending, current_root)
|
|
546
|
+
if pending is not None
|
|
547
|
+
else _scenario_report(_plan(repository_path, legacy_namespace, policy_from))
|
|
548
|
+
)
|
|
549
|
+
return _payload("namespace-migration-plan", decision)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def receipts_of(root: Path, plan_id: str) -> dict[str, dict[str, Any] | None]:
|
|
553
|
+
directory = root / RECEIPTS_DIRECTORY
|
|
554
|
+
found: dict[str, dict[str, Any] | None] = {"prepared": None, "settled": None}
|
|
555
|
+
for name, key in ((f"{plan_id}.prepared.json", "prepared"), (f"{plan_id}.json", "settled")):
|
|
556
|
+
path = directory / name
|
|
557
|
+
if os.path.lexists(path):
|
|
558
|
+
if path.is_symlink() or not path.is_file():
|
|
559
|
+
raise _blocked(
|
|
560
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is not regular: {path}"
|
|
561
|
+
)
|
|
562
|
+
try:
|
|
563
|
+
value = json.loads(path.read_bytes())
|
|
564
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
565
|
+
raise _blocked(
|
|
566
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is unreadable: {path}"
|
|
567
|
+
) from exc
|
|
568
|
+
if not isinstance(value, dict):
|
|
569
|
+
raise _blocked(
|
|
570
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is not an object: {path}"
|
|
571
|
+
)
|
|
572
|
+
found[key] = value
|
|
573
|
+
return found
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _payload(command: str, decision: dict[str, Any]) -> dict[str, Any]:
|
|
577
|
+
from .controller import envelope
|
|
578
|
+
|
|
579
|
+
payload = envelope(command)
|
|
580
|
+
payload["decision"] = decision
|
|
581
|
+
return payload
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _fsync_directory(path: Path) -> None:
|
|
585
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
586
|
+
try:
|
|
587
|
+
os.fsync(descriptor)
|
|
588
|
+
finally:
|
|
589
|
+
os.close(descriptor)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _replace_document(path: Path, raw: bytes) -> None:
|
|
593
|
+
"""Rewrite content under the interrupted-write name the Controller already ignores."""
|
|
594
|
+
|
|
595
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
596
|
+
try:
|
|
597
|
+
os.fchmod(descriptor, 0o600)
|
|
598
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
599
|
+
stream.write(raw)
|
|
600
|
+
stream.flush()
|
|
601
|
+
os.fsync(stream.fileno())
|
|
602
|
+
_crash_point("namespace_rewrite_temp_fsynced")
|
|
603
|
+
os.replace(name, path)
|
|
604
|
+
except BaseException:
|
|
605
|
+
with suppress(FileNotFoundError):
|
|
606
|
+
os.unlink(name)
|
|
607
|
+
raise
|
|
608
|
+
_fsync_directory(path.parent)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _write_document(path: Path, raw: bytes) -> None:
|
|
612
|
+
"""Publish a complete new document without replacing a concurrent destination."""
|
|
613
|
+
|
|
614
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
615
|
+
try:
|
|
616
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
617
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
618
|
+
middle = len(raw) // 2
|
|
619
|
+
stream.write(raw[:middle])
|
|
620
|
+
stream.flush()
|
|
621
|
+
_crash_point("namespace_conversion_temp_partial")
|
|
622
|
+
stream.write(raw[middle:])
|
|
623
|
+
stream.flush()
|
|
624
|
+
os.fsync(stream.fileno())
|
|
625
|
+
os.link(name, path, follow_symlinks=False)
|
|
626
|
+
_fsync_directory(path.parent)
|
|
627
|
+
except FileExistsError as exc:
|
|
628
|
+
raise _blocked(
|
|
629
|
+
"NAMESPACE_CONFLICT", f"destination appeared while publishing {path}"
|
|
630
|
+
) from exc
|
|
631
|
+
finally:
|
|
632
|
+
with suppress(FileNotFoundError):
|
|
633
|
+
os.unlink(name)
|
|
634
|
+
_fsync_directory(path.parent)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _clear_atomic_temps(root: Path) -> None:
|
|
638
|
+
"""Discard only ignored atomic JSON scratch while both namespace locks are held."""
|
|
639
|
+
|
|
640
|
+
if not root.is_dir():
|
|
641
|
+
return
|
|
642
|
+
for directory, _, files in os.walk(root, followlinks=False):
|
|
643
|
+
parent = Path(directory)
|
|
644
|
+
removed = False
|
|
645
|
+
for name in files:
|
|
646
|
+
if not _is_temp(name):
|
|
647
|
+
continue
|
|
648
|
+
path = parent / name
|
|
649
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
650
|
+
raise _blocked(
|
|
651
|
+
"NAMESPACE_EVIDENCE_INVALID", f"atomic scratch is not regular: {path}"
|
|
652
|
+
)
|
|
653
|
+
path.unlink()
|
|
654
|
+
removed = True
|
|
655
|
+
if removed:
|
|
656
|
+
_fsync_directory(parent)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _write_receipt(root: Path, name: str, value: dict[str, Any]) -> str:
|
|
660
|
+
"""Write-once evidence, tolerant of the replay that finds it already there."""
|
|
661
|
+
|
|
662
|
+
with p._absolute_directory(root / RECEIPTS_DIRECTORY, create=True) as chain:
|
|
663
|
+
parent = chain[-1]
|
|
664
|
+
if name in os.listdir(parent.fd):
|
|
665
|
+
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent.fd)
|
|
666
|
+
try:
|
|
667
|
+
_, digest = p._transfer(descriptor)
|
|
668
|
+
finally:
|
|
669
|
+
os.close(descriptor)
|
|
670
|
+
return digest
|
|
671
|
+
return p._write_once(parent, name, value)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@contextmanager
|
|
675
|
+
def _fenced(current_root: Path, legacy_root: Path) -> Iterator[None]:
|
|
676
|
+
"""Hold the destination repository lock - and the legacy one when it exists - throughout.
|
|
677
|
+
|
|
678
|
+
The destination lock is the arbiter every runtime verb already takes, so claiming it
|
|
679
|
+
before the tree exists is what keeps ``adopt``/``acquire``/``integration-enqueue`` from
|
|
680
|
+
bootstrapping a second identity beside the namespace being moved. The legacy lock is
|
|
681
|
+
claimed without waiting: a caller that holds it is an activation in flight, and an
|
|
682
|
+
acquire-then-block order here would be the reverse of the lock order that flow uses.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
if current_root.is_symlink() or legacy_root.is_symlink():
|
|
687
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "namespace root is a symlink")
|
|
688
|
+
current_root.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
689
|
+
destination = os.open(
|
|
690
|
+
current_root / LOCK_NAME,
|
|
691
|
+
os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
692
|
+
0o600,
|
|
693
|
+
)
|
|
694
|
+
except OSError as exc:
|
|
695
|
+
raise _blocked("NAMESPACE_LOCK_INVALID", f"destination lock is unusable: {exc}") from exc
|
|
696
|
+
legacy_descriptor: int | None = None
|
|
697
|
+
try:
|
|
698
|
+
if not stat.S_ISREG(os.fstat(destination).st_mode):
|
|
699
|
+
raise _blocked(
|
|
700
|
+
"NAMESPACE_LOCK_INVALID", f"destination lock is not a regular file: {current_root}"
|
|
701
|
+
)
|
|
702
|
+
try:
|
|
703
|
+
fcntl.flock(destination, fcntl.LOCK_EX)
|
|
704
|
+
except OSError as exc:
|
|
705
|
+
raise _blocked(
|
|
706
|
+
"NAMESPACE_LOCK_INVALID", f"destination lock cannot be claimed: {exc}"
|
|
707
|
+
) from exc
|
|
708
|
+
legacy_path = legacy_root / LOCK_NAME
|
|
709
|
+
if legacy_root.is_dir():
|
|
710
|
+
if legacy_path.is_symlink() or not legacy_path.is_file():
|
|
711
|
+
raise _blocked("NAMESPACE_LOCK_MISSING", f"legacy lock is unusable: {legacy_path}")
|
|
712
|
+
try:
|
|
713
|
+
legacy_descriptor = os.open(legacy_path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
714
|
+
except OSError as exc:
|
|
715
|
+
raise _blocked("NAMESPACE_LOCK_INVALID", f"legacy lock is unusable: {exc}") from exc
|
|
716
|
+
if not stat.S_ISREG(os.fstat(legacy_descriptor).st_mode):
|
|
717
|
+
raise _blocked(
|
|
718
|
+
"NAMESPACE_LOCK_INVALID", f"legacy lock is not a regular file: {legacy_path}"
|
|
719
|
+
)
|
|
720
|
+
try:
|
|
721
|
+
fcntl.flock(legacy_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
722
|
+
except BlockingIOError as exc:
|
|
723
|
+
raise _blocked(
|
|
724
|
+
"NAMESPACE_BUSY", "another writer holds the legacy namespace lock; retry later"
|
|
725
|
+
) from exc
|
|
726
|
+
except OSError as exc:
|
|
727
|
+
raise _blocked(
|
|
728
|
+
"NAMESPACE_LOCK_INVALID", f"legacy lock cannot be claimed: {exc}"
|
|
729
|
+
) from exc
|
|
730
|
+
yield
|
|
731
|
+
finally:
|
|
732
|
+
if legacy_descriptor is not None:
|
|
733
|
+
fcntl.flock(legacy_descriptor, fcntl.LOCK_UN)
|
|
734
|
+
os.close(legacy_descriptor)
|
|
735
|
+
fcntl.flock(destination, fcntl.LOCK_UN)
|
|
736
|
+
os.close(destination)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _relocate(
|
|
740
|
+
legacy_root: Path, current_root: Path, plan: dict[str, Any]
|
|
741
|
+
) -> tuple[list[str], list[str], list[str]]:
|
|
742
|
+
"""Move, rewrite or clear every planned document; each entry is independently replayable."""
|
|
743
|
+
|
|
744
|
+
moved: list[str] = []
|
|
745
|
+
rewritten: list[str] = []
|
|
746
|
+
cleared: list[str] = []
|
|
747
|
+
movable = set(plan["moves"])
|
|
748
|
+
for relative in sorted(movable | set(plan["redundant"]) | set(plan["rewrites"])):
|
|
749
|
+
if relative == AUTHORITY_FILE:
|
|
750
|
+
continue
|
|
751
|
+
source, destination = legacy_root / relative, current_root / relative
|
|
752
|
+
expected_source = plan["sources"].get(relative)
|
|
753
|
+
if relative in plan.get("mutable_drift", ()):
|
|
754
|
+
if source.is_file():
|
|
755
|
+
if expected_source is None or _sha256(source.read_bytes()) != _sha256(
|
|
756
|
+
expected_source
|
|
757
|
+
):
|
|
758
|
+
raise _blocked("NAMESPACE_SOURCE_DRIFT", f"legacy content changed: {relative}")
|
|
759
|
+
source.unlink()
|
|
760
|
+
cleared.append(relative)
|
|
761
|
+
continue
|
|
762
|
+
target = plan["files"][relative]
|
|
763
|
+
if destination.is_symlink():
|
|
764
|
+
raise _blocked("NAMESPACE_CONFLICT", f"destination content is a symlink: {relative}")
|
|
765
|
+
resident = destination.read_bytes() if destination.is_file() else None
|
|
766
|
+
if resident is None:
|
|
767
|
+
if not source.is_file() or source.is_symlink():
|
|
768
|
+
raise _blocked(
|
|
769
|
+
"NAMESPACE_SOURCE_MISSING", f"neither namespace holds {relative} any more"
|
|
770
|
+
)
|
|
771
|
+
if _sha256(source.read_bytes()) != _sha256(expected_source or b""):
|
|
772
|
+
raise _blocked("NAMESPACE_SOURCE_DRIFT", f"legacy content changed: {relative}")
|
|
773
|
+
destination.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
774
|
+
if source.read_bytes() == target:
|
|
775
|
+
os.rename(source, destination)
|
|
776
|
+
else:
|
|
777
|
+
_write_document(destination, target)
|
|
778
|
+
if relative in plan["lease_conversions"]:
|
|
779
|
+
_crash_point("namespace_converted_lease_published")
|
|
780
|
+
source.unlink()
|
|
781
|
+
_fsync_directory(destination.parent)
|
|
782
|
+
moved.append(relative)
|
|
783
|
+
elif resident != target:
|
|
784
|
+
if relative in movable or _converted(relative, resident) != target:
|
|
785
|
+
raise _blocked("NAMESPACE_CONFLICT", f"destination content changed: {relative}")
|
|
786
|
+
_replace_document(destination, target)
|
|
787
|
+
_crash_point("namespace_destination_rewritten")
|
|
788
|
+
rewritten.append(relative)
|
|
789
|
+
if source.is_file() and _sha256(source.read_bytes()) == _sha256(expected_source or b""):
|
|
790
|
+
source.unlink()
|
|
791
|
+
cleared.append(relative)
|
|
792
|
+
return moved, rewritten, cleared
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _switch_authority(legacy_root: Path, current_root: Path, plan: dict[str, Any]) -> bool:
|
|
796
|
+
"""Make the destination namespace live at exactly one instant.
|
|
797
|
+
|
|
798
|
+
Nothing treats a namespace as authoritative without ``repo.json``, so renaming the
|
|
799
|
+
identity document last is the whole cutover - and re-checking its bytes against the
|
|
800
|
+
plan is what proves the tree that became live is the tree that was approved.
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
source, destination = legacy_root / AUTHORITY_FILE, current_root / AUTHORITY_FILE
|
|
804
|
+
expected = _sha256(plan["files"][AUTHORITY_FILE])
|
|
805
|
+
if destination.is_symlink():
|
|
806
|
+
raise _blocked("NAMESPACE_CONFLICT", f"{AUTHORITY_FILE} destination is a symlink")
|
|
807
|
+
if destination.is_file():
|
|
808
|
+
if _sha256(destination.read_bytes()) != expected:
|
|
809
|
+
raise _blocked("NAMESPACE_CONFLICT", f"{AUTHORITY_FILE} appeared with other content")
|
|
810
|
+
switched = False
|
|
811
|
+
elif source.is_file() and not source.is_symlink():
|
|
812
|
+
if _sha256(source.read_bytes()) != expected:
|
|
813
|
+
raise _blocked("NAMESPACE_SOURCE_DRIFT", f"{AUTHORITY_FILE} changed during migration")
|
|
814
|
+
os.rename(source, destination)
|
|
815
|
+
_fsync_directory(current_root)
|
|
816
|
+
switched = True
|
|
817
|
+
else:
|
|
818
|
+
raise _blocked(
|
|
819
|
+
"NAMESPACE_IDENTITY_MISSING", f"no {AUTHORITY_FILE} landed in either namespace"
|
|
820
|
+
)
|
|
821
|
+
if _sha256(destination.read_bytes()) != expected:
|
|
822
|
+
raise _blocked(
|
|
823
|
+
"NAMESPACE_IDENTITY_MISMATCH", "the authority document changed mid-migration"
|
|
824
|
+
)
|
|
825
|
+
source.unlink(missing_ok=True)
|
|
826
|
+
return switched
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _prune_legacy(legacy_root: Path) -> bool:
|
|
830
|
+
"""Remove the legacy namespace only once it demonstrably holds nothing of ours."""
|
|
831
|
+
|
|
832
|
+
if not os.path.lexists(legacy_root):
|
|
833
|
+
return True
|
|
834
|
+
if not legacy_root.is_dir():
|
|
835
|
+
raise _blocked("NAMESPACE_RESIDUE", f"legacy namespace is not a directory: {legacy_root}")
|
|
836
|
+
for directory, _, files in os.walk(legacy_root, topdown=False):
|
|
837
|
+
base = Path(directory)
|
|
838
|
+
for name in sorted(files):
|
|
839
|
+
path = base / name
|
|
840
|
+
if _is_temp(name):
|
|
841
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
842
|
+
raise _blocked("NAMESPACE_RESIDUE", f"atomic scratch is not regular: {path}")
|
|
843
|
+
path.unlink()
|
|
844
|
+
continue
|
|
845
|
+
if path.name != LOCK_NAME or base != legacy_root:
|
|
846
|
+
raise _blocked(
|
|
847
|
+
"NAMESPACE_RESIDUE",
|
|
848
|
+
f"legacy namespace still holds {path.relative_to(legacy_root)}",
|
|
849
|
+
)
|
|
850
|
+
if base != legacy_root:
|
|
851
|
+
try:
|
|
852
|
+
base.rmdir()
|
|
853
|
+
except OSError as exc:
|
|
854
|
+
raise _blocked(
|
|
855
|
+
"NAMESPACE_RESIDUE", f"legacy directory is not empty: {base}"
|
|
856
|
+
) from exc
|
|
857
|
+
try:
|
|
858
|
+
(legacy_root / LOCK_NAME).unlink(missing_ok=True)
|
|
859
|
+
legacy_root.rmdir()
|
|
860
|
+
except OSError as exc:
|
|
861
|
+
raise _blocked(
|
|
862
|
+
"NAMESPACE_RESIDUE", f"legacy namespace could not be removed: {exc}"
|
|
863
|
+
) from exc
|
|
864
|
+
parent = legacy_root.parent
|
|
865
|
+
if parent.is_dir() and not any(os.scandir(parent)):
|
|
866
|
+
parent.rmdir()
|
|
867
|
+
_fsync_directory(parent.parent)
|
|
868
|
+
if os.path.lexists(legacy_root):
|
|
869
|
+
raise _blocked("NAMESPACE_RESIDUE", f"legacy namespace remains: {legacy_root}")
|
|
870
|
+
return True
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _evidence_digests(root: Path, plan: dict[str, Any]) -> dict[str, str]:
|
|
874
|
+
"""Map only evidence that had a historical path in the legacy namespace."""
|
|
875
|
+
|
|
876
|
+
found: dict[str, str] = {}
|
|
877
|
+
for relative, entry in sorted(plan["manifest"].items()):
|
|
878
|
+
if relative.split("/", 1)[0] not in relocation.DIRECTORIES or not entry["source_sha256"]:
|
|
879
|
+
continue
|
|
880
|
+
path = root / relative
|
|
881
|
+
if (
|
|
882
|
+
path.is_symlink()
|
|
883
|
+
or not path.is_file()
|
|
884
|
+
or _sha256(path.read_bytes()) != entry["source_sha256"]
|
|
885
|
+
):
|
|
886
|
+
raise _blocked("NAMESPACE_SOURCE_DRIFT", f"historical evidence changed: {relative}")
|
|
887
|
+
found[relative] = entry["source_sha256"]
|
|
888
|
+
return found
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _apply_policy(plan: dict[str, Any]) -> dict[str, Any] | None:
|
|
892
|
+
move = plan["policy"]["move"]
|
|
893
|
+
if not move:
|
|
894
|
+
return None
|
|
895
|
+
source, destination = Path(move), Path(plan["policy"]["target"] or "")
|
|
896
|
+
expected = plan["policy"]["sha256"]
|
|
897
|
+
if not source.is_file():
|
|
898
|
+
if (
|
|
899
|
+
destination.is_file()
|
|
900
|
+
and not destination.is_symlink()
|
|
901
|
+
and _sha256(destination.read_bytes()) == expected
|
|
902
|
+
):
|
|
903
|
+
return {"from": str(source), "to": str(destination), "sha256": expected}
|
|
904
|
+
raise _blocked("NAMESPACE_POLICY_DRIFT", "the legacy policy document is missing")
|
|
905
|
+
if _sha256(source.read_bytes()) != expected:
|
|
906
|
+
raise _blocked("NAMESPACE_POLICY_DRIFT", "the legacy policy document changed")
|
|
907
|
+
if os.path.lexists(destination):
|
|
908
|
+
raise _blocked("NAMESPACE_CONFLICT", f"a policy authority already exists: {destination}")
|
|
909
|
+
destination.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
|
|
910
|
+
os.rename(source, destination)
|
|
911
|
+
_fsync_directory(destination.parent)
|
|
912
|
+
if source.parent.is_dir() and not any(os.scandir(source.parent)):
|
|
913
|
+
source.parent.rmdir()
|
|
914
|
+
return {"from": str(source), "to": str(destination), "sha256": expected}
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _resume_plan(
|
|
918
|
+
repository_path: Path,
|
|
919
|
+
legacy_namespace: str,
|
|
920
|
+
witness: dict[str, Any],
|
|
921
|
+
expected_plan_id: str,
|
|
922
|
+
policy_from: Path | None,
|
|
923
|
+
) -> dict[str, Any]:
|
|
924
|
+
"""Recover the prepared plan without folding later destination writes into its CAS."""
|
|
925
|
+
|
|
926
|
+
canonical, legacy_root, current_root = _roots(repository_path, legacy_namespace)
|
|
927
|
+
if not isinstance(witness, dict) or any(key not in witness for key in IDENTITY_FIELDS):
|
|
928
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "prepared migration receipt is incomplete")
|
|
929
|
+
identity = {key: witness[key] for key in IDENTITY_FIELDS}
|
|
930
|
+
if (
|
|
931
|
+
witness.get("contract") != CONTRACT
|
|
932
|
+
or witness.get("schema_version") != SCHEMA_VERSION
|
|
933
|
+
or witness.get("phase") != "PREPARED"
|
|
934
|
+
or witness.get("plan_id") != expected_plan_id
|
|
935
|
+
or p._sha(identity) != expected_plan_id
|
|
936
|
+
or witness.get("repository_path") != str(canonical)
|
|
937
|
+
or witness.get("common_dir") != str(legacy_root.parent.parent)
|
|
938
|
+
or witness.get("legacy_root") != str(legacy_root)
|
|
939
|
+
or witness.get("current_root") != str(current_root)
|
|
940
|
+
or witness.get("scenario") not in {REQUIRED, CONVERGENCE}
|
|
941
|
+
):
|
|
942
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "prepared migration identity differs")
|
|
943
|
+
manifest = witness.get("manifest")
|
|
944
|
+
if manifest is None:
|
|
945
|
+
# Older prepared receipts had no replay manifest. They remain recoverable
|
|
946
|
+
# only while their original plan still re-derives byte for byte.
|
|
947
|
+
planned = _plan(repository_path, legacy_namespace, policy_from, serialized=True)
|
|
948
|
+
if planned["plan_id"] != expected_plan_id:
|
|
949
|
+
raise _blocked("NAMESPACE_PLAN_DRIFT", "legacy prepared receipt has no replay manifest")
|
|
950
|
+
return planned
|
|
951
|
+
if not isinstance(manifest, dict) or AUTHORITY_FILE not in manifest:
|
|
952
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "prepared migration manifest is invalid")
|
|
953
|
+
policy = witness.get("policy")
|
|
954
|
+
if (
|
|
955
|
+
not isinstance(policy, dict)
|
|
956
|
+
or policy.get("target") != witness["policy_target"]
|
|
957
|
+
or policy.get("sha256") != witness["policy_sha256"]
|
|
958
|
+
or (policy_from is not None and policy.get("move") not in {None, str(policy_from)})
|
|
959
|
+
):
|
|
960
|
+
raise _blocked("NAMESPACE_POLICY_DRIFT", "prepared policy authority differs")
|
|
961
|
+
target_digests: dict[str, str] = {}
|
|
962
|
+
for relative, entry in manifest.items():
|
|
963
|
+
_relative(relative)
|
|
964
|
+
if not isinstance(entry, dict) or entry.get("kind") not in {
|
|
965
|
+
MOVED,
|
|
966
|
+
REDUNDANT,
|
|
967
|
+
DESTINATION_ONLY,
|
|
968
|
+
REWRITE,
|
|
969
|
+
}:
|
|
970
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"invalid manifest entry: {relative}")
|
|
971
|
+
for key in ("source_sha256", "current_sha256", "target_sha256"):
|
|
972
|
+
value = entry.get(key)
|
|
973
|
+
if value is not None and (
|
|
974
|
+
not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
|
|
975
|
+
):
|
|
976
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"invalid {key}: {relative}")
|
|
977
|
+
if entry.get("target_sha256") is None:
|
|
978
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", f"missing target digest: {relative}")
|
|
979
|
+
target_digests[relative] = entry["target_sha256"]
|
|
980
|
+
if p._sha(target_digests) != witness["merged_digest"]:
|
|
981
|
+
raise _blocked(
|
|
982
|
+
"NAMESPACE_EVIDENCE_INVALID", "prepared manifest does not match plan identity"
|
|
983
|
+
)
|
|
984
|
+
legacy_tree = _tree(legacy_root, skip_names=frozenset({LOCK_NAME}))
|
|
985
|
+
current_tree = _tree(
|
|
986
|
+
current_root,
|
|
987
|
+
skip_names=frozenset({LOCK_NAME, relocation.RECORD}),
|
|
988
|
+
skip_prefixes=(f"{RECEIPTS_DIRECTORY}/",),
|
|
989
|
+
)
|
|
990
|
+
for root in (legacy_root, current_root):
|
|
991
|
+
for row in _unfinished(root, canonical, serialized=True):
|
|
992
|
+
raise _blocked(
|
|
993
|
+
"NAMESPACE_UNSAFE", f"unfinished transaction {row['transaction_id']} in {root}"
|
|
994
|
+
)
|
|
995
|
+
authorities = [
|
|
996
|
+
tree[AUTHORITY_FILE] for tree in (legacy_tree, current_tree) if AUTHORITY_FILE in tree
|
|
997
|
+
]
|
|
998
|
+
if not authorities or any(
|
|
999
|
+
_authority_id({AUTHORITY_FILE: raw}) != witness["repository_id"] for raw in authorities
|
|
1000
|
+
):
|
|
1001
|
+
raise _blocked(
|
|
1002
|
+
"NAMESPACE_IDENTITY_MISMATCH", "repository authority differs from prepared receipt"
|
|
1003
|
+
)
|
|
1004
|
+
switched = AUTHORITY_FILE not in legacy_tree and AUTHORITY_FILE in current_tree
|
|
1005
|
+
files: dict[str, bytes] = {}
|
|
1006
|
+
sources: dict[str, bytes] = {}
|
|
1007
|
+
drifted: set[str] = set()
|
|
1008
|
+
for relative, entry in sorted(manifest.items()):
|
|
1009
|
+
if entry["kind"] == DESTINATION_ONLY and relative != AUTHORITY_FILE:
|
|
1010
|
+
continue
|
|
1011
|
+
source, resident = legacy_tree.get(relative), current_tree.get(relative)
|
|
1012
|
+
if source is not None:
|
|
1013
|
+
if _sha256(source) != entry["source_sha256"]:
|
|
1014
|
+
raise _blocked("NAMESPACE_SOURCE_DRIFT", f"legacy content changed: {relative}")
|
|
1015
|
+
sources[relative] = source
|
|
1016
|
+
if resident is not None and _sha256(resident) == entry["target_sha256"]:
|
|
1017
|
+
files[relative] = resident
|
|
1018
|
+
continue
|
|
1019
|
+
if relative == AUTHORITY_FILE and switched and resident is not None:
|
|
1020
|
+
# Once the destination owns the repository, ordinary Controller writes
|
|
1021
|
+
# may advance repo.json as well as records. Its repository_id was
|
|
1022
|
+
# checked above; replay must keep the live authority document.
|
|
1023
|
+
files[relative] = resident
|
|
1024
|
+
continue
|
|
1025
|
+
candidate = source if source is not None else resident
|
|
1026
|
+
if (
|
|
1027
|
+
candidate is not None
|
|
1028
|
+
and _sha256(_converted(relative, candidate)) == entry["target_sha256"]
|
|
1029
|
+
and (resident is None or _sha256(resident) == entry["current_sha256"])
|
|
1030
|
+
):
|
|
1031
|
+
files[relative] = _converted(relative, candidate)
|
|
1032
|
+
continue
|
|
1033
|
+
if (
|
|
1034
|
+
switched
|
|
1035
|
+
and resident is not None
|
|
1036
|
+
and relative.split("/", 1)[0] in MUTABLE_AFTER_SWITCH
|
|
1037
|
+
and entry["kind"] != DESTINATION_ONLY
|
|
1038
|
+
):
|
|
1039
|
+
drifted.add(relative)
|
|
1040
|
+
continue
|
|
1041
|
+
raise _blocked(
|
|
1042
|
+
"NAMESPACE_CONFLICT", f"prepared document changed or disappeared: {relative}"
|
|
1043
|
+
)
|
|
1044
|
+
return {
|
|
1045
|
+
**identity,
|
|
1046
|
+
"plan_id": expected_plan_id,
|
|
1047
|
+
"scenario": witness["scenario"],
|
|
1048
|
+
"moves": sorted(r for r, e in manifest.items() if e["kind"] == MOVED),
|
|
1049
|
+
"redundant": sorted(r for r, e in manifest.items() if e["kind"] == REDUNDANT),
|
|
1050
|
+
"rewrites": sorted(r for r, e in manifest.items() if e["kind"] == REWRITE),
|
|
1051
|
+
"lease_conversions": witness.get("lease_conversions", []),
|
|
1052
|
+
"policy": policy,
|
|
1053
|
+
"manifest": manifest,
|
|
1054
|
+
"files": files,
|
|
1055
|
+
"sources": sources,
|
|
1056
|
+
"mutable_drift": drifted,
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _settled_decision(
|
|
1061
|
+
settled: dict[str, Any], plan_id: str, legacy_root: Path, current_root: Path
|
|
1062
|
+
) -> dict[str, Any]:
|
|
1063
|
+
if (
|
|
1064
|
+
settled.get("contract") != CONTRACT
|
|
1065
|
+
or settled.get("schema_version") != SCHEMA_VERSION
|
|
1066
|
+
or settled.get("phase") != "SETTLED"
|
|
1067
|
+
or settled.get("plan_id") != plan_id
|
|
1068
|
+
or settled.get("legacy_root") != str(legacy_root)
|
|
1069
|
+
or settled.get("current_root") != str(current_root)
|
|
1070
|
+
):
|
|
1071
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "settled migration receipt differs")
|
|
1072
|
+
if os.path.lexists(legacy_root):
|
|
1073
|
+
raise _blocked("NAMESPACE_RESIDUE", "legacy namespace reappeared after settlement")
|
|
1074
|
+
try:
|
|
1075
|
+
relocation._record(current_root)
|
|
1076
|
+
except ControllerError as exc:
|
|
1077
|
+
raise _blocked(
|
|
1078
|
+
"NAMESPACE_EVIDENCE_INVALID", "settled relocation evidence is invalid"
|
|
1079
|
+
) from exc
|
|
1080
|
+
return {
|
|
1081
|
+
"code": CONVERGED if settled["scenario"] == CONVERGENCE else MIGRATED,
|
|
1082
|
+
"idempotent": True,
|
|
1083
|
+
"changed": False,
|
|
1084
|
+
"plan_id": plan_id,
|
|
1085
|
+
"receipt": settled,
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def migrate_namespace(
|
|
1090
|
+
repository_path: Path,
|
|
1091
|
+
*,
|
|
1092
|
+
legacy_namespace: str = LEGACY_ROOT_NAME,
|
|
1093
|
+
policy_from: Path | None = None,
|
|
1094
|
+
actor: str | None = None,
|
|
1095
|
+
expected_plan_id: str | None = None,
|
|
1096
|
+
dry_run: bool = False,
|
|
1097
|
+
) -> dict[str, Any]:
|
|
1098
|
+
"""Move one repository's metadata namespace, or prove it needs no move.
|
|
1099
|
+
|
|
1100
|
+
Every step is replayable. The plan identity is the compare-and-set, the prepared
|
|
1101
|
+
receipt is written before anything destructive, a document moves only when its
|
|
1102
|
+
planned bytes still match on disk, the legacy namespace disappears only when it is
|
|
1103
|
+
demonstrably empty, and the relocation record is written only after the old root is
|
|
1104
|
+
gone - which is exactly when the frozen reference resolver demands it.
|
|
1105
|
+
"""
|
|
1106
|
+
|
|
1107
|
+
if dry_run or expected_plan_id is None:
|
|
1108
|
+
decision = plan_namespace_migration(
|
|
1109
|
+
repository_path, legacy_namespace=legacy_namespace, policy_from=policy_from
|
|
1110
|
+
)["decision"]
|
|
1111
|
+
return _payload("namespace-migrate", decision)
|
|
1112
|
+
if re.fullmatch(r"[0-9a-f]{64}", expected_plan_id) is None:
|
|
1113
|
+
raise _blocked("NAMESPACE_PLAN_DRIFT", "expected plan id must be an exact SHA-256")
|
|
1114
|
+
_, legacy_root, current_root = _roots(repository_path, legacy_namespace)
|
|
1115
|
+
pending = _pending_receipt(current_root)
|
|
1116
|
+
if pending is not None and pending.get("plan_id") != expected_plan_id:
|
|
1117
|
+
raise _blocked("NAMESPACE_PLAN_DRIFT", "resume the exact unsettled migration plan")
|
|
1118
|
+
found = receipts_of(current_root, expected_plan_id)
|
|
1119
|
+
if found["settled"] is not None:
|
|
1120
|
+
return _payload(
|
|
1121
|
+
"namespace-migrate",
|
|
1122
|
+
_settled_decision(found["settled"], expected_plan_id, legacy_root, current_root),
|
|
1123
|
+
)
|
|
1124
|
+
if found["prepared"] is None:
|
|
1125
|
+
plan = _plan(repository_path, legacy_namespace, policy_from)
|
|
1126
|
+
if expected_plan_id != plan["plan_id"]:
|
|
1127
|
+
raise _blocked("NAMESPACE_PLAN_DRIFT", "namespace state changed after the plan")
|
|
1128
|
+
if plan["scenario"] == CURRENT:
|
|
1129
|
+
return _payload(
|
|
1130
|
+
"namespace-migrate",
|
|
1131
|
+
{**_scenario_report(plan), "dry_run": False, "idempotent": True, "changed": False},
|
|
1132
|
+
)
|
|
1133
|
+
if not actor:
|
|
1134
|
+
raise _blocked("INVALID_REQUEST", "an actor is required to migrate a namespace")
|
|
1135
|
+
|
|
1136
|
+
with _fenced(current_root, legacy_root):
|
|
1137
|
+
found = receipts_of(current_root, expected_plan_id)
|
|
1138
|
+
settled = found["settled"]
|
|
1139
|
+
if settled is not None:
|
|
1140
|
+
return _payload(
|
|
1141
|
+
"namespace-migrate",
|
|
1142
|
+
_settled_decision(settled, expected_plan_id, legacy_root, current_root),
|
|
1143
|
+
)
|
|
1144
|
+
witness = found["prepared"]
|
|
1145
|
+
planned = (
|
|
1146
|
+
_resume_plan(repository_path, legacy_namespace, witness, expected_plan_id, policy_from)
|
|
1147
|
+
if witness is not None
|
|
1148
|
+
else _plan(repository_path, legacy_namespace, policy_from, serialized=True)
|
|
1149
|
+
)
|
|
1150
|
+
if planned["plan_id"] != expected_plan_id:
|
|
1151
|
+
raise _blocked(
|
|
1152
|
+
"NAMESPACE_PLAN_DRIFT", "namespace changed before the transition was fenced"
|
|
1153
|
+
)
|
|
1154
|
+
plan_id = expected_plan_id
|
|
1155
|
+
scenario = witness["scenario"] if witness is not None else planned["scenario"]
|
|
1156
|
+
if witness is None:
|
|
1157
|
+
_write_receipt(
|
|
1158
|
+
current_root,
|
|
1159
|
+
f"{plan_id}.prepared.json",
|
|
1160
|
+
{
|
|
1161
|
+
"contract": CONTRACT,
|
|
1162
|
+
"schema_version": SCHEMA_VERSION,
|
|
1163
|
+
"phase": "PREPARED",
|
|
1164
|
+
"plan_id": plan_id,
|
|
1165
|
+
"actor": actor,
|
|
1166
|
+
"scenario": scenario,
|
|
1167
|
+
**{key: planned[key] for key in IDENTITY_FIELDS if key != "contract"},
|
|
1168
|
+
"manifest": planned["manifest"],
|
|
1169
|
+
"policy": planned["policy"],
|
|
1170
|
+
"lease_conversions": planned["lease_conversions"],
|
|
1171
|
+
"prepared_at": now_iso(),
|
|
1172
|
+
},
|
|
1173
|
+
)
|
|
1174
|
+
_crash_point("namespace_receipt_prepared")
|
|
1175
|
+
moved, rewritten, cleared = _relocate(legacy_root, current_root, planned)
|
|
1176
|
+
_clear_atomic_temps(current_root)
|
|
1177
|
+
_crash_point("namespace_entries_moved")
|
|
1178
|
+
switched = _switch_authority(legacy_root, current_root, planned)
|
|
1179
|
+
_crash_point("namespace_switched")
|
|
1180
|
+
legacy_removed = _prune_legacy(legacy_root)
|
|
1181
|
+
if not legacy_removed or os.path.lexists(legacy_root):
|
|
1182
|
+
raise _blocked("NAMESPACE_RESIDUE", "legacy namespace remains after cleanup")
|
|
1183
|
+
expected_references = _evidence_digests(current_root, planned)
|
|
1184
|
+
if os.path.lexists(current_root / relocation.RECORD):
|
|
1185
|
+
try:
|
|
1186
|
+
record = relocation._record(current_root)
|
|
1187
|
+
except ControllerError as exc:
|
|
1188
|
+
raise _blocked(
|
|
1189
|
+
"NAMESPACE_EVIDENCE_INVALID", "relocation record is invalid"
|
|
1190
|
+
) from exc
|
|
1191
|
+
if (
|
|
1192
|
+
record["previous_root"] != str(legacy_root)
|
|
1193
|
+
or record["files"] != expected_references
|
|
1194
|
+
):
|
|
1195
|
+
raise _blocked(
|
|
1196
|
+
"NAMESPACE_EVIDENCE_INVALID", "relocation record differs from prepared evidence"
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
record = None
|
|
1200
|
+
if record is None:
|
|
1201
|
+
identity = os.stat(current_root)
|
|
1202
|
+
record = {
|
|
1203
|
+
"contract": relocation.CONTRACT,
|
|
1204
|
+
"previous_root": str(legacy_root),
|
|
1205
|
+
"current_root": str(current_root),
|
|
1206
|
+
"root_identity": [identity.st_dev, identity.st_ino],
|
|
1207
|
+
"files": expected_references,
|
|
1208
|
+
}
|
|
1209
|
+
record = {**record, "sha256": p._sha(record)}
|
|
1210
|
+
_write_once_document(current_root, relocation.RECORD, record)
|
|
1211
|
+
_crash_point("namespace_record_written")
|
|
1212
|
+
policy = _apply_policy(planned)
|
|
1213
|
+
if policy is not None:
|
|
1214
|
+
_crash_point("namespace_policy_moved")
|
|
1215
|
+
receipt = {
|
|
1216
|
+
"contract": CONTRACT,
|
|
1217
|
+
"schema_version": SCHEMA_VERSION,
|
|
1218
|
+
"phase": "SETTLED",
|
|
1219
|
+
"plan_id": plan_id,
|
|
1220
|
+
"actor": actor,
|
|
1221
|
+
"scenario": scenario,
|
|
1222
|
+
**{key: planned[key] for key in IDENTITY_FIELDS if key != "contract"},
|
|
1223
|
+
"moved": sorted(set(moved) | set(planned["moves"])),
|
|
1224
|
+
"destination_rewrites": sorted(set(rewritten) | set(planned["rewrites"])),
|
|
1225
|
+
"redundant_cleared": cleared,
|
|
1226
|
+
"authority_switched": switched,
|
|
1227
|
+
"lease_conversions": planned["lease_conversions"],
|
|
1228
|
+
"policy": policy,
|
|
1229
|
+
"legacy_namespace_removed": legacy_removed,
|
|
1230
|
+
"relocation_record_sha256": record["sha256"],
|
|
1231
|
+
"manifest": planned["manifest"],
|
|
1232
|
+
"settled_at": now_iso(),
|
|
1233
|
+
}
|
|
1234
|
+
_write_receipt(current_root, f"{plan_id}.json", receipt)
|
|
1235
|
+
_crash_point("namespace_receipt_settled")
|
|
1236
|
+
MetadataStore(inventory(repository_path).common_dir).load()
|
|
1237
|
+
return _payload(
|
|
1238
|
+
"namespace-migrate",
|
|
1239
|
+
{
|
|
1240
|
+
"code": CONVERGED if scenario == CONVERGENCE else MIGRATED,
|
|
1241
|
+
"idempotent": False,
|
|
1242
|
+
"changed": True,
|
|
1243
|
+
"plan_id": plan_id,
|
|
1244
|
+
"receipt": receipt,
|
|
1245
|
+
"metadata_path": str(current_root),
|
|
1246
|
+
},
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _write_once_document(root: Path, name: str, value: dict[str, Any]) -> str:
|
|
1251
|
+
with p._absolute_directory(root) as chain:
|
|
1252
|
+
return p._write_once(chain[-1], name, value)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def namespace_migration_receipts(repository_path: Path) -> dict[str, Any]:
|
|
1256
|
+
"""List the immutable migration receipts of one repository."""
|
|
1257
|
+
|
|
1258
|
+
canonical, legacy_root, current_root = _roots(repository_path, LEGACY_ROOT_NAME)
|
|
1259
|
+
directory = current_root / RECEIPTS_DIRECTORY
|
|
1260
|
+
entries = []
|
|
1261
|
+
if os.path.lexists(directory) and (directory.is_symlink() or not directory.is_dir()):
|
|
1262
|
+
raise _blocked("NAMESPACE_EVIDENCE_INVALID", "migration receipt directory is invalid")
|
|
1263
|
+
if directory.is_dir():
|
|
1264
|
+
for path in sorted(directory.glob("*.json")):
|
|
1265
|
+
if path.is_symlink() or not path.is_file():
|
|
1266
|
+
raise _blocked(
|
|
1267
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is not regular: {path}"
|
|
1268
|
+
)
|
|
1269
|
+
try:
|
|
1270
|
+
raw = path.read_bytes()
|
|
1271
|
+
value = json.loads(raw)
|
|
1272
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
1273
|
+
raise _blocked(
|
|
1274
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is unreadable: {path}"
|
|
1275
|
+
) from exc
|
|
1276
|
+
if not isinstance(value, dict):
|
|
1277
|
+
raise _blocked(
|
|
1278
|
+
"NAMESPACE_EVIDENCE_INVALID", f"migration receipt is not an object: {path}"
|
|
1279
|
+
)
|
|
1280
|
+
entries.append(
|
|
1281
|
+
{
|
|
1282
|
+
"name": path.name,
|
|
1283
|
+
"plan_id": value.get("plan_id"),
|
|
1284
|
+
"phase": value.get("phase"),
|
|
1285
|
+
"scenario": value.get("scenario"),
|
|
1286
|
+
"actor": value.get("actor"),
|
|
1287
|
+
"repository_id": value.get("repository_id"),
|
|
1288
|
+
"stamp": value.get("prepared_at") or value.get("settled_at"),
|
|
1289
|
+
"sha256": _sha256(raw),
|
|
1290
|
+
}
|
|
1291
|
+
)
|
|
1292
|
+
return _payload(
|
|
1293
|
+
"namespace-migration-receipts",
|
|
1294
|
+
{
|
|
1295
|
+
"code": "NAMESPACE_RECEIPTS",
|
|
1296
|
+
"repository_path": str(canonical),
|
|
1297
|
+
"legacy_root": str(legacy_root),
|
|
1298
|
+
"directory": str(directory),
|
|
1299
|
+
"receipts": entries,
|
|
1300
|
+
},
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def verify_namespace(
|
|
1305
|
+
repository_path: Path, *, legacy_namespace: str = LEGACY_ROOT_NAME
|
|
1306
|
+
) -> dict[str, Any]:
|
|
1307
|
+
"""Re-derive a completed migration from immutable evidence instead of trusting a flag.
|
|
1308
|
+
|
|
1309
|
+
A settled receipt must still describe the tree on disk: every path it moved has to be
|
|
1310
|
+
present in the destination namespace, the relocation record has to validate against
|
|
1311
|
+
this directory's own identity, and the legacy namespace has to be gone - residue
|
|
1312
|
+
disables historical reference resolution today.
|
|
1313
|
+
"""
|
|
1314
|
+
|
|
1315
|
+
canonical, legacy_root, current_root = _roots(repository_path, legacy_namespace)
|
|
1316
|
+
problems: list[str] = []
|
|
1317
|
+
prepared: dict[str, dict[str, Any]] = {}
|
|
1318
|
+
settled: list[dict[str, Any]] = []
|
|
1319
|
+
directory = current_root / RECEIPTS_DIRECTORY
|
|
1320
|
+
if os.path.lexists(directory) and (directory.is_symlink() or not directory.is_dir()):
|
|
1321
|
+
problems.append("RECEIPT_DIRECTORY_INVALID")
|
|
1322
|
+
for path in sorted(directory.glob("*.json")) if directory.is_dir() else []:
|
|
1323
|
+
if path.is_symlink() or not path.is_file():
|
|
1324
|
+
problems.append("RECEIPT_SHAPE_INVALID")
|
|
1325
|
+
continue
|
|
1326
|
+
try:
|
|
1327
|
+
value = json.loads(path.read_bytes())
|
|
1328
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
1329
|
+
problems.append("RECEIPT_UNREADABLE")
|
|
1330
|
+
continue
|
|
1331
|
+
if not isinstance(value, dict):
|
|
1332
|
+
problems.append("RECEIPT_SHAPE_INVALID")
|
|
1333
|
+
continue
|
|
1334
|
+
if path.name.endswith(".prepared.json"):
|
|
1335
|
+
plan_id = path.name.removesuffix(".prepared.json")
|
|
1336
|
+
if value.get("plan_id") != plan_id or value.get("phase") != "PREPARED":
|
|
1337
|
+
problems.append("PREPARED_RECEIPT_INVALID")
|
|
1338
|
+
prepared[plan_id] = value
|
|
1339
|
+
continue
|
|
1340
|
+
settled.append(value)
|
|
1341
|
+
if value.get("contract") != CONTRACT or value.get("phase") != "SETTLED":
|
|
1342
|
+
problems.append("RECEIPT_SHAPE_INVALID")
|
|
1343
|
+
if value.get("current_root") != str(current_root) or value.get("legacy_root") != str(
|
|
1344
|
+
legacy_root
|
|
1345
|
+
):
|
|
1346
|
+
problems.append("RECEIPT_TARGET_DIFFERS")
|
|
1347
|
+
authority = current_root / AUTHORITY_FILE
|
|
1348
|
+
if not authority.is_file():
|
|
1349
|
+
problems.append("AUTHORITY_MISSING")
|
|
1350
|
+
continue
|
|
1351
|
+
try:
|
|
1352
|
+
authority_document = json.loads(authority.read_bytes())
|
|
1353
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
1354
|
+
problems.append("AUTHORITY_INVALID")
|
|
1355
|
+
continue
|
|
1356
|
+
if not isinstance(authority_document, dict):
|
|
1357
|
+
problems.append("AUTHORITY_INVALID")
|
|
1358
|
+
continue
|
|
1359
|
+
if value.get("repository_id") != authority_document.get("repository_id"):
|
|
1360
|
+
problems.append("RECEIPT_IDENTITY_DIFFERS")
|
|
1361
|
+
moved = value.get("moved")
|
|
1362
|
+
if not isinstance(moved, list) or any(not isinstance(item, str) for item in moved):
|
|
1363
|
+
problems.append("RECEIPT_SHAPE_INVALID")
|
|
1364
|
+
continue
|
|
1365
|
+
for relative in moved:
|
|
1366
|
+
try:
|
|
1367
|
+
_relative(relative)
|
|
1368
|
+
except ControllerError:
|
|
1369
|
+
problems.append("RECEIPT_SHAPE_INVALID")
|
|
1370
|
+
continue
|
|
1371
|
+
if not (current_root / relative).is_file():
|
|
1372
|
+
problems.append(f"MOVED_DOCUMENT_MISSING:{relative}")
|
|
1373
|
+
settled_ids = {item["plan_id"] for item in settled if isinstance(item.get("plan_id"), str)}
|
|
1374
|
+
for plan_id in sorted(prepared):
|
|
1375
|
+
if plan_id not in settled_ids:
|
|
1376
|
+
problems.append(f"PREPARED_UNSETTLED:{plan_id}")
|
|
1377
|
+
if os.path.lexists(legacy_root):
|
|
1378
|
+
problems.append("LEGACY_NAMESPACE_PRESENT")
|
|
1379
|
+
record_state = "absent"
|
|
1380
|
+
if (current_root / relocation.RECORD).is_file():
|
|
1381
|
+
try:
|
|
1382
|
+
relocation._record(current_root)
|
|
1383
|
+
record_state = "valid"
|
|
1384
|
+
except ControllerError as exc:
|
|
1385
|
+
record_state = "invalid"
|
|
1386
|
+
problems.append(f"RELOCATION_RECORD:{exc.code}")
|
|
1387
|
+
if settled and record_state == "absent":
|
|
1388
|
+
problems.append("RELOCATION_RECORD_MISSING")
|
|
1389
|
+
return _payload(
|
|
1390
|
+
"namespace-migration-verify",
|
|
1391
|
+
{
|
|
1392
|
+
"code": "NAMESPACE_VERIFIED" if not problems else "NAMESPACE_VERIFY_FAILED",
|
|
1393
|
+
"problems": sorted(set(problems)),
|
|
1394
|
+
"repository_path": str(canonical),
|
|
1395
|
+
"current_root": str(current_root),
|
|
1396
|
+
"legacy_root_present": os.path.lexists(legacy_root),
|
|
1397
|
+
"relocation_record": record_state,
|
|
1398
|
+
"prepared_receipts": [
|
|
1399
|
+
{"plan_id": plan_id, "settled": plan_id in settled_ids}
|
|
1400
|
+
for plan_id in sorted(prepared)
|
|
1401
|
+
],
|
|
1402
|
+
"settled_receipts": [
|
|
1403
|
+
{
|
|
1404
|
+
"plan_id": item.get("plan_id"),
|
|
1405
|
+
"scenario": item.get("scenario"),
|
|
1406
|
+
"repository_id": item.get("repository_id"),
|
|
1407
|
+
"moved": len(item["moved"]) if isinstance(item.get("moved"), list) else 0,
|
|
1408
|
+
}
|
|
1409
|
+
for item in settled
|
|
1410
|
+
],
|
|
1411
|
+
},
|
|
1412
|
+
)
|