driverclient 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.
- driverclient/__init__.py +73 -0
- driverclient/__main__.py +4 -0
- driverclient/config.json +36 -0
- driverclient/config.py +98 -0
- driverclient/core/__init__.py +0 -0
- driverclient/core/hardware.py +345 -0
- driverclient/core/http.py +304 -0
- driverclient/events.py +141 -0
- driverclient/main.py +71 -0
- driverclient/ops/__init__.py +15 -0
- driverclient/ops/automate.py +118 -0
- driverclient/ops/capture.py +679 -0
- driverclient/ops/install.py +276 -0
- driverclient/ops/resolve.py +147 -0
- driverclient/ops/scan.py +316 -0
- driverclient/py.typed +0 -0
- driverclient-0.2.0.dist-info/METADATA +179 -0
- driverclient-0.2.0.dist-info/RECORD +19 -0
- driverclient-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/capture.py — Three capture operations for the Driver Server client.
|
|
3
|
+
|
|
4
|
+
capture_all() Dump every package in DriverStore to the repo.
|
|
5
|
+
Server deduplicates by SHA. No resolve needed.
|
|
6
|
+
|
|
7
|
+
capture_missing() Scan + resolve to find HWIDs the repo lacks, then export
|
|
8
|
+
any of those that are already installed locally and submit
|
|
9
|
+
them. No Windows Update.
|
|
10
|
+
|
|
11
|
+
wu_update() Scan + resolve for not-found HWIDs, trigger Windows Update,
|
|
12
|
+
poll incrementally (submit each new batch as it appears),
|
|
13
|
+
stop after wu_wait_minutes or 2 empty polls.
|
|
14
|
+
|
|
15
|
+
Upload pipeline (all three operations):
|
|
16
|
+
export pool (parallel_exports workers) — pnputil /export-driver + pack
|
|
17
|
+
↓ queue
|
|
18
|
+
upload pool (parallel_uploads workers) — POST /submit (8 workers default)
|
|
19
|
+
|
|
20
|
+
Public API:
|
|
21
|
+
capture_all() -> CaptureResult
|
|
22
|
+
capture_missing(force=False) -> CaptureResult
|
|
23
|
+
wu_update(force=False) -> CaptureResult
|
|
24
|
+
"""
|
|
25
|
+
import queue
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import functools
|
|
37
|
+
|
|
38
|
+
from driverclient import events
|
|
39
|
+
from driverclient.config import get
|
|
40
|
+
from driverclient.core.hardware import compute_fingerprint, get_model_profile
|
|
41
|
+
from driverclient.core.http import BadRequestError, http_post_retry, http_upload_package, pack_driver_archive
|
|
42
|
+
from driverclient.ops.resolve import ResolveResult, resolve
|
|
43
|
+
from driverclient.ops.scan import Device, ScanResult, scan
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _post_wu_session(cfg: dict, wu_start, hwids_tried: list, captured: int) -> None:
|
|
47
|
+
"""Report a Windows Update session so server WU analytics has data.
|
|
48
|
+
|
|
49
|
+
Best-effort — never blocks or raises into the capture flow.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
duration = max(0, int((datetime.now(timezone.utc) - wu_start).total_seconds()))
|
|
53
|
+
http_post_retry(
|
|
54
|
+
f"{cfg['local_repo_url']}/submit/wu-session",
|
|
55
|
+
{
|
|
56
|
+
"machine_id": _machine_meta().get("machine_id", ""),
|
|
57
|
+
"ran_at": wu_start.isoformat(),
|
|
58
|
+
"hwids_tried": hwids_tried,
|
|
59
|
+
"wu_installed_count": captured,
|
|
60
|
+
"captured_count": captured,
|
|
61
|
+
"duration_seconds": duration,
|
|
62
|
+
},
|
|
63
|
+
timeout=cfg.get("timeout_short", 30),
|
|
64
|
+
)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
events.warn("windows_update", f" [warn] wu-session report failed: {e}",
|
|
67
|
+
error=str(e))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@functools.lru_cache(maxsize=1)
|
|
71
|
+
def _machine_meta() -> dict:
|
|
72
|
+
"""Detect this machine's vendor/model/serial/chassis + fingerprint once.
|
|
73
|
+
|
|
74
|
+
Threaded through every capture upload so the server can populate
|
|
75
|
+
submitter_* fields — otherwise the inventory shows model as 'unknown'.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
prof = get_model_profile()
|
|
79
|
+
except Exception:
|
|
80
|
+
prof = {}
|
|
81
|
+
try:
|
|
82
|
+
machine_id = compute_fingerprint()
|
|
83
|
+
except Exception:
|
|
84
|
+
machine_id = ""
|
|
85
|
+
return {
|
|
86
|
+
"vendor": prof.get("vendor", ""),
|
|
87
|
+
"model": prof.get("model", ""),
|
|
88
|
+
"friendly_model": prof.get("friendly_model", ""),
|
|
89
|
+
"serial": prof.get("serial", ""),
|
|
90
|
+
"chassis": prof.get("chassis_type", ""),
|
|
91
|
+
"machine_id": machine_id,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_WINDOWS_INF = Path("C:\\Windows\\INF")
|
|
95
|
+
|
|
96
|
+
_PNPUTIL_KEYS: dict[str, str] = {
|
|
97
|
+
"published name": "published_name",
|
|
98
|
+
"original name": "original_name",
|
|
99
|
+
"provider name": "provider_name",
|
|
100
|
+
"class name": "class_name",
|
|
101
|
+
"driver version": "driver_version",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CaptureResult:
|
|
107
|
+
submitted: int = 0
|
|
108
|
+
skipped: int = 0
|
|
109
|
+
failed: int = 0
|
|
110
|
+
still_missing: int = 0
|
|
111
|
+
wu_triggered: bool = False
|
|
112
|
+
|
|
113
|
+
def merge(self, other: "CaptureResult") -> None:
|
|
114
|
+
self.submitted += other.submitted
|
|
115
|
+
self.skipped += other.skipped
|
|
116
|
+
self.failed += other.failed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ══ Public operations ══════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
def capture_all() -> CaptureResult:
|
|
122
|
+
"""Export every DriverStore package and submit. Server deduplicates."""
|
|
123
|
+
cfg = get()
|
|
124
|
+
events.start("capture", "[capture-all] Enumerating DriverStore packages…")
|
|
125
|
+
packages = _enumerate_all_packages(cfg)
|
|
126
|
+
if not packages:
|
|
127
|
+
events.done("capture", "[capture-all] No packages found", total=0)
|
|
128
|
+
return CaptureResult()
|
|
129
|
+
events.progress("capture",
|
|
130
|
+
f"[capture-all] {len(packages)} package(s) — exporting + submitting…",
|
|
131
|
+
total=len(packages))
|
|
132
|
+
result = _export_and_submit(packages, cfg)
|
|
133
|
+
events.done("capture",
|
|
134
|
+
f"[capture-all] Done — {result.submitted} submitted "
|
|
135
|
+
f"{result.skipped} skipped {result.failed} failed",
|
|
136
|
+
total=len(packages), submitted=result.submitted,
|
|
137
|
+
skipped=result.skipped, failed=result.failed)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def capture_missing(force: bool = False) -> CaptureResult:
|
|
142
|
+
"""
|
|
143
|
+
Find HWIDs the repo has no package for, export + submit any that are
|
|
144
|
+
already installed locally. No Windows Update.
|
|
145
|
+
"""
|
|
146
|
+
cfg = get()
|
|
147
|
+
scan_result = scan(force=force)
|
|
148
|
+
res_result = resolve(force=force)
|
|
149
|
+
|
|
150
|
+
if not res_result.has_missing:
|
|
151
|
+
events.done("capture",
|
|
152
|
+
"[capture-missing] Repo is complete for this machine — nothing to submit",
|
|
153
|
+
total=0)
|
|
154
|
+
return CaptureResult()
|
|
155
|
+
|
|
156
|
+
not_found_set = set(res_result.not_found)
|
|
157
|
+
events.progress("capture",
|
|
158
|
+
f"[capture-missing] {len(not_found_set)} HWID(s) missing from repo",
|
|
159
|
+
total=len(not_found_set))
|
|
160
|
+
|
|
161
|
+
to_export = _find_installed_packages_for_hwids(not_found_set, scan_result, cfg)
|
|
162
|
+
if not to_export:
|
|
163
|
+
events.done("capture",
|
|
164
|
+
"[capture-missing] None of the missing HWIDs have locally-installed drivers",
|
|
165
|
+
total=0, still_missing=len(not_found_set))
|
|
166
|
+
return CaptureResult(still_missing=len(not_found_set))
|
|
167
|
+
|
|
168
|
+
events.progress("capture",
|
|
169
|
+
f"[capture-missing] Exporting {len(to_export)} locally-installed package(s)…",
|
|
170
|
+
total=len(to_export))
|
|
171
|
+
result = _export_and_submit(to_export, cfg)
|
|
172
|
+
result.still_missing = max(0, len(not_found_set) - result.submitted)
|
|
173
|
+
events.done("capture",
|
|
174
|
+
f"[capture-missing] Done — {result.submitted} submitted "
|
|
175
|
+
f"{result.skipped} skipped {result.failed} failed",
|
|
176
|
+
total=len(to_export), submitted=result.submitted,
|
|
177
|
+
skipped=result.skipped, failed=result.failed,
|
|
178
|
+
still_missing=result.still_missing)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def wu_update(force: bool = False) -> CaptureResult:
|
|
183
|
+
"""
|
|
184
|
+
Scan → resolve → filter not-found HWIDs to those with no local driver
|
|
185
|
+
and not in wu_ignore_classes → trigger WU → poll → submit new packages.
|
|
186
|
+
|
|
187
|
+
Gate logic (applied before WU is triggered):
|
|
188
|
+
has_driver=True → skip (driver on machine; capture_missing handles repo upload)
|
|
189
|
+
class in ignore → skip (wu_ignore_classes config)
|
|
190
|
+
not in scan → include (unknown device — safer to attempt)
|
|
191
|
+
"""
|
|
192
|
+
cfg = get()
|
|
193
|
+
scan_result = scan(force=force)
|
|
194
|
+
res_result = resolve(force=force)
|
|
195
|
+
|
|
196
|
+
if not res_result.has_missing:
|
|
197
|
+
events.done("windows_update", "[wu-update] No missing HWIDs — nothing to trigger WU for",
|
|
198
|
+
total=0)
|
|
199
|
+
return CaptureResult()
|
|
200
|
+
|
|
201
|
+
wu_hwids = _filter_wu_hwids(res_result.not_found, scan_result, cfg)
|
|
202
|
+
skipped = len(res_result.not_found) - len(wu_hwids)
|
|
203
|
+
|
|
204
|
+
if not wu_hwids:
|
|
205
|
+
events.done("windows_update",
|
|
206
|
+
"[wu-update] All missing HWIDs have local drivers or are ignored — skipping WU",
|
|
207
|
+
total=0, still_missing=len(res_result.not_found))
|
|
208
|
+
return CaptureResult(still_missing=len(res_result.not_found))
|
|
209
|
+
|
|
210
|
+
if skipped:
|
|
211
|
+
events.start("windows_update",
|
|
212
|
+
f"[wu-update] {len(wu_hwids)} HWID(s) need WU "
|
|
213
|
+
f"({skipped} skipped — has local driver or ignored class)",
|
|
214
|
+
total=len(wu_hwids), skipped=skipped)
|
|
215
|
+
else:
|
|
216
|
+
events.start("windows_update",
|
|
217
|
+
f"[wu-update] {len(wu_hwids)} HWID(s) not in repo — triggering Windows Update…",
|
|
218
|
+
total=len(wu_hwids))
|
|
219
|
+
|
|
220
|
+
if not _trigger_wu():
|
|
221
|
+
return CaptureResult(still_missing=len(wu_hwids))
|
|
222
|
+
|
|
223
|
+
wu_start = datetime.now(timezone.utc)
|
|
224
|
+
total = _poll_and_submit_incremental(wu_start, cfg)
|
|
225
|
+
total.wu_triggered = True
|
|
226
|
+
total.still_missing = max(0, len(wu_hwids) - total.submitted)
|
|
227
|
+
_post_wu_session(cfg, wu_start, list(wu_hwids), total.submitted)
|
|
228
|
+
events.done("windows_update",
|
|
229
|
+
f"[wu-update] Done — {total.submitted} captured "
|
|
230
|
+
f"{total.still_missing} still missing",
|
|
231
|
+
submitted=total.submitted, still_missing=total.still_missing)
|
|
232
|
+
return total
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def wu_full() -> CaptureResult:
|
|
236
|
+
"""
|
|
237
|
+
Trigger a full Windows Update scan with no HWID filtering.
|
|
238
|
+
Captures ALL packages WU installs and submits them to the repo.
|
|
239
|
+
Use to harvest WU drivers onto the repo regardless of current repo state.
|
|
240
|
+
No scan or resolve needed.
|
|
241
|
+
"""
|
|
242
|
+
cfg = get()
|
|
243
|
+
events.start("windows_update",
|
|
244
|
+
"[wu-full] Triggering full Windows Update scan (no HWID filtering)…")
|
|
245
|
+
|
|
246
|
+
if not _trigger_wu():
|
|
247
|
+
return CaptureResult()
|
|
248
|
+
|
|
249
|
+
wu_start = datetime.now(timezone.utc)
|
|
250
|
+
total = _poll_and_submit_incremental(wu_start, cfg)
|
|
251
|
+
total.wu_triggered = True
|
|
252
|
+
_post_wu_session(cfg, wu_start, [], total.submitted)
|
|
253
|
+
events.done("windows_update",
|
|
254
|
+
f"[wu-full] Done — {total.submitted} submitted "
|
|
255
|
+
f"{total.skipped} skipped {total.failed} failed",
|
|
256
|
+
submitted=total.submitted, skipped=total.skipped, failed=total.failed)
|
|
257
|
+
return total
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ══ WU HWID filtering ═════════════════════════════════════════════════════════
|
|
261
|
+
|
|
262
|
+
def _filter_wu_hwids(
|
|
263
|
+
not_found: list[str],
|
|
264
|
+
scan_result: ScanResult,
|
|
265
|
+
cfg: dict,
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""
|
|
268
|
+
Return only the not_found HWIDs that genuinely need Windows Update:
|
|
269
|
+
- Device has no driver installed locally (has_driver=False)
|
|
270
|
+
- Device class is not in wu_ignore_classes config
|
|
271
|
+
- If HWID is not in scan at all → include (unknown device, safer to try)
|
|
272
|
+
"""
|
|
273
|
+
ignore_classes: set[str] = {
|
|
274
|
+
c.lower() for c in cfg.get("wu_ignore_classes", [])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
hwid_to_device: dict[str, Device] = {}
|
|
278
|
+
for dev in scan_result.devices:
|
|
279
|
+
for hwid in dev.hwids:
|
|
280
|
+
hwid_to_device[hwid.upper()] = dev
|
|
281
|
+
|
|
282
|
+
result = []
|
|
283
|
+
for hwid in not_found:
|
|
284
|
+
dev = hwid_to_device.get(hwid.upper())
|
|
285
|
+
if dev is None:
|
|
286
|
+
result.append(hwid)
|
|
287
|
+
continue
|
|
288
|
+
if dev.has_driver:
|
|
289
|
+
continue
|
|
290
|
+
if ignore_classes and dev.class_name.lower() in ignore_classes:
|
|
291
|
+
continue
|
|
292
|
+
result.append(hwid)
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ══ Package enumeration ════════════════════════════════════════════════════════
|
|
298
|
+
|
|
299
|
+
def _enumerate_all_packages(cfg: dict) -> list[dict]:
|
|
300
|
+
try:
|
|
301
|
+
r = subprocess.run(
|
|
302
|
+
["pnputil", "/enum-drivers"],
|
|
303
|
+
capture_output=True, text=True,
|
|
304
|
+
timeout=cfg.get("timeout_short", 30),
|
|
305
|
+
)
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
events.error("capture", "[capture] pnputil not found — must run on Windows",
|
|
308
|
+
error="pnputil_not_found")
|
|
309
|
+
return []
|
|
310
|
+
except Exception as e:
|
|
311
|
+
events.error("capture", f"[capture] pnputil failed: {e}", error=str(e))
|
|
312
|
+
return []
|
|
313
|
+
return _parse_enum_drivers(r.stdout)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _parse_enum_drivers(output: str) -> list[dict]:
|
|
317
|
+
packages: list[dict] = []
|
|
318
|
+
current: dict = {}
|
|
319
|
+
for line in output.splitlines():
|
|
320
|
+
stripped = line.strip()
|
|
321
|
+
if not stripped:
|
|
322
|
+
if current.get("published_name"):
|
|
323
|
+
packages.append(dict(current))
|
|
324
|
+
current = {}
|
|
325
|
+
elif ":" in stripped:
|
|
326
|
+
raw_key, _, val = stripped.partition(":")
|
|
327
|
+
mapped = _PNPUTIL_KEYS.get(raw_key.strip().lower())
|
|
328
|
+
if mapped:
|
|
329
|
+
current[mapped] = val.strip()
|
|
330
|
+
if current.get("published_name"):
|
|
331
|
+
packages.append(current)
|
|
332
|
+
return packages
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
|
|
336
|
+
"""Return packages whose C:\\Windows\\INF mtime >= cutoff."""
|
|
337
|
+
result = []
|
|
338
|
+
for pkg in _enumerate_all_packages(cfg):
|
|
339
|
+
inf_path = _WINDOWS_INF / pkg["published_name"]
|
|
340
|
+
if not inf_path.exists():
|
|
341
|
+
continue
|
|
342
|
+
mtime = datetime.fromtimestamp(inf_path.stat().st_mtime, tz=timezone.utc)
|
|
343
|
+
if mtime >= cutoff:
|
|
344
|
+
result.append(pkg)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _find_installed_packages_for_hwids(
|
|
349
|
+
not_found_hwids: set[str],
|
|
350
|
+
scan_result: ScanResult,
|
|
351
|
+
cfg: dict,
|
|
352
|
+
) -> list[dict]:
|
|
353
|
+
locally_installed = {
|
|
354
|
+
hwid for hwid in not_found_hwids
|
|
355
|
+
if hwid in scan_result.installed_versions
|
|
356
|
+
}
|
|
357
|
+
if not locally_installed:
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
hwid_to_published = _map_hwids_to_published(locally_installed, cfg)
|
|
361
|
+
all_packages = {
|
|
362
|
+
p["published_name"]: p
|
|
363
|
+
for p in _enumerate_all_packages(cfg)
|
|
364
|
+
if p.get("published_name")
|
|
365
|
+
}
|
|
366
|
+
return [
|
|
367
|
+
all_packages[pub]
|
|
368
|
+
for pub in set(hwid_to_published.values())
|
|
369
|
+
if pub in all_packages
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _map_hwids_to_published(hwids: set[str], cfg: dict) -> dict[str, str]:
|
|
374
|
+
mapping: dict[str, str] = {}
|
|
375
|
+
try:
|
|
376
|
+
r = subprocess.run(
|
|
377
|
+
["pnputil", "/enum-devices", "/drivers"],
|
|
378
|
+
capture_output=True, text=True,
|
|
379
|
+
timeout=cfg.get("timeout_short", 30),
|
|
380
|
+
)
|
|
381
|
+
except Exception:
|
|
382
|
+
return mapping
|
|
383
|
+
|
|
384
|
+
state: dict = {"published": "", "hwids": [], "in_hwids": False}
|
|
385
|
+
for line in r.stdout.splitlines():
|
|
386
|
+
stripped = line.strip()
|
|
387
|
+
if not stripped:
|
|
388
|
+
_commit_hwid_block(state, hwids, mapping)
|
|
389
|
+
else:
|
|
390
|
+
_parse_hwid_line(line, stripped, state)
|
|
391
|
+
|
|
392
|
+
return mapping
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _commit_hwid_block(state: dict, hwids: set[str], mapping: dict) -> None:
|
|
396
|
+
"""Flush accumulated HWIDs for the current device into mapping."""
|
|
397
|
+
if state["published"]:
|
|
398
|
+
for h in state["hwids"]:
|
|
399
|
+
if h in hwids:
|
|
400
|
+
mapping[h] = state["published"]
|
|
401
|
+
state["published"] = ""
|
|
402
|
+
state["hwids"] = []
|
|
403
|
+
state["in_hwids"] = False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _parse_hwid_line(line: str, stripped: str, state: dict) -> None:
|
|
407
|
+
"""Update parser state from one non-empty pnputil /enum-devices line."""
|
|
408
|
+
low = stripped.lower()
|
|
409
|
+
if low.startswith("published name") and ":" in stripped:
|
|
410
|
+
state["published"] = stripped.split(":", 1)[1].strip()
|
|
411
|
+
state["in_hwids"] = False
|
|
412
|
+
elif low.startswith(("hardware ids", "compatible ids")):
|
|
413
|
+
state["in_hwids"] = True
|
|
414
|
+
elif state["in_hwids"] and "\\" in stripped and not stripped.endswith(":"):
|
|
415
|
+
state["hwids"].append(stripped.upper())
|
|
416
|
+
elif ":" in stripped and not line.startswith(" "):
|
|
417
|
+
state["in_hwids"] = False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ══ Windows Update ═════════════════════════════════════════════════════════════
|
|
421
|
+
|
|
422
|
+
def _trigger_wu() -> bool:
|
|
423
|
+
for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
|
|
424
|
+
try:
|
|
425
|
+
subprocess.run(cmd, capture_output=True, timeout=30)
|
|
426
|
+
events.ok("windows_update", "[wu-update] Windows Update scan triggered")
|
|
427
|
+
return True
|
|
428
|
+
except Exception:
|
|
429
|
+
continue
|
|
430
|
+
events.error("windows_update", "[wu-update] Could not trigger Windows Update",
|
|
431
|
+
error="trigger_failed")
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _poll_and_submit_incremental(wu_start: datetime, cfg: dict) -> CaptureResult:
|
|
436
|
+
"""
|
|
437
|
+
Poll every wu_poll_interval_seconds for new DriverStore packages.
|
|
438
|
+
Submit each new batch immediately. Stop after wu_wait_minutes total
|
|
439
|
+
OR after 2 consecutive polls with no new packages.
|
|
440
|
+
"""
|
|
441
|
+
wait_min = cfg.get("wu_wait_minutes", 10)
|
|
442
|
+
poll_secs = cfg.get("wu_poll_interval_seconds", 30)
|
|
443
|
+
deadline = wu_start + timedelta(minutes=wait_min)
|
|
444
|
+
seen:set[str] = set()
|
|
445
|
+
total = CaptureResult()
|
|
446
|
+
empty_polls = 0
|
|
447
|
+
|
|
448
|
+
events.progress("windows_update",
|
|
449
|
+
f"[wu-update] Polling every {poll_secs}s, max {wait_min} min…")
|
|
450
|
+
|
|
451
|
+
while datetime.now(timezone.utc) < deadline:
|
|
452
|
+
all_new = _enumerate_packages_since(wu_start, cfg)
|
|
453
|
+
new_batch = [
|
|
454
|
+
p for p in all_new
|
|
455
|
+
if p.get("published_name", "") not in seen
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
if new_batch:
|
|
459
|
+
empty_polls = 0
|
|
460
|
+
for p in new_batch:
|
|
461
|
+
seen.add(p.get("published_name", ""))
|
|
462
|
+
events.progress("windows_update",
|
|
463
|
+
f"[wu-update] {len(new_batch)} new package(s) — submitting…",
|
|
464
|
+
current=len(new_batch))
|
|
465
|
+
total.merge(_export_and_submit(new_batch, cfg))
|
|
466
|
+
else:
|
|
467
|
+
empty_polls += 1
|
|
468
|
+
if empty_polls >= 2:
|
|
469
|
+
events.progress("windows_update",
|
|
470
|
+
"[wu-update] 2 consecutive empty polls — stopping")
|
|
471
|
+
break
|
|
472
|
+
remaining = int((deadline - datetime.now(timezone.utc)).total_seconds())
|
|
473
|
+
events.progress("windows_update",
|
|
474
|
+
f"[wu-update] no new drivers — {remaining}s remaining…")
|
|
475
|
+
|
|
476
|
+
time.sleep(poll_secs)
|
|
477
|
+
|
|
478
|
+
return total
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ══ Export pool → Upload pool pipeline ════════════════════════════════════════
|
|
482
|
+
|
|
483
|
+
def _export_and_submit(packages: list[dict], cfg: dict) -> CaptureResult:
|
|
484
|
+
"""
|
|
485
|
+
Two-stage pipeline:
|
|
486
|
+
export pool (parallel_exports) → pnputil /export-driver + pack
|
|
487
|
+
upload pool (parallel_uploads) → POST /submit
|
|
488
|
+
|
|
489
|
+
Deduplicates by published_name before starting.
|
|
490
|
+
"""
|
|
491
|
+
parallel_exports = cfg.get("parallel_exports", 4)
|
|
492
|
+
parallel_uploads = cfg.get("parallel_uploads", 8)
|
|
493
|
+
|
|
494
|
+
# Dedup by published_name
|
|
495
|
+
seen: set[str] = set()
|
|
496
|
+
unique: list[dict] = []
|
|
497
|
+
for pkg in packages:
|
|
498
|
+
name = pkg.get("published_name", "")
|
|
499
|
+
if name and name not in seen:
|
|
500
|
+
seen.add(name)
|
|
501
|
+
unique.append(pkg)
|
|
502
|
+
|
|
503
|
+
if not unique:
|
|
504
|
+
return CaptureResult()
|
|
505
|
+
|
|
506
|
+
counts = {"submitted": 0, "skipped": 0, "failed": 0}
|
|
507
|
+
lock = threading.Lock()
|
|
508
|
+
upload_q: queue.Queue = queue.Queue(maxsize=parallel_uploads * 2)
|
|
509
|
+
|
|
510
|
+
# Start upload workers
|
|
511
|
+
upload_threads = [
|
|
512
|
+
threading.Thread(target=_upload_worker, args=(upload_q, counts, lock, cfg),
|
|
513
|
+
daemon=True)
|
|
514
|
+
for _ in range(parallel_uploads)
|
|
515
|
+
]
|
|
516
|
+
for t in upload_threads:
|
|
517
|
+
t.start()
|
|
518
|
+
|
|
519
|
+
# Run export pool — each worker exports + packs then puts payload into upload_q
|
|
520
|
+
with ThreadPoolExecutor(max_workers=parallel_exports) as ex:
|
|
521
|
+
futures = [ex.submit(_export_one, pkg, upload_q, counts, lock, cfg)
|
|
522
|
+
for pkg in unique]
|
|
523
|
+
for f in futures:
|
|
524
|
+
try:
|
|
525
|
+
f.result()
|
|
526
|
+
except Exception as e:
|
|
527
|
+
events.error("dump", f" [error] export: {e}", error=str(e))
|
|
528
|
+
with lock:
|
|
529
|
+
counts["failed"] += 1
|
|
530
|
+
|
|
531
|
+
# Signal upload workers to stop
|
|
532
|
+
for _ in upload_threads:
|
|
533
|
+
upload_q.put(None)
|
|
534
|
+
upload_q.join()
|
|
535
|
+
|
|
536
|
+
return CaptureResult(
|
|
537
|
+
submitted=counts["submitted"],
|
|
538
|
+
skipped=counts["skipped"],
|
|
539
|
+
failed=counts["failed"],
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _export_one(pkg: dict, upload_q: queue.Queue,
|
|
544
|
+
counts: dict, lock: threading.Lock, cfg: dict) -> None:
|
|
545
|
+
"""Thin wrapper: export + pack via _do_export, then enqueue for upload."""
|
|
546
|
+
published = pkg["published_name"]
|
|
547
|
+
class_name = pkg.get("class_name", "").lower()
|
|
548
|
+
events.progress("dump",
|
|
549
|
+
f" [export] {published} ({pkg.get('provider_name', '?')} {class_name})",
|
|
550
|
+
published_name=published, provider=pkg.get("provider_name", "?"),
|
|
551
|
+
class_name=class_name)
|
|
552
|
+
|
|
553
|
+
payload = _do_export(pkg, cfg)
|
|
554
|
+
if payload is None:
|
|
555
|
+
with lock:
|
|
556
|
+
counts["failed"] += 1
|
|
557
|
+
return
|
|
558
|
+
upload_q.put(payload)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _do_export(pkg: dict, cfg: dict) -> dict | None:
|
|
562
|
+
"""
|
|
563
|
+
Run pnputil /export-driver, pack the files, return an upload payload dict.
|
|
564
|
+
TemporaryDirectory cleans up after packing completes — no double-export.
|
|
565
|
+
Returns None on any error.
|
|
566
|
+
"""
|
|
567
|
+
published = pkg["published_name"]
|
|
568
|
+
original = pkg.get("original_name", published)
|
|
569
|
+
class_name = pkg.get("class_name", "").lower()
|
|
570
|
+
exts = _pick_extensions(class_name, cfg)
|
|
571
|
+
timeout = cfg.get("timeout_long", 120)
|
|
572
|
+
|
|
573
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
574
|
+
dest = Path(tmp) / "pkg"
|
|
575
|
+
dest.mkdir()
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
r = subprocess.run(
|
|
579
|
+
["pnputil", "/export-driver", published, str(dest)],
|
|
580
|
+
capture_output=True, text=True, timeout=timeout,
|
|
581
|
+
)
|
|
582
|
+
except subprocess.TimeoutExpired:
|
|
583
|
+
events.warn("dump", f" [warn] export timed out: {published}",
|
|
584
|
+
published_name=published, error="timeout")
|
|
585
|
+
return None
|
|
586
|
+
except Exception as e:
|
|
587
|
+
events.warn("dump", f" [warn] export error {published}: {e}",
|
|
588
|
+
published_name=published, error=str(e))
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
if r.returncode != 0:
|
|
592
|
+
events.warn("dump", f" [warn] export failed {published}: {r.stderr[:120]}",
|
|
593
|
+
published_name=published, error=r.stderr[:120])
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
archive_bytes, archive_name, encoding = pack_driver_archive(
|
|
598
|
+
dest, Path(original).stem, exts
|
|
599
|
+
)
|
|
600
|
+
except Exception as e:
|
|
601
|
+
events.warn("dump", f" [warn] pack error {published}: {e}",
|
|
602
|
+
published_name=published, error=str(e))
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
"published": published,
|
|
607
|
+
"original": original,
|
|
608
|
+
"provider": pkg.get("provider_name", "Unknown"),
|
|
609
|
+
"class_name": class_name,
|
|
610
|
+
"driver_version": pkg.get("driver_version", ""),
|
|
611
|
+
"archive_bytes": archive_bytes,
|
|
612
|
+
"archive_name": archive_name,
|
|
613
|
+
"encoding": encoding,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _upload_worker(upload_q: queue.Queue, counts: dict,
|
|
618
|
+
lock: threading.Lock, cfg: dict) -> None:
|
|
619
|
+
"""Upload worker: pulls payloads from queue and POSTs to /submit."""
|
|
620
|
+
repo_url = cfg["local_repo_url"]
|
|
621
|
+
node_key = cfg.get("node_key", "")
|
|
622
|
+
timeout = cfg.get("timeout_medium", 60)
|
|
623
|
+
|
|
624
|
+
while True:
|
|
625
|
+
item = upload_q.get()
|
|
626
|
+
if item is None:
|
|
627
|
+
upload_q.task_done()
|
|
628
|
+
return
|
|
629
|
+
try:
|
|
630
|
+
resp = http_upload_package(
|
|
631
|
+
url=f"{repo_url}/submit/upload",
|
|
632
|
+
node_key=node_key,
|
|
633
|
+
metadata={
|
|
634
|
+
"published_name": item["published"],
|
|
635
|
+
"original_name": item["original"],
|
|
636
|
+
"provider": item["provider"],
|
|
637
|
+
"driver_version": item["driver_version"],
|
|
638
|
+
"class_name": item["class_name"],
|
|
639
|
+
"source_type": "captured_live",
|
|
640
|
+
**_machine_meta(),
|
|
641
|
+
},
|
|
642
|
+
archive_bytes=item["archive_bytes"],
|
|
643
|
+
archive_name=item["archive_name"],
|
|
644
|
+
content_encoding=item["encoding"],
|
|
645
|
+
timeout=timeout,
|
|
646
|
+
)
|
|
647
|
+
# The client is done the instant the local repo acks. It does NOT
|
|
648
|
+
# interpret upstream queue/scan state — "accepted/queued/buffered"
|
|
649
|
+
# means handed off successfully; only explicit dedup statuses skip.
|
|
650
|
+
status = resp.get("status")
|
|
651
|
+
if status in ("already_approved", "already_present", "already_queued"):
|
|
652
|
+
key = "skipped"
|
|
653
|
+
else: # submitted / accepted / queued / buffered → handed off
|
|
654
|
+
key = "submitted"
|
|
655
|
+
with lock:
|
|
656
|
+
counts[key] += 1
|
|
657
|
+
except BadRequestError as e:
|
|
658
|
+
events.warn("upload",
|
|
659
|
+
f" [skip] {item.get('published', '?')} rejected by local_repo: {e}",
|
|
660
|
+
published_name=item.get("published", "?"), error=str(e))
|
|
661
|
+
with lock:
|
|
662
|
+
counts["failed"] += 1
|
|
663
|
+
except Exception as e:
|
|
664
|
+
events.error("upload", f" [error] upload {item.get('published', '?')}: {e}",
|
|
665
|
+
published_name=item.get("published", "?"), error=str(e))
|
|
666
|
+
with lock:
|
|
667
|
+
counts["failed"] += 1
|
|
668
|
+
finally:
|
|
669
|
+
upload_q.task_done()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
# ══ Extension selection ════════════════════════════════════════════════════════
|
|
673
|
+
|
|
674
|
+
def _pick_extensions(class_name: str, cfg: dict) -> list[str]:
|
|
675
|
+
overrides: dict[str, list[str]] = cfg.get("dump_extensions_overrides", {})
|
|
676
|
+
for key, exts in overrides.items():
|
|
677
|
+
if key.lower() in class_name:
|
|
678
|
+
return exts
|
|
679
|
+
return cfg.get("dump_extensions", [".inf", ".sys", ".cat"])
|