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.
@@ -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)