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.
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.
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!
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.
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.).
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.
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 P
ush der
lokalen Datei auf die Remote-Maschine zu sein, dann kommt
der G
et, I
gnore,
D
elete, L
ocal delete, R
emote 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.
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!
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();
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;
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 Schilliarbeitet 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. |