model-mirror-cli 0.2.0__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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .hub import read_snapshot_plan
|
|
9
|
+
from .state import read_verification_state, utc_now
|
|
10
|
+
from .torrent import (
|
|
11
|
+
PUBLICATION_PROFILE,
|
|
12
|
+
HybridMetainfo,
|
|
13
|
+
TorrentPublicationError,
|
|
14
|
+
build_publication_descriptor,
|
|
15
|
+
create_hybrid_metainfo_from_coverage,
|
|
16
|
+
)
|
|
17
|
+
from .torrent_coverage import load_coverage, upgrade_coverage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PUBLICATION_SCHEMA = "model-mirror-torrent-publication"
|
|
21
|
+
PUBLICATION_VERSION = 1
|
|
22
|
+
RECOVERY_SCHEMA = "model-mirror-torrent-recovery"
|
|
23
|
+
RECOVERY_VERSION = 1
|
|
24
|
+
FENCE_SCHEMA = "model-mirror-publication-fence"
|
|
25
|
+
FENCE_VERSION = 1
|
|
26
|
+
TORRENT_ROOT = Path(".model-mirror") / "torrent"
|
|
27
|
+
PUBLICATIONS_DIR = TORRENT_ROOT / "publications"
|
|
28
|
+
FENCE_FILE = TORRENT_ROOT / "fence.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class PublicationRecord:
|
|
33
|
+
repo_id: str
|
|
34
|
+
repo_type: str
|
|
35
|
+
resolved_commit: str
|
|
36
|
+
profile: str
|
|
37
|
+
descriptor_sha256: str
|
|
38
|
+
metainfo_sha256: str
|
|
39
|
+
infohash_v1: str
|
|
40
|
+
infohash_v2: str
|
|
41
|
+
magnet_uri: str
|
|
42
|
+
torrent_path: str
|
|
43
|
+
recovery_path: str
|
|
44
|
+
payload_fingerprints: dict[str, dict[str, int]]
|
|
45
|
+
lifecycle: str = "published"
|
|
46
|
+
desired_seed: bool = False
|
|
47
|
+
client_mode: str = "managed"
|
|
48
|
+
observed_backend: str = "stopped"
|
|
49
|
+
observed_detail: str = ""
|
|
50
|
+
maintenance_resume_seed: bool = False
|
|
51
|
+
content_verification: str = "upstream-verified"
|
|
52
|
+
publication_trust: str = "local-verified-publication"
|
|
53
|
+
upstream_provenance: str = "upstream-verified"
|
|
54
|
+
upstream_availability: str = "available"
|
|
55
|
+
created_at_utc: str = ""
|
|
56
|
+
updated_at_utc: str = ""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def publication_id(self) -> str:
|
|
60
|
+
return f"huggingface:{self.repo_type}:{self.repo_id}@{self.resolved_commit}"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def active(self) -> bool:
|
|
64
|
+
return self.lifecycle != "retired"
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"schema": PUBLICATION_SCHEMA,
|
|
69
|
+
"version": PUBLICATION_VERSION,
|
|
70
|
+
"publication_id": self.publication_id,
|
|
71
|
+
"repo_id": self.repo_id,
|
|
72
|
+
"repo_type": self.repo_type,
|
|
73
|
+
"resolved_commit": self.resolved_commit,
|
|
74
|
+
"profile": self.profile,
|
|
75
|
+
"descriptor_sha256": self.descriptor_sha256,
|
|
76
|
+
"metainfo_sha256": self.metainfo_sha256,
|
|
77
|
+
"infohash_v1": self.infohash_v1,
|
|
78
|
+
"infohash_v2": self.infohash_v2,
|
|
79
|
+
"magnet_uri": self.magnet_uri,
|
|
80
|
+
"torrent_path": self.torrent_path,
|
|
81
|
+
"recovery_path": self.recovery_path,
|
|
82
|
+
"payload_fingerprints": self.payload_fingerprints,
|
|
83
|
+
"lifecycle": self.lifecycle,
|
|
84
|
+
"desired_seed": self.desired_seed,
|
|
85
|
+
"client_mode": self.client_mode,
|
|
86
|
+
"observed_backend": self.observed_backend,
|
|
87
|
+
"observed_detail": self.observed_detail,
|
|
88
|
+
"maintenance_resume_seed": self.maintenance_resume_seed,
|
|
89
|
+
"content_verification": self.content_verification,
|
|
90
|
+
"publication_trust": self.publication_trust,
|
|
91
|
+
"upstream_provenance": self.upstream_provenance,
|
|
92
|
+
"upstream_availability": self.upstream_availability,
|
|
93
|
+
"created_at_utc": self.created_at_utc,
|
|
94
|
+
"updated_at_utc": self.updated_at_utc,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, value: object, *, source: Path) -> PublicationRecord:
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
raise ValueError(f"Torrent publication record must contain an object: {source}")
|
|
101
|
+
if value.get("schema") != PUBLICATION_SCHEMA or value.get("version") != PUBLICATION_VERSION:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Unsupported torrent publication format in {source}: "
|
|
104
|
+
f"schema={value.get('schema')!r} version={value.get('version')!r}"
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
fingerprints = value["payload_fingerprints"]
|
|
108
|
+
if not isinstance(fingerprints, dict):
|
|
109
|
+
raise TypeError
|
|
110
|
+
record = cls(
|
|
111
|
+
repo_id=str(value["repo_id"]),
|
|
112
|
+
repo_type=str(value["repo_type"]),
|
|
113
|
+
resolved_commit=str(value["resolved_commit"]),
|
|
114
|
+
profile=str(value["profile"]),
|
|
115
|
+
descriptor_sha256=require_digest(value["descriptor_sha256"], 64, source),
|
|
116
|
+
metainfo_sha256=require_digest(value["metainfo_sha256"], 64, source),
|
|
117
|
+
infohash_v1=require_digest(value["infohash_v1"], 40, source),
|
|
118
|
+
infohash_v2=require_digest(value["infohash_v2"], 64, source),
|
|
119
|
+
magnet_uri=str(value["magnet_uri"]),
|
|
120
|
+
torrent_path=str(value["torrent_path"]),
|
|
121
|
+
recovery_path=str(value["recovery_path"]),
|
|
122
|
+
payload_fingerprints={
|
|
123
|
+
str(path): {
|
|
124
|
+
"size": int(fingerprint["size"]),
|
|
125
|
+
"mtime_ns": int(fingerprint["mtime_ns"]),
|
|
126
|
+
}
|
|
127
|
+
for path, fingerprint in fingerprints.items()
|
|
128
|
+
},
|
|
129
|
+
lifecycle=str(value.get("lifecycle", "published")),
|
|
130
|
+
desired_seed=bool(value.get("desired_seed", False)),
|
|
131
|
+
client_mode=str(value.get("client_mode", "managed")),
|
|
132
|
+
observed_backend=str(value.get("observed_backend", "unknown")),
|
|
133
|
+
observed_detail=str(value.get("observed_detail", "")),
|
|
134
|
+
maintenance_resume_seed=bool(value.get("maintenance_resume_seed", False)),
|
|
135
|
+
content_verification=str(value.get("content_verification", "upstream-verified")),
|
|
136
|
+
publication_trust=str(
|
|
137
|
+
value.get("publication_trust", "local-verified-publication")
|
|
138
|
+
),
|
|
139
|
+
upstream_provenance=str(value.get("upstream_provenance", "upstream-verified")),
|
|
140
|
+
upstream_availability=str(value.get("upstream_availability", "available")),
|
|
141
|
+
created_at_utc=str(value.get("created_at_utc", "")),
|
|
142
|
+
updated_at_utc=str(value.get("updated_at_utc", "")),
|
|
143
|
+
)
|
|
144
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
145
|
+
raise ValueError(f"Malformed torrent publication record: {source}") from exc
|
|
146
|
+
if value.get("publication_id") not in {None, record.publication_id}:
|
|
147
|
+
raise ValueError(f"Torrent publication identity mismatch: {source}")
|
|
148
|
+
if record.lifecycle not in {"published", "maintenance", "unhealthy", "retired"}:
|
|
149
|
+
raise ValueError(f"Malformed torrent publication lifecycle in {source}")
|
|
150
|
+
if record.client_mode not in {"managed", "external"}:
|
|
151
|
+
raise ValueError(f"Malformed torrent publication client mode in {source}")
|
|
152
|
+
return record
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True, slots=True)
|
|
156
|
+
class PublicationResult:
|
|
157
|
+
record: PublicationRecord
|
|
158
|
+
record_path: Path
|
|
159
|
+
torrent_path: Path
|
|
160
|
+
recovery_path: Path
|
|
161
|
+
created: bool
|
|
162
|
+
coverage_hashed_files: int
|
|
163
|
+
coverage_hashed_bytes: int
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def publication_dir(root: Path, commit: str, profile: str = PUBLICATION_PROFILE) -> Path:
|
|
167
|
+
return root / PUBLICATIONS_DIR / f"{profile}--{commit}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def publication_record_path(root: Path, commit: str, profile: str = PUBLICATION_PROFILE) -> Path:
|
|
171
|
+
return publication_dir(root, commit, profile) / "publication.json"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def fence_path(root: Path) -> Path:
|
|
175
|
+
return root / FENCE_FILE
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_publication(path: Path) -> PublicationRecord | None:
|
|
179
|
+
if not path.exists():
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
raise ValueError(f"Malformed torrent publication record: {path}") from exc
|
|
185
|
+
return PublicationRecord.from_dict(value, source=path)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_fenced_publication(root: Path) -> tuple[PublicationRecord, Path] | None:
|
|
189
|
+
path = fence_path(root)
|
|
190
|
+
if not path.exists():
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
raise ValueError(f"Malformed publication fence: {path}") from exc
|
|
196
|
+
if (
|
|
197
|
+
not isinstance(value, dict)
|
|
198
|
+
or value.get("schema") != FENCE_SCHEMA
|
|
199
|
+
or value.get("version") != FENCE_VERSION
|
|
200
|
+
):
|
|
201
|
+
raise ValueError(f"Unsupported publication fence format: {path}")
|
|
202
|
+
relative = Path(str(value.get("publication_record", "")))
|
|
203
|
+
if not relative.parts or relative.is_absolute() or ".." in relative.parts:
|
|
204
|
+
raise ValueError(f"Unsafe publication record path in fence: {path}")
|
|
205
|
+
record_path = root / relative
|
|
206
|
+
record = load_publication(record_path)
|
|
207
|
+
if record is None or not record.active:
|
|
208
|
+
raise ValueError(f"Publication fence points to a missing or retired record: {path}")
|
|
209
|
+
if value.get("publication_id") != record.publication_id:
|
|
210
|
+
raise ValueError(f"Publication fence identity mismatch: {path}")
|
|
211
|
+
return record, record_path
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_publication(
|
|
215
|
+
root: Path,
|
|
216
|
+
*,
|
|
217
|
+
repo_id: str,
|
|
218
|
+
repo_type: str,
|
|
219
|
+
desired_seed: bool | None = None,
|
|
220
|
+
client_mode: str | None = None,
|
|
221
|
+
) -> PublicationResult:
|
|
222
|
+
if client_mode not in {None, "managed", "external"}:
|
|
223
|
+
raise ValueError(f"unsupported torrent client mode: {client_mode}")
|
|
224
|
+
imported = reusable_imported_publication(
|
|
225
|
+
root,
|
|
226
|
+
repo_id=repo_id,
|
|
227
|
+
repo_type=repo_type,
|
|
228
|
+
desired_seed=desired_seed,
|
|
229
|
+
client_mode=client_mode,
|
|
230
|
+
)
|
|
231
|
+
if imported is not None:
|
|
232
|
+
return imported
|
|
233
|
+
descriptor = build_publication_descriptor(root, repo_id=repo_id, repo_type=repo_type)
|
|
234
|
+
existing_fence = load_fenced_publication(root)
|
|
235
|
+
if existing_fence is not None and existing_fence[0].resolved_commit != descriptor.resolved_commit:
|
|
236
|
+
raise TorrentPublicationError(
|
|
237
|
+
f"archive is fenced by publication {existing_fence[0].publication_id}; "
|
|
238
|
+
"retire it before publishing another commit"
|
|
239
|
+
)
|
|
240
|
+
snapshot = read_snapshot_plan(root)
|
|
241
|
+
if snapshot is None: # pragma: no cover - descriptor already enforces this
|
|
242
|
+
raise TorrentPublicationError("pinned snapshot is missing")
|
|
243
|
+
coverage_result = upgrade_coverage(root, snapshot)
|
|
244
|
+
coverage = load_coverage(coverage_result.path)
|
|
245
|
+
if coverage is None or not coverage_result.complete: # pragma: no cover - defensive
|
|
246
|
+
raise TorrentPublicationError("torrent hash coverage is incomplete after upgrade")
|
|
247
|
+
artifact = create_hybrid_metainfo_from_coverage(root, descriptor, coverage)
|
|
248
|
+
|
|
249
|
+
target_dir = publication_dir(root, descriptor.resolved_commit, descriptor.profile)
|
|
250
|
+
torrent_path = target_dir / f"{root.name}@{descriptor.resolved_commit}.torrent"
|
|
251
|
+
recovery_path = target_dir / "recovery.json"
|
|
252
|
+
record_path = target_dir / "publication.json"
|
|
253
|
+
existing = load_publication(record_path)
|
|
254
|
+
if existing is not None and (
|
|
255
|
+
existing.metainfo_sha256 != artifact.metainfo_sha256
|
|
256
|
+
or existing.descriptor_sha256 != artifact.descriptor_sha256
|
|
257
|
+
or existing.infohash_v1 != artifact.infohash_v1
|
|
258
|
+
or existing.infohash_v2 != artifact.infohash_v2
|
|
259
|
+
):
|
|
260
|
+
raise TorrentPublicationError(
|
|
261
|
+
f"existing publication conflicts with deterministic output: {record_path}"
|
|
262
|
+
)
|
|
263
|
+
if torrent_path.exists() and hashlib.sha256(torrent_path.read_bytes()).hexdigest() != artifact.metainfo_sha256:
|
|
264
|
+
raise TorrentPublicationError(f"existing torrent artifact is corrupt or conflicting: {torrent_path}")
|
|
265
|
+
|
|
266
|
+
now = utc_now()
|
|
267
|
+
effective_mode = client_mode or (existing.client_mode if existing is not None else "managed")
|
|
268
|
+
effective_desired = (
|
|
269
|
+
desired_seed
|
|
270
|
+
if desired_seed is not None
|
|
271
|
+
else (existing.desired_seed if existing is not None else False)
|
|
272
|
+
)
|
|
273
|
+
record = PublicationRecord(
|
|
274
|
+
repo_id=descriptor.repo_id,
|
|
275
|
+
repo_type=descriptor.repo_type,
|
|
276
|
+
resolved_commit=descriptor.resolved_commit,
|
|
277
|
+
profile=descriptor.profile,
|
|
278
|
+
descriptor_sha256=artifact.descriptor_sha256,
|
|
279
|
+
metainfo_sha256=artifact.metainfo_sha256,
|
|
280
|
+
infohash_v1=artifact.infohash_v1,
|
|
281
|
+
infohash_v2=artifact.infohash_v2,
|
|
282
|
+
magnet_uri=artifact.magnet_uri,
|
|
283
|
+
torrent_path=torrent_path.relative_to(root).as_posix(),
|
|
284
|
+
recovery_path=recovery_path.relative_to(root).as_posix(),
|
|
285
|
+
payload_fingerprints={
|
|
286
|
+
item.path: {
|
|
287
|
+
"size": coverage.files[item.path].size,
|
|
288
|
+
"mtime_ns": coverage.files[item.path].mtime_ns,
|
|
289
|
+
}
|
|
290
|
+
for item in descriptor.files
|
|
291
|
+
},
|
|
292
|
+
lifecycle="published",
|
|
293
|
+
desired_seed=effective_desired,
|
|
294
|
+
client_mode=effective_mode,
|
|
295
|
+
observed_backend=(
|
|
296
|
+
existing.observed_backend
|
|
297
|
+
if existing is not None and existing.client_mode == effective_mode
|
|
298
|
+
else ("external" if effective_mode == "external" else "pending")
|
|
299
|
+
),
|
|
300
|
+
observed_detail=existing.observed_detail if existing is not None else "",
|
|
301
|
+
maintenance_resume_seed=False,
|
|
302
|
+
created_at_utc=existing.created_at_utc if existing is not None else now,
|
|
303
|
+
updated_at_utc=now,
|
|
304
|
+
)
|
|
305
|
+
write_bytes_atomic(torrent_path, artifact.metainfo)
|
|
306
|
+
write_json_atomic(recovery_path, recovery_value(record))
|
|
307
|
+
write_json_atomic(record_path, record.to_dict())
|
|
308
|
+
write_json_atomic(
|
|
309
|
+
fence_path(root),
|
|
310
|
+
{
|
|
311
|
+
"schema": FENCE_SCHEMA,
|
|
312
|
+
"version": FENCE_VERSION,
|
|
313
|
+
"publication_id": record.publication_id,
|
|
314
|
+
"publication_record": record_path.relative_to(root).as_posix(),
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
return PublicationResult(
|
|
318
|
+
record=record,
|
|
319
|
+
record_path=record_path,
|
|
320
|
+
torrent_path=torrent_path,
|
|
321
|
+
recovery_path=recovery_path,
|
|
322
|
+
created=existing is None or existing.lifecycle == "retired",
|
|
323
|
+
coverage_hashed_files=coverage_result.hashed_files,
|
|
324
|
+
coverage_hashed_bytes=coverage_result.hashed_bytes,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def reusable_imported_publication(
|
|
329
|
+
root: Path,
|
|
330
|
+
*,
|
|
331
|
+
repo_id: str,
|
|
332
|
+
repo_type: str,
|
|
333
|
+
desired_seed: bool | None,
|
|
334
|
+
client_mode: str | None,
|
|
335
|
+
) -> PublicationResult | None:
|
|
336
|
+
state = read_verification_state(root)
|
|
337
|
+
if (
|
|
338
|
+
state is None
|
|
339
|
+
or not state.resolved_commit
|
|
340
|
+
or state.repo_id != repo_id
|
|
341
|
+
or state.repo_type != repo_type
|
|
342
|
+
):
|
|
343
|
+
return None
|
|
344
|
+
path = publication_record_path(root, state.resolved_commit)
|
|
345
|
+
record = load_publication(path)
|
|
346
|
+
if record is None or record.content_verification != "torrent-verified":
|
|
347
|
+
return None
|
|
348
|
+
matches, detail = payload_fingerprints_match(root, record)
|
|
349
|
+
if not matches:
|
|
350
|
+
raise TorrentPublicationError(f"cannot reuse imported publication: {detail}")
|
|
351
|
+
torrent_path = root / record.torrent_path
|
|
352
|
+
try:
|
|
353
|
+
metainfo = torrent_path.read_bytes()
|
|
354
|
+
except OSError as exc:
|
|
355
|
+
raise TorrentPublicationError(f"imported torrent artifact is unavailable: {torrent_path}") from exc
|
|
356
|
+
if hashlib.sha256(metainfo).hexdigest() != record.metainfo_sha256:
|
|
357
|
+
raise TorrentPublicationError(f"imported torrent artifact digest mismatch: {torrent_path}")
|
|
358
|
+
|
|
359
|
+
was_retired = record.lifecycle == "retired"
|
|
360
|
+
record.lifecycle = "published"
|
|
361
|
+
if client_mode is not None:
|
|
362
|
+
record.client_mode = client_mode
|
|
363
|
+
if desired_seed is not None:
|
|
364
|
+
record.desired_seed = desired_seed
|
|
365
|
+
record.observed_backend = (
|
|
366
|
+
"external"
|
|
367
|
+
if record.client_mode == "external"
|
|
368
|
+
else "pending" if record.desired_seed else "stopped"
|
|
369
|
+
)
|
|
370
|
+
record.observed_detail = ""
|
|
371
|
+
record.updated_at_utc = utc_now()
|
|
372
|
+
recovery_path = root / record.recovery_path
|
|
373
|
+
write_json_atomic(recovery_path, recovery_value(record))
|
|
374
|
+
write_json_atomic(path, record.to_dict())
|
|
375
|
+
write_json_atomic(
|
|
376
|
+
fence_path(root),
|
|
377
|
+
{
|
|
378
|
+
"schema": FENCE_SCHEMA,
|
|
379
|
+
"version": FENCE_VERSION,
|
|
380
|
+
"publication_id": record.publication_id,
|
|
381
|
+
"publication_record": path.relative_to(root).as_posix(),
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
return PublicationResult(
|
|
385
|
+
record=record,
|
|
386
|
+
record_path=path,
|
|
387
|
+
torrent_path=torrent_path,
|
|
388
|
+
recovery_path=recovery_path,
|
|
389
|
+
created=was_retired,
|
|
390
|
+
coverage_hashed_files=0,
|
|
391
|
+
coverage_hashed_bytes=0,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def set_seed_desired(
|
|
396
|
+
root: Path,
|
|
397
|
+
*,
|
|
398
|
+
desired: bool,
|
|
399
|
+
client_mode: str | None = None,
|
|
400
|
+
) -> PublicationRecord:
|
|
401
|
+
fenced = load_fenced_publication(root)
|
|
402
|
+
if fenced is None:
|
|
403
|
+
raise TorrentPublicationError("archive has no active torrent publication")
|
|
404
|
+
record, path = fenced
|
|
405
|
+
if client_mode is not None:
|
|
406
|
+
if client_mode not in {"managed", "external"}:
|
|
407
|
+
raise ValueError(f"unsupported torrent client mode: {client_mode}")
|
|
408
|
+
record.client_mode = client_mode
|
|
409
|
+
record.desired_seed = desired
|
|
410
|
+
if record.client_mode == "external":
|
|
411
|
+
record.observed_backend = "external" if desired else "stopped"
|
|
412
|
+
elif desired:
|
|
413
|
+
record.observed_backend = "pending"
|
|
414
|
+
else:
|
|
415
|
+
record.observed_backend = (
|
|
416
|
+
"stopping"
|
|
417
|
+
if record.observed_backend in {"seeding", "stopping"}
|
|
418
|
+
else "stopped"
|
|
419
|
+
)
|
|
420
|
+
record.observed_detail = ""
|
|
421
|
+
record.maintenance_resume_seed = False
|
|
422
|
+
record.updated_at_utc = utc_now()
|
|
423
|
+
write_json_atomic(path, record.to_dict())
|
|
424
|
+
return record
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def retire_publication(root: Path) -> PublicationRecord:
|
|
428
|
+
fenced = load_fenced_publication(root)
|
|
429
|
+
if fenced is None:
|
|
430
|
+
raise TorrentPublicationError("archive has no active torrent publication")
|
|
431
|
+
record, path = fenced
|
|
432
|
+
if record.observed_backend in {"seeding", "stopping"}:
|
|
433
|
+
raise TorrentPublicationError(
|
|
434
|
+
f"cannot retire while backend state is {record.observed_backend}; "
|
|
435
|
+
"stop the seed and wait for backend detachment"
|
|
436
|
+
)
|
|
437
|
+
record.lifecycle = "retired"
|
|
438
|
+
record.desired_seed = False
|
|
439
|
+
record.observed_backend = "stopped"
|
|
440
|
+
record.observed_detail = ""
|
|
441
|
+
record.maintenance_resume_seed = False
|
|
442
|
+
record.updated_at_utc = utc_now()
|
|
443
|
+
write_json_atomic(path, record.to_dict())
|
|
444
|
+
fence_path(root).unlink()
|
|
445
|
+
return record
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def update_observed_backend(
|
|
449
|
+
root: Path,
|
|
450
|
+
*,
|
|
451
|
+
state: str,
|
|
452
|
+
detail: str = "",
|
|
453
|
+
lifecycle: str | None = None,
|
|
454
|
+
) -> PublicationRecord:
|
|
455
|
+
fenced = load_fenced_publication(root)
|
|
456
|
+
if fenced is None:
|
|
457
|
+
raise TorrentPublicationError("archive has no active torrent publication")
|
|
458
|
+
record, path = fenced
|
|
459
|
+
record.observed_backend = state
|
|
460
|
+
record.observed_detail = detail
|
|
461
|
+
if lifecycle is not None:
|
|
462
|
+
record.lifecycle = lifecycle
|
|
463
|
+
record.updated_at_utc = utc_now()
|
|
464
|
+
write_json_atomic(path, record.to_dict())
|
|
465
|
+
return record
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def begin_maintenance(root: Path) -> PublicationRecord | None:
|
|
469
|
+
fenced = load_fenced_publication(root)
|
|
470
|
+
if fenced is None:
|
|
471
|
+
return None
|
|
472
|
+
record, path = fenced
|
|
473
|
+
if record.client_mode == "external" and record.observed_backend != "stopped":
|
|
474
|
+
raise TorrentPublicationError(
|
|
475
|
+
"published payload is assigned to an external torrent client; "
|
|
476
|
+
"stop it, then run model-mirror torrent stop before repair"
|
|
477
|
+
)
|
|
478
|
+
if record.lifecycle != "maintenance":
|
|
479
|
+
record.maintenance_resume_seed = record.desired_seed
|
|
480
|
+
record.desired_seed = False
|
|
481
|
+
record.lifecycle = "maintenance"
|
|
482
|
+
if record.observed_backend == "seeding":
|
|
483
|
+
record.observed_backend = "stopping"
|
|
484
|
+
record.observed_detail = ""
|
|
485
|
+
record.updated_at_utc = utc_now()
|
|
486
|
+
write_json_atomic(path, record.to_dict())
|
|
487
|
+
return record
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def wait_for_maintenance_detach(root: Path, *, timeout_seconds: float = 10.0) -> None:
|
|
491
|
+
import time
|
|
492
|
+
|
|
493
|
+
deadline = time.monotonic() + timeout_seconds
|
|
494
|
+
while True:
|
|
495
|
+
fenced = load_fenced_publication(root)
|
|
496
|
+
if fenced is None:
|
|
497
|
+
return
|
|
498
|
+
record = fenced[0]
|
|
499
|
+
if record.observed_backend not in {"seeding", "stopping"}:
|
|
500
|
+
return
|
|
501
|
+
if time.monotonic() >= deadline:
|
|
502
|
+
raise TorrentPublicationError(
|
|
503
|
+
f"managed seeder did not detach {record.publication_id}; "
|
|
504
|
+
"ensure model-mirror torrent serve is running or stop the backend before repair"
|
|
505
|
+
)
|
|
506
|
+
time.sleep(0.1)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def finish_maintenance(root: Path, *, healthy: bool) -> PublicationRecord | None:
|
|
510
|
+
fenced = load_fenced_publication(root)
|
|
511
|
+
if fenced is None:
|
|
512
|
+
return None
|
|
513
|
+
record, path = fenced
|
|
514
|
+
resume = record.maintenance_resume_seed
|
|
515
|
+
record.maintenance_resume_seed = False
|
|
516
|
+
record.lifecycle = "published" if healthy else "unhealthy"
|
|
517
|
+
record.desired_seed = resume if healthy else False
|
|
518
|
+
record.observed_backend = "pending" if record.desired_seed else "stopped"
|
|
519
|
+
if not healthy:
|
|
520
|
+
record.observed_detail = "same-commit maintenance did not finish cleanly"
|
|
521
|
+
else:
|
|
522
|
+
record.observed_detail = ""
|
|
523
|
+
record.updated_at_utc = utc_now()
|
|
524
|
+
write_json_atomic(path, record.to_dict())
|
|
525
|
+
return record
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def assert_commit_update_allowed(root: Path, target_commit: str) -> None:
|
|
529
|
+
fenced = load_fenced_publication(root)
|
|
530
|
+
if fenced is None or fenced[0].resolved_commit == target_commit:
|
|
531
|
+
return
|
|
532
|
+
record = fenced[0]
|
|
533
|
+
raise TorrentPublicationError(
|
|
534
|
+
f"update blocked by active publication {record.publication_id}; "
|
|
535
|
+
f"run model-mirror torrent retire {record.repo_id} before updating"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def payload_fingerprints_match(root: Path, record: PublicationRecord) -> tuple[bool, str]:
|
|
540
|
+
for rel, expected in record.payload_fingerprints.items():
|
|
541
|
+
path = root / rel
|
|
542
|
+
if not path.exists() or not path.is_file() or path.is_symlink():
|
|
543
|
+
return False, f"missing or unsafe payload file: {rel}"
|
|
544
|
+
stat = path.stat()
|
|
545
|
+
if stat.st_size != expected["size"] or stat.st_mtime_ns != expected["mtime_ns"]:
|
|
546
|
+
return False, f"payload fingerprint changed: {rel}"
|
|
547
|
+
return True, ""
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def recovery_value(record: PublicationRecord) -> dict:
|
|
551
|
+
return {
|
|
552
|
+
"schema": RECOVERY_SCHEMA,
|
|
553
|
+
"version": RECOVERY_VERSION,
|
|
554
|
+
"publication_id": record.publication_id,
|
|
555
|
+
"profile": record.profile,
|
|
556
|
+
"descriptor_sha256": record.descriptor_sha256,
|
|
557
|
+
"metainfo_sha256": record.metainfo_sha256,
|
|
558
|
+
"infohash_v1": record.infohash_v1,
|
|
559
|
+
"infohash_v2": record.infohash_v2,
|
|
560
|
+
"magnet_uri": record.magnet_uri,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def write_json_atomic(path: Path, value: dict) -> None:
|
|
565
|
+
write_bytes_atomic(
|
|
566
|
+
path,
|
|
567
|
+
(json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8"),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def write_bytes_atomic(path: Path, value: bytes) -> None:
|
|
572
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
573
|
+
temporary = path.with_name(f".{path.name}.tmp")
|
|
574
|
+
temporary.write_bytes(value)
|
|
575
|
+
temporary.replace(path)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def require_digest(value: object, length: int, source: Path) -> str:
|
|
579
|
+
text = str(value).lower()
|
|
580
|
+
if len(text) != length or any(character not in "0123456789abcdef" for character in text):
|
|
581
|
+
raise ValueError(f"Malformed digest in torrent publication record: {source}")
|
|
582
|
+
return text
|