furtka/webinstaller/drives.py
Daniel Maksymilian Syrnicki 15b876c70a
Some checks failed
CI / lint (push) Failing after 25s
CI / test (push) Successful in 31s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Failing after 2s
feat: webinstaller writes archinstall config + execs install, styled
Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.

Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
  validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
  (creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
  `archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
  archlinux container before committing.

Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
  so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
  as install targets.

Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
  "Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
  entries. Verified zero leftovers against the current releng profile.

Styling:
- static/style.css adopts the website's design tokens (palette,
  typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
  indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
  table and a destructive "wipe drive" button.

Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.

README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:54:49 +02:00

113 lines
2.9 KiB
Python

import subprocess
def get_drive_health(device):
try:
result = subprocess.run(
["smartctl", "-H", device],
capture_output=True,
)
output = result.stdout.decode()
if "PASSED" in output:
return 10
elif "FAILED" in output:
return 0
return 5
except Exception as e:
print(f"Error checking SMART status for {device}: {e}")
return 5
def get_drive_type_score(device):
name = device.lower()
if "nvme" in name:
return 15
if "ssd" in name:
return 10
return 5
def parse_size_gb(size_str):
size_str = size_str.strip().upper().replace(",", ".")
if not size_str:
return None
if size_str.endswith("T"):
return float(size_str[:-1]) * 1024
if size_str.endswith("G"):
return float(size_str[:-1])
if size_str.endswith("M"):
return float(size_str[:-1]) / 1024
return None
def get_size_score(size_gb):
if size_gb is None:
return 5
if size_gb < 128:
return 5
if size_gb < 512:
return 7
return 10
def score_device(device, size_gb):
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
def parse_lsblk_output(output):
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
CD-ROM (rom) don't show up as install targets.
"""
devices = []
for line in output.strip().split("\n"):
if not line:
continue
parts = line.split()
if len(parts) < 3:
continue
name, size, dev_type = parts[0], parts[1], parts[2]
if dev_type != "disk":
continue
device = f"/dev/{name}"
devices.append(
{
"name": device,
"size": size,
"score": score_device(device, parse_size_gb(size)),
}
)
devices.sort(key=lambda d: d["score"], reverse=True)
return devices
def list_scored_devices():
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
try:
result = subprocess.run(
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error listing devices: {e}")
return []
return parse_lsblk_output(result.stdout)
def main():
devices = list_scored_devices()
if not devices:
print("No storage devices found.")
return
print(f"\n{'Device':<20} {'Size':<10} {'Score'}")
print("-" * 40)
for d in devices:
print(f"{d['name']:<20} {d['size']:<10} {d['score']}")
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})")
if __name__ == "__main__":
main()