Netz und doppelter Boden (Linux-Magazin, Januar 2006)

Nicht immer vertrauen Entwickler ihre Source-Dateien früh genug einem Revision-Control-System an. Ein Perl-Dämon überwacht eine Verzeichnishierarchie, wird von der dnotify-Schnittstelle des Kernels über Änderungen informiert und versioniert alle Dateien transparent mit RCS.

Während früher Phasen eines Projekts probieren Entwickler gerne verschiedene Möglichkeiten aus, und nicht immer ist es ratsam, die ersten Ergebnisse schon der Versionskontrolle zu übergeben. Existiert das Repository noch nicht oder man konnte sich noch nicht über die Struktur einigen, wird ohne Sicherheitsnetz gearbeitet und manches Stück guter Code fällt einem übereilten rm * oder einem großflächigem Löschvorgang im Editor zum Opfer.

Mit dem Perlskript noworries erfolgt Versionskontrolle automatisch. Bei jedem Sichern einer Datei im Editor oder Shell-Manipulationen wie rm und mv erhält ein unsichtbar im Hintergrund laufender Dämon eine Nachricht, auf die er sich die neu erzeugte oder geänderte Datei schnappt und mit RCS versioniert. Für den Benutzer läuft der Vorgang magisch ab, wie Abbildung 1 zeigt. In der Shell wird zunächst eine neue Datei erzeugt und diese dann mit rm gelöscht. Ohne Perl- Hexerei wäre das das Ende der Datei datei, doch ein Aufruf von noworries -l datei im früheren Verzeichnis der Datei zeigt an, dass der Versionierer vor 17 Sekunden eine Sicherungskopie angelegt hat. Mit noworries -r 1.1 datei wird diese hervorgeholt und nach STDOUT geschrieben. Wie ist das möglich?

Abbildung 1: Eine neu angelegte Datei wird gelöscht und ihr Inhalt anschließend mit noworries gerettet.

Der Trick liegt keineswegs in manipulierten Shell-Funktionen, alles geht mit rechten Dingen zu. Allerdings läuft im Hintergrund eine Instanz des Skripts mit der Option -w (watch) und bespitzelt den File Alteration Monitor, der wiederum an der dnotify-Schnittstelle des Betriebssystemkernels lauscht. Immer wenn das Dateisystem ein neues Verzeichnis oder eine Datei erzeugt, verschiebt, löscht, oder deren Inhalt manipuliert, kommt eine Nachricht über den Vorfall im Kernel an. Der File Alteration Monitor (FAM) dockt sich an dnotify an und bringt sein Interesse an Ereignissen in bestimmten Dateiverzeichnissen zum Ausdruck. Auf dem CPAN gibt es ein passendes Perl-Modul (SGI::FAM), das die C-Schnittstelle nach Perl verlagert. Ein Aufruf der Methode next_event() blockt den Dämon dann so lange, bis ein Ergeignis vorliegt. Ein CPU-intensives Pollen ist nicht erforderlich.

Abbildung 2 zeigt ein weiteres Beispiel. Dort wird eine Datei zunächst erzeugt und dann zweimal hintereinander modifiziert. Im Hintergrund hat der Dämon deswegen drei Versionen angelegt (1.1, 1.2 und 1.3). Der Aufruf noworries -l datei zeigt sie anschließend an, auch wenn zwischenzeitlich die Datei gelöscht wurde.

Mit der Option -r 1.2 wählt der Benutzer anschließend die mittlere Version aus und leitet die Ausgabe zurück in die Datei datei, die der Dämon natürlich sofort wieder versioniert. Abbildung 3 zeigt dessen Aktionen, die er der Ordnung halber in der Logdatei /tmp/noworries.log mitprotokolliert.

Abbildung 2: An eine neu angelegte Datei wird in zwei Einzelschritten jeweils eine Zeile angehängt. noworries holt anschließend auf Anfrage wieder Version 2 hervor.

Abbildung 3: ... denn der Dämon überwacht hinter den Kulissen das Dateisystem und legt versionierte Sicherungskopien an, sobald sich etwas in den überwachten Verzeichnissen verändert.

noworries kümmert sich um alle Dateien und Verzeichnisse in beliebiger Tiefe unter ~/noworries im Home-Verzeichnis des Benutzers. Das ist die Spielwiese, in der auch gerne neue Verzeichnisse angelegt oder Tarbälle extrahiert werden dürfen. Parallel dazu baut der Dämon mit jeder neuen Datei eine Struktur unter ~/.noworries.rcs auf. Jedes Unterverzeichnis enthält ein Verzeichnis RCS, in der die versionierten Dateien lagern. rcs ist ein Unix-Urgestein und wird auch heute noch von Versions- Kontrollsystemen wie CVS oder Perforce verwendet. Um eine Datei datei 'einzuchecken', ist folgende Kommandosequenz erforderlich:

    echo "Daten!" >datei
    mkdir RCS
    ci datei
    co -l datei

Der Befehl ci aus dem rcs-Fundus erzeugt eine Versionsdatei RCS/datei,v. Das anschließende aus-checken mit co mit der Option -l (für lock) holt die aktuelle Version wieder zurück ins aktuelle Verzeichnis. Verändert man anschließend datei, gefolgt von einer weiteren ci/co-Kommandosequenz, liegen schon zwei Versionen vor, die co separat hervorholen kann. Das ebenfalls aus dem rcs-Fundus stammende Programm rlog erlaubt es, Meta-Informationen über die eingecheckten Versionen anzusehen.

Die Namen dieser Hilfsprogramme definiert Listing noworries in den Zeilen 18 bis 20. So wie angegeben müssen sie im PATH liegen, damit noworries sie aufrufen kann. Falls notwendig, lassen sich die vollständigen Pfade einkodieren.

noworries nutzt die von Sysadm::Install exportierten Funktionen mkd (Verzeichnis erzeugen), cp (Datei kopieren), cd (Verzeichnis wechseln), cdback (zurück zum alten Verzeichnis) und tap (Programme ausführen und Ausgaben aufsammeln), die alten Snapshot-Hasen natürlich schon aus [4] bekannt sind.

Überwachungsstaat

Bevor SGI::FAM Nachrichten über modifizierte Dateien in einem Verzeichnis erhält, muss FAM erst einmal Interesse daran beim Kernel bekunden. Der Aufruf $fam->monitor(...) mit dem Verzeichnis ~/noworries als Argument lässt Events eintrudeln, falls direkt in ~/noworries ein neues Verzeichnis angelegt oder eine Datei erzeugt wird. Allerdings gilt das nicht für Unterverzeichnisse, für diese ruft SGI::FAM sofort einen eigenen Monitor auf, sobald es von ihrer Erzeugung erfährt.

Mit der Option -w aufgerufen, ist noworries im Dämon-Mode und führt die Endlosschleife in der ab Zeile 64 definierten Funktion watcher aus. Der Methodenaufruf next_event() in Zeile 74 blockiert, bis eines der vielen Ereignisse eintritt, die FAM verwaltet. Um herauszufinden, welcher der aktiven Verzeichnismonitore angeschlagen hat, liefert die in Zeile 76 aufgerufene Methode which() des SGI::FAM-Objekts das auslösende Verzeichnis. Die Methode filename() des Events gibt den Namen des neuen, existierenden, modifizierten oder gelöschten Objekts zurück. Dabei kann es sich sowohl um ein Verzeichnis als auch um eine Datei handeln.

Die Art des Events gibt die Methode type() an. Für noworries interessante Werte sind "create" und "change". Neu erzeugte Verzeichnisse werden mit der Methode monitor() gleich in den Überwachungsstaat assimiliert, während neu erzeugte oder geänderte Dateien an die ab Zeile 133 definierte Funktion check_in() weitergereicht werden. Ähnliches gilt für Verzeichnisse, die der Dämon mit find findet, wenn er hochfährt und ~/noworries schon existiert. Die Hilfsfunktion subdir() ab Zeile 117 schraubt sich hierzu tiefer und tiefer in eine Verzeichnisstruktur und liefert alle darunter liegenden Verzeichnisse in beliebiger Tiefe.

Der ab Zeile 205 folgende Dokumentationsabschnitt dient nicht nur der Illustration, falls jemand perldoc noworries aufruft, sondern wird auch von der Funktion pod2usage() ausgegeben, falls jemand vergisst, die richtigen Parameter anzugeben.

Temporäre vi- oder emacs-Dateien zu versionieren, ergäbe keinen Sinn. Deshalb filtern die Zeilen 81 und 83 sie aus.

Geht es daran, dem Versionskontrollsystem eine Datei einzuverleiben, untersucht check_in ab Zeile 133 zunächst, ob es sich um eine Textdatei handelt. Binärdateien weist check_in ab Zeile 138 zurück. Die Funktion wird mit einem Pfadnamen relativ zu ~/noworries aufgerufen, da watcher() in Zeile 67 dort hin gesprungen ist. Zeile 148 kopiert die Originaldatei in den RCS-Baum und Zeile 153 ruft das Programm ci mit den Optionen -t und -m auf. Beiden übergibt es den Wert -, denn sowohl der erste als auch alle folgenden Check-in-Kommentare sind bedeutungslos. Es ist aber wichtig, zumindest irgendetwas anzugeben, da ci sonst interaktiv nachfragt. Zeile 158 führt den oben beschriebenen check-out aus und damit ist die Datei im Kasten. Die ausgecheckte Kopie wird bei der nächsten Änderung überschrieben und die neue Version mit ci eingecheckt.

Der wievielte ist heute?

Um abzufragen, welche Versionen für eine Datei verfügbar sind, ruft noworries die rcs-Funktion rlog auf. Diese liefert liefert die Versionsnummern mit Datumsangaben im Format yyyy/mm/dd hh:mm:ss und einer Angabe über die veränderten Zeilen gegenüber der letzten Version. Die erste Version hat naturgemäß nichts dergleichen, aber wenn Version 1.2 ``lines: +10 -0'' aufweist, heißt das, dass gegenüber Version 1.1 ganze 10 Zeilen hinzukamen, während keine Zeile gelöscht wurde.

Mit Datumsberechnungen hilft das Modul DateTime vom CPAN. Die RCS-Datumsangaben werden mit DateTime::Format::Strptime geparst und in Sekunden-seit-1970 umgerechnet. Der Konstruktor nimmt hierzu einen Formatstring der Form "%Y/%m/%d %H:%M:%S" entgegen und der anschließende Aufruf von parse_datetime() liefert im Erfolgsfall ein komplett initialisiertes DateTime-Objekt zurück. Durch die etwas unübersichtliche Ausgabe des Hilfsprogramms rlog hangelt sich die while-Schleife ab Zeile 187 mit einem mehrzeiligen regulären Ausdruck.

Die Funktion time_diff() ab Zeile 163 nimmt ein DateTime-Objekt entgegen und rechnet aus, wie alt eine Version in Sekunden, Minuten, Stunden, Tagen oder Wochen ist.

Listing 1: noworries

    001 #!/usr/bin/perl -w
    002 use strict;
    003 use Sysadm::Install qw(:all);
    004 use File::Find;
    005 use SGI::FAM;
    006 use Log::Log4perl qw(:easy);
    007 use File::Basename;
    008 use Getopt::Std;
    009 use File::Spec::Functions qw(rel2abs
    010                              abs2rel);
    011 use DateTime;
    012 use DateTime::Format::Strptime;
    013 use Pod::Usage;
    014 
    015 my $RCS_DIR = "$ENV{HOME}/.noworries.rcs";
    016 my $SAFE_DIR = "$ENV{HOME}/noworries";
    017 
    018 my $CI   = "ci";
    019 my $CO   = "co";
    020 my $RLOG = "rlog";
    021 
    022 getopts("dr:wl", \my %opts);
    023 
    024 mkd $RCS_DIR unless -d $RCS_DIR;
    025 
    026 Log::Log4perl->easy_init({ 
    027   category => 'main',
    028   level    => $opts{d} ? $DEBUG : $INFO, 
    029   file     => $opts{w} && !$opts{d} ? 
    030       "/tmp/noworries.log" : "stdout",
    031   layout   => "%d %p %m%n" });
    032 
    033 if($opts{w}) {
    034   INFO "$0 starting up";
    035   watcher();
    036 
    037 } elsif($opts{r} or $opts{l}) {
    038   my($file) = @ARGV;
    039   pod2usage("No file given") 
    040       unless defined $file;
    041 
    042   my $filename = basename $file;
    043 
    044   my $absfile = rel2abs($file);
    045   my $relfile = abs2rel($absfile,
    046                         $SAFE_DIR);
    047 
    048   my $reldir = dirname($relfile);
    049   cd "$RCS_DIR/$reldir";
    050 
    051   if($opts{l}) {
    052     rlog($filename);
    053   } else {
    054     sysrun("co", "-r$opts{r}", 
    055            "-p", $filename);
    056   }
    057   cdback;
    058 
    059 } else {
    060     pod2usage("No valid option given");
    061 }
    062 
    063 ###########################################
    064 sub watcher {
    065 ###########################################
    066 
    067   cd $SAFE_DIR;
    068     
    069   my $fam = SGI::FAM->new();
    070   watch_subdirs(".", $fam);
    071 
    072   while (1) {
    073       # Block until next event
    074     my $event=$fam->next_event();
    075     
    076     my $dir = $fam->which($event);
    077     my $fullpath = $dir . "/" . 
    078                    $event->filename();
    079     
    080       # Emacs temp files
    081     next if $fullpath =~ /~$/;
    082       # Vi temp files
    083     next if $fullpath =~ /\.sw[px]x?$/;
    084     
    085     DEBUG "Event: ", $event->type, 
    086           "(", $event->filename, ")";
    087     
    088     if($event->type eq "create" and 
    089        -d $fullpath) {
    090       DEBUG "Dynamically adding monitor ",
    091             "for directory $fullpath\n";
    092       $fam->monitor($fullpath);
    093 
    094     } elsif($event->type =~ /create|change/
    095             and -f $fullpath) {
    096       check_in($fullpath);
    097     }
    098   }
    099 }
    100     
    101 ###########################################
    102 sub watch_subdirs {
    103 ###########################################
    104     my($start_dir, $fam) = @_;
    105 
    106     $fam->monitor($start_dir);
    107 
    108     for my $dir (subdirs($start_dir)) {
    109         DEBUG "Adding monitor for $dir";
    110         $fam->monitor($dir);
    111     }
    112 
    113     return $fam;
    114 }
    115 
    116 ###########################################
    117 sub subdirs {
    118 ###########################################
    119     my($dir) = @_;
    120 
    121     my @dirs = ();
    122 
    123     find sub {
    124         return unless -d;
    125         return if /^\.\.?$/;
    126         push @dirs, $File::Find::name;
    127     }, $dir;
    128 
    129     return @dirs;
    130 }
    131 
    132 ###########################################
    133 sub check_in {
    134 ###########################################
    135   my ($file) = @_;
    136 
    137   if(! -T $file) {
    138       DEBUG "Skipping non-text file $file";
    139       return;
    140   }
    141 
    142   my $rel_dir = dirname($file);
    143   my $rcs_dir = "$RCS_DIR/$rel_dir/RCS";
    144 
    145   mkd $rcs_dir unless -d $rcs_dir;
    146 
    147   cd "$RCS_DIR/$rel_dir";
    148   cp "$SAFE_DIR/$file", ".";
    149   my $filename = basename($file);
    150 
    151   INFO "Checking $filename into RCS";
    152   my ($stdout, $stderr, $exit_code) = 
    153       tap($CI, "-t-", "-m-", $filename);
    154   INFO "Check-in result: ",
    155        "rc=$exit_code $stdout $stderr";
    156 
    157   ($stdout, $stderr, $exit_code) = 
    158                  tap($CO, "-l", $filename);
    159   cdback;
    160 }
    161 
    162 ###########################################
    163 sub time_diff {
    164 ###########################################
    165   my ($dt) = @_;
    166 
    167   my $dur = DateTime->now() - $dt;
    168 
    169   for(qw(weeks days hours 
    170          minutes seconds)) {
    171       my $u = $dur->in_units($_);
    172       return "$u $_" if $u;
    173   }
    174 }
    175 
    176 ###########################################
    177 sub rlog {
    178 ###########################################
    179   my ($file) = @_;
    180 
    181   my ($stdout, $stderr, $exit_code) = 
    182                         tap($RLOG, $file);
    183 
    184   my $p = DateTime::Format::Strptime->new(
    185            pattern => '%Y/%m/%d %H:%M:%S');
    186 
    187   while($stdout =~ /^revision\s(\S+).*?
    188                     date:\s(.*?); 
    189                     (.*?)$/gmxs) {
    190     my($rev, $date, $rest) = ($1, $2, $3);
    191 
    192       (my $lines) = 
    193                ($rest =~ /lines:\s+(.*)/);
    194       $lines ||= "first version";
    195 
    196       my $dt = $p->parse_datetime($date);
    197 
    198       print "$rev ", time_diff($dt), 
    199             " ago ($lines)\n";
    200   }
    201 }
    202 
    203 __END__
    204 
    205 =head1 NAME
    206 
    207     noworries - Developing with a safety net
    208 
    209 =head1 SYNOPSIS
    210 
    211                   # Print previous version
    212     noworries -r revision file # C
    213 
    214                   # List all revisions
    215     noworries -l file
    216 
    217     noworries -w  # Start the watcher

Leider ist dnotify nicht in der Lage, große Dateimengen zu verwalten. Nach etwa zweihundert Unterverzeichnissen ist typischerweise Schluss. In neueren Kerneln wurde dnotify deswegen durch inotify ersetzt, das sparsamer mit Ressourcen umgeht und besser skaliert. Auch FAM hat ausgedient, Gamin [3] soll der Nachfolger werden.

Der dnotify-Mechanismus des Kernels arbeitet nicht etwa mit den Inodes des Dateisystems, sondern tatsächlich mit Dateinamen, so dass ein mv datei1 datei2 zwei Events auslöst: Einen vom Typ "delete" und einen weiteren vom Typ "create". noworries stört das nicht, denn "delete"-Events ignoriert es, und falls die gleiche Datei später wieder auftaucht, wird sie einfach als neueste Version eingecheckt. Das Skript sollte man nur auf der lokalen Platte und nicht unter NFS verwenden, da FAM nur dann effizient arbeitet, wenn NFS-Gegenseite auch ein FAM läuft. Falls nicht, pollt es die andere Seite in regelmäßigen Abständen, was das Verfahren ad absurdum führt.

Installation

Vom CPAN sind die Module SGI::FAM, Sysadm::Install, DateTime, DateTime::Format::Strptime und Pod::Usage zu installieren, ihre Abhängigkeiten löst eine CPAN-Shell schnell auf. Wenn SGI::FAM beim Übersetzen mit der Fehlermeldung

    FAM.c:813: error: storage size of 'RETVAL' isn't known

stoppt, sollte Zeile 813 in FAM.c von enum FAMCodes RETVAL; in FAMCodes RETVAL; umgeändert werden, dann führt ein neuerliches make zum Erfolg.

Damit der Dämon immer aktiv ist, sollte er mit

    x777:3:respawn:su mschilli -c "/home/mschilli/bin/noworries -w"

in die /etc/inittab aufgenommen und der init-Dämon anschließend mit init q darauf hingewiesen werden. Der Prozess muss unter der ID des richtigen Benutzers laufen, damit $ENV{HOME} im Skript auf das richtige Home-Verzeichnis zeigt. init sorgt dafür, dass der Dämon noworries beim Systemstart hochfährt und die respawn-Option stellt sicher, dass ein eventuell versehentlich terminierter Prozess sofort wieder gestartet wird. Den Aufruf des Dämons sollte man vor der Installation aber auf jeden Fall zunächst von der Kommandozeile versuchen. Bei Problemen hilft die Option -d für debug weiter, die detaillierte Statusausgaben statt in der Datei /tmp/noworries.log in die Standardausgabe leitet.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2006/01/Perl

[2]
FAM Homepage: http://oss.sgi.com/projects/fam/

[3]
Gamin Homepage: http://www.gnome.org/~veillard/gamin/

[4]
``Muschelperle'', Michael Schilli, http://www.linux-magazin.de/Artikel/ausgabe/2005/02/perl/perl.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.