# EduxOS Installer (MaSt) Der [EduxOS Installer](#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. ```{figure} ../../img/live_build/installer.png :alt: Installer :name: eduxos_installer Hauptseite des Installers ``` ## 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: ```bash 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: ```bash 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). ```{math} :label: sector_count max(p1.start + p1.size, p2.start + p2.size, ..., pn.start + pn.size) ``` Im Normalfall kann dann die Anzahl der Sektoren mit der Formel {eq}`sector_count` 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: ```bash 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: ```bash ➜ 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. ```bash 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. ## 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. ### Python und Flask :::{note} 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](https://gitlab.informatik.hs-augsburg.de/edu-linux/anwendungen/edu-linux-server) auszutauschen. Somit könnte der edu-linux-server das Backend des Installers hosten. ```python 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](https://pypi.org/project/celery/), welches einen Broker benötigt, um Resultate zu speichern und Tasks auszurollen. Auf dem Branch [maxist-add-gui](https://gitlab.informatik.hs-augsburg.de/edu-linux/hardware/edux-installer/-/tree/maxist-add-gui?ref_type=heads) 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. ### 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: ```rust 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. ```{figure} ../../img/live_build/eduxos_installer_error.png :alt: Installer Error :name: eduxos_installer_error Fehlermeldung im Installer ``` {numref}`eduxos_installer_error` 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: ```{code-block} rust --- caption: router.rs lineno-start: 116 --- #[axum::debug_handler] async fn install_handler( State(state): State, mut payload: Multipart ) -> (StatusCode, String) { // -- snip -- // Input-Check und Fehlerbehandlung mit early Returns // -- snip -- // Starte Installation im Hintergrund und gebe OK Result zurück spawn(installer::run_installer(disk_path, source_path, Some(state.task_tx))); (StatusCode::OK, String::new()) } ``` 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: ```{code-block} rust --- caption: installer.rs lineno-start: 476 --- pub async fn run_installer( target: String, source: Option, task_tx: Option>, ) { log::info!("Running installer"); let mut installer: EduxOSInstaller = EduxOSInstaller::new(); installer.set_source(&source); let res: Result<(), String> = installer.set_target(target); if let Err(err) = res { log::error!("Could not assign target disk: {err}"); return; } let mut task_list: TaskList = TaskList::new(installer, task_tx); if let Err(err) = task_list.execute_tasklist().await { log::error!("Got error when running tasklist: {err}"); }; } ``` ```{code-block} rust --- caption: task.rs lineno-start: 202 --- async fn broadcast_state(&self) { if let Some(tx) = self.task_tx.clone() { // Wait a short time to prevent broadcaster lag tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; let _ = tx.send(Snapshot::TaskList(self.task_list.clone())); } } ``` ```{code-block} rust --- caption: router.rs lineno-start: 91 --- #[axum::debug_handler] async fn tasks_get( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|ws: WebSocket| async { tasks_stream(state, ws).await }) } async fn tasks_stream(app_state: AppState, mut ws: WebSocket) { let mut rx: broadcast::Receiver = app_state.task_tx.subscribe(); loop{ match rx.recv().await { Ok(msg) => { log::debug!("Sending Snapshot message to ws {msg:?}"); if let Err(err) = ws.send(Message::Text(serde_json::to_string(&msg).unwrap())) .await { log::error!("Got an unexpected error when sending ws message: {err}"); } }, Err(err) => log::error!("Got error when unpacking broadcaster snapshot: {err}") } } } ``` 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. ```{code-block} rust --- caption: lib.rs lineno-start: 18 --- #[serde_as] #[derive(Clone, Serialize, Deserialize, Debug)] pub enum Snapshot { TaskList(Vec), } ``` Im Frontend wird der Endpunkt `/installer/tasks` subscribed und bei Änderungen der Container neu gerendert, in dem die Tasks zu sehen sind: ```{code-block} js --- caption: index.mjs lineno-start: 379 --- /*********************************************************** * * Websocket communication for task info * ***********************************************************/ let ws_url = new URL("/installer/tasks", window.location.href); ws_url.protocol = ws_url.protocol.replace("http", "ws"); let ws = new WebSocket(ws_url.href); ws.onmessage = (ev) => { let json = JSON.parse(ev.data); let task_list = json.TaskList; render( html`<${TaskList} task_list=${task_list} />`, document.getElementById("TaskList") ) } ``` In {numref}`tasklist_seq` 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 {numref}`task_success`). ```{figure} ../../img/live_build/task_ablauf.png :alt: Tasklist Flow :name: tasklist_seq Task Ablauf ``` ```{figure} ../../img/live_build/task_success.png :alt: Tasklist Flow :name: task_success Erfolgreiche Installation ``` ```{figure} ../../img/live_build/eduxos_installer_tasklist.png :alt: Installer Tasklist :name: eduxos_installer_tasklist Task Liste im Installer ``` {numref}`eduxos_installer_tasklist` zeigt, noch einmal, wie eine aktive Task Liste in der Web GUI aussieht. **Frontend** Im Frontend des Installers wird das Javascript Framework [Preact](https://preactjs.com/) 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)](https://github.com/developit/htm) verwendet, um die Integration von Preact noch einfacher zu gestalten: ```{code-block} js --- caption: index.mjs lineno-start: 1 --- import { h, render } from "/installer/lib/preact.js"; import htm from "/installer/lib/htm.js"; const 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}``). ```{figure} ../../img/live_build/installation_accept.png :alt: Installer Prompt :name: installation_accept Bestätigung der Auswahl im EduxOS Installer ```