Spieglein, Spieglein (Linux-Magazin, Mai 2000)

Um zwei Verzeichnisbäume auf zwei verschiedenen Rechnern synchron zu halten, gibt es Tools wie rdist und rsync, die jedoch nicht überall zur Verfügung stehen. Ein Perl-Modul hilft nach und bietet sogar noch zusätzliche Funktionalität.

Um auf perlmeister.com üblicherweise etwas zu verändern, schicke ich Skripts und Dateien, die ich lokal getestet habe, per FTP an den Rechner bei meinem Internetprovider. Andererseits darf ich dort auch per telnet zugreifen, und so wird sich schon mal schnell eingeloggt, um kleine Fehler zu korrigieren. Das hat natürlich Inkonsistenzen zur Folge, Live- und Testversion driften mit der Zeit auseinander.

Ordnung stellt da ein Spiegelprogramm her, das schlau genug ist, zu erkennen, ob Dateien auf beiden Rechnern in unterschiedlichen Versionen vorliegen und auch, welche zuletzt verändert wurden. Ist's die Testversion, muß die Live-Version nachgezogen werden, wurde die Live-Version eigenmächtig verändert, muss der Fix wieder in die Test-Version einfließen.

Dabei sollten möglichst wenig Datenbytes durch die Leitung rauschen, da die Verbindung aus einer guten alten Telefonleitung mit Modemanschluss besteht.

Lösen lässt sich dieses Problem mit jeweils einer Zustandsdatei, die für alle Dateien unter einem Verzeichnisbaum deren Checksummen auflistet -- zusammen mit dem Datum, an dem die Dateien zuletzt modifiziert wurden. Diese Zustandsdatei wird in zwei Versionen erzeugt, eine für's Live- und eine für's Testsystem. Stellt man beide gegenüber, wird schnell klar, welche Dateien wohin wandern müssen um Konsistenz zu erzielen. Hier ein Auszug einer aktuell auf perlmeister.com gewonnenen Datei:

    KdAcNrPHbMAlXEebDTifMw 946759666 HTML/perl/index.html
    pn9+7O+DmVZMMpcbutYcBg 901438268 HTML/images/perlmeister.jpg
    I1cKtvJijHAiQGfBvz1M2A 915775874 HTML/perlpower/cdrom/scripts/basehtml.pl
    ...

Das erste Feld jeder Zeile der Synchronisationsdatei ist ein nach dem MD5-Verfahren generierter Digest-Stempel aus dem Inhalt der Datei, deren Name und Pfad im dritten Feld abgelegt sind. Zwei Dateien verschiedenen Inhalts liefern (fast) garantiert zwei verschiedene 16-Byte-Checksummen, die hier Base64-kodiert abgelegt sind. Im zweiten Feld jeder Zeile der Synchronisationsdatei steht das Datum der letzten Dateimodifikation, gemessen in den Unix-üblichen Sekunden seit 1970.

Zeigen nun die zwei Statusdateien von Live- und Testsystem zwei verschiedene MD5-Stempel einer Datei, so liegt sie offensichtlich auf den Rechnern A und B in unterschiedlichen Versionen vor. Weist das Modifikationsdatum der Datei auf Rechner A auf einen späteren Zeitpunkt als das der Datei auf Rechner B, wurde die Datei offensichtlich auf Rechner A zuletzt verändert und muss zur Synchronisation von A nach B kopiert werden. Liegt umgekehrt der Zeitstempel der Datei auf Rechner B weiter vorn, muss die B-Version nach A kopiert werden, um beide Verzeichnisbäume auf dem neuesten Stand zu halten.

Objektorientiert synchronisieren

Das in Listing Sync.pm dargestellte Modul Sync bietet nun eine objektorientierte Schnittstelle, um diese Statusdateien zu erzeugen, wieder auszulesen und mit dem Verzeichnisbaum abzugleichen. Die erste Version der Statusdatei erzeugt Sync, indem es den Verzeichnisbaum traversiert, zu jeder Datei einen MD5-Stempel erzeugt und ihn zusammen mit dem ebenfalls leicht verfügbaren letzten Modifikationsdatum ablegt. Allerdings ist die Erzeugung eines MD5-Digests eine aufwendige Angelegenheit: Jede Datei muss geöffnet, vollständig ausgelesen und die Daten wüsten Berechnungen unterworfen werden. Das kann sich bei unter Umständen tausenden von Dateien schon länger hinziehen. Geschickter ist es, eine schon bestehende Statusdatei dazu zu verwenden, nur die MD5-Stempel derjenigen Dateien zu berechnen, die sich seit dem letzten Lauf geändert haben. Da dieser Datumsstempel sowohl in der Statusdatei als auch (ohne die Datei zu öffnen) im Dateisystem erhältlich ist, kann so die Rechenzeit beim synchronisieren drastisch reduziert werden.

Hierbei gibt es vier Möglichkeiten:

Das Modul Sync bietet die Schnittstelle, um auf einem lokalen Rechner eine wie oben aufgelistete Statusdatei für einen Verzeichnisbaum zu erzeugen:

    use Sync;
    my $status = Sync->new(-basedir => "/usr/bin");

erzeugt ein neues Objekt vom Typ Sync, welches den Verzeichnisbaum unterhalb von /usr/bin kontrolliert. Existiert bereits eine Statusdatei von früheren Läufen her, kann diese zur beschleunigten Bearbeitung mit

    $status->read_status_file() or warn "No status file";

eingelesen werden. Sie liegt, falls vorhanden, definitionsgemäß als .syncstatus im Top-Verzeichnis des vorher angegebenen Dateibaums vor. Das Sync-Objekt, auf das $status zeigt, speichert die eingelesenen Werten intern. Um nun den Zustand des Verzeichnisbaumes zu kontrollieren, dazu dient die update_status-Methode:

    $status->update_status();

rattert durch den Baum und bringt das Sync-Objekt auf den neuesten Stand. Erst

    $status->write_status_file();

schreibt die gesammelten Werte wieder in die ursprüngliche Statusdatei zurück. Um deren Zustand mit einer weiteren Statusdatei zu vergleichen und eventuelle Synchronisierungsmaßnahmen abzuleiten, erzeugt man einfach ein zweites Sync-Objekt mit

    my $remote_status = Sync->new();
    $remote->read_status_file();

welches in diesem Fall wegen des im Konstrukturaufruf fehlenden -basedir-Parameters die Datei .syncstatus im aktuellen Verzeichnis sucht und einliest. Der anschließende Vergleich mit

    @actions = $status->compare($remote);

liefert eine Liste mit Vorschlägen zurück, um die festgestellten Inkonsistenzen beheben. Sync selbst führt diese Aktionen nicht aus, sondern gibt nur die für den Abgleich nötigen Informationen an Sync-Nutzer weiter -- wie das unten vorgestellte Skript sync.pl, welches nach Bedarf die passenden FTP-Befehle in Gang setzt.

Serverabzug

Listing syncserver.pl zeigt eine einfache Anwendung von Sync.pm, die auf dem Remote-Server läuft: syncserver.pl erstellt eine Statusdatei des Dateibaums unterhalb von /home/mschilli. Da es vorkommen kann, dass bestimmte Zweige des Baums uninteressant sind, erlaubt es das Sync-Modul, dem Konstruktor eine Liste von regulären Ausdrücken mitzugeben. Der Parameter -exclude nimmt eine Referenz auf eine Liste entgegen, die die regulären Ausdrücke als Elemente im Stringformat enthält. Passt auch nur ein Ausdruck auf einen Teil eines Pfades im Dateibaum, verfolgt Sync diesen nicht weiter, sondern fährt im Nachbarpfad fort. syncserver.pl definiert in den Zeilen 6 bis 9 einen Array @EXCLUDE, der die Elemente .htaccess, .htpasswd und HTML/_ enthält, denn die Apache-Kontrolldateien .ht* möchte ich nicht synchronisieren und auch die Pfade HTML/_vti_bin, HTML/_vti_log etc. sollen keine Rolle spielen, darum HTML/_, das passt auf alle.

Zeile 11 erzeugt ein Sync-Objekt, in dem es dem Konstruktor den Namen des Dateibaumes und eine Referenz auf den Array mit den Ausnahmeverzeichnissen mitgibt. Zeile 13 liest eine eventuell schon vorhandene Statusdatei ein, um eine höhere Verarbeitungsgeschwindigkeit zu erzielen. Falls diese nicht existiert, wird kein großes Rambazamba veranstaltet, sondern nur eine kleine Warnung ausgegeben -- beim ersten Aufruf von syncserver.pl ist das normal. Zeile 14 führt den Abgleich mit dem Dateibaum durch, Zeile 15 schreibt eine aufgefrischte Version der .syncstatus-Datei nach /home/schilli -- das war's!

Kopieraktionen mit Telnet und FTP

Das in Listing sync.pl vorgestellte Skript macht nun auf der lokalen Maschine folgendes, um den Rechner mit einem entfernten Server zu synchronisieren: Es ruft über telnet das Skript syncserver.pl auf der Remote-Maschine auf, welches dort den Dateibaum analysiert und eine neue Statusdatei .syncstatus anlegt. Anschließend startet die lokale Maschine einen FTP-Prozess, der sich diese Statusdatei von der Remote-Maschine holt. Einmal eingetroffen, wird sie von einem Sync-Objekt eingelesen und dieses anschließend mit einem zweiten Sync-Objekt, das den lokalen Zustand der Maschine widerspiegelt, verglichen.

Entsprechend den oben dargelegten Ergebnissen des MD5-Stempel- und Datumsvergleichs wird es nun eventuell notwendig, eine Reihe von Dateien zwischen Local- und Remote-Rechner hin- oder herzukopieren. Da sich dies unter Umständen kritisch auswirken kann, bietet sync.pl eine interaktive Schnittstelle an, die den Anwender auswählen lässt, ob er

will. Dabei schlägt sync.pl das entsprechend seinen Untersuchungen Vernünftigste als Default-Eintrag vor, so dass der Anwender in den meisten Fällen nur die Return-Taste drücken muss, um den richtigen Vorgang einzuleiten. Eine typische Session mit sync.pl sieht folgendermaßen aus:

    $ sync.pl
    Running sync.pl on remote.host.com ...
    Grabbing .syncstatus from remote.host.com ...
    Reading in remote .syncstatus ...
    4 actions necessary.
    ...
    bin/myscript.pl -- Local newer:
    [G]et Remote
    [P]ush Local
    [I]gnore
    [D]elete local/remote
    [L]ocal delete
    [R]emote delete
    [P]> _

sync.pl hat also festgestellt, dass die Datei bin/myscript.pl (Pfad relativ zum überwachten Verzeichnisbaum) lokal in einer aktuelleren Version (dem Zeitstempel nach zu urteilen) vorliegt als auf dem Remote-Server und meldet dies mit "Local newer". Tippte der Benutzer jetzt G für Get Remote (Groß- und Kleinbuchstaben werden gleichermaßen anerkannt), gefolgt von der Return-Taste, kopierte sync.pl die Server-Version auf den lokalen Rechner -- doch das wäre im vorliegenden Fall falsch, da ja, wie gemeldet, die lokale Version die neuere ist, die es zu propagieren gilt. Mit P liesse sich der ``richtige'' Vorgang, der Push der lokalen Datei auf den Server, einleiten. Die Eingabe L veranlasst sync.pl, die lokale Version zu löschen, mit R annulliert es die Remote-Version. Mit D für Delete radiert das Spiegelprogramm beide Versionen aus. Mit I wird die Inkonsistenz ignoriert und mit der nächsten notwendigen Aktion fortgefahren. Wie oben ersichtlich, hat sync.pl in der letzten Zeile der Ausgabe bereits mit P die vernünftigste Lösung vorgeschlagen -- drückt der Anwender lediglich die Return-Taste, wandert eine Kopie der neuen lokalen Version auf den Server und der Abgleich ist -- höchstwahrscheinlich zur Zufriedenheit aller -- durchgeführt.

Wie funktioniert's?

Die Konfigurationssektion in sync.pl zwischen den Zeilen 4 und 16 legt eine Reihe von Parametern fest: Das lokale Verzeichnis des zu spiegelnden Verzeichnisbaums ($LOCAL_DIR), eine Liste von regulären Ausdrücken von Zweigen, die wir vom Spiegeln ausschließen wollen (@EXCLUDE), den Remote-Rechner und das zu spiegelnde Verzeichnis dort ($REMOTE_HOST und $REMOTE_DIR), Benutzername und Passwort dort ($USERNAME, $PASSWD), einen regulären Ausdruck für den dort erwarteten Shell-Prompt, den Namen des Skripts, das auf dem Remote-Server die Status-Datei erzeugt ($SERVERSYNC), den Namen des GNU-Zip-Programms dort ($GZIP) und eine Konstante für den Timeout einer Telnet-Session in Sekunden ($TELNET_TO).

Die Zeilen 19 bis 23 ziehen benötigte Zusatzmodule herein: Das oben schon erwähnte und weiter unten ausführlicher beschriebene Sync zur Synchronisation zweier Verzeichnisbäume, sowie die Helfer Net::FTP und Net::Telnet, die den Netzwerkverkehr über telnet und ftp regeln.

File::Basename stellt die basename- und dirname-Funktionen parat, die wie ihre Verwandten in der Shell funktionieren und Datei- und Pfad aus einer vollständigen Pfadangabe extrahieren. File::Path ist in der Lage, beliebig verschachtelte Verzeichnisse auf einen Schlag anzulegen, die exportierte Funktion mkpath ist ein mkdir mit Tiefenwirkung.

Es folgt die Erzeugung der Statusdatei des lokalen Baums in den Zeilen 28 bis 32. Zeile 33 legt den im Modul Sync.pm versteckten Namen der Statusdatei in der Variablen $stat_file für später ab.

Sodann muss der Server ebenfalls eine Statusdatei erstellen. Zeile 39 erzeugt ein Telnet-Objekt, dann werden die Verbindung geöffnet, Benutzername und Passwort zum Einloggen gesendet und in der sich öffnenden Shell das Kommando sync.pl abgesetzt, welches wiederum auf dem Remote-Server den Verzeichnisbaum durchstöbert und nach getaner Arbeit eine Status-Datei anlegt. Das gzip-Programm auf dem Server komprimiert diese und die Zeilen 54 bis 64 nutzen das Net::FTP-Modul, um sie auf die lokale Maschine zu ziehen, wo sie ein Sync-Objekt mit dem Namen $remote einliest. Zeile 77 lässt die compare-Methode des Sync-Objekts die Aktionen bestimmen, die sich aus dem Vergleich beider Statusdateien ergeben.

Ab Zeile 82 wird wieder per FTP am Server angedockt, um die notwendigen Dateiabgleiche durchzuführen -- nicht ohne vorher mit dem Anwender Rücksprache zu halten, freilich.

Wie sich später im Modul Sync zeigen wird, besteht eine Aktion aus einer Referenz auf eine Liste mit drei Elementen: Die (relative) Pfadangabe der betreffenden Datei, ein String mit Buchstaben für die erlaubten Aktionen ("GPI" bedeutet z.B. Get, Push, Ignore) und eine erklärenden Nachricht.

Zeile 98 druckt die Einleitung aus, die dem Benutzer hilft, sich für eine Aktion zu entscheiden, Zeile 99 ruft mit ask_choice eine in Zeile 131 definierte Funktion auf, die eine Reihe von Aktionen zur Auswahl stellt, die erste Aktion im String zum Default-Wert macht und dem Benutzer eine Entscheidung abverlangt.

Den Rückgabewert von ask_choice erhält $choice zugewiesen. Es ist einer der Buchstaben g, p, r, l, d oder i, der, wie oben beschrieben, indiziert, wie der Abgleich durchzuführen ist (g: Get, p: Push, etc.).

Beschränkungen

Mit dem FTP-Abgleich sind einige Beschränkungen verbunden: So kümmert sich sync.pl nicht um die Benutzerrechte von Dateien und man muss, falls notwendig, beim ersten Mal von Hand die Ausführungsrechte ändern. Das Synchronisationsverfahren geht auch davon aus, dass die lokale und die Remote-Maschine in etwa dieselbe Uhrzeit fahren, andernfalls ist nicht klar, welche Version einer Datei zuletzt verändert wurde. Und, eine Beschränkung von dem weiter unten in Sync.pm verwendeten Modul File::Find: Es verfolgt keine symbolischen Links.

Herzstück

Dreh- und Angelpunkt ist freilich das Modul Sync aus Listing Sync.pm, das drei Zusatzmodule verwendet: IO::File für neumodische File-Handles, die man ohne Glob-Jonglierung in normalen Variablen speichern kann (schön für Filehandles als Instanzvariablen für Objekte), weiter File::Find, um Dateibäume zu durchsuchen und schließlich Digest::MD5, das Modul, das MD5-Stempel erzeugt.

Der Konstruktor new ab Zeile 16 nimmt mit dem Hash %hash die flexiblen Parameter entgegen, so dass auf

    Sync->new(-basedir => "abc", 
              -exclude => ["/tmp"]);

der Hash %hash unter den Keys -basedir und -exclude die entsprechenden Werte führt. Zeile 24 schiebt den Namen der Statusdatei auf die exclude-Liste, um diese automatisch vom Abgleich auszuschließen. Zeile 27 compiliert die als Strings gegebenen regulären Ausdrücke mit dem in perl 5.005_03 brandneuen qr-Operator in reguläre Ausdrücke und schiebt diese auf eine Liste, die unter der Instanzvariablen exclude aufgehängt ist.

read_status_file ab Zeile 35 liest die Statusdatei ein, die im obersten Verzeichnis des zu spiegelnden Verzeichnisbaums liegt. Jede Zeile der Datei führt den MD5-Hash, den Zeitpunkt der letzten Modifikation und die Datei selbst mitsamt relativer Pfadangabe, vom Verzeichnisbaum aus gerechnet.

Den Zustand des überwachten Dateibaums hält das Sync-Objekt in $self->{status} vor, eine Referenz auf einen Hash, der zu jeder Pfadangabe auf eine Liste verweist, die den MD5-Hash und den Modifizierungszeitstempel der jeweiligen Datei enthält.

update_status ab Zeile 56 ruft die find-Funktion des File::Find-Moduls auf, die für alles Gefundene unter dem basedir-Verzeichnis die finder-Funktion aufruft und in $_ (eine Schrulle des File::Find-Moduls) den jeweiligen Dateisystemeintrag übergibt. Dort geht's in Zeile 78 gleich wieder zurück, falls das Gefundene keine Datei ist. Die Zeilen 80 und 81 legen in $path den Pfad dorthin ab -- relativ zum Basis-Verzeichnis, das in der Instanzvariablen basedir liegt.

Die foreach-Schleife zwischen 83 und 87 probiert, ob einer der für uninteressante Pfade angegebenen regulären Ausdrücke passt und, falls ja, wird $_ auf den ursprünglichen Wert zurückgesetzt (das will Find::Find so) und zurückgesprungen. Zeile 89 bestimmt das Modifikationsdatum der Datei und legt es in $mod_time ab. Wenn der finder über eine Datei stolpert, die nicht im Hash unter $self->{status} hängt, also noch nie bearbeitet wurde, oder eine Datei zwar dort registriert ist, aber ihr Zeitstempel anzeigt, dass sie zwischenzeitlich modifiziert wurde, konstruiert Zeile 96 ein neues Digest::MD5-Objekt, dessen addfile-Methode einen Filehandle-Glob einer geöffneten Datei entgegennimmt, die Daten einliest und den MD5-Wert bestimmt. Die b64digest-Methode macht einen Base64-codierten String daraus, der zusammen mit dem Zeitstempel in eine Liste wandert, die später wieder unter dem Pfadnamen in $self->{status}->{Pfadname} auffindbar ist. Zeile 105 setzt, File::Find zuliebe, $_ wieder auf den Wert zurück, den es beim Aufruf der finder-Funktion hatte.

Wenn update_status also aus der find-Funktion zurückgekehrt, haben alle neuen und veränderten Dateien ihren Weg ins Sync-Objekt gefunden -- doch was ist mit Dateien, die das Sync-Objekt nach dem Lesen der Statusdatei kannte, die aber zwischenzeitlich aus dem Dateibaum verschwunden sind? Eine Hilfs-Instanzvariable status_chk des Sync-Objekts wurde vor dem Aufruf von find in Zeile 60 initialisiert, um auf einen Hash zu zeigen, der alle zu diesem Zeitpunkt bekannten Dateipfade als Schlüssel enthält. In finder werden aus diesem Hilfs-Hash in Zeile 91 alle Dateipfade gelöscht, die auch tatsächlich im Dateisystem gefunden wurden. Kommt update_status aus find zurück, bleiben in $self->{status_chk} nur Dateien übrig, die verschwunden sind und deshalb befreit die foreach-Schleife in 67 bis 69 den Status-Hash von ihnen.

Die compare-Methode des Sync-Objekts ab Zeile 130 vergleicht zwei Sync-Objekte und schlägt Aktionen vor, die Dateibäume zu bereinigen. Eine Aktion besteht aus der Pfadangabe der Datei, einem String, dessen Zeichen geeignete Kopier/Löschaktionen symbolisieren und einer kurzen Meldung, die den Zustand beschreibt. Stellt compare also fest, dass z.B. die zwei MD5-Stempel einer Datei gleich, der lokale Zeitstempel aber weiter in der Zukunft liegt als der der Remote-Server-Version, schließt es messerscharf, dass die Datei lokal zuletzt verändert wurde und schlägt, wie in den Zeilen 152-153, "PGIDLR" vor: Das vernünftigste scheint der Push der lokalen Datei auf die Remote-Maschine zu sein, dann kommt der Get, Ignore, Delete, Local delete, Remote delete. Dateien, die nur in der Statusdatei des Remote-Servers auftauchen aber nicht in der des lokalen Rechners, behandelt die erste foreach-Schleife nicht, deswegen kommt in Zeile 164 eine zweite Schleife zum Einsatz, die nach Dateien sucht, die ausschließlich auf dem Remote-Server gefunden wurden. Zeile 169 eliminiert wieder unerwünschte Pfade (für den Fall, dass sie syncserver.pl durchschlüpfen ließ) und Zeile 176 gibt schließlich @actions zurück, eine Liste mit Aktionen als Elementen, sortiert nach den Dateipfaden, die jeweils als zweites Element in den Unter-Listen stehen (deswegen $a->[0] und $b->[0]).

write_status_file ab Zeile 130 schreibt einfach den Status-Hash in die Statusdatei, ein Eintrag pro Zeile, und die Einzelfelder durch Leerzeichen getrennt:

    MD5-Stempel Zeitstempel Dateipfad
    ...

stat_file ab Zeile 180 ist nur eine Accessor-Funktion für die Klassenvariable $STAT_FILE, die den Namen der Statusdatei festlegt.

Installation

Net::Telnet, Net::FTP und Digest::MD5 gibt's auf dem CPAN. Sie lassen sich am einfachsten mit der CPAN-Shell installieren:

    perl -MCPAN -eshell
    cpan> install Net::Telnet
    cpan> install Net::FTP
    cpan> install Digest::MD5

Der Rest der verwendeten Module ist bei perl 5.005_03 dabei, mit dem das Skript wegen dem verwendeten qr-Konstrukt auch laufen muss. syncserver.pl kommt auf die Remote-Maschine, in einen Pfad, unter dem die telnet-Shell das Skript auch findet. Der ``Shebang'', also die erste Zeile, die den Interpreter festlegt, muss, falls der Perl-Interpreter dort nicht unter /usr/bin/perl wartet, korrigiert werden. Ausserdem müssen auf der Remote-Maschine die Module Sync.pm und Digest::MD5 verfügbar sein. Die lokale Maschine braucht sync.pl, Sync.pm Net::Telnet, Net::FTP und Digest::MD5. Dann noch schnell die Zeilen 9 bis 16 in sync.pl an die lokalen Gegebenheiten angepasst -- und schon kann der Abgleich mit

    sync.pl

beginnen -- bis zum nächsten Mal, spiegelt fleissig!

Listing syncserver.pl

    01 #!/usr/bin/perl -w
    02 
    03 use Sync;
    04 
    05 my $LOCAL_DIR   = "/home/mschilli";
    06 my @EXCLUDE     = qw( .htaccess
    07                       .htpasswd
    08                       HTML/_
    09                     );
    10 
    11 $local  = Sync->new(-basedir => $LOCAL_DIR, 
    12                     -exclude => \@EXCLUDE);
    13 $local->read_status_file() or warn "No status file found";
    14 $local->update_status();
    15 $local->write_status_file();

Listing Sync.pm

    001 package Sync;
    002 ##################################################
    003 # mschilli1@aol.com, 2000
    004 ##################################################
    005 
    006 use IO::File;
    007 use File::Find;
    008 use Digest::MD5 qw(md5_base64);
    009 
    010 my $STAT_FILE = ".syncstatus";
    011 
    012 ##################################################
    013 # $status = Sync->new(-basedir => "base/dir",
    014 #     -exclude => ["/excluded", '/dir/.*\.gif']);
    015 ##################################################
    016 sub new {
    017 ##################################################
    018     my ($class, %hash) = @_;
    019 
    020     my $self = { basedir => $hash{-basedir} || ".",
    021                  status  => {}
    022                };
    023 
    024     push( @{$hash{-exclude}}, $STAT_FILE);
    025 
    026     if(exists $hash{-exclude}) {
    027         $self->{exclude} = 
    028                  [map qr($_), @{$hash{-exclude}}];
    029     }
    030 
    031     bless $self, $class;
    032 }
    033 
    034 ##################################################
    035 sub read_status_file {
    036 ##################################################
    037     my $self = shift;
    038 
    039     my $file = "$self->{basedir}/$STAT_FILE";
    040 
    041     open STAT, "<$file" or return 0;
    042 
    043     while(<STAT>) {
    044         # MD5-Hash, timestamp, path
    045         if(/(\S+) (\d+) (.*)/) {
    046             $self->{status}->{$3} = [$1, $2];
    047         }
    048     }
    049 
    050     close STAT;
    051 
    052     return 1;
    053 }
    054 
    055 ##################################################
    056 sub update_status {
    057 ##################################################
    058     my($self) = @_;
    059 
    060     $self->{status_chk} = { %{$self->{status}} };
    061 
    062     find( sub { $self->finder }, $self->{basedir});
    063 
    064     # Everything that's left in status_chk is a
    065     # ref in the .syncstatus file without a 
    066     # corresponding real file -- axe them all!
    067     foreach my $path (keys %{$self->{status_chk}}) {
    068         delete $self->{status}->{$path};
    069     }
    070 }
    071 
    072 ##################################################
    073 sub finder {
    074 ##################################################
    075     my ($self)  = @_;
    076     my $file    = $_;
    077 
    078     return unless -f $file;
    079 
    080     my $path = "$File::Find::dir/$file";
    081     $path =~ s#^$self->{basedir}/##;
    082 
    083     foreach my $regex (@{$self->{exclude}}) {
    084         if($path =~ $regex) {
    085             $_ = $file; return 1;
    086         }
    087     }
    088 
    089     my $mod_time = (stat($file))[9];
    090 
    091     delete $self->{status_chk}->{$path} if 
    092         exists $self->{status}->{$path};
    093 
    094     if(!exists $self->{status}->{$path} ||
    095        $mod_time > $self->{status}->{$path}->[1]) {
    096             my $ctx = Digest::MD5->new();
    097             open FILE, "<$file" or 
    098                          die "Cannot open $file";
    099             $ctx->addfile(*FILE);
    100             close FILE;
    101             $self->{status}->{$path} = 
    102                       [$ctx->b64digest, $mod_time];
    103     }
    104 
    105     $_ = $file;
    106 }
    107 
    108 
    109 ##################################################
    110 sub write_status_file {
    111 ##################################################
    112     my $self = shift;
    113 
    114     my $path = "$self->{basedir}/$STAT_FILE";
    115 
    116     my $sfh = IO::File->new("> $path") or
    117         die "Cannot open $path";
    118 
    119     foreach my $path (keys %{$self->{status}}) {
    120         print $sfh "@{$self->{status}->{$path}}" .
    121                    " $path\n";
    122     }
    123 
    124     $sfh->close;
    125 }
    126 
    127 ##################################################
    128 # Compare
    129 ##################################################
    130 sub compare {
    131     my ($self, $remote) = @_;
    132 
    133     my @actions = ();
    134 
    135     foreach $path (keys %{$self->{status}}) {
    136 
    137         if(exists $remote->{status}->{$path}) {
    138 
    139             my ($local_md5, $local_time) = 
    140                       @{$self->{status}->{$path}};
    141             my ($remote_md5, $remote_time) = 
    142                     @{$remote->{status}->{$path}};
    143             
    144             next if $remote_md5 eq $local_md5;
    145 
    146             if($remote_time > $local_time) {
    147                 # Server got a newer version
    148                 push(@actions, 
    149                 [$path, "GPIDLR", "Remote newer"]);
    150             } else {
    151                 # Client got a newer version
    152                 push(@actions, 
    153                  [$path, "PGIDLR", "Local newer"]);
    154             }
    155         } else {
    156             # File's not on remote -- local only
    157             push(@actions, 
    158                  [$path, "PLI", "Local only"]);
    159         }
    160     }
    161 
    162     # Files on the remote server but not local:
    163 REMOTE:
    164     foreach $path (keys %{$remote->{status}}) {
    165 
    166         next if exists $self->{status}->{$path};
    167 
    168         foreach my $regex (@{$self->{exclude}}) {
    169             next REMOTE if $path =~ $regex;
    170         }
    171 
    172         push(@actions, 
    173              [$path, "GRI", "Remote only"]);
    174     }
    175 
    176     sort { $a->[0] cmp $b->[0] } @actions;
    177 }
    178 
    179 ##################################################
    180 sub stat_file {
    181 ##################################################
    182     return $STAT_FILE;
    183 }
    184 
    185 1;

Listing sync.pl

    001 #!/usr/bin/perl -w
    002 
    003 ##################################################
    004 my $LOCAL_DIR   = "/my/local/directory";
    005 my @EXCLUDE     = qw( .htaccess
    006                       .htpasswd
    007                       HTML/_
    008                     );
    009 my $REMOTE_HOST = "remote.host.com";
    010 my $REMOTE_DIR  = ".";
    011 my $USERNAME    = "ratbert";
    012 my $PASSWD      = "nixgibts!";
    013 my $PROMPT      = '/\$/';
    014 my $SERVERSYNC  = "sync.pl";
    015 my $GZIP        = "gzip";
    016 my $TELNET_TO   = 300;
    017 ##################################################
    018 
    019 use Sync;
    020 use Net::FTP;
    021 use Net::Telnet;
    022 use File::Basename;
    023 use File::Path;
    024 
    025 ##################################################
    026 # Read local tree
    027 ##################################################
    028 $local  = Sync->new(-basedir => $LOCAL_DIR, 
    029                     -exclude => \@EXCLUDE);
    030 $local->read_status_file() or warn "No status file";
    031 $local->update_status();
    032 $local->write_status_file();
    033 my $stat_file = $local->stat_file();
    034 
    035 ##################################################
    036 # First, run the sync.pl script on the server
    037 ##################################################
    038 print "Running $SERVERSYNC on $REMOTE_HOST ...\n";
    039 $telnet = new Net::Telnet (Timeout => $TELNET_TO,
    040                            Prompt  => $PROMPT);
    041 $telnet->open($REMOTE_HOST) or 
    042                    die "Cannot open $REMOTE_HOST";
    043 $telnet->login($USERNAME, $PASSWD) or 
    044                                die "Cannot login";
    045 $telnet->prompt($PROMPT);
    046 $telnet->cmd("cd $REMOTE_DIR; $SERVERSYNC");
    047 $telnet->cmd("$GZIP -9c $stat_file >$stat_file.gz");
    048 $telnet->close;
    049 
    050 ##################################################
    051 # Now, fetch the .syncstatus file from the server
    052 ##################################################
    053 print "Grabbing $stat_file from $REMOTE_HOST ...\n";
    054 $ftp = Net::FTP->new($REMOTE_HOST) or 
    055              die "Cannot connect to $REMOTE_HOST";
    056 $ftp->login($USERNAME, $PASSWD) or 
    057              die "Cannot login";
    058 $ftp->cwd($REMOTE_DIR) or 
    059              die "Cannot chddir to $REMOTE_DIR";
    060 $ftp->binary;
    061 $ftp->get("$stat_file.gz") or
    062              die "Cannot get $stat_file.gz";
    063 system("$GZIP -df $stat_file.gz");
    064 $ftp->quit;
    065 
    066 ##################################################
    067 # Read remote tree via status file
    068 ##################################################
    069 print "Reading in remote $stat_file ...\n";
    070 $remote = Sync->new(-exclude => \@EXCLUDE);
    071 $remote->read_status_file() or 
    072                       warn "No status file found";
    073 
    074 ##################################################
    075 # Compare and derive actions
    076 ##################################################
    077 @actions = $local->compare($remote);
    078 
    079 ##################################################
    080 # Put/Get files according to actions defined
    081 ##################################################
    082 $ftp = Net::FTP->new($REMOTE_HOST) or 
    083              die "Cannot connect to $REMOTE_HOST";
    084 $ftp->login($USERNAME, $PASSWD) or 
    085              die "Cannot login";
    086 $ftp->cwd($REMOTE_DIR) or 
    087              die "Cannot chdir to $REMOTE_DIR";
    088 $ftp->binary;
    089 
    090 print scalar @actions, " actions necessary.\n";
    091 
    092 foreach my $action (@actions) {
    093     my ($path, $choices, $reason) = @$action;
    094 
    095     my $local_dir  = dirname("$LOCAL_DIR/$path");
    096     my $local_file = basename($path);
    097 
    098     print "$path -- $reason\n";
    099     my $choice = ask_choice($choices);
    100     
    101     $choice eq 'g' and do {
    102         print "GET $path\n";
    103         mkpath($local_dir) unless -d $local_dir;
    104         $ftp->get($path, "$local_dir/$local_file") or
    105             die "GET failed" };
    106 
    107     $choice eq 'p' and do {
    108         print "PUT $path\n";
    109         $ftp->mkdir(dirname($path), 1);
    110         $ftp->put("$local_dir/$local_file", $path) or
    111             die "PUT failed" };
    112 
    113     $choice eq 'r' || $choice eq 'd' and do {
    114         print "DELETE remote $path\n";
    115         $ftp->delete($path) or die "Delete failed" };
    116 
    117     $choice eq 'l' || $choice eq 'd' and do {
    118         print "DELETE local $path\n";
    119         unlink "$local_dir/$local_file" or 
    120                                die "Unlink failed";
    121     };
    122 
    123     $choice eq 'i' and do { print "Ignoring\n" };
    124 
    125     print "\n\n";
    126 }
    127 
    128 $ftp->quit;
    129 
    130 ##################################################
    131 sub ask_choice {
    132 ##################################################
    133     my $choices = shift;
    134     my $default_action = substr($choices, 0, 1);
    135 
    136     local $| = 1;
    137 
    138     my %messages = (
    139         G => "[G]et Remote",
    140         P => "[P]ush Local",
    141         R => "[R]emote delete",
    142         L => "[L]ocal delete",
    143         D => "[D]elete local/remote",
    144         I => "[I]gnore" );
    145 
    146     {   while($choices =~ /(.)/g) {
    147             print $messages{$1}, "\n";
    148         }
    149         print "[$default_action]> ";
    150 
    151         chop($word = <STDIN>);
    152         $word = $default_action unless $word;
    153         redo unless exists $messages{uc($word)};
    154         return lc($word);
    155     }
    156 }

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.