1: <?php
2:
3: namespace GAubry\Shell;
4:
5: use Psr\Log\LoggerInterface;
6: use GAubry\Helpers\Helpers;
7:
8: /**
9: *
10: */
11: class ShellAdapter
12: {
13:
14: /**
15: * Cache of status of file system paths.
16: *
17: * @var array
18: * @see getPathStatus()
19: * @see Shell_PathStatus
20: */
21: private $_aFileStatus;
22:
23: /**
24: * PSR-3 logger
25: *
26: * @var \Psr\Log\LoggerInterface
27: * @see exec()
28: */
29: private $_oLogger;
30:
31: /**
32: * Default configuration.
33: *
34: * @var array
35: */
36: private static $aDefaultConfig = array(
37: // (string) Path of Bash:
38: 'bash_path' => '/bin/bash',
39:
40: // (string) List of '-o option' options used for all SSH and SCP commands:
41: 'ssh_options' => '-o ServerAliveInterval=10 -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o BatchMode=yes',
42:
43: // (int) Maximal number of command shells launched simultaneously (parallel processes):
44: 'parallelization_max_nb_processes' => 10,
45:
46: // (int) Maximal number of parallel RSYNC (overriding 'parallelization_max_nb_processes'):
47: 'rsync_max_nb_processes' => 5,
48:
49: // (array) List of exclusion patterns for RSYNC command (converted into list of '--exclude <pattern>'):
50: 'default_rsync_exclude' => array(
51: '.bzr/', '.cvsignore', '.git/', '.gitignore', '.svn/', 'cvslog.*', 'CVS', 'CVS.adm'
52: )
53: );
54:
55: /**
56: * Current configuration.
57: *
58: * @var array
59: * @see $aDefaultConfig
60: */
61: private $_aConfig;
62:
63: /**
64: * Bash pattern command to call 'parallelize.sh' script.
65: *
66: * @var string
67: */
68: private $sParallelizeCmdPattern;
69:
70: /**
71: * Constructor.
72: *
73: * @param \Psr\Log\LoggerInterface $oLogger Used to log exectued shell commands
74: * @param array $aConfig see $aDefaultConfig
75: */
76: public function __construct (LoggerInterface $oLogger, array $aConfig = array())
77: {
78: $this->_oLogger = $oLogger;
79: $this->_aConfig = Helpers::arrayMergeRecursiveDistinct(self::$aDefaultConfig, $aConfig);
80: $this->_aFileStatus = array();
81: $this->sParallelizeCmdPattern = $this->_aConfig['bash_path']
82: . ' ' . realpath(__DIR__ . '/../../inc/parallelize.sh') . ' "%s" "%s"';
83: }
84:
85: /**
86: * Launch parallel processes running pattern filled with each of specified values.
87: * If number of values is greater than $iMax, then several batches are launched.
88: *
89: * Example: $this->parallelize(array('user1@server', 'user2@server'), "ssh [] /bin/bash <<EOF\nls -l\nEOF\n", 2);
90: * Example: $this->parallelize(array('a', 'b'), 'cat /path/to/resources/[].txt', 2);
91: *
92: * @param array $aValues liste de valeurs qui viendront remplacer le(s) '[]' du pattern
93: * @param string $sPattern pattern possédant une ou plusieurs occurences de paires de crochets vides '[]'
94: * qui seront remplacées dans les processus lancés en parallèle par l'une des valeurs spécifiées.
95: * @param int $iMax nombre maximal de processus lancés en parallèles
96: * @return array liste de tableau associatif : array(
97: * array(
98: * 'value' => (string)"l'une des valeurs de $aValues",
99: * 'error_code' => (int)code de retour Shell,
100: * 'elapsed_time' => (int) temps approximatif en secondes,
101: * 'cmd' => (string) commande shell exécutée,
102: * 'output' => (string) sortie standard,
103: * 'error' => (string) sortie d'erreur standard,
104: * ), ...
105: * )
106: * @throws \RuntimeException si le moindre code de retour Shell non nul apparaît.
107: * @throws \RuntimeException si une valeur hors de $aValues apparaît dans les entrées 'value'.
108: * @throws \RuntimeException s'il manque des valeurs de $aValues dans le résultat final.
109: */
110: public function parallelize (array $aValues, $sPattern, $iMax)
111: {
112: // Segmentation de la demande de parallélisation en lots séquentiels de taille maîtrisée :
113: $aAllValues = $aValues;
114: $aAllResults = array();
115: while (count($aValues) > $iMax) {
116: $aSubset = array_slice($aValues, 0, $iMax);
117: $aAllResults = array_merge($aAllResults, $this->parallelize($aSubset, $sPattern, $iMax));
118: $aValues = array_slice($aValues, $iMax);
119: }
120:
121: // Exécution de la demande de parallélisation :
122: $sCmd = sprintf(
123: $this->sParallelizeCmdPattern,
124: addcslashes(implode(' ', $aValues), '"'),
125: addcslashes($sPattern, '"')
126: );
127: $aExecResult = $this->exec($sCmd);
128:
129: // Découpage du flux de retour d'exécution :
130: $sResult = implode("\n", $aExecResult) . "\n";
131: $sRegExp = '#^---\[(.*?)\]-->(\d+)\|(\d+)s\n\[CMD\]\n(.*?)\n\[OUT\]\n(.*?)\[ERR\]\n(.*?)///#ms';
132: preg_match_all($sRegExp, $sResult, $aMatches, PREG_SET_ORDER);
133:
134: // Formatage des résultats :
135: $aResult = array();
136: foreach ($aMatches as $aSet) {
137: $aResult[] = array(
138: 'value' => $aSet[1],
139: 'error_code' => (int)$aSet[2],
140: 'elapsed_time' => (int)$aSet[3],
141: 'cmd' => $aSet[4],
142: 'output' => (strlen($aSet[5]) > 0 ? substr($aSet[5], 0, -1) : ''),
143: 'error' => (strlen($aSet[6]) > 0 ? substr($aSet[6], 0, -1) : '')
144: );
145: }
146:
147: // Pas de code d'erreur shell ni de valeur non attendue ?
148: foreach ($aResult as $aSubResult) {
149: if ($aSubResult['error_code'] !== 0) {
150: $sMsg = $aSubResult['error'] . "\nParallel result:\n" . print_r($aResult, true);
151: throw new \RuntimeException($sMsg, $aSubResult['error_code']);
152: } else if ( ! in_array($aSubResult['value'], $aValues)) {
153: $sMsg = "Not asked value: '" . $aSubResult['value'] . "'!\n"
154: . "Aksed values: '" . implode("', '", $aValues) . "'\n"
155: . "Parallel result:\n" . print_r($aResult, true);
156: throw new \RuntimeException($sMsg, 1);
157: }
158: }
159:
160: // Tous le monde est-il là ?
161: $aAllResults = array_merge($aAllResults, $aResult);
162: if (count($aAllResults) != count($aAllValues)) {
163: $sMsg = "Missing values!\n"
164: . "Aksed values: '" . implode("', '", $aValues) . "'\n"
165: . "Parallel result:\n" . print_r($aAllResults, true);
166: throw new \RuntimeException($sMsg, 1);
167: }
168:
169: return $aAllResults;
170: }
171:
172: /**
173: * Exécute la commande shell spécifiée et retourne la sortie découpée par ligne dans un tableau.
174: * En cas d'erreur shell (code d'erreur <> 0), lance une exception incluant le message d'erreur.
175: *
176: * @param string $sCmd
177: * @return array tableau indexé du flux de sortie shell découpé par ligne
178: * @throws \RuntimeException en cas d'erreur shell
179: */
180: public function exec ($sCmd)
181: {
182: $this->_oLogger->debug('[DEBUG] shell# ' . trim($sCmd, " \t"));
183: $sFullCmd = '( ' . $sCmd . ' ) 2>&1';
184: exec($sFullCmd, $aResult, $iReturnCode);
185: if ($iReturnCode !== 0) {
186: throw new \RuntimeException(
187: "Exit code not null: $iReturnCode. Result: '" . implode("\n", $aResult) . "'",
188: $iReturnCode
189: );
190: }
191: return $aResult;
192: }
193:
194: /**
195: * Exécute la commande shell spécifiée en l'encapsulant au besoin dans une connexion SSH
196: * pour atteindre les hôtes distants.
197: *
198: * @param string $sPatternCmd commande au format printf
199: * @param string $sParam paramètre du pattern $sPatternCmd, permettant en plus de décider si l'on
200: * doit encapsuler la commande dans un SSH (si serveur distant) ou non.
201: * @return array tableau indexé du flux de sortie shell découpé par ligne
202: * @throws \RuntimeException en cas d'erreur shell
203: * @see isRemotePath()
204: */
205: public function execSSH ($sPatternCmd, $sParam)
206: {
207: return $this->exec($this->buildSSHCmd($sPatternCmd, $sParam));
208: }
209:
210: /**
211: * Retourne la commande Shell spécifiée envoyée à sprintf avec $sParam,
212: * et encapsule au besoin le tout dans une connexion SSH
213: * pour atteindre les hôtes distants (si $sParam est un hôte distant).
214: *
215: * @param string $sPatternCmd commande au format printf
216: * @param string $sParam paramètre du pattern $sPatternCmd, permettant en plus de décider si l'on
217: * doit encapsuler la commande dans un SSH (si serveur distant) ou non.
218: * @return string la commande Shell spécifiée envoyée à sprintf avec $sParam,
219: * et encapsule au besoin le tout dans une connexion SSH
220: * pour atteindre les hôtes distants (si $sParam est un hôte distant).
221: * @see isRemotePath()
222: */
223: public function buildSSHCmd ($sPatternCmd, $sParam)
224: {
225: list($bIsRemote, $sServer, $sRealPath) = $this->isRemotePath($sParam);
226: $sCmd = sprintf($sPatternCmd, $this->escapePath($sRealPath));
227: //$sCmd = vsprintf($sPatternCmd, array_map(array(self, 'escapePath'), $mParams));
228: if ($bIsRemote) {
229: $sCmd = 'ssh ' . $this->_aConfig['ssh_options'] . " -T $sServer "
230: . $this->_aConfig['bash_path'] . " <<EOF\n$sCmd\nEOF\n";
231: }
232: return $sCmd;
233: }
234:
235: /**
236: * Retourne l'une des constantes de Shell_PathStatus, indiquant pour le chemin spécifié s'il est
237: * inexistant, un fichier, un répertoire, un lien symbolique sur fichier ou encore un lien symbolique sur
238: * répertoire.
239: *
240: * Les éventuels slash terminaux sont supprimés.
241: * Si le statut est différent de inexistant, l'appel est mis en cache.
242: * Un appel à remove() s'efforce de maintenir cohérent ce cache.
243: *
244: * Le chemin spécifié peut concerner un hôte distant (user@server:/path), auquel cas un appel SSH sera effectué.
245: *
246: * @param string $sPath chemin à tester, de la forme [user@server:]/path
247: * @return int l'une des constantes de Shell_PathStatus
248: * @throws \RuntimeException en cas d'erreur shell
249: * @see Shell_PathStatus
250: * @see _aFileStatus
251: */
252: public function getPathStatus ($sPath)
253: {
254: if (substr($sPath, -1) === '/') {
255: $sPath = substr($sPath, 0, -1);
256: }
257: if (isset($this->_aFileStatus[$sPath])) {
258: $iStatus = $this->_aFileStatus[$sPath];
259: } else {
260: $sFormat = '[ -h %1$s ] && echo -n 1; [ -d %1$s ] && echo 2 || ([ -f %1$s ] && echo 1 || echo 0)';
261: //$aResult = $this->execSSH($sFormat, $sPath);
262: $aResult = $this->exec($this->buildSSHCmd($sFormat, $sPath));
263: $iStatus = (int)$aResult[0];
264: if ($iStatus !== 0) {
265: $this->_aFileStatus[$sPath] = $iStatus;
266: }
267: }
268: return $iStatus;
269: }
270:
271: /**
272: * Pour chaque serveur retourne l'une des constantes de Shell_PathStatus, indiquant pour le chemin spécifié
273: * s'il est inexistant, un fichier, un répertoire, un lien symbolique sur fichier
274: * ou encore un lien symbolique sur répertoire.
275: *
276: * Comme getPathStatus(), mais sur une liste de serveurs.
277: *
278: * Les éventuels slash terminaux sont supprimés.
279: * Si le statut est différent de inexistant, l'appel est mis en cache.
280: * Un appel à remove() s'efforce de maintenir cohérent ce cache.
281: *
282: * @param string $sPath chemin à tester, sans mention de serveur
283: * @param array $aServers liste de serveurs sur lesquels faire la demande de statut
284: * @return array tableau associatif listant par serveur (clé) le status (valeur, constante de Shell_PathStatus)
285: * @throws \RuntimeException en cas d'erreur shell
286: * @see getPathStatus()
287: */
288: public function getParallelSSHPathStatus ($sPath, array $aServers)
289: {
290: if (substr($sPath, -1) === '/') {
291: $sPath = substr($sPath, 0, -1);
292: }
293:
294: // Déterminer les serveurs pour lesquels nous n'avons pas la réponse en cache :
295: $aResult = array();
296: foreach ($aServers as $sServer) {
297: $sKey = $sServer . ':' . $sPath;
298: if (isset($this->_aFileStatus[$sKey])) {
299: $aResult[$sServer] = $this->_aFileStatus[$sKey];
300: }
301: }
302: $aServersToCheck = array_diff($aServers, array_keys($aResult));
303:
304: // Paralléliser l'appel sur chacun des serveurs restants :
305: if (count($aServersToCheck) > 0) {
306: $sFormat = '[ -h %1$s ] && echo -n 1; [ -d %1$s ] && echo 2 || ([ -f %1$s ] && echo 1 || echo 0)';
307: $sPattern = $this->buildSSHCmd($sFormat, '[]:' . $sPath);
308: $aParallelResult = $this->parallelize($aServersToCheck, $sPattern, $this->_aConfig['parallelization_max_nb_processes']);
309:
310: // Traiter les résultats et MAJ le cache :
311: foreach ($aParallelResult as $aServerResult) {
312: $sServer = $aServerResult['value'];
313: $iStatus = (int)$aServerResult['output'];
314: if ($iStatus !== 0) {
315: $this->_aFileStatus[$sServer . ':' . $sPath] = $iStatus;
316: }
317: $aResult[$sServer] = $iStatus;
318: }
319: }
320:
321: return $aResult;
322: }
323:
324: /**
325: * Retourne un triplet dont la 1re valeur (bool) indique si le chemin spécifié commence par
326: * '[user@]servername_or_ip:', la 2e (string) est le serveur (ou chaîne vide si $sPath est local),
327: * et la 3e (string) est le chemin dépourvu de l'éventuel serveur.
328: *
329: * @param string $sPath chemin au format [[user@]servername_or_ip:]/path
330: * @return array triplet dont la 1re valeur (bool) indique si le chemin spécifié commence par
331: * '[user@]servername_or_ip:', la 2e (string) est le serveur (ou chaîne vide si $sPath est local),
332: * et la 3e (string) est le chemin dépourvu de l'éventuel serveur.
333: */
334: public function isRemotePath ($sPath)
335: {
336: $result = preg_match('/^((?:[^@]+@)?[^:]+):(.+)$/i', $sPath, $aMatches);
337: $bIsRemotePath = ($result === 1);
338: if ($bIsRemotePath) {
339: $sServer = $aMatches[1];
340: $sRealPath = $aMatches[2];
341: } else {
342: $sServer = '';
343: $sRealPath = $sPath;
344: }
345:
346: return array($bIsRemotePath, $sServer, $sRealPath);
347: }
348:
349: /**
350: * Copie un chemin vers un autre.
351: * Les jokers '*' et '?' sont autorisés.
352: * Par exemple copiera le contenu de $sSrcPath si celui-ci se termine par '/*'.
353: * Si le chemin de destination n'existe pas, il sera créé.
354: *
355: * @param string $sSrcPath chemin source, au format [[user@]hostname_or_ip:]/path
356: * @param string $sDestPath chemin de destination, au format [[user@]hostname_or_ip:]/path
357: * @param bool $bIsDestFile précise si le chemin de destination est un simple fichier ou non,
358: * information nécessaire si l'on doit créer une partie de ce chemin si inexistant
359: * @return array tableau indexé du flux de sortie shell découpé par ligne
360: * @throws \RuntimeException en cas d'erreur shell
361: */
362: public function copy ($sSrcPath, $sDestPath, $bIsDestFile=false)
363: {
364: if ($bIsDestFile) {
365: $this->mkdir(pathinfo($sDestPath, PATHINFO_DIRNAME));
366: } else {
367: $this->mkdir($sDestPath);
368: }
369: list(, $sSrcServer, ) = $this->isRemotePath($sSrcPath);
370: list(, $sDestServer, $sDestRealPath) = $this->isRemotePath($sDestPath);
371:
372: if ($sSrcServer != $sDestServer) {
373: $sCmd = 'scp ' . $this->_aConfig['ssh_options'] . ' -rpq '
374: . $this->escapePath($sSrcPath) . ' ' . $this->escapePath($sDestPath);
375: return $this->exec($sCmd);
376: } else {
377: $sCmd = 'cp -a %s ' . $this->escapePath($sDestRealPath);
378: return $this->execSSH($sCmd, $sSrcPath);
379: }
380: }
381:
382: /**
383: * Crée un lien symbolique de chemin $sLinkPath vers la cible $sTargetPath.
384: *
385: * @param string $sLinkPath nom du lien, au format [[user@]hostname_or_ip:]/path
386: * @param string $sTargetPath cible sur laquelle faire pointer le lien, au format [[user@]hostname_or_ip:]/path
387: * @return array tableau indexé du flux de sortie shell découpé par ligne
388: * @throws \DomainException si les chemins référencent des serveurs différents
389: * @throws \RuntimeException en cas d'erreur shell
390: */
391: public function createLink ($sLinkPath, $sTargetPath)
392: {
393: list(, $sLinkServer, ) = $this->isRemotePath($sLinkPath);
394: list(, $sTargetServer, $sTargetRealPath) = $this->isRemotePath($sTargetPath);
395: if ($sLinkServer != $sTargetServer) {
396: throw new \DomainException("Hosts must be equals. Link='$sLinkPath'. Target='$sTargetPath'.");
397: }
398: $aResult = $this->execSSH('mkdir -p "$(dirname %1$s)" && ln -snf "' . $sTargetRealPath . '" %1$s', $sLinkPath);
399: // TODO optimisation possible :
400: // $this->_aFileStatus[$sPath] = Shell_PathStatus::STATUS_SYMLINKED_DIR ou STATUS_SYMLINKED_FILE;
401: return $aResult;
402: }
403:
404: /**
405: * Entoure le chemin de guillemets doubles en tenant compte des jokers '*' et '?' qui ne les supportent pas.
406: * Par exemple : '/a/b/img*jpg', donnera : '"/a/b/img"*"jpg"'.
407: * Pour rappel, '*' vaut pour 0 à n caractères, '?' vaut pour exactement 1 caractère (et non 0 à 1).
408: *
409: * @param string $sPath
410: * @return string
411: */
412: public function escapePath ($sPath)
413: {
414: $sEscapedPath = preg_replace('#(\*|\?)#', '"\1"', '"' . $sPath . '"');
415: $sEscapedPath = str_replace('""', '', $sEscapedPath);
416: return $sEscapedPath;
417: }
418:
419: /**
420: * Supprime le chemin spécifié, répertoire ou fichier, distant ou local.
421: * S'efforce de maintenir cohérent le cache de statut de chemins rempli par getPathStatus().
422: *
423: * @param string $sPath chemin à supprimer, au format [[user@]hostname_or_ip:]/path
424: * @return array tableau indexé du flux de sortie shell découpé par ligne
425: * @throws \DomainException si chemin invalide (garde-fou)
426: * @throws \RuntimeException en cas d'erreur shell
427: * @see getPathStatus()
428: */
429: public function remove ($sPath)
430: {
431: $sPath = trim($sPath);
432:
433: // Garde-fou :
434: if (empty($sPath) || strlen($sPath) < 4) {
435: throw new \DomainException("Illegal path: '$sPath'");
436: }
437:
438: // Supprimer du cache de getPathStatus() :
439: foreach (array_keys($this->_aFileStatus) as $sCachedPath) {
440: if (substr($sCachedPath, 0, strlen($sPath)+1) === $sPath . '/') {
441: unset($this->_aFileStatus[$sCachedPath]);
442: }
443: }
444: unset($this->_aFileStatus[$sPath]);
445:
446: return $this->execSSH('rm -rf %s', $sPath);
447: }
448:
449: /**
450: * Effectue un tar gzip du répertoire $sSrcPath dans $sBackupPath.
451: *
452: * @param string $sSrcPath au format [[user@]hostname_or_ip:]/path
453: * @param string $sBackupPath au format [[user@]hostname_or_ip:]/path
454: * @return array tableau indexé du flux de sortie shell découpé par ligne
455: * @throws \RuntimeException en cas d'erreur shell
456: */
457: public function backup ($sSrcPath, $sBackupPath)
458: {
459: list($bIsSrcRemote, $sSrcServer, $sSrcRealPath) = $this->isRemotePath($sSrcPath);
460: list(, $sBackupServer, $sBackupRealPath) = $this->isRemotePath($sBackupPath);
461:
462: if ($sSrcServer != $sBackupServer) {
463: $sTmpDir = ($bIsSrcRemote ? $sSrcServer. ':' : '') . realpath(sys_get_temp_dir()) . '/'
464: . uniqid('deployment_', true);
465: $sTmpPath = $sTmpDir . '/' . pathinfo($sBackupPath, PATHINFO_BASENAME);
466: return array_merge(
467: $this->backup($sSrcPath, $sTmpPath),
468: $this->copy($sTmpPath, $sBackupPath, true),
469: $this->remove($sTmpDir)
470: );
471: } else {
472: $this->mkdir(pathinfo($sBackupPath, PATHINFO_DIRNAME));
473: $sSrcFile = pathinfo($sSrcRealPath, PATHINFO_BASENAME);
474: $sFormat = 'cd %1$s; tar cfpz %2$s ./%3$s';
475: if ($bIsSrcRemote) {
476: $sSrcDir = pathinfo($sSrcRealPath, PATHINFO_DIRNAME);
477: $sFormat = 'ssh %4$s <<EOF' . "\n" . $sFormat . "\nEOF\n";
478: $sCmd = sprintf(
479: $sFormat,
480: $this->escapePath($sSrcDir),
481: $this->escapePath($sBackupRealPath),
482: $this->escapePath($sSrcFile),
483: $sSrcServer
484: );
485: } else {
486: $sSrcDir = pathinfo($sSrcPath, PATHINFO_DIRNAME);
487: $sCmd = sprintf(
488: $sFormat,
489: $this->escapePath($sSrcDir),
490: $this->escapePath($sBackupPath),
491: $this->escapePath($sSrcFile)
492: );
493: }
494: return $this->exec($sCmd);
495: }
496: }
497:
498: /**
499: * Crée le chemin spécifié s'il n'existe pas déjà, avec les droits éventuellement transmis dans tous les cas.
500: *
501: * @param string $sPath chemin à créer, au format [[user@]hostname_or_ip:]/path
502: * @param string $sMode droits utilisateur du chemin appliqués même si ce dernier existe déjà.
503: * Par exemple '644'.
504: * @param array $aValues liste de valeurs (string) optionnelles pour générer autant de demandes de
505: * synchronisation en parallèle. Dans ce cas ces valeurs viendront remplacer l'une après l'autre
506: * les occurences de crochets vide '[]' présents dans $sSrcPath ou $sDestPath.
507: * @throws \RuntimeException en cas d'erreur shell
508: */
509: public function mkdir ($sPath, $sMode='', array $aValues=array())
510: {
511: // On passe par 'chmod' car 'mkdir -m xxx' exécuté ssi répertoire inexistant :
512: if ($sMode !== '') {
513: $sMode = " && chmod $sMode %1\$s";
514: }
515: $sPattern = "mkdir -p %1\$s$sMode";
516: $sCmd = $this->buildSSHCmd($sPattern, $sPath);
517: //var_dump($sPath, $sPattern, $sCmd);
518:
519: if (strpos($sPath, '[]') !== false && count($aValues) > 0) {
520: $aParallelResult = $this->parallelize($aValues, $sCmd, $this->_aConfig['parallelization_max_nb_processes']);
521:
522: // Traiter les résultats et MAJ le cache :
523: foreach ($aParallelResult as $aServerResult) {
524: $sValue = $aServerResult['value'];
525: $sFinalPath = str_replace('[]', $sValue, $sPath);
526: $this->_aFileStatus[$sFinalPath] = PathStatus::STATUS_DIR;
527: }
528: } else {
529: $this->exec($sCmd);
530: $this->_aFileStatus[$sPath] = PathStatus::STATUS_DIR;
531: }
532: }
533:
534: /**
535: * Synchronise une source avec une ou plusieurs destinations.
536: *
537: * @param string $sSrcPath au format [[user@]hostname_or_ip:]/path
538: * @param string $sDestPath chaque destination au format [[user@]hostname_or_ip:]/path
539: * @param array $aValues liste de valeurs (string) optionnelles pour générer autant de demandes de
540: * synchronisation en parallèle. Dans ce cas ces valeurs viendront remplacer l'une après l'autre
541: * les occurences de crochets vide '[]' présents dans $sSrcPath ou $sDestPath.
542: * @param array $aIncludedPaths chemins à transmettre aux paramètres --include de la commande shell rsync.
543: * Il précéderons les paramètres --exclude.
544: * @param array $aExcludedPaths chemins à transmettre aux paramètres --exclude de la commande shell rsync
545: * @param string $sRsyncPattern
546: * @return array tableau indexé du flux de sortie shell des commandes rsync exécutées,
547: * découpé par ligne et analysé par _resumeSyncResult()
548: */
549: public function sync ($sSrcPath, $sDestPath, array $aValues=array(),
550: array $aIncludedPaths=array(), array $aExcludedPaths=array(),
551: $sRsyncPattern='')
552: {
553: if (empty($sRsyncPattern)) {
554: $sRsyncPattern = 'rsync -axz --delete %1$s%2$s--stats -e "ssh '
555: . $this->_aConfig['ssh_options'] . '" %3$s %4$s';
556: }
557:
558: // Cas non gérés :
559: list($bIsSrcRemote, $sSrcServer, $sSrcRealPath) = $this->isRemotePath($sSrcPath);
560: list($bIsDestRemote, $sDestServer, $sDestRealPath) = $this->isRemotePath($sDestPath);
561: $this->mkdir($sDestPath, '', $aValues);
562:
563: // Inclusions / exclusions :
564: $sIncludedPaths = (count($aIncludedPaths) === 0
565: ? ''
566: : '--include="' . implode('" --include="', array_unique($aIncludedPaths)) . '" ');
567: $aExcludedPaths = array_unique(array_merge($this->_aConfig['default_rsync_exclude'], $aExcludedPaths));
568: $sExcludedPaths = (count($aExcludedPaths) === 0
569: ? ''
570: : '--exclude="' . implode('" --exclude="', $aExcludedPaths) . '" ');
571:
572: // Construction de la commande :
573: $sRsyncCmd = sprintf(
574: $sRsyncPattern,
575: $sIncludedPaths, $sExcludedPaths, '%s', '%s'
576: );
577: if (substr($sSrcPath, -2) === '/*') {
578: $sRsyncCmd = 'if ls -1 "' . substr($sSrcRealPath, 0, -2) . '" | grep -q .; then ' . $sRsyncCmd . '; fi';
579: }
580: if ($bIsSrcRemote && $bIsDestRemote) {
581: $sFinalDestPath = ($sSrcServer == $sDestServer ? $sDestRealPath : $sDestPath);
582: $sRsyncCmd = sprintf($sRsyncCmd, '%s', $this->escapePath($sFinalDestPath));
583: $sRsyncCmd = $this->buildSSHCmd($sRsyncCmd, $sSrcPath);
584: } else {
585: $sRsyncCmd = sprintf($sRsyncCmd, $this->escapePath($sSrcPath), $this->escapePath($sDestPath));
586: }
587:
588: if (count($aValues) === 0 || (count($aValues) === 1 && $aValues[0] == '')) {
589: $aValues=array('-');
590: }
591: $aParallelResult = $this->parallelize($aValues, $sRsyncCmd, $this->_aConfig['rsync_max_nb_processes']);
592: $aAllResults = array();
593: foreach ($aParallelResult as $aServerResult) {
594: if ($aServerResult['value'] == '-') {
595: $sHeader = '';
596: } else {
597: $sHeader = "Server: " . $aServerResult['value']
598: . ' (~' . $aServerResult['elapsed_time'] . 's)' . "\n";
599: }
600: $aRawOutput = explode("\n", $aServerResult['output']);
601: $sOutput = $this->_resumeSyncResult($aRawOutput);
602: $aOutput = array($sHeader . $sOutput);
603: $aAllResults = array_merge($aAllResults, $aOutput);
604: }
605:
606: return $aAllResults;
607: }
608:
609: /**
610: * Analyse la sortie shell de commandes rsync et en propose un résumé.
611: *
612: * Exemple :
613: * - entrée :
614: * Number of files: 1774
615: * Number of files transferred: 2
616: * Total file size: 64093953 bytes
617: * Total transferred file size: 178 bytes
618: * Literal data: 178 bytes
619: * Matched data: 0 bytes
620: * File list size: 39177
621: * File list generation time: 0.013 seconds
622: * File list transfer time: 0.000 seconds
623: * Total bytes sent: 39542
624: * Total bytes received: 64
625: * sent 39542 bytes received 64 bytes 26404.00 bytes/sec
626: * total size is 64093953 speedup is 1618.29
627: * - sortie :
628: * Number of transferred files ( / total): 2 / 1774
629: * Total transferred file size ( / total): <1 / 61 Mio
630: *
631: * @param array $aRawResult tableau indexé du flux de sortie shell de la commande rsync, découpé par ligne
632: * @return array du tableau indexé du flux de sortie shell de commandes rsync résumé
633: * et découpé par ligne
634: */
635: private function _resumeSyncResult (array $aRawResult)
636: {
637: if (count($aRawResult) === 0 || (count($aRawResult) === 1 && $aRawResult[0] == '')) {
638: $sResult = 'Empty source directory.';
639: } else {
640: $aKeys = array(
641: 'number of files',
642: 'number of files transferred',
643: 'total file size',
644: 'total transferred file size',
645: );
646: $aEmptyStats = array_fill_keys($aKeys, '?');
647:
648: $aStats = $aEmptyStats;
649: foreach ($aRawResult as $sLine) {
650: if (preg_match('/^([^:]+):\s(\d+)\b/i', $sLine, $aMatches) === 1) {
651: $sKey = strtolower($aMatches[1]);
652: if (isset($aStats[$sKey])) {
653: $aStats[$sKey] = (int)$aMatches[2];
654: }
655: }
656: }
657:
658: list($sTransferred, $sTransfUnit) =
659: Helpers::intToMultiple($aStats['total transferred file size'], true);
660: list($sTotal, $sTotalUnit) = Helpers::intToMultiple($aStats['total file size'], true);
661:
662: $sResult = 'Number of transferred files ( / total): ' . $aStats['number of files transferred']
663: . ' / ' . $aStats['number of files'] . "\n"
664: . 'Total transferred file size ( / total): '
665: . round($sTransferred) . ' ' . $sTransfUnit . 'o / ' . round($sTotal) . ' ' . $sTotalUnit . 'o';
666: }
667: return $sResult;
668: }
669: }
670: