Um neue Linux-Distros auf echter Hardware auszuprobieren, empfiehlt sich ein bootbarer USB-Stick mit einem heruntergeladenen Image im Iso-Format. Ein Reboot des Rechners mit eingestöpseltem Stick bringt dann (nach eventuellen Eingriffen in die Boot-Order im Bios) oft ein Live-System hoch, mit dem sich herrlich herumspielen lässt.
Wie kommt die Iso-Datei auf den Stick? Letztendlich passiert das ganz einfach mittels eines dd-Kommandos, das die Iso-Datei als Eingabe (if) und den Device-Eintrag des Sticks (zum Beispiel /dev/sdd) als Ausgabe (of) erwartet. Ubuntu-Tools wie der "Startup Disk Creator" machen es mit grafischer UI noch komfortabler, ein fader Nachgeschmack bleibt jedoch: Auf keinen Fall sollte das Tool einen Bug aufweisen, der statt des Sticks aus Versehen die im Device-Tree gar nicht so weit entfernte Festplatte überschreibt. Wie schwer wäre es wohl, ein ähnliches Tool in Go zu schreiben, eines, das aktiv auf das Einstöpseln des Usb-Sticks wartet und dann beim User um Bestätigung anfragt, um die Iso-Datei dorthin zu kopieren? Außerdem erschließt sich Go-Studenten bei Tools der Marke Eigenbau das ein oder andere Verfahren, mit dem Go-Programmierer Alltagsaufgaben lösen.
Eine Iso-Datei, die mehrere Gigabyte an Daten fasst, ist gar nicht so einfach auf ein anderes Dateisystem wie den Stick zu kopieren. Utilities with cp oder dd lesen nicht etwa alle Daten der Ausgangsdatei in einem Rutsch von der Platte, denn das würde viel kostbares RAM belegen, ohne den Prozess abzukürzen. Vielmehr lesen Kopiertools die Daten in typischerweise Megabyte-großen Happen aus der Source-Datei, die sie sofort in die gleichzeitig geöffnete Zieldatei wegschreiben.
Genauso funktioniert auch Listing 1, in dem die Funktion cpChunks() als Parameter die Namen der Ausgangs- und Zieldatei, sowie einen geöffneten Go-Channel erwartet. Letzterer zapft der Aufrufer als Informationsquelle an, um zu sehen, wie weit der Kopiervorgang schon gediegen ist. Dazu schickt cpChunks() nach jedem kopierten Happen einen Prozentwert in den Channel, der den Bruchteil der bereits kopierten Bytes im Verhältnis zur Gesamtzahl angibt. Letztere hat sich die Funktion mit Hilfe der Systemfunktion os.Stat() anfangs vom Dateisystem eingeholt, das weiß, wie groß die in ihm liegenden Dateien tatsächlich sind.
|
| Abbildung 1: Das Go-Programm wartet auf das Einstöpseln des USB-Sticks |
|
| Abbildung 2: Der eingestöpselte 32gb-Stick wurde erkannt, das Programm wartet auf die Bestätigung seitens des Users. |
|
| Abbildung 3: Der Kopiervorgang hat begonnen, ein Fortschrittsbalken zeigt an, wieviel Bytes der Iso-Datei schon auf den Stick kopiert wurden. |
|
| Abbildung 4: Der bootbare USB-Stick hat nun alle erforderlichen Daten und ist zum Einsatz bereit. |
Damit Go-Programmierer ohne viel Kleistercode zu schreiben Daten zwischen unterschiedlichen Funktionen umherpumpen können, akzeptieren viele Libraries die Standard-Interfaces Reader und Writer. Die Library-Funktion erhält vom Aufrufer einen Pointer auf ein Reader-Objekt und zapft mit Read() daraus häppchenweise Daten ab. Der Ursprung der Daten ist dabei einerlei. Ob es sich um JSON-Daten von einem Webserver oder über einen File-Deskriptor eingelesene Blöcke vom lokalen Dateisystem handelt, kann der aufgerufenen Funktion herzlich egal sein. Der Vorteil: So bleibt sie flexibel, und braucht bei Änderungen der Datenquelle keine Änderungen im Code, denn das Interface bleibt das gleiche.
So öffnet Listing 1 die Ausgangsdatei, erhält aus dem Open()-Call ein Objekt vom Typ os.File, gibt dieses aber weiter an NewReader() des Pakets bufio, das einen Reader zurückgibt, mit dem der Aufrufer die Bytes aus der Datei schrittweise anpumpen kann. Ähnliches gilt für die Zieldatei, die in der Applikation ja als Device-Eintrag des Usb-Sticks vorliegt, aber auf Unix ist schönerweise alles eine Datei. Der Aufruf von os.OpenFile() mit der Option O_WRONLY in Zeile 25 öffnet den Eintrag zum Schreiben, setzt aber (wie bei Device-Einträgen üblich) voraus, dass letzterer schon existiert - die sonst bei Dateien übliche Option O_CREATE fehlt hier bewusst. Zeile 30 kreiert aus dem File-Objekt ein neues Writer-Objekt und der Kopiervorgang kann beginnen.
01 package main
02
03 import (
04 "bufio"
05 "os"
06 "io"
07 )
08
09 func cpChunks(src, dst string,
10 percent chan<- int) error {
11 data := make([]byte, 1024*256)
12
13 in, err := os.Open(src)
14 if err != nil {
15 return err
16 }
17 reader := bufio.NewReader(in)
18 defer in.Close()
19
20 fi, err := in.Stat()
21 if err != nil {
22 return err
23 }
24
25 out, err := os.OpenFile(dst,
26 os.O_WRONLY, 0644)
27 if err != nil {
28 return err
29 }
30 writer := bufio.NewWriter(out)
31 defer out.Close()
32
33 total := 0
34
35 for {
36 count, err := reader.Read(data)
37 total += count
38 data = data[:count]
39
40 if err == io.EOF {
41 break
42 } else if err != nil {
43 return err
44 }
45
46 _, err = writer.Write(data)
47 if err != nil {
48 return err
49 }
50
51 percent <- int(int64(total) *
52 int64(100) / fi.Size())
53 }
54
55 return nil
56 }
In der For-Schleife ab Zeile 35 holt nun der Reader entsprechend des vorher in Zeile 11 definierten Puffers data vier Megabyte große Datenbrocken ab. Dabei liefert die Read()-Funktion aber nicht immer vier Megabytes, denn am Ende der Datei können's auch mal weniger sein. Deshalb ist es essentiell, in Zeile 38 das data-Slice auf die tatsächlich geholten Bytes zu kürzen, gäbe die Funktion den Puffer einfach an den Writer weiter, schriebe ihn letzterer eiskalt in voller Länge in die Zieldatei. Aus einer fünf Megabyte großen Ausgangsdatei käme so eine acht Megabyte große Zieldatei heraus, die letzten drei Megabytes bestünden aus uninitialisiertem Müll.
Da das Einlesen der Daten in kleinen Schritten erfolgt und Zeile 20 mit os.Stat() vorher die Größe der Ausgangsdatei ermittelt hat, weiß die Funktion in jedem Schleifendurchgang, wie weit sie beim Kopieren schon fortgeschritten und wieviel noch zu tun ist. Zeile 51 schreibt dieses Verhältnis als Prozent-Integer in den als percent vom Aufrufer in die Funktion hereingereichten Go-Channel. Der Aufrufer liest später die eintrudelnden Werte und kann so einen Fortschrittsbalken nach rechts bewegen, während die Funktion noch arbeitet. Echtes Multitasking!
Wie nun findet der Flasher heraus, wann der neu angeschlossene USB-Stick erscheint? Die Funktion driveWatch() ab Zeile 14 in Listing 2 ruft hierzu zunächst devices() ab Zeile 64 auf, um zu sehen, welche Device-Einträge auf dem System unter dem Schema /dev/sd* zu sehen sind. Dort stehen normalerweise unter /dev/sda die erste Festplatte, und unter /dev/sdb und höher finden sich vielleicht noch andere SATA-Devices. Usb-Sticks erscheinen auf meinem System üblicherweise unter /dev/sdd, aber andernorts könnte das varieren. Das in Go eingebaute Globbing, das auch die Shell verwendet um zum Beispiel Wildcards wie * in Treffer umzuwandeln, meldet übrigens bei ungültigen Dateipfaden keinen Fehler, nur falsche Glob-Ausdrücke meckert es an. Findet ein Glob-Ausdruck in Go nichts, ist es angebracht, auch nach anderen Ursachen wie falschen Pfade oder mangelnden Zugangsberechtigungen zu forschen.
01 package main
02
03 import (
04 "bytes"
05 "errors"
06 "fmt"
07 "os/exec"
08 "path/filepath"
09 "strconv"
10 "strings"
11 "time"
12 )
13
14 func driveWatch(
15 done chan error) chan string {
16 seen := map[string]bool{}
17 init := true
18 drivech := make(chan string)
19 go func() {
20 for {
21 dpaths, err := devices()
22 if err != nil {
23 done <- err
24 }
25 for _, dpath := range dpaths {
26 if _, ok := seen[dpath]; !ok {
27 seen[dpath] = true
28 if !init {
29 drivech <- dpath
30 }
31 }
32 }
33 init = false
34 time.Sleep(1 * time.Second)
35 }
36 }()
37 return drivech
38 }
39
40 func driveSize(
41 path string) (string, error) {
42 var out bytes.Buffer
43 cmd := exec.Command(
44 "sfdisk", "-s", path)
45 cmd.Stdout = &out
46 cmd.Stderr = &out
47
48 err := cmd.Run()
49 if err != nil {
50 return "", err
51 }
52
53 sizeStr := strings.TrimSuffix(
54 out.String(), "\n")
55 size, err := strconv.Atoi(sizeStr)
56 if err != nil {
57 return "", err
58 }
59
60 return fmt.Sprintf("%.1f GB",
61 float64(size)/float64(1024*1024)), nil
62 }
63
64 func devices() ([]string, error) {
65 devices := []string{}
66 paths, _ := filepath.Glob("/dev/sd*")
67 if len(paths) == 0 {
68 return devices,
69 errors.New("No devices found")
70 }
71 for _, path := range paths {
72 devices = append(devices, path)
73 //filepath.Base(path))
74 }
75 return devices, nil
76 }
Deshalb sucht devices() ab Zeile 64 alle Einträge ab, und driveWatch() nimmt diese Pfade entgegen, um die Map-Variable seen mit den gefundenen Einträgen zu initialisieren. Diese Suche läuft asynchron ab, denn driveWatch() startet in Zeile 19 mit go func() eine parallel laufende Go-Routine. Das Hauptprogramm hüpft derweil ans Ende und gibt den neu angelegten Go-Channel drivech an den Aufrufer zurück, um diesem nach der anfänglichen Init-Phase neu entdeckte Drives zu melden.
Die im Hintergrund weiter aktive Go-Routine läuft derweil in einer Endlosschleife. Anfangs führt die Variable init ab Zeile 17 den Wert true, aber sobald die Funktion nach dem ersten Durchgang der For-Schleife alle bestehenden Devices abgeklappert hat, setzt Zeile 33 die Variable init auf false. Im Sekundentakt geht's nun weiter, immer wieder setzt sich die For-Schleife nach der Sleep-Anweisung in 34 in Bewegung und liest die aktuellen Device-Einträge. Findet sich ein neuer, der noch nicht in der Map seen steht, schiebt Zeile 29 den Pfad des Eintrags in den Go-Channel drivech, und das Hauptprogramm schnappt ihn von dort aus auf, nachdem es sehnlichst in Listing 3 in Zeile 57 blockierend (aber asynchron in einer Go-Routine) auf das Ergebnis gewartet hat.
Um herauszufinden, welche Speicherkapazität der gefundene Usb-Stick bietet, setzt Listing 2 in Zeile 43 den Befehl
$ sfdisk -s /dev/sdd
ab. In der Standardausgabe des in Go per os.Exec-Paket abgesetzten Shell-Kommandos steht ein einziger Integerwert, der die Kapazität des Sticks in Kilo-(!)-Bytes anzeigt. Vom Ergebnisstring schneidet Zeile 53 den Zeilenumbruch ab und Zeile 55 konvertiert ihn mit Atoi() aus dem Paket strconv in einen Integer. Zeile 61 dividiert das Ganze durch ein Megabyte, sodass die Kapazität in Gigabytes im Fließkommaformat herauskommt. Die Funktion gibt den Wert formschön als String zurück, damit der User in der UI verifizieren kann, ob es sich um einen Usb-Stick und nicht etwa um eine viel größere Festplatte handelt.
001 package main
002
003 import (
004 "flag"
005 "fmt"
006 ui "github.com/gizak/termui/v3"
007 "github.com/gizak/termui/v3/widgets"
008 "os"
009 "path"
010 )
011
012 func main() {
013 flag.Parse()
014 if flag.NArg() != 1 {
015 usage("Argument missing")
016 }
017 isofile := flag.Arg(0)
018 _, err := os.Stat(isofile)
019 if err != nil {
020 usage(fmt.Sprintf("%v\n", err))
021 }
022
023 if err = ui.Init(); err != nil {
024 panic(err)
025 }
026 var globalError error
027 defer func() {
028 if globalError != nil {
029 fmt.Printf("Error: %v\n",
030 globalError)
031 }
032 }()
033 defer ui.Close()
034
035 p := widgets.NewParagraph()
036 p.SetRect(0, 0, 55, 3)
037 p.Text = "Insert USB Stick"
038 p.TextStyle.Fg = ui.ColorBlack
039 ui.Render(p)
040
041 pb := widgets.NewGauge()
042 pb.Percent = 100
043 pb.SetRect(0, 2, 55, 5)
044 pb.Label = " "
045 pb.BarColor = ui.ColorBlack
046
047 done := make(chan error)
048 update := make(chan int)
049 confirm := make(chan bool)
050
051 uiEvents := ui.PollEvents()
052 drivech := driveWatch(done)
053
054 var usbPath string
055
056 go func() {
057 usbPath = <-drivech
058
059 size, err := driveSize(usbPath)
060 if err != nil {
061 done <- err
062 return
063 }
064
065 p.Text = fmt.Sprintf("Write to %s " +
066 "(%s)? Hit 'y' to continue.\n",
067 usbPath, size)
068 ui.Render(p)
069 }()
070
071 go func() {
072 for {
073 pb.Percent = <-update
074 ui.Render(pb)
075 }
076 }()
077
078 go func() {
079 <-confirm
080 p.Text = fmt.Sprintf(
081 "Copying to %s ...\n", usbPath)
082 ui.Render(p)
083 update <- 0
084 err := cpChunks(
085 isofile, usbPath, update)
086 if err != nil {
087 done <- err
088 }
089 p.Text = fmt.Sprintf("Done.\n")
090 update <- 0
091 ui.Render(p, pb)
092 }()
093
094 for {
095 select {
096 case err := <-done:
097 if err != nil {
098 globalError = err
099 return
100 }
101 case e := <-uiEvents:
102 switch e.ID {
103 case "q", "<C-c>":
104 return
105 case "y":
106 confirm <- true
107 }
108 }
109 }
110 }
111
112 func usage(msg string) {
113 fmt.Printf("%s\n", msg)
114 fmt.Printf("usage: %s iso-file\n",
115 path.Base(os.Args[0]))
116 os.Exit(1)
117 }
Ein Tool mit einer UI, auch wenn es nur eine Terminal-Applikation ist, macht doch ungleich mehr her als eines, das nur auf der Standardausgabe operiert, gerade wenn es dem User Eingaben zur Auswahl oder Bestätigung abverlangt. Das Hauptprogramm in Listing 3 nutzt dazu die schon in vorherigen Ausgaben vorgestellte Terminal-UI termui ([2]), deren Event-Framework sich zügig mit asynchron aufgerufenen Go-Routinen bedienen lässt. Die in den Abbildungen 1 bis 4 gezeigte UI offenbart zwei sogenannte Widgets, die übereinander im Hauptfenster der Terminal-UI liegen. Das obere ist dabei ein Text-Widget in der Variablen p, das dem User Statusmeldungen liefert und neue Instruktionen übermittelt. Das untere Widget, das die Variable pb referenziert, ist ein Fortschrittsbalken vom Type Gauge, der über einen Go-Kanal Updates einliest, und den Balken entsprechend der eintrudelnden Prozentwerte von links nach rechts bewegt.
Zunächst aber prüft Zeile 14 in Listing 3, ob das Hauptprogramm tatsächlich wie vorgeschrieben mit einer Iso-Datei als Parameter aufgerufen wurde und verzweigt zur usage()-Hilfeseite ab Zeile 112 falls das nicht der Fall ist. Für die interne Kommunikation zwischen den verschiedenen Programmteilen nutzt der Code sage und schreibe fünf verschiedene Channels, die Go-Programmierer laut offiziellen Richtlinien nur in homöopathischen Dosen verwenden sollten, aber was soll's!
Der schon besprochene Channel drivech meldet der in Zeile 57 blockierenden Go-Routine frisch eingestöpselte Usb-Sticks. Weiter bietet der Channel update einen Kommunikationskanal zwischen dem Datenkopierer cpChunks() in Listing 1 und dem Hauptprogramm. Immer wenn der Kopierer einen neuen Prozentwert meldet, löst Zeile 73 ihre Blockade und setzt den Prozentwert des Fortschrittsbalken in der Variable pb entsprechend. Der nachfolgende Aufruf der Funktion Render() frischt die UI auf und sorgt dafür, dass der Balken sich auch sichtbar bewegt. Sind alle Daten auf dem Usb-Stick angekommen, setzt Zeile 90 den Progressbar wieder auf 0%.
Tastatureingaben wie "Ctrl-C" oder "q" fängt die mit PollEvents() in Zeile 51 angestoßene Eventschleife ebenfalls über den Channel uiEvents ab. Zeile 101 analysiert die gedrückte Taste und läutet bei den beiden Abbrechersequenzen das Programmende ein. Wurde der Usb-Stick bereits erkannt, bremst die Go-Routine ab Zeile 78, indem sie auf Daten aus dem confirm-Channel in Zeile 79 wartet. Drückt der User die Taste y, speist Zeile 106 das Ereignis in den Channel confirm ein, Zeile 78 schnappt es auf, und öffnet die Schleusen zum Kopieren.
Der Channel done wiederum dient dem Hauptprogramm zur Kontrolle darüber, wann die UI zusammengefaltet und das Programm beendet werden soll. Dabei stellt sich das Problem, dass eine Terminal-UI nicht einfach nach Stderr schreiben oder das Programm mit panic() abbrechen kann, falls ein schwerer Fehler auftritt, denn Stderr ist im Grafikmodus geblockt und ein abrupt abgebrochenes Programm ließe ein unbenutzbares Terminal zurück, das der User nur durch das Schließen des Terminalfensters und dem Öffnen eines neuen reparieren könnte.
Listing 1 behilft sich damit, eventuell auftretende fatale Fehler in den Channel done einzuspeisen, wo Zeile 96 sie aufschnappt und in der in Zeile 26 deklarierten Variablen globalError ablegt. Die geschickte Aufreihung von defer-Anweisungen in den Zeilen 27 und 33 sorgt dafür, dass immer zuerst die UI geschlossen und erst danach der zum Programmabbruch führende Fehler in globalError auf Stdout ausgegeben wird. Nacheinander abgesetzte defer-Anweisungen kommen nämlich in umgekehrter Reihenfolge zur Ausführung: Go baut einen defer-Stack auf, indem es die ersten Einträge als letztes ausführt. Da das defer in Zeile 27 den globalen Fehler ausgibt und das defer in Zeile 33 die UI zusammenfaltet, faltet das Hauptprogramm am Ende immer erst die UI zusammen und gibt dann erst den Fehler aus. Umgekehrt ginge der Fehler verloren.
Mit den drei Dateien isoflash.go, cpchunks.go und drive.go in einem Verzeichnis führt der Aufruf
go mod init isoflash
go build
sudo ./isoflash ubuntu.iso
zu einem Binary isoflash, das der User per sudo aufrufen sollte, damit es auch Schreibrechte auf den Usb-Stick bekommt. Es weist den User sogleich an, den Usb-Stick einzustöpseln, findet ihn, und fängt an, nach erfolgter Bestätigung seitens des User, die Daten zu kopieren und den aktuellen Stand der Dinge mit einem Fortschrittsbalken anzuzeigen. Der Spass kann losgehen!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/08/snapshot/
Michael Schilli, "Fortschritt auf Raten": Linux-Magazin 12/18, S.X, <U>https://www.linux-magazin.de/ausgaben/2018/12/snapshot-9/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc