Startstöpsel (Linux-Magazin, August 2020)

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.

Kopieren studieren

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.

Go-Design: Reader/Writer

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.

Listing 1: cpchunks.go

    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.

Prozente durch die Röhre

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.

Listing 2: drive.go

    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.

Drives entdecken

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.

Listing 3: isoflash.go

    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 }

Schöner mit UI

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.

Aufgeschoben oder aufgehoben?

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.

Feuer frei

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!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/08/snapshot/

[2]

Michael Schilli, "Fortschritt auf Raten": Linux-Magazin 12/18, S.X, <U>https://www.linux-magazin.de/ausgaben/2018/12/snapshot-9/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc