Distro-Papparazzi (Linux-Magazin, März 2013)

Wann frischen Fedora, Debian und Co. jeweils ihre Pakete auf? Ein Perl-Skript bemüht verschiedene Plugins, die auf den FTP- und HTTP-Servern der Distros herumschnüffeln, die Releasedaten heraussuchen und Vergleichslisten erstellen.

Alle großen Distros listen ihre Pakete mit dem zugehörigen Release-Datum irgendwo auf dem Netz auf. Ein Skript braucht also nur die Anfrage eines Users entgegenzunehmen, die verschiedenen Lokalitäten aufzusuchen, passende Paketnamen auszufiltern, die Datumsangaben einsammeln und die Ergebnisse dann tabelliert auszugeben.

Kein Standard

Die Schwierigkeit besteht nun freilich darin, dass die Distros ihre Release-Informationen in unterschiedlichen Formaten anbieten. So liegen die Pakete bei Ubuntu und Debian auf einem FTP-Server. Allerdings nicht alle in einem Verzeichnis, sondern aufgespalten in Unterverzeichnisse, deren Namen aus dem ersten Buchstaben des Paketnamens bestehen (Abbildung [1]). So liegt das Paket pulseaudio bei Debian im Verzeichnis p, während ein Server in Esslingen alle Fedora-Pakete in Verzeichnis zur Prozessorarchitektur auflistet (Abbildung 2). SUSE verfährt ähnlich, zeigt aber das Modifikationsdatum der einzelnen Dateien nicht wie ein FTP-Server an sondern in einem wohl selbsterfundenen Format (etwa 28-Nov-2012 17:02, Abbildung 3).

Abbildung 1: Debian und Ubuntu stellen die Release-Pakete in einer zweistufigen Hierarchie auf einem FTP-Server zur Schau.

Abbildung 2: Der FTP-Server von Fedora mit den aktuellen Paketen und deren Releasedaten

Abbildung 3: Suse wirft alle RPMs in ein Verzeichnis und serviert sie mit einem eigenen Datumsformat über HTTP

Plugins überwinden Unterschiede

Ein Skript, das diese Informationen von den unterschiedlichen Servern abholt, steht nun vor dem Problem, dass es einerseits Gemeinsamkeiten zwischen den Distros gibt, wie zum Beispiel die FTP-Server-artige Darstellung, aber andererseits auch Unterschiede wie die verschiedenen URLs oder das Datumsformat. Die unterschiedlichen Paketnamen zwischen den Distros stellen ein weiteres Problem dar, das sich teilweise durch unscharfe Suchen und manuelle Auswahl lösen lässt.

Abbildung 4: Das Skript dist in Aktion: Verschiedene Distro-Server berichten ihre Releasedaten zum Pulseaudio-Paket.

Duplizieren verpönt

Code zu duplizieren gilt in Entwicklerkreisen als schädlich, denn ändert sich eine Kleinigkeit, müssen alle Duplikate aufgespürt und nachbearbeitet werden. In der objektorientierten Programmierung existiert zu diesem Zweck der Mechanismus der Vererbung und das heute vorgestellte Perl-Skript dist bedient sich darum zum Einholen der Informationen verschiedner Plugins, die ihrerseits von anderen Plugins oder Utility-Modulen erben. Das Skript in Listing 1 definiert eine Klasse Distro, die eine Methode list() anbietet, die auf einen Suchstring hin die verschiedenen Distro-Server abklappert. Das Skript nimmt die Abfrage auf der Kommandozeile entgegen (z. B. distro pulseaudio), findet dann zur Laufzeit heraus, wieviele Distro-Plugins der User installiert hat, und ruft die list()-Methode jedes einzelnen der Reihe nach auf.

Alle Plugins halten sich an eine vorgegebene Schnittstelle, akzeptieren einen Aufruf der Methode list() mit einem Suchstring und geben eine Referenz auf einen Array mit den Treffern zurück. Jeder Treffer besteht aus einer Referenz auf einen Hash mit den Einträgen pkg und mtime, die den Namen gefundener Pakete und deren letztes Modifikationsdatum in Sekunden seit 1970 enthalten.

Code sparen mit Mouse

Wer viel objektorientiert in Perl programmiert, dem geht die weitschweifige Syntax für oft genutzte Bausteine wie Konstruktoren oder Parameterabfragen relativ schnell auf die Nerven. Deswegen hat es sich vor einiger Zeit das CPAN-Modul Moose zur Aufgabe gemacht, Perl mittels syntaktischer Zauberei zu einer objektorientierten Sprache erster Wahl zu mausern. Nun bietet Moose aber viele Funktionen, die einfache objekorientierte Programme nur selten nutzen, und deswegen haben einige CPAN-Programmierer abgespeckte Versionen ins Leben gerufen, die unter anderem Mouse und Moo heißen.

Listing 1 verwendet Mouse, könnte aber genauso gut mit Moose arbeiten, da die bei Moose übliche Verzögerung beim Laden im Sekundenbereich bei einem Einmal-Skript keine wesentliche Rolle spielt.

Listing 1: dist

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Log::Log4perl qw(:easy);
    04 # Log::Log4perl->easy_init($DEBUG);
    05 
    06 my( $query ) = @ARGV;
    07 die "usage: $0 query" if !defined $query;
    08 
    09 my $distro = Distro->new();
    10 $distro->list( $query );
    11 
    12 ###########################################
    13 package Distro;
    14 ###########################################
    15 use Mouse;
    16 use Module::Pluggable 
    17   require     => 1,
    18   search_dirs => ['.'];
    19 
    20 sub list {
    21   my( $self, $query ) = @_;
    22 
    23   for my $plugin ( $self->plugins() ) {
    24     next unless $plugin->can( "list" );
    25 
    26     print "[$plugin]\n";
    27     my $data = $plugin->list( $query );
    28 
    29     for ( @$data ) {
    30       print "$_->{ pkg }\t", 
    31         $plugin->dateformat( 
    32             $_->{ mtime } ), "\n";
    33     }
    34   }
    35 }

Beim Studium von Listing 1 fällt auf, dass die ab Zeile 13 definierte Klasse Distro keinen Konstruktor new() definiert, dass aber das Hauptprogramm eben diesen in Zeile 9 aufruft. Das Geheimnis ist schnell gelüftet, denn use Mouse im Code der Klasse schmuggelt heimlich einen Konstruktor ein. Dieser ruft nicht nur ein Distro-Objekt ins Leben sondern könnte bei Bedarf sogar noch benamte Parameter verarbeiten und intern im Objekt und mit sauberen Accessors nach außen verwalten.

Plugins ohne Ballast

Eine weitere Besonderheit in Listing 1 ist die mit dem CPAN-Modul Module::Pluggable eingeschleuste Plugin-Verwaltung. Der Parameter search_dirs in Zeile 18 gibt mit "." an, dass das Modul später im aktuellen Verzeichnis nach Dateien der Form Distro/Plugin/xxx.pm suchen wird. Die Option require ist auf einen wahren Wert gesetzt, also lädt das Modul mittels plugins() gefundene Plugins automatisch, instantiiert ihre Klasse und gibt eine Referenz auf das enstandene Objekt zurück. Jeder Plugin bietet (entweder direkt oder ererbt) eine Methode list() zum Einholen der Informationen an, sowie eine weitere Methode namens dateformat(), die das Sekundendatum in ein anwenderfreundliches Stringformat verwandelt.

Erben statt arbeiten

Listing 2 zeigt einen simplen Plugin zum Abfragen des Ubuntu-Servers. Das Debian-Derivat nutzt das gleiche Format zur Darstellung der Releaseprodukte wie die väterliche Debian-Distro. Um sich Arbeit zu sparen erbt der Debian-Plugin in Zeile 5 deswegen mit dem Mouse-Schlüsselwort extends vom Basisplugin Distro::Plugin::Debian und definiert lediglich eine Funktion base_url() mit dem Basis-URL zum FTP-Server des Ubuntu-Projekts. Die list()-Methode bietet der Ubuntu-Plugin ohne eine Zeile Code über die gleichnamige Methode in der Basisklasse Debian an. Auch wenn Perl später die ererbte Methode in einem anderen Modul ausführt, weiß es doch, dass es sich bei dem gerade aktiven Objekt um ein Ubuntu-Plugin und nicht um einen Debian-Plugin handelt. Ruft dann der Code im Debian-Plugin base_url() auf, springt Perl die Methode im Ubuntu-Plugin an. So reicht eine einzige Zeile im Ubuntu-Plugin, um die Debian-Funktionen mit einer anderen URL zu offerieren.

Listing 2: Ubuntu.pm

    01 ###########################################
    02 package Distro::Plugin::Ubuntu;
    03 ###########################################
    04 use Mouse;
    05 extends 'Distro::Plugin::Debian';
    06 
    07 sub base_url {
    08   return 
    09    "ftp://ftp.ubuntu.com/ubuntu/pool/main";
    10 }
    11 
    12 1;

Das Debian-Modul in Listing 3 muss schon etwas mehr schuften, definiert aber ebenfalls eine Funktion base_url(), die dort auf den Debian-FTP-Server zeigt. Die Methode list() ab Zeile 12 nimmt vereinbarungsgemäß einen Suchstring entgegen, extrahiert mit der Perl-Funktion substr() dessen ersten Buchstaben und baut einen URL zur zweistufigen Verzeichnisstruktur auf, in der das gesuchte Paket liegt. Die ererbte Methode dirlist aus dem Modul Distro::Plugin::FTP interpretiert das Directory-Listing eines FTP-Servers, extrahiert Paket- und Datumsangaben und gibt die vereinbarte Datenstruktur als Referenz auf einen Hash zurück.

Listing 3: Debian.pm

    01 ###########################################
    02 package Distro::Plugin::Debian;
    03 ###########################################
    04 use Mouse;
    05 extends 'Distro::Plugin::FTP';
    06 
    07 sub base_url {
    08   return 
    09    "ftp://ftp.debian.org/debian/pool/main";
    10 }
    11 
    12 sub list {
    13   my( $self, $query ) = @_;
    14 
    15   my $first = substr( $query, 0, 1 );
    16   my $url = $self->base_url() . 
    17     "/$first/$query";
    18 
    19   return $self->dirlist( $url );
    20 }
    21 
    22 1;

Das FTP-Modul in Listing 4 nutzt zum Einholen der FTP-URL das CPAN-Modul und Tausendsassa LWP::UserAgent. Die in den Abbildungen 1 und 2 sichtbaren Zeitstempel versteht das CPAN-Modul File::Listing einzulesen und zu interpretieren. Seine Methode parse_dir nimmt die Ausgabezeilen des FTP-Servers entgegen und fieselt Dateinamen, Dateityp, die Größe in Bytes, das Datum der letzten Modifikation und Berechtigungs-Modus heraus. Zurück kommt eine Liste mit Werten, von denen sich Zeile 28 nur Name und Datum schnappt und als Eintrag an die später ans Hauptprogramm zurückgereichte Datenstruktur anhängt.

Damit das Hauptprogramm den Sekundenwert des Zeitstempels ohne Mühe in ein DateTime-Objekt umwandeln kann, ruft die Methode dateformat() ab Zeile 39 in Distro::Plugin::FTP den alternativen DateTime-Konstruktor from_epoch() auf, der ein DateTime-Objekt des Zeitstempels zurückgibt. Beim FTP-Modul handelt es sich nicht um einen Distro-Plugin, sondern lediglich um ein Utility-Paket. Es definiert deshalb auch keine list()-Methode. Das Hauptprogramm in Listing 1 prüft dies mit der allen Perl-Objekten eigenen Methode can() in Zeile 24 und überspringt den Plugin in der Distroliste.

Listing 4: FTP.pm

    01 ###########################################
    02 package Distro::Plugin::FTP;
    03 ###########################################
    04 use Mouse;
    05 use LWP::UserAgent;
    06 use Log::Log4perl qw(:easy);
    07 use File::Listing;
    08 
    09 sub dirlist {
    10   my( $self, $url ) = @_;
    11 
    12   DEBUG "Listing $url";
    13 
    14   my $ua = LWP::UserAgent->new();
    15   my $resp = $ua->get( $url );
    16 
    17   my $listing = $resp->content();
    18   my @lines   = split /\n/, $listing;
    19   pop @lines;
    20 
    21   my @data = ();
    22 
    23   for (File::Listing::parse_dir(
    24           \@lines, 'GMT')) {
    25     my($name, $type, $size, 
    26        $mtime, $mode) = @$_;
    27     push @data, 
    28          { pkg => $name, mtime => $mtime };
    29   }
    30 
    31   DEBUG "Found ", scalar @data, " results";
    32   return \@data;
    33 }
    34 
    35 ###########################################
    36 sub dateformat  {
    37 ###########################################
    38   my( $self, $time ) = @_;
    39    
    40   my $dt = DateTime->from_epoch( 
    41       epoch => $time );
    42   return "$dt";
    43 }
    44 
    45 1;

Fedora-Pakete liegen ebenfalls auf einem FTP-Server, allerdings ohne die zweistufige Schichtung der Debian- und Ubuntu-Server. Folgerichtig erbt der Plugin in Listing 5 vom FTP-Plugin und stellt selbst nur die Methode list() zur Verfügung, die mit dirlist() die Daten einholt und sie mit einem einfachen Pattern Match auf diejenigen beschränkt, die auf den hereingereichten Suchstring passen. Der Rest, einschließlich der Datumskonvertierung mit dateformat(), wird ererbt.

Listing 5: Fedora.pm

    01 ###########################################
    02 package Distro::Plugin::Fedora;
    03 ###########################################
    04 use Mouse;
    05 extends 'Distro::Plugin::FTP';
    06 
    07 sub list {
    08   my( $self, $query ) = @_;
    09 
    10   my $listing = $self->dirlist( 
    11     "ftp://ftp-stud.hs-esslingen.de/pub/" .
    12     "fedora/linux//updates/17/x86_64" );
    13 
    14   my @result = grep {
    15          # match anywhere, not only front
    16        $_->{ pkg } =~ /$query/
    17   } @$listing;
    18 
    19   return \@result;
    20 }
    21 
    22 1;

OpenSuse tanzt aus der Reihe

Abbildung 5: Der Suse-Server sträubt sich mit unstrukturiertem HTML gegen das Scraping.

OpenSuse wiederum zeigt seine Pakete auf der in Listing 6 in Zeile 14 angegebenen URL auf einer HTML-Seite, auf der es von eingebettete Images und Links zu den .rpm-Dateien nur so wuchert (Abbildung 5). Was ein Link zu einem Paket ist und was wiederum nur als Navigationselement dient ist nicht ganz trivial herauszufinden, da auf der Seite keine HTML-Tags mit class-Attributen stehen.

Deshalb macht der Scraper Web::Scraper in Listing 6 das beste draus, extrahiert zunächst in Zeile 18 den gesamten Textsalat, der sich zwischen den <pre>-Tags befindet, um dann mit dem regulären Ausdruck in den Zeile 31-33 den strukturierten Text mit den RPM-Paketen und deren Releasedaten zu erfassen. Suses kreatives Datumsformat muss ein speziell eingerichteter DateTime-Formatter interpretieren. Als Zeitzone übergibt er in Zeile 28 den String "UTC", also die Standardzeit am Längengrad Null.

Listing 6: Suse.pm

    01 ###########################################
    02 package Distro::Plugin::Suse;
    03 ###########################################
    04 use Mouse;
    05 extends 'Distro::Plugin::FTP';
    06 use DateTime::Format::Strptime;
    07 use Web::Scraper;
    08 use URI;
    09 
    10 sub list {
    11   my( $self, $query ) = @_;
    12 
    13   my @result = ();
    14   my $url = "http://download.opensuse.org".
    15    "/update/openSUSE-current/x86_64/";
    16 
    17   my $rpms = scraper {
    18       process "pre", "text" => 'TEXT';
    19   };
    20 
    21   my $html = 
    22     $rpms->scrape( URI->new( $url ) );
    23   my $text = $html->{ text };
    24 
    25       # b=28-Nov-2012 c=17:02
    26   my $f = DateTime::Format::Strptime->new(
    27       pattern   => "%d-%b-%Y %H:%M",
    28       time_zone => "UTC",
    29   );
    30 
    31   while( $text =~ /^\s*(\S+\.rpm)
    32                     \s+(\d\S+)
    33                     \s+(\d\S+)
    34                   /msgx ) {
    35     my $pkg  = $1;
    36     my $date = "$2 $3";
    37 
    38     next if $pkg !~ /$query/;
    39 
    40     my $dt = $f->parse_datetime( $date );
    41     push @result, { 
    42       pkg => $pkg, mtime => $dt->epoch() };
    43   }
    44 
    45   return \@result;
    46 }
    47 
    48 1;

Zugeknöpfte Distros

Die Plugins lassen sich auch für andere Distros erweitern. So gibt sich Red Hat noch weniger Scraper-freundlich und bietet Paketinformationen nur an, wenn sich das Skript durch einige Webformulare klickt. Ähnlich kompliziert gibt sich SLES, die Suse Enterprise-Edition. Mit einem Scraper wie WWW::Mechanize vom CPAN sind mächtigere Plugins, die auch diese Klippen umschiffen, jedoch schnell implementiert.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2013/03/Perl

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.