8.3.3.2. EduxOS Installer (MaSt)

Der EduxOS Installer ist die Live Alternative zum Debian Installer.

Ziel des Installers ist es, ein Live System auf einen USB Stick oder anderes Speichermedium zu kopieren, sodass das neu entstandene System alle Einstellungen des alten Systems beinhaltet und sofort genutzt werden kann. Es soll aber auch möglich sein, eine .iso Datei auf einen Datenträger zu flashen.

Installer

Abb. 8.3 Hauptseite des Installers

8.3.3.2.1. Allgemeines

Um diesen Prozess durchführen zu können, müssen zunächst ein paar Konzepte verstanden werden.

Wie kopiere ich ein gesamtes System auf ein anderes?

Die Antwort hierbei ist relativ simpel: Das momentan verwendete Gerät muss 1:1 auf den Datenträger kopiert, bzw. geflashed werden. Dazu kann das dd Tool hilfreich sein. Bei root Disk /dev/sda und USB Stick /dev/sdb würde das Kommando so aussehen:

dd if=/dev/sda of=/dev/sdb bs=4M conv=fsync

Was wenn meine Target Disk kleiner ist als meine Source Disk

Dieser Command schreibt /dev/sda Byte für Byte auf /dev/sdb. Allerdings gibt es dabei ein Problem: /dev/sdb muss genauso groß oder gar größer als /dev/sda sein. Wenn also das aktuelle System auf einer 512GB SSD liegt, kann dieser Befehl nicht auf einen 8GB USB Stick angewendet werden.

Zum Glück hat der dd Befehl das Argument count, welches nur eine bestimme Anzahl an Blöcken vom if auf das of kopiert. Dazu muss zuerst die Größe der Sektoren von /dev/sda ermittelt werden:

sfdisk --json /dev/sda

sfdisk gibt eine detaillierte Beschreibung des Partition Tables, der Größe und der Partitionen der Festplatte aus. Die hier wichtigen Attribute der Disk selbst sind die Größe der Sektoren (sectorsize) und die Partitionen (partitions). Jede Partition hat einen Startblock (start), eine Größe in Sektoren (size) und einen Dateisystemtypen (type).

(8.1)\[max(p1.start + p1.size, p2.start + p2.size, ..., pn.start + pn.size)\]

Im Normalfall kann dann die Anzahl der Sektoren mit der Formel (8.1) berechnet werden, wenn p für eine Partition, d für eine Disk und n für die letzte Partition stehen.

Das Ergebnis ${SECTORS} kann dann in dd gebraucht werden, wenn d.sectorsize in ${SECTORSIZE} eingesetzt wird:

dd if=/dev/sda of=/dev/sdb bs=${SECTORSIZE} count=${SECTORS} conv=fsync

Wie funktioniert das, wenn die Persistenz Partition über den Rest der Disk gestreckt wird?

Damit haben wir ein weiteres Problem: Wie viel Speicher wird tatsächlich benötigt und wie viel Speicher ist leer?

Dazu muss ein anderes Konzept als eben angewendet werden. Anstatt mit dd alles zu kopieren, sollen alle Partitionen außer der Persistenz Partition kopiert werden. sfdisk kann hier wieder weiter helfen. Durch sfdisk kann das Partitionslayout der ursprünglichen Disk erfasst werden. Im Installer kann dann die letzte Partition entfernt und die neue Partitionstabelle auf das Target geschrieben werden. Anschließend wird dem Target eine Partition hinzugefügt, die die Persistenz verwalten soll.

Wo ist dabei das Problem?

Das Partitionslayout der Live Distribution sieht wie folgt aus:

  live-build git:(main)$ fdisk -l live-image-amd64.hybrid.iso
Disk live-image-amd64.hybrid.iso: 3.41 GiB, 3657433088 bytes, 7143424 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xffc6fbd7

Device                       Boot Start     End Sectors  Size Id Type
live-image-amd64.hybrid.iso1 *       64 7143423 7143360  3.4G  0 Empty
live-image-amd64.hybrid.iso2        740   10915   10176    5M ef EFI (FAT-12/16/32)

Wie gut erkennbar ist, ist die EFI Partition innerhalb der ersten Partition eingebettet, ohne dass es sich bei Partition 1 um eine extended Partition handelt. Das hat zur Folge, dass sich Programme wie sfdisk, fdisk und parted weigern, dieses Layout auf eine andere Disk zu übertragen. Leider haben wir keine andere Möglichkeit gefunden die Partitionstabelle von Source auf Target zu übertragen, als den dd Befehl, welcher einfach die ganze, unmodifizierte Tabelle rüber kopiert.

dd if=/dev/sda of=/dev/sdb bs=${SECTORSIZE} count=${START_OF_FIRST_PARTITION} conv=fsync

Danach wird über fdisk die letzte Partition aus der Partitionstabelle gelöscht und neu mit anderer Größe hinzugefügt. Anschließend können dann die Inhalte der statischen Partitionen mit dd kopiert werden. Nachdem auf der Persistenz Partition ein Dateisystem erstellt wurde, können die Dateien der alten Persistenz Partition auf die neue kopiert werden. Dazu müssen beide Partitionen gemountet sein. Zuletzt wird der sync Befehl ausgeführt, um alle noch ausstehenden Schreibvorgänge auf die Festplatten zu bringen und beide Partitionen werden unmounted, um den Vorgang zu beenden.

Was passiert, wenn während dem Kopieren Änderungen im Dateisystem vorgenommen werden?

Das Kopieren funktioniert so gut, da die Änderungen, die während des Kopierens auf dem Filesystem entstehen, im tmpfs der RAMDISK liegen und nicht auf die Festplatten geschrieben werden.

8.3.3.2.2. Implementierung

Es gibt zwei Ansätze des EduxOS Installers. Die Variante, die in EduxOS verwendet wird ist die Rust Implementierung, da es bei Python und Flask zu viele Probleme gab, die im Folgenden erläutert werden.

8.3.3.2.2.1. Python und Flask

Bemerkung

Diese Software ist lediglich eine Herangehensweise gewesen und wird nicht in EduxOS verwendet.

Konzept

Die Python Anwendung war die erste Idee, da es einfach möglich wäre, die Flask App des standalone Installers durch die des edu-linux-servers auszutauschen. Somit könnte der edu-linux-server das Backend des Installers hosten.

from flask import Flask, render_template, request, send_from_directory, redirect, url_for
from flask_wtf import CSRFProtect
from werkzeug.utils import secure_filename

# -- snip --

def _setup(
    app: Flask,
    installer: EduxInstaller,
    asset_folder: Path,
    default_route: bool = True,
    upload_folder: Path = None,
    secret_key: Literal = None,
):
    # -- snip --

    if default_route:
        @app.route('/', methods=['GET'])
        def index_route():
            return redirect(url_for('index'))

    @app.route('/installer', methods=['GET'])
    def index():
        return render_template(
            ...
        )

    # -- snip --

Der eben gezeigte Python Code zeigt die Funktion setup. Da setup eine Flask app als Argument nimmt, hätte diese Funktion auch aus dem edu-linux-server Backend aus aufgerufen werden können, allerdings mit der Flask app des edu-linux-servers, anstatt des edux-installers.

Problemstellung

Flask ist in der verwendeten Version strikt synchron. Aufrufe von Endpunkten (z.B. /installer) blockieren den Programmablauf. Demnach ist es wichtig, dass Routen schnell behandelt werden, sodass im Frontend früh Antworten ankommen und gerendert werden können.

Der Installationsprozess kann mehrere Minuten dauern, in dieser Zeit wäre das Webfrontend nicht ansprechbar. Es gibt ein paar Möglichkeiten, um Tasks in Flask zu schedulen, es ist allerdings mit zusätzlichem Overhead zu rechnen. Eine bekannte Methode des Task Schedulings ist Celery, welches einen Broker benötigt, um Resultate zu speichern und Tasks auszurollen. Auf dem Branch maxist-add-gui liegt die Implementierung mit Celery und Redis als Broker Client vor. Der zusätzliche Overhead dieser Anwendung beträgt hunderte Megabyte durch Docker, Redis und Celery, weshalb diese Idee vergessen wurde und die letzte Idee angefangen wurde: Umschreiben des Installers in Rust.

8.3.3.2.2.2. Rust und Axum

Auch wenn der Installer so nicht direkt durch den edu-linux-server gehostet werden kann, ist es dennoch möglich, den Installer als Daemon bei System Boot zu starten und von der Main Page auf den Endpunkt (localhost:8081/#) weiterzuleiten. Des weiteren kommt Rust mit vielen Vorteilen gegenüber Python.

Verwaltung von Abhängigkeiten

Rust hat den großen Vorteil, dass der Sourcecode und alle Build-dependencies nicht auf dem Target liegen müssen und stattdessen eine einzige ELF Binary erzeugt wird.

Fehlerbehandlung

Ein weiterer Vorteil von Rust, ist die Leichtgewichtigkeit und die sichere Fehlerbehandlung. Python und andere Programmiersprachen arbeiten mit Exceptions, welche den Programmablauf spalten und abbrechen können, während in Rust (panics ausgenommen) Fehler über Result Typen behandelt werden.

Results sind Enums mit den beiden Konstanten Ok und Err. Im Fehlerfall liegt der Fehler in der Err Enum Konstante und kann anders behandelt werden, als das Result im Ok Typen:

use tokio::process::Command;

// -- snip --

async fn unmount(mount_point: &str) -> Result<(), String> {
    match Command::new("umount")
        .arg(mount_point)
        .stdout(std::process::Stdio::piped())
        .spawn() {
        Ok(mut umount_cmd) => {
            log::info!("{mount_point} wird umnounted ...");

            match umount_cmd.wait().await {
                Ok(status) => {
                    if status.success() {
                        log::info!("{mount_point} wurde erfolgreich unmounted!");
                        Ok(())
                    } else {
                        return Err(format!("'umount {mount_point}' Kommando ist fehlgeschlagen."))
                    }
                },
                Err(err) => return Err(format!("Status Code von 'umount {mount_point}' konnte nicht ermittelt werden: {err}"))
            }
        },
        Err(err) => return Err(format!("'umount {mount_point}' konnte nicht gestartet werden: {err}"))
    }
}

In diesem Beispiel wurden alle möglichen Fehlerquellen beim unmounten eines Mountpoints abgedeckt.

Installer Error

Abb. 8.4 Fehlermeldung im Installer

Abb. 8.4 zeigt, wie Fehler in der grafischen Oberfläche gerendert werden.

Einfache Verwendung von Websockets und Hintergrundtasks

Axum Routen werden asynchron aufgerufen und können Hintergrundprozesse starten. Im /installer/install Endpunkt wird der Installations Task gestartet und ein Code zurückgegeben, der beschreibt, ob der Task erfolgreich gestartet werden konnte oder nicht:

Quellcode 8.5 router.rs
116#[axum::debug_handler]
117async fn install_handler(
118    State(state): State<AppState>,
119    mut payload: Multipart
120) -> (StatusCode, String) {
121    // -- snip --
122    // Input-Check und Fehlerbehandlung mit early Returns
123    // -- snip --
124
125    // Starte Installation im Hintergrund und gebe OK Result zurück
126    spawn(installer::run_installer(disk_path, source_path, Some(state.task_tx)));
127    (StatusCode::OK, String::new())
128}

Alle Ereignisse, die innerhalb des Tasks installer::run_installer passieren, werden durch Thread Broadcaster an den Websocket Endpunkt /installer/tasks weitergeleitet und als message Event emitted:

Quellcode 8.6 installer.rs
476pub async fn run_installer(
477    target: String,
478    source: Option<String>,
479    task_tx: Option<broadcast::Sender<Snapshot>>,
480) {
481    log::info!("Running installer");
482    let mut installer: EduxOSInstaller = EduxOSInstaller::new();
483    installer.set_source(&source);
484    let res: Result<(), String> = installer.set_target(target);
485    if let Err(err) = res {
486        log::error!("Could not assign target disk: {err}");
487        return;
488    }
489    let mut task_list: TaskList = TaskList::new(installer, task_tx);
490    if let Err(err) = task_list.execute_tasklist().await {
491        log::error!("Got error when running tasklist: {err}");
492    };
493}
Quellcode 8.7 task.rs
202async fn broadcast_state(&self) {
203    if let Some(tx) = self.task_tx.clone() {
204        // Wait a short time to prevent broadcaster lag
205        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
206        let _ = tx.send(Snapshot::TaskList(self.task_list.clone()));
207    }
208}
Quellcode 8.8 router.rs
 91#[axum::debug_handler]
 92async fn tasks_get(
 93    ws: WebSocketUpgrade,
 94    State(state): State<AppState>,
 95) -> impl IntoResponse {
 96    ws.on_upgrade(|ws: WebSocket| async { tasks_stream(state, ws).await })
 97}
 98
 99async fn tasks_stream(app_state: AppState, mut ws: WebSocket) {
100    let mut rx: broadcast::Receiver<Snapshot> = app_state.task_tx.subscribe();
101
102    loop{
103        match rx.recv().await {
104            Ok(msg) => {
105                log::debug!("Sending Snapshot message to ws {msg:?}");
106                if let Err(err) = ws.send(Message::Text(serde_json::to_string(&msg).unwrap()))
107                .await {
108                    log::error!("Got an unexpected error when sending ws message: {err}");
109                }
110            },
111            Err(err) => log::error!("Got error when unpacking broadcaster snapshot: {err}")
112        }
113    }
114}

Die hier gesendeten Nachrichten werden als JSON Payload kodiert. Gesendet werden Snapshots, welche eine TaskList beinhalten. Eine TaskList ist nichts anderes als ein Vector bzw. eine Liste an Tasks. Tasks haben einen Status (pending, active, failed, …) und eine Beschreibung, die im Frontend gezeigt wird.

Quellcode 8.9 lib.rs
18#[serde_as]
19#[derive(Clone, Serialize, Deserialize, Debug)]
20pub enum Snapshot {
21    TaskList(Vec<Task>),
22}

Im Frontend wird der Endpunkt /installer/tasks subscribed und bei Änderungen der Container neu gerendert, in dem die Tasks zu sehen sind:

Quellcode 8.10 index.mjs
379/***********************************************************
380 *
381 * Websocket communication for task info
382 *
383 ***********************************************************/
384
385let ws_url = new URL("/installer/tasks", window.location.href);
386ws_url.protocol = ws_url.protocol.replace("http", "ws");
387let ws = new WebSocket(ws_url.href);
388
389ws.onmessage = (ev) => {
390  let json = JSON.parse(ev.data);
391  let task_list = json.TaskList;
392  render(
393    html`<${TaskList} task_list=${task_list} />`,
394    document.getElementById("TaskList")
395  )
396}

In Abb. 8.5 wird der Task Ablauf noch einmal genauer beleuchtet. Wie gut zu sehen ist, gibt der Router früh eine Response, sodass das Frontend weiterhin aktiv bleibt und der Hintergrundprozess nicht blockiert. Wenn der Hintergrundprozess fertig ist, wird das vom Frontend erkannt und darauf reagiert. Im Fehlerfall wird der Fehler und im Idealfall die Erfolgsbenachrichtigung an den Nutzer weitergegeben (siehe Abb. 8.6).

Tasklist Flow

Abb. 8.5 Task Ablauf

Tasklist Flow

Abb. 8.6 Erfolgreiche Installation

Installer Tasklist

Abb. 8.7 Task Liste im Installer

Abb. 8.7 zeigt, noch einmal, wie eine aktive Task Liste in der Web GUI aussieht.

Frontend

Im Frontend des Installers wird das Javascript Framework Preact verwendet. Preact ist nur knapp 16 Kilobyte groß und stellt Render Funktionen zur Verfügung, welche schnell, einfach und dynamisch HTML Elemente rendern können. Außerdem wird Hyperscript Tagged Markup (htm) verwendet, um die Integration von Preact noch einfacher zu gestalten:

Quellcode 8.11 index.mjs
1import { h, render } from "/installer/lib/preact.js";
2import htm from "/installer/lib/htm.js";
3
4const html = htm.bind(h);

Um zu verhindern, dass ein Nutzer ausversehen eine Falsche Disk beschreibt, wird der Nutzer vor der Installation noch ein Mal dazu aufgerufen, seine Auswahl zu bestätigen (siehe {numref}``).

Installer Prompt

Abb. 8.8 Bestätigung der Auswahl im EduxOS Installer