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,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import signal
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import Config, REPO_TYPE_DIRS
|
|
10
|
+
from .lock import ModelBusyError, ModelLock
|
|
11
|
+
from .torrent import TorrentPublicationError, load_libtorrent, verified_seed_params
|
|
12
|
+
from .torrent_publication import (
|
|
13
|
+
PublicationRecord,
|
|
14
|
+
fence_path,
|
|
15
|
+
load_fenced_publication,
|
|
16
|
+
payload_fingerprints_match,
|
|
17
|
+
update_observed_backend,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ActiveSeed:
|
|
23
|
+
root: Path
|
|
24
|
+
record: PublicationRecord
|
|
25
|
+
handle: object
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ManagedSeeder:
|
|
29
|
+
def __init__(self, *, libtorrent_module=None, session=None, listen_interfaces: str | None = None):
|
|
30
|
+
self.lt = libtorrent_module or load_libtorrent()
|
|
31
|
+
if session is None:
|
|
32
|
+
settings = {}
|
|
33
|
+
if listen_interfaces:
|
|
34
|
+
settings["listen_interfaces"] = listen_interfaces
|
|
35
|
+
session = self.lt.session(settings)
|
|
36
|
+
self.session = session
|
|
37
|
+
self.active: dict[str, ActiveSeed] = {}
|
|
38
|
+
|
|
39
|
+
def reconcile(self, config: Config) -> None:
|
|
40
|
+
discovered = {root: record for root, record in discover_publications(config)}
|
|
41
|
+
for publication_id, active in list(self.active.items()):
|
|
42
|
+
record = discovered.get(active.root)
|
|
43
|
+
if (
|
|
44
|
+
record is None
|
|
45
|
+
or record.publication_id != publication_id
|
|
46
|
+
or not record.desired_seed
|
|
47
|
+
or record.client_mode != "managed"
|
|
48
|
+
or record.lifecycle != "published"
|
|
49
|
+
):
|
|
50
|
+
if record is None:
|
|
51
|
+
self.session.remove_torrent(active.handle)
|
|
52
|
+
del self.active[publication_id]
|
|
53
|
+
else:
|
|
54
|
+
try:
|
|
55
|
+
with ModelLock(
|
|
56
|
+
active.root,
|
|
57
|
+
"torrent-reconcile",
|
|
58
|
+
record.repo_id,
|
|
59
|
+
record.repo_type,
|
|
60
|
+
):
|
|
61
|
+
current = load_fenced_publication(active.root)
|
|
62
|
+
if current is not None and (
|
|
63
|
+
current[0].desired_seed
|
|
64
|
+
and current[0].client_mode == "managed"
|
|
65
|
+
and current[0].lifecycle == "published"
|
|
66
|
+
):
|
|
67
|
+
continue
|
|
68
|
+
self.session.remove_torrent(active.handle)
|
|
69
|
+
del self.active[publication_id]
|
|
70
|
+
if current is not None:
|
|
71
|
+
update_observed_backend(active.root, state="stopped")
|
|
72
|
+
except ModelBusyError:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
for root, record in discovered.items():
|
|
76
|
+
if (
|
|
77
|
+
not record.desired_seed
|
|
78
|
+
and record.client_mode == "managed"
|
|
79
|
+
and record.observed_backend == "stopping"
|
|
80
|
+
):
|
|
81
|
+
self._set_observed(root, "stopped")
|
|
82
|
+
continue
|
|
83
|
+
if (
|
|
84
|
+
not record.desired_seed
|
|
85
|
+
or record.client_mode != "managed"
|
|
86
|
+
or record.lifecycle != "published"
|
|
87
|
+
or record.publication_id in self.active
|
|
88
|
+
):
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
with ModelLock(root, "torrent-reconcile", record.repo_id, record.repo_type):
|
|
92
|
+
current = load_fenced_publication(root)
|
|
93
|
+
if current is None:
|
|
94
|
+
continue
|
|
95
|
+
record = current[0]
|
|
96
|
+
if (
|
|
97
|
+
not record.desired_seed
|
|
98
|
+
or record.client_mode != "managed"
|
|
99
|
+
or record.lifecycle != "published"
|
|
100
|
+
):
|
|
101
|
+
continue
|
|
102
|
+
matches, detail = payload_fingerprints_match(root, record)
|
|
103
|
+
if not matches:
|
|
104
|
+
update_observed_backend(
|
|
105
|
+
root,
|
|
106
|
+
state="unhealthy",
|
|
107
|
+
detail=detail,
|
|
108
|
+
lifecycle="unhealthy",
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
metainfo = read_verified_metainfo(root, record)
|
|
113
|
+
params = verified_seed_params(metainfo, root, libtorrent_module=self.lt)
|
|
114
|
+
handle = self.session.add_torrent(params)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
update_observed_backend(root, state="error", detail=str(exc))
|
|
117
|
+
continue
|
|
118
|
+
self.active[record.publication_id] = ActiveSeed(root, record, handle)
|
|
119
|
+
update_observed_backend(root, state="seeding")
|
|
120
|
+
except ModelBusyError:
|
|
121
|
+
continue
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
self._set_observed(root, "error", str(exc))
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
for publication_id, active in list(self.active.items()):
|
|
127
|
+
try:
|
|
128
|
+
with ModelLock(
|
|
129
|
+
active.root,
|
|
130
|
+
"torrent-reconcile",
|
|
131
|
+
active.record.repo_id,
|
|
132
|
+
active.record.repo_type,
|
|
133
|
+
):
|
|
134
|
+
self.session.remove_torrent(active.handle)
|
|
135
|
+
update_observed_backend(
|
|
136
|
+
active.root,
|
|
137
|
+
state="stopped",
|
|
138
|
+
detail="managed seeder stopped",
|
|
139
|
+
)
|
|
140
|
+
del self.active[publication_id]
|
|
141
|
+
except ModelBusyError:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
def _set_observed(
|
|
145
|
+
self,
|
|
146
|
+
root: Path,
|
|
147
|
+
state: str,
|
|
148
|
+
detail: str = "",
|
|
149
|
+
*,
|
|
150
|
+
lifecycle: str | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
fenced = load_fenced_publication(root)
|
|
153
|
+
if fenced is None:
|
|
154
|
+
return
|
|
155
|
+
record = fenced[0]
|
|
156
|
+
try:
|
|
157
|
+
with ModelLock(root, "torrent-reconcile", record.repo_id, record.repo_type):
|
|
158
|
+
update_observed_backend(root, state=state, detail=detail, lifecycle=lifecycle)
|
|
159
|
+
except ModelBusyError:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def discover_publications(config: Config) -> list[tuple[Path, PublicationRecord]]:
|
|
164
|
+
result = []
|
|
165
|
+
archive_root = Path(config.directory)
|
|
166
|
+
for type_dir in REPO_TYPE_DIRS.values():
|
|
167
|
+
base = archive_root / type_dir
|
|
168
|
+
if not base.exists():
|
|
169
|
+
continue
|
|
170
|
+
for candidate in sorted(base.glob("*/*")):
|
|
171
|
+
if not fence_path(candidate).exists():
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
fenced = load_fenced_publication(candidate)
|
|
175
|
+
except (OSError, ValueError):
|
|
176
|
+
continue
|
|
177
|
+
if fenced is not None:
|
|
178
|
+
result.append((candidate, fenced[0]))
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def read_verified_metainfo(root: Path, record: PublicationRecord) -> bytes:
|
|
183
|
+
relative = Path(record.torrent_path)
|
|
184
|
+
if not relative.parts or relative.is_absolute() or ".." in relative.parts:
|
|
185
|
+
raise TorrentPublicationError("unsafe torrent artifact path in publication record")
|
|
186
|
+
path = root / relative
|
|
187
|
+
try:
|
|
188
|
+
metainfo = path.read_bytes()
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise TorrentPublicationError(f"torrent artifact is unavailable: {path}") from exc
|
|
191
|
+
if hashlib.sha256(metainfo).hexdigest() != record.metainfo_sha256:
|
|
192
|
+
raise TorrentPublicationError(f"torrent artifact digest mismatch: {path}")
|
|
193
|
+
return metainfo
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def serve(
|
|
197
|
+
config: Config,
|
|
198
|
+
*,
|
|
199
|
+
poll_seconds: float = 2.0,
|
|
200
|
+
once: bool = False,
|
|
201
|
+
libtorrent_module=None,
|
|
202
|
+
session=None,
|
|
203
|
+
) -> None:
|
|
204
|
+
if poll_seconds <= 0:
|
|
205
|
+
raise ValueError("poll interval must be positive")
|
|
206
|
+
seeder = ManagedSeeder(libtorrent_module=libtorrent_module, session=session)
|
|
207
|
+
if once:
|
|
208
|
+
seeder.reconcile(config)
|
|
209
|
+
seeder.close()
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
stopped = False
|
|
213
|
+
|
|
214
|
+
def stop(_signum, _frame):
|
|
215
|
+
nonlocal stopped
|
|
216
|
+
stopped = True
|
|
217
|
+
|
|
218
|
+
previous = {}
|
|
219
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
220
|
+
previous[signum] = signal.signal(signum, stop)
|
|
221
|
+
try:
|
|
222
|
+
while not stopped:
|
|
223
|
+
seeder.reconcile(config)
|
|
224
|
+
time.sleep(poll_seconds)
|
|
225
|
+
finally:
|
|
226
|
+
seeder.close()
|
|
227
|
+
for signum, handler in previous.items():
|
|
228
|
+
signal.signal(signum, handler)
|
model_mirror/verify.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .checksums import file_hashes, load_manifest, iter_payload_files, record_is_current
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class RemoteVerifyResult:
|
|
11
|
+
files_checked: int = 0
|
|
12
|
+
hashes_checked: int = 0
|
|
13
|
+
missing: list[str] = field(default_factory=list)
|
|
14
|
+
size_mismatches: list[str] = field(default_factory=list)
|
|
15
|
+
hash_mismatches: list[str] = field(default_factory=list)
|
|
16
|
+
cached_hash_missing: list[str] = field(default_factory=list)
|
|
17
|
+
extras: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def ok(self) -> bool:
|
|
21
|
+
return not (
|
|
22
|
+
self.missing
|
|
23
|
+
or self.size_mismatches
|
|
24
|
+
or self.hash_mismatches
|
|
25
|
+
or self.cached_hash_missing
|
|
26
|
+
or self.extras
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def metadata_path(item) -> str:
|
|
31
|
+
direct = getattr(item, "path", None)
|
|
32
|
+
if direct is not None:
|
|
33
|
+
return direct
|
|
34
|
+
return getattr(item, "rfilename")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def metadata_size(item) -> int | None:
|
|
38
|
+
return getattr(item, "size", None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def metadata_lfs_sha256(item) -> str | None:
|
|
42
|
+
direct = getattr(item, "lfs_sha256", None)
|
|
43
|
+
if direct:
|
|
44
|
+
return direct
|
|
45
|
+
lfs = getattr(item, "lfs", None)
|
|
46
|
+
if lfs is None:
|
|
47
|
+
return None
|
|
48
|
+
return getattr(lfs, "sha256", None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def metadata_blob_id(item) -> str | None:
|
|
52
|
+
return getattr(item, "blob_id", None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_remote(
|
|
56
|
+
root: Path,
|
|
57
|
+
metadata,
|
|
58
|
+
*,
|
|
59
|
+
cached: bool = False,
|
|
60
|
+
from_manifest: bool = False,
|
|
61
|
+
check_hashes: bool = True,
|
|
62
|
+
strict: bool = False,
|
|
63
|
+
) -> RemoteVerifyResult:
|
|
64
|
+
result = RemoteVerifyResult()
|
|
65
|
+
manifest: dict[str, dict] = {}
|
|
66
|
+
if cached or from_manifest:
|
|
67
|
+
manifest = load_manifest(root)
|
|
68
|
+
|
|
69
|
+
expected_paths = set()
|
|
70
|
+
for item in metadata:
|
|
71
|
+
rel = metadata_path(item)
|
|
72
|
+
expected_paths.add(rel)
|
|
73
|
+
path = root / rel
|
|
74
|
+
if not path.exists():
|
|
75
|
+
result.missing.append(rel)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
result.files_checked += 1
|
|
79
|
+
stat = path.stat()
|
|
80
|
+
expected_size = metadata_size(item)
|
|
81
|
+
actual_size = stat.st_size
|
|
82
|
+
if expected_size is not None and actual_size != expected_size:
|
|
83
|
+
result.size_mismatches.append(rel)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if not check_hashes:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
expected_lfs_hash = metadata_lfs_sha256(item)
|
|
90
|
+
if expected_lfs_hash is not None:
|
|
91
|
+
manifest_hash = current_manifest_hash(manifest, rel, stat.st_size, stat.st_mtime_ns, "sha256")
|
|
92
|
+
if manifest_hash is not None:
|
|
93
|
+
actual_hash = manifest_hash
|
|
94
|
+
elif cached:
|
|
95
|
+
result.cached_hash_missing.append(rel)
|
|
96
|
+
continue
|
|
97
|
+
else:
|
|
98
|
+
actual_hash = file_hashes(path).sha256
|
|
99
|
+
result.hashes_checked += 1
|
|
100
|
+
if actual_hash != expected_lfs_hash:
|
|
101
|
+
result.hash_mismatches.append(rel)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
expected_blob_id = metadata_blob_id(item)
|
|
105
|
+
if expected_blob_id is None:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
manifest_hash = current_manifest_hash(manifest, rel, stat.st_size, stat.st_mtime_ns, "git_blob_sha1")
|
|
109
|
+
if manifest_hash is not None:
|
|
110
|
+
actual_hash = manifest_hash
|
|
111
|
+
elif cached:
|
|
112
|
+
result.cached_hash_missing.append(rel)
|
|
113
|
+
continue
|
|
114
|
+
else:
|
|
115
|
+
actual_hash = file_hashes(path).git_blob_sha1
|
|
116
|
+
result.hashes_checked += 1
|
|
117
|
+
if actual_hash != expected_blob_id:
|
|
118
|
+
result.hash_mismatches.append(rel)
|
|
119
|
+
|
|
120
|
+
if strict:
|
|
121
|
+
result.extras = [
|
|
122
|
+
path.relative_to(root).as_posix()
|
|
123
|
+
for path in iter_payload_files(root)
|
|
124
|
+
if path.relative_to(root).as_posix() not in expected_paths
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def current_manifest_hash(
|
|
131
|
+
manifest: dict[str, dict],
|
|
132
|
+
rel: str,
|
|
133
|
+
size: int,
|
|
134
|
+
mtime_ns: int,
|
|
135
|
+
hash_key: str,
|
|
136
|
+
) -> str | None:
|
|
137
|
+
row = manifest.get(rel)
|
|
138
|
+
if not record_is_current(row, size, mtime_ns):
|
|
139
|
+
return None
|
|
140
|
+
value = row.get(hash_key)
|
|
141
|
+
return str(value) if value else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def merge_checksum_result(remote_result, checksum_result) -> None:
|
|
145
|
+
for attr, values in (
|
|
146
|
+
("missing", checksum_result.missing),
|
|
147
|
+
("hash_mismatches", checksum_result.failures),
|
|
148
|
+
("extras", checksum_result.extras),
|
|
149
|
+
):
|
|
150
|
+
existing = getattr(remote_result, attr)
|
|
151
|
+
for value in values:
|
|
152
|
+
if value not in existing:
|
|
153
|
+
existing.append(value)
|