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.
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).
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.
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:
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:
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}
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}
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.
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:
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).
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:
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}``).