feat: graphical installer in the live ISO
The ISO now boots straight into a GTK installer instead of dropping to a tty. Structure: - installer/arcline-installer: small GTK3 (Python) frontend that drives scripts/deploy-disk.sh — pick a disk, choose boot mode, type the device path to confirm, watch the deploy log, reboot. Pure helper logic is tested against lsblk (lowercase keys, pseudo-devices filtered). - scripts/build-live.sh: builds build/rootfs/<edition>-live by cloning the CLEAN rootfs and layering on live-boot, a minimal X session (Xorg + openbox), the installer, and the deploy tooling under /usr/lib/arcline (deploy-disk.sh + btrfs/init.sh + edition fstabs, laid out so the scripts' own path resolution works unchanged). - overlays/live/: arcline-installer.service + session script that start Xorg on vt1 (with -allow-root) and run the installer as the X client. - build-iso.sh: builds the live rootfs for the squashfs AND stages the clean rootfs archive into isofiles/install/ — the installer deploys the clean archive, so what's installed is the hardened system, never the live session with the installer in it. - Refactor: ARCLINE_LIVE handling removed from build-rootfs.sh and configure-system.sh (now lives entirely in build-live.sh). - validate.sh now checks overlays shell scripts + installer python. - docs updated (building.md, architecture.md, installer/README.md).
This commit is contained in:
71
installer/README.md
Normal file
71
installer/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Arcline graphical installer
|
||||
|
||||
`installer/arcline-installer` is the GTK3 frontend that the live ISO boots
|
||||
into. It is a thin wrapper around `scripts/deploy-disk.sh` — the same,
|
||||
auditable deploy core the scripted installer and disk-image builder use.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
boot ISO → live session (Xorg on vt1) → installer window
|
||||
pick target disk → confirm device path → deploy-disk.sh → reboot
|
||||
```
|
||||
|
||||
The installer extracts `install/arcline-<edition>.tar.xz` (the *clean* rootfs
|
||||
archive staged on the ISO by `build-iso.sh`) to a temp dir and runs:
|
||||
|
||||
```
|
||||
/usr/lib/arcline/scripts/deploy-disk.sh <device> <rootfs> <edition> --boot <bios|efi>
|
||||
```
|
||||
|
||||
streaming the deploy log into the window. Everything destructive lives in
|
||||
`deploy-disk.sh`, which the live image ships (with its dependencies) at
|
||||
`/usr/lib/arcline`:
|
||||
|
||||
```
|
||||
/usr/lib/arcline/scripts/{common,deploy-disk,apply-fstab}.sh
|
||||
/usr/lib/arcline/btrfs/init.sh
|
||||
/usr/lib/arcline/editions/{server,workstation,cloud}/fstab
|
||||
```
|
||||
|
||||
The layout mirrors the repo so the scripts' own path resolution works
|
||||
unchanged.
|
||||
|
||||
## Building the ISO
|
||||
|
||||
```bash
|
||||
make iso-server # or iso-workstation / iso-cloud
|
||||
```
|
||||
|
||||
`build-iso.sh` builds the clean rootfs, then `build-live.sh` clones it into
|
||||
`build/rootfs/<edition>-live` and layers on live-boot, a minimal X session
|
||||
(openbox), the installer, and the deploy tooling. The live session rootfs is
|
||||
**never** what gets installed — the installer always deploys the clean
|
||||
archive, so what you end up with on disk is exactly the hardened system, not
|
||||
the live session with the installer in it.
|
||||
|
||||
## Autostart
|
||||
|
||||
`overlays/live/etc/systemd/system/arcline-installer.service` starts
|
||||
`arcline-installer-session` on boot: it launches Xorg on vt1 (with
|
||||
`-allow-root`, since the live session runs as root) and the installer as the
|
||||
X client. When the installer exits, X and the session end.
|
||||
|
||||
## Manual run (for development / on a box without the ISO)
|
||||
|
||||
```bash
|
||||
# on any Arcline system with the deploy tooling:
|
||||
sudo /usr/local/bin/arcline-installer
|
||||
```
|
||||
|
||||
The app refuses to run as a non-root user and errors if the deploy tooling is
|
||||
missing.
|
||||
|
||||
## Notes
|
||||
|
||||
- Disk discovery uses `lsblk` (util-linux); read-only devices are hidden.
|
||||
- Confirmation mirrors `scripts/install.sh`: you must type the exact device
|
||||
path before the install button will proceed.
|
||||
- Root is hidden by design; the installer runs before any user session.
|
||||
- For UEFI machines, pick "UEFI" boot mode (or use `BOOT=efi` when building);
|
||||
see `docs/secureboot.md` for signing the boot chain for Secure Boot.
|
||||
260
installer/arcline-installer
Executable file
260
installer/arcline-installer
Executable file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Arcline OS — graphical installer (runs in the live session)
|
||||
#
|
||||
# A small GTK3 frontend for scripts/deploy-disk.sh. It boots to the console
|
||||
# of the live ISO and walks through: pick a target disk -> confirm (type the
|
||||
# device path, like scripts/install.sh) -> install, streaming the deploy log
|
||||
# into the window. Everything destructive is delegated to deploy-disk.sh,
|
||||
# which is shipped in the live image at /usr/lib/arcline.
|
||||
#
|
||||
# Requires root (the live session runs as root). No network needed.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import GLib, Gtk
|
||||
|
||||
DEPLOY = "/usr/lib/arcline/scripts/deploy-disk.sh"
|
||||
ARCHIVE_CANDIDATES = (
|
||||
"/install/*.tar.xz",
|
||||
"/run/live/medium/install/*.tar.xz",
|
||||
"/lib/live/mount/medium/install/*.tar.xz",
|
||||
"/media/*/install/*.tar.xz",
|
||||
"/mnt/*/install/*.tar.xz",
|
||||
)
|
||||
|
||||
|
||||
def find_archive():
|
||||
for pattern in ARCHIVE_CANDIDATES:
|
||||
hits = sorted(glob.glob(pattern))
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def current_edition():
|
||||
try:
|
||||
with open("/etc/arcline-release", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"Edition:\s*(\S+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
return "server"
|
||||
|
||||
|
||||
def list_disks():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["lsblk", "-J", "-o", "NAME,SIZE,TYPE,MODEL,RM,RO"],
|
||||
capture_output=True, text=True, check=True, timeout=10,
|
||||
).stdout
|
||||
data = json.loads(out)
|
||||
except (subprocess.SubprocessError, json.JSONDecodeError):
|
||||
return []
|
||||
disks = []
|
||||
for d in data.get("blockdevices", []):
|
||||
if d.get("type") != "disk" or d.get("ro"):
|
||||
continue
|
||||
name = d.get("name", "")
|
||||
# hide pseudo-devices that are never install targets
|
||||
if name.startswith(("zram", "loop", "ram")):
|
||||
continue
|
||||
disks.append(d)
|
||||
return disks
|
||||
|
||||
|
||||
class Installer(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Arcline OS Installer")
|
||||
self.set_default_size(760, 640)
|
||||
self.proc = None
|
||||
self.tmpdir = None
|
||||
self.install_ok = False
|
||||
|
||||
self.archive = find_archive()
|
||||
self.edition = current_edition()
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
box.set_margin_top(16)
|
||||
box.set_margin_bottom(16)
|
||||
box.set_margin_start(20)
|
||||
box.set_margin_end(20)
|
||||
self.add(box)
|
||||
|
||||
title = Gtk.Label()
|
||||
title.set_markup(f"<big><b>Arcline OS Installer</b> — {self.edition} edition</big>")
|
||||
box.pack_start(title, False, False, 0)
|
||||
|
||||
# install source
|
||||
if self.archive:
|
||||
src = Gtk.Label()
|
||||
src.set_halign(Gtk.Align.START)
|
||||
src.set_markup(f"<small>Installing from: <tt>{self.archive}</tt></small>")
|
||||
box.pack_start(src, False, False, 0)
|
||||
else:
|
||||
warn = Gtk.Label(label="WARNING: no install archive found (expected /install/*.tar.xz on the ISO)")
|
||||
warn.set_name("warn")
|
||||
box.pack_start(warn, False, False, 0)
|
||||
|
||||
# target disk
|
||||
self.disk_combo = Gtk.ComboBoxText()
|
||||
self.disks = list_disks()
|
||||
for d in self.disks:
|
||||
model = (d.get("model") or "").strip()
|
||||
removable = "(removable)" if d.get("rm") else ""
|
||||
self.disk_combo.append_text(f"/dev/{d['name']} {d.get('size','?')} {model} {removable}".strip())
|
||||
if not self.disks:
|
||||
self.disk_combo.append_text("(no writable disks found)")
|
||||
self.disk_combo.set_active(0)
|
||||
row_disk = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row_disk.pack_start(Gtk.Label(label="Target disk:"), False, False, 0)
|
||||
row_disk.pack_start(self.disk_combo, True, True, 0)
|
||||
box.pack_start(row_disk, False, False, 0)
|
||||
|
||||
# boot mode
|
||||
self.boot_combo = Gtk.ComboBoxText()
|
||||
for label, val in (("BIOS (grub-pc)", "bios"), ("UEFI (grub-efi-amd64)", "efi")):
|
||||
self.boot_combo.append(label, val)
|
||||
self.boot_combo.set_active(0)
|
||||
row_boot = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row_boot.pack_start(Gtk.Label(label="Boot mode:"), False, False, 0)
|
||||
row_boot.pack_start(self.boot_combo, True, True, 0)
|
||||
box.pack_start(row_boot, False, False, 0)
|
||||
|
||||
# danger + confirm
|
||||
danger = Gtk.Label()
|
||||
danger.set_name("warn")
|
||||
danger.set_markup("⚠ <b>ALL DATA on the target disk will be destroyed.</b>")
|
||||
box.pack_start(danger, False, False, 0)
|
||||
|
||||
row_confirm = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row_confirm.pack_start(Gtk.Label(label="Type the device path to confirm:"), False, False, 0)
|
||||
self.confirm = Gtk.Entry()
|
||||
self.confirm.set_placeholder_text("/dev/sda")
|
||||
row_confirm.pack_start(self.confirm, True, True, 0)
|
||||
box.pack_start(row_confirm, False, False, 0)
|
||||
|
||||
# install button
|
||||
self.install_btn = Gtk.Button(label="Install Arcline OS")
|
||||
self.install_btn.set_sensitive(self.archive is not None and bool(self.disks))
|
||||
self.install_btn.connect("clicked", self.on_install)
|
||||
box.pack_start(self.install_btn, False, False, 0)
|
||||
|
||||
# output
|
||||
self.output = Gtk.TextView()
|
||||
self.output.set_editable(False)
|
||||
self.output.set_monospace(True)
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scroller.set_vexpand(True)
|
||||
scroller.add(self.output)
|
||||
box.pack_start(scroller, True, True, 0)
|
||||
|
||||
# status + reboot
|
||||
self.status = Gtk.Label(label="")
|
||||
box.pack_start(self.status, False, False, 0)
|
||||
self.reboot_btn = Gtk.Button(label="Reboot")
|
||||
self.reboot_btn.set_sensitive(False)
|
||||
self.reboot_btn.connect("clicked", lambda *_: subprocess.Popen(["systemctl", "reboot"]))
|
||||
box.pack_start(self.reboot_btn, False, False, 0)
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────
|
||||
def append(self, text):
|
||||
buf = self.output.get_buffer()
|
||||
buf.insert(buf.get_end_iter(), text)
|
||||
|
||||
def selected_device(self):
|
||||
i = self.disk_combo.get_active()
|
||||
if i < 0 or i >= len(self.disks):
|
||||
return None
|
||||
return f"/dev/{self.disks[i]['name']}"
|
||||
|
||||
def boot_mode(self):
|
||||
return self.boot_combo.get_active_id() or "bios"
|
||||
|
||||
# ── install ─────────────────────────────────────────────────────────────
|
||||
def on_install(self, _btn):
|
||||
dev = self.selected_device()
|
||||
if not dev:
|
||||
self.status.set_text("Select a target disk first.")
|
||||
return
|
||||
if self.confirm.get_text().strip() != dev:
|
||||
self.status.set_markup(f"<span color='red'>Type <b>{dev}</b> exactly to confirm.</span>")
|
||||
return
|
||||
|
||||
self.install_btn.set_sensitive(False)
|
||||
self.status.set_text("Preparing…")
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="arcline-install-")
|
||||
try:
|
||||
shutil.unpack_archive(self.archive, self.tmpdir, format="xztar")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status.set_markup(f"<span color='red'>Failed to extract archive: {exc}</span>")
|
||||
self.install_btn.set_sensitive(True)
|
||||
return
|
||||
|
||||
cmd = [DEPLOY, dev, self.tmpdir, self.edition, "--boot", self.boot_mode()]
|
||||
self.append(f"$ {' '.join(cmd)}\n")
|
||||
self.proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1,
|
||||
)
|
||||
self.status.set_text("Installing… this takes a while.")
|
||||
GLib.timeout_add(100, self.drain)
|
||||
|
||||
def drain(self):
|
||||
if self.proc is None:
|
||||
return False
|
||||
line = self.proc.stdout.readline()
|
||||
if line:
|
||||
self.append(line)
|
||||
# keep the tail visible
|
||||
adj = self.output.get_parent().get_vadjustment()
|
||||
adj.set_value(adj.get_upper())
|
||||
return True
|
||||
rc = self.proc.poll()
|
||||
if rc is None:
|
||||
return True
|
||||
self.proc.stdout.close()
|
||||
self.proc = None
|
||||
if rc == 0:
|
||||
self.install_ok = True
|
||||
self.status.set_markup("<span color='green'>Install complete. Remove the media and reboot.</span>")
|
||||
self.reboot_btn.set_sensitive(True)
|
||||
else:
|
||||
self.status.set_markup(f"<span color='red'>Install failed (exit {rc}). See log above.</span>")
|
||||
self.install_btn.set_sensitive(True)
|
||||
if self.tmpdir:
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
self.tmpdir = None
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
print("error: the Arcline installer must run as root", file=sys.stderr)
|
||||
return 1
|
||||
if not os.path.exists(DEPLOY):
|
||||
print(f"error: {DEPLOY} not found — installer is incomplete", file=sys.stderr)
|
||||
return 1
|
||||
win = Installer()
|
||||
win.show_all()
|
||||
Gtk.main()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user