Skip to content

Commit 00a3e53

Browse files
committed
feat(fleet): write the queued images to every drive that is plugged in
Arm via the queue block toggle with a typed ARM confirmation. A session lives only for the current run, expires after an hour idle, stamps each flashed drive by serial so re-inserted sticks are skipped, and requires every queued image to fit the drive's reported capacity. A failed flash disarms the session with an error instead of silently retrying.
1 parent 1ebb316 commit 00a3e53

4 files changed

Lines changed: 781 additions & 16 deletions

File tree

core/fleet.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Fleet flashing: write the queued images to every drive that is plugged in.
2+
3+
A fleet session is never persisted: arming is an explicit, per-run action
4+
that survives only while the app is open and expires after an hour without
5+
any activity, so an unattended machine cannot silently keep flashing
6+
drives forever.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
import time
13+
from dataclasses import dataclass, field
14+
from typing import Any
15+
16+
IDLE_EXPIRY_SECONDS = 60 * 60
17+
BYTES_PER_GB = 1_000_000_000
18+
19+
20+
@dataclass
21+
class FleetSession:
22+
"""One armed session: the images to write and what has been done."""
23+
24+
images: list[str]
25+
arm_ts: float = field(default_factory=time.monotonic)
26+
last_activity: float = field(default_factory=time.monotonic)
27+
done_serials: set[str] = field(default_factory=set)
28+
done_count: int = 0
29+
failed_count: int = 0
30+
31+
def image_sizes(self) -> dict[str, int]:
32+
return {
33+
path: os.path.getsize(path)
34+
for path in self.images
35+
if os.path.isfile(path)
36+
}
37+
38+
def fits_on_drive(self, image: str, drive: dict[str, Any]) -> bool:
39+
"""The drive's reported capacity must hold the whole image.
40+
41+
The picker reports sizes in decimal gigabytes (size_gb * 1e9),
42+
so fleet applies the same convention for the capacity check.
43+
"""
44+
size = self.image_sizes().get(image)
45+
if size is None:
46+
return False
47+
capacity = drive.get("size_gb")
48+
if not isinstance(capacity, (int, float)):
49+
return False
50+
return size <= capacity * BYTES_PER_GB
51+
52+
def mark_flashed(self, drive: dict[str, Any]) -> None:
53+
fp = drive_fingerprint(drive)
54+
if fp is not None:
55+
self.done_serials.add(fp)
56+
self.done_count += 1
57+
self.last_activity = time.monotonic()
58+
59+
def mark_failed(self) -> None:
60+
self.failed_count += 1
61+
self.last_activity = time.monotonic()
62+
63+
def expired(self, now: float | None = None) -> bool:
64+
"""Idle expiry: armed but nothing happened for an hour."""
65+
return (now if now is not None else time.monotonic()) - self.last_activity > IDLE_EXPIRY_SECONDS
66+
67+
68+
def drive_fingerprint(drive: dict[str, Any]) -> str | None:
69+
"""Stable per-stick identity for a session.
70+
71+
The serial is preferred; physical paths are stable enough for a
72+
session when the stick reports no serial at all.
73+
"""
74+
serial = drive.get("serial")
75+
if serial:
76+
return str(serial)
77+
path = drive.get("physical_path")
78+
return str(path) if path else None
79+
80+
81+
def pick_candidate(
82+
drives: list[dict[str, Any]],
83+
session: FleetSession,
84+
now: float | None = None,
85+
) -> dict[str, Any] | None:
86+
"""First drive that has not been flashed yet and fits every image."""
87+
if session.expired(now):
88+
return None
89+
sizes = session.image_sizes()
90+
if not sizes or len(sizes) != len(session.images):
91+
return None
92+
for drive in drives:
93+
fp = drive_fingerprint(drive)
94+
if fp is not None and fp in session.done_serials:
95+
continue
96+
if all(
97+
session.fits_on_drive(image, drive) for image in session.images
98+
):
99+
return drive
100+
return None

tests/test_fleet.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Fleet-mode policy tests: capacity gating, per-stick tracking, expiry.
2+
3+
No real drives and no Qt are involved; every behaviour here is pure
4+
policy logic from core.fleet.
5+
"""
6+
7+
from core import fleet
8+
9+
10+
def _drive(serial="SN123", path=r"\\.\PHYSICALDRIVE1", size_gb=32):
11+
return {"serial": serial, "physical_path": path, "size_gb": size_gb}
12+
13+
14+
def _image(tmp_path, name="ubuntu.iso", size=1_000):
15+
p = tmp_path / name
16+
p.write_bytes(b"\x00" * size)
17+
return str(p)
18+
19+
20+
class TestDriveFingerprint:
21+
def test_serial_preferred(self):
22+
assert fleet.drive_fingerprint(_drive()) == "SN123"
23+
24+
def test_path_fallback_without_serial(self):
25+
assert (
26+
fleet.drive_fingerprint(_drive(serial="")) == r"\\.\PHYSICALDRIVE1"
27+
)
28+
29+
def test_none_when_neither_known(self):
30+
assert fleet.drive_fingerprint({"size_gb": 8}) is None
31+
32+
33+
class TestCapacityGate:
34+
def test_image_fits_with_room(self, tmp_path):
35+
img = _image(tmp_path, size=2_000)
36+
session = fleet.FleetSession(images=[img])
37+
assert session.fits_on_drive(img, _drive(size_gb=8))
38+
39+
def test_capacity_boundary_is_inclusive(self, tmp_path):
40+
img = _image(tmp_path, size=1_000)
41+
# 0.000001 * 1e9 = exactly 1000 bytes of claimed capacity
42+
session = fleet.FleetSession(images=[img])
43+
assert session.fits_on_drive(img, _drive(size_gb=0.000001))
44+
45+
def test_image_larger_than_drive_rejected(self, tmp_path):
46+
img = _image(tmp_path, size=2_000)
47+
session = fleet.FleetSession(images=[img])
48+
assert not session.fits_on_drive(img, _drive(size_gb=0.000001))
49+
50+
def test_zero_capacity_rejected(self, tmp_path):
51+
img = _image(tmp_path, size=1_000)
52+
session = fleet.FleetSession(images=[img])
53+
assert not session.fits_on_drive(img, _drive(size_gb=0))
54+
55+
def test_missing_image_rejected(self):
56+
session = fleet.FleetSession(images=[r"C:\nope.iso"])
57+
assert not session.fits_on_drive(r"C:\nope.iso", _drive())
58+
59+
def test_drive_without_capacity_rejected(self, tmp_path):
60+
img = _image(tmp_path, size=1_000)
61+
session = fleet.FleetSession(images=[img])
62+
assert not session.fits_on_drive(img, {"serial": "SN"})
63+
64+
def test_all_images_must_fit_for_candidate(self, tmp_path):
65+
small = _image(tmp_path, "a.iso", size=1_000)
66+
big = _image(tmp_path, "b.iso", size=2_000)
67+
# capacity is exactly enough for `small` but not both
68+
session = fleet.FleetSession(images=[small, big])
69+
drive = _drive(size_gb=0.000001)
70+
assert not fleet.pick_candidate([drive], session)
71+
72+
def test_missing_image_blocks_candidate(self, tmp_path):
73+
good = _image(tmp_path, "a.iso", size=1_000)
74+
session = fleet.FleetSession(images=[good, r"C:\gone.iso"])
75+
assert fleet.pick_candidate([_drive()], session) is None
76+
77+
78+
class TestSessionTracking:
79+
def test_flashed_drive_is_skipped(self, tmp_path):
80+
img = _image(tmp_path, size=1_000)
81+
session = fleet.FleetSession(images=[img])
82+
d1 = _drive(serial="SN1")
83+
d2 = _drive(serial="SN2")
84+
assert fleet.pick_candidate([d1, d2], session) is d1
85+
session.mark_flashed(d1)
86+
assert session.done_count == 1
87+
assert fleet.pick_candidate([d1, d2], session) is d2
88+
89+
def test_failed_drive_can_be_retried(self, tmp_path):
90+
"""A failure is recorded but does not blacklist the stick: the
91+
operator may re-insert it for another attempt."""
92+
img = _image(tmp_path, size=1_000)
93+
session = fleet.FleetSession(images=[img])
94+
d = _drive(serial="SN1")
95+
session.mark_failed()
96+
assert fleet.pick_candidate([d], session) is d
97+
assert session.failed_count == 1
98+
99+
def test_finished_session_sweeps_every_stick_once(self, tmp_path):
100+
img = _image(tmp_path, size=1_000)
101+
session = fleet.FleetSession(images=[img])
102+
ds = [_drive(serial=f"SN{i}") for i in range(4)]
103+
for d in ds:
104+
assert fleet.pick_candidate(ds, session) is d
105+
session.mark_flashed(d)
106+
assert fleet.pick_candidate(ds, session) is None
107+
assert session.done_count == 4
108+
109+
def test_sticks_without_serial_tracked_by_path(self, tmp_path):
110+
img = _image(tmp_path, size=1_000)
111+
session = fleet.FleetSession(images=[img])
112+
d = _drive(serial="", path=r"\\.\PHYSICALDRIVE9")
113+
assert fleet.pick_candidate([d], session) is d
114+
session.mark_flashed(d)
115+
assert fleet.pick_candidate([d], session) is None
116+
117+
118+
class TestExpiry:
119+
def test_expired_session_blocks_candidates(self, tmp_path):
120+
img = _image(tmp_path, size=1_000)
121+
session = fleet.FleetSession(images=[img])
122+
now = 1_000.0
123+
session.last_activity = now - fleet.IDLE_EXPIRY_SECONDS - 1
124+
assert fleet.pick_candidate([_drive()], session, now=now) is None
125+
assert session.expired(now)
126+
127+
def test_active_session_keeps_candidates(self, tmp_path):
128+
img = _image(tmp_path, size=1_000)
129+
session = fleet.FleetSession(images=[img])
130+
now = 1_000.0
131+
session.last_activity = now - 10
132+
assert fleet.pick_candidate([_drive()], session, now=now) is not None

0 commit comments

Comments
 (0)