PageRenderTime 24ms CodeModel.GetById 19ms RepoModel.GetById 1ms app.codeStats 0ms

/deployment/src/Deployment/Deployer.php

https://gitlab.com/ilyales/vigma
PHP | 497 lines | 385 code | 49 blank | 63 comment | 21 complexity | 9f7d07e82441be3276e7e74b14044cf5 MD5 | raw file
  1. <?php
  2. /**
  3. * FTP Deployment
  4. *
  5. * Copyright (c) 2009 David Grudl (https://davidgrudl.com)
  6. */
  7. namespace Deployment;
  8. /**
  9. * Synchronizes local and remote.
  10. *
  11. * @author David Grudl
  12. */
  13. class Deployer
  14. {
  15. const TEMPORARY_SUFFIX = '.deploytmp';
  16. /** @var string */
  17. public $deploymentFile = '.htdeployment';
  18. /** @var string[] */
  19. public $ignoreMasks = [];
  20. /** @var bool */
  21. public $testMode = FALSE;
  22. /** @var bool */
  23. public $allowDelete = FALSE;
  24. /** @var string[] relative paths */
  25. public $toPurge;
  26. /** @var array of string|callable */
  27. public $runBefore;
  28. /** @var array of string|callable */
  29. public $runAfterUpload;
  30. /** @var array of string|callable */
  31. public $runAfter;
  32. /** @var string */
  33. public $tempDir = '';
  34. /** @var string */
  35. private $localDir;
  36. /** @var string */
  37. private $remoteDir;
  38. /** @var Logger */
  39. private $logger;
  40. /** @var string[] */
  41. public $preprocessMasks = [];
  42. /** @var array */
  43. private $filters;
  44. /** @var Server */
  45. private $server;
  46. /**
  47. * @param Server
  48. * @param string local directory
  49. */
  50. public function __construct(Server $server, $localDir, Logger $logger)
  51. {
  52. $this->localDir = realpath($localDir);
  53. if (!$this->localDir) {
  54. throw new \InvalidArgumentException("Directory $localDir not found.");
  55. }
  56. $this->server = $server;
  57. $this->logger = $logger;
  58. }
  59. /**
  60. * Synchronize remote and local.
  61. * @return void
  62. */
  63. public function deploy()
  64. {
  65. $this->logger->log("Connecting to server");
  66. $this->server->connect();
  67. $this->remoteDir = $this->server->getDir();
  68. $runBefore = [NULL, NULL];
  69. foreach ($this->runBefore as $job) {
  70. $runBefore[is_string($job) && preg_match('#^local:#', $job)][] = $job;
  71. }
  72. if ($runBefore[1]) {
  73. $this->logger->log("\nLocal-jobs:");
  74. $this->runJobs($runBefore[1]);
  75. $this->logger->log('');
  76. }
  77. $remotePaths = $this->loadDeploymentFile();
  78. if (is_array($remotePaths)) {
  79. $this->logger->log("Loaded remote $this->deploymentFile file");
  80. } else {
  81. $this->logger->log("Remote $this->deploymentFile file not found");
  82. $remotePaths = [];
  83. }
  84. $this->logger->log("Scanning files in $this->localDir");
  85. $localPaths = $this->collectPaths();
  86. unset($localPaths["/$this->deploymentFile"], $remotePaths["/$this->deploymentFile"]);
  87. $toDelete = $this->allowDelete ? array_keys(array_diff_key($remotePaths, $localPaths)) : [];
  88. $toUpload = array_keys(array_diff_assoc($localPaths, $remotePaths));
  89. if ($localPaths !== $remotePaths) { // ignores allowDelete
  90. $deploymentFile = $this->writeDeploymentFile($localPaths);
  91. $toUpload[] = "/$this->deploymentFile"; // must be last
  92. }
  93. if (!$toUpload && !$toDelete) {
  94. $this->logger->log('Already synchronized.', 'lime');
  95. return;
  96. } elseif ($this->testMode) {
  97. $this->logger->log("\nUploading:\n" . implode("\n", $toUpload), 'green', FALSE);
  98. $this->logger->log("\nDeleting:\n" . implode("\n", $toDelete), 'maroon', FALSE);
  99. if (isset($deploymentFile)) {
  100. unlink($deploymentFile);
  101. }
  102. return;
  103. }
  104. $this->logger->log("Creating remote file $this->deploymentFile.running");
  105. $runningFile = "$this->remoteDir/$this->deploymentFile.running";
  106. $this->server->createDir(str_replace('\\', '/', dirname($runningFile)));
  107. $this->server->writeFile(tempnam($this->tempDir, 'deploy'), $runningFile);
  108. if ($runBefore[0]) {
  109. $this->logger->log("\nBefore-jobs:");
  110. $this->runJobs($runBefore[0]);
  111. }
  112. if ($toUpload) {
  113. $this->logger->log("\nUploading:");
  114. $this->uploadPaths($toUpload);
  115. if ($this->runAfterUpload) {
  116. $this->logger->log("\nAfter-upload-jobs:");
  117. $this->runJobs($this->runAfterUpload);
  118. }
  119. $this->logger->log("\nRenaming:");
  120. $this->renamePaths($toUpload);
  121. unlink($deploymentFile);
  122. }
  123. if ($toDelete) {
  124. $this->logger->log("\nDeleting:");
  125. $this->deletePaths($toDelete);
  126. }
  127. foreach ((array) $this->toPurge as $path) {
  128. $this->logger->log("\nCleaning $path");
  129. $this->server->purge($this->remoteDir . '/' . $path, function ($path) {
  130. static $counter;
  131. $path = substr($path, strlen($this->remoteDir));
  132. $path = preg_match('#/(.{1,60})$#', $path, $m) ? $m[1] : substr(basename($path), 0, 60);
  133. $this->logger->progress(str_pad($path . ' ' . str_repeat('.', $counter++ % 30 + 60 - strlen($path)), 90));
  134. });
  135. $this->logger->progress(str_repeat(' ', 91));
  136. }
  137. if ($this->runAfter) {
  138. $this->logger->log("\nAfter-jobs:");
  139. $this->runJobs($this->runAfter);
  140. }
  141. $this->logger->log("\nDeleting remote file $this->deploymentFile.running");
  142. $this->server->removeFile($runningFile);
  143. }
  144. /**
  145. * Appends preprocessor for files.
  146. * @param string file extension
  147. * @param callable
  148. * @return void
  149. */
  150. public function addFilter($extension, $filter, $cached = FALSE)
  151. {
  152. $this->filters[$extension][] = ['filter' => $filter, 'cached' => $cached];
  153. return $this;
  154. }
  155. /**
  156. * Downloads and decodes .htdeployment from the server.
  157. * @return string[]|NULL relative paths, starts with /
  158. */
  159. private function loadDeploymentFile()
  160. {
  161. $tempFile = tempnam($this->tempDir, 'deploy');
  162. try {
  163. $this->server->readFile($this->remoteDir . '/' . $this->deploymentFile, $tempFile);
  164. } catch (ServerException $e) {
  165. return;
  166. }
  167. $content = gzinflate(file_get_contents($tempFile));
  168. $res = [];
  169. foreach (explode("\n", $content) as $item) {
  170. if (count($item = explode('=', $item, 2)) === 2) {
  171. $res[$item[1]] = $item[0] === '1' ? TRUE : $item[0];
  172. }
  173. }
  174. return $res;
  175. }
  176. /**
  177. * Prepares .htdeployment for upload.
  178. * @return string
  179. */
  180. public function writeDeploymentFile($localPaths)
  181. {
  182. $s = '';
  183. foreach ($localPaths as $k => $v) {
  184. $s .= "$v=$k\n";
  185. }
  186. $file = $this->localDir . '/' . $this->deploymentFile;
  187. @mkdir(dirname($file), 0777, TRUE); // @ dir may exists
  188. file_put_contents($file, gzdeflate($s, 9));
  189. return $file;
  190. }
  191. /**
  192. * Uploades files and creates directories.
  193. * @param string[] relative paths, starts with /
  194. * @return void
  195. */
  196. private function uploadPaths(array $paths)
  197. {
  198. $prevDir = NULL;
  199. foreach ($paths as $num => $path) {
  200. $remotePath = $this->remoteDir . $path;
  201. $isDir = substr($remotePath, -1) === '/';
  202. $remoteDir = $isDir ? substr($remotePath, 0, -1) : str_replace('\\', '/', dirname($remotePath));
  203. if ($remoteDir !== $prevDir) {
  204. $prevDir = $remoteDir;
  205. $this->server->createDir($remoteDir);
  206. }
  207. if ($isDir) {
  208. $this->writeProgress($num + 1, count($paths), $path, NULL, 'green');
  209. continue;
  210. }
  211. $localFile = $this->preprocess($path);
  212. if ($localFile !== $this->localDir . $path) {
  213. $path .= ' (filters applied)';
  214. }
  215. $this->server->writeFile($localFile, $remotePath . self::TEMPORARY_SUFFIX, function ($percent) use ($num, $paths, $path) {
  216. $this->writeProgress($num + 1, count($paths), $path, $percent, 'green');
  217. });
  218. $this->writeProgress($num + 1, count($paths), $path, NULL, 'green');
  219. }
  220. }
  221. /**
  222. * Renames uploaded files.
  223. * @param string[] relative paths, starts with /
  224. * @return void
  225. */
  226. private function renamePaths(array $paths)
  227. {
  228. $files = array_values(array_filter($paths, function ($path) { return substr($path, -1) !== '/'; }));
  229. foreach ($files as $num => $file) {
  230. $this->writeProgress($num + 1, count($files), "Renaming $file", NULL, 'olive');
  231. $remoteFile = $this->remoteDir . $file;
  232. $this->server->renameFile($remoteFile . self::TEMPORARY_SUFFIX, $remoteFile);
  233. }
  234. }
  235. /**
  236. * Deletes files and directories.
  237. * @param string[] relative paths, starts with /
  238. * @return void
  239. */
  240. private function deletePaths(array $paths)
  241. {
  242. rsort($paths);
  243. foreach ($paths as $num => $path) {
  244. $remotePath = $this->remoteDir . $path;
  245. $this->writeProgress($num + 1, count($paths), "Deleting $path", NULL, 'maroon');
  246. try {
  247. if (substr($path, -1) === '/') { // is directory?
  248. $this->server->removeDir($remotePath);
  249. } else {
  250. $this->server->removeFile($remotePath);
  251. }
  252. } catch (ServerException $e) {
  253. $this->logger->log("Unable to delete $remotePath", 'red');
  254. }
  255. }
  256. }
  257. /**
  258. * Scans directory.
  259. * @param string relative subdir, starts with /
  260. * @return string[] relative paths, starts with /
  261. */
  262. public function collectPaths($subdir = '')
  263. {
  264. $list = [];
  265. $iterator = dir($this->localDir . $subdir);
  266. $counter = 0;
  267. while (FALSE !== ($entry = $iterator->read())) {
  268. $this->logger->progress(str_pad(str_repeat('.', $counter++ % 40), 40));
  269. $path = "$this->localDir$subdir/$entry";
  270. $short = "$subdir/$entry";
  271. if ($entry == '.' || $entry == '..') {
  272. continue;
  273. } elseif ($this->matchMask($short, $this->ignoreMasks, is_dir($path))) {
  274. $this->logger->log(str_pad("Ignoring .$short", 40), 'gray');
  275. continue;
  276. } elseif (is_dir($path)) {
  277. $list[$short . '/'] = TRUE;
  278. $list += $this->collectPaths($short);
  279. } elseif (is_file($path)) {
  280. $list[$short] = self::hashFile($this->preprocess($short));
  281. }
  282. }
  283. $iterator->close();
  284. return $list;
  285. }
  286. /**
  287. * Calls preprocessors on file.
  288. * @param string relative path, starts with /
  289. * @return string full path
  290. */
  291. private function preprocess($file)
  292. {
  293. $ext = pathinfo($file, PATHINFO_EXTENSION);
  294. if (!isset($this->filters[$ext]) || !$this->matchMask($file, $this->preprocessMasks)) {
  295. return $this->localDir . $file;
  296. }
  297. $full = $this->localDir . str_replace('/', DIRECTORY_SEPARATOR, $file);
  298. $content = file_get_contents($full);
  299. foreach ($this->filters[$ext] as $info) {
  300. if ($info['cached'] && is_file($tempFile = $this->tempDir . '/' . md5($content))) {
  301. $content = file_get_contents($tempFile);
  302. } else {
  303. $content = call_user_func($info['filter'], $content, $full);
  304. if ($info['cached']) {
  305. file_put_contents($tempFile, $content);
  306. }
  307. }
  308. }
  309. if (empty($info['cached'])) {
  310. $tempFile = tempnam($this->tempDir, 'deploy');
  311. file_put_contents($tempFile, $content);
  312. }
  313. return $tempFile;
  314. }
  315. /**
  316. * @param array of string|callable
  317. * @return void
  318. */
  319. private function runJobs(array $jobs)
  320. {
  321. foreach ($jobs as $job) {
  322. if (is_string($job) && preg_match('#^(https?|local|remote):\s*(.+)#', $job, $m)) {
  323. if ($m[1] === 'local') {
  324. $out = @system($m[2], $code);
  325. $err = $code !== 0;
  326. } elseif ($m[1] === 'remote') {
  327. try {
  328. $out = $this->server->execute($m[2]);
  329. } catch (ServerException $e) {
  330. $out = $e->getMessage();
  331. }
  332. $err = isset($e);
  333. } else {
  334. $ch = curl_init();
  335. curl_setopt($ch, CURLOPT_URL, $job);
  336. curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
  337. $out = curl_exec($ch);
  338. if ($err = (bool) curl_errno($ch)) {
  339. $out = curl_error($ch) . "\n" . $out;
  340. }
  341. }
  342. $this->logger->log($job . ($out == NULL ? '' : ": $out")); // intentionally ==
  343. if ($err) {
  344. throw new \RuntimeException('Error in job');
  345. }
  346. } elseif (is_callable($job)) {
  347. if ($job($this->server, $this->logger, $this) === FALSE) {
  348. throw new \RuntimeException('Error in job');
  349. }
  350. } else {
  351. throw new \InvalidArgumentException("Invalid job $job.");
  352. }
  353. }
  354. }
  355. /**
  356. * Computes hash.
  357. * @param string absolute path
  358. * @return string
  359. */
  360. public static function hashFile($file)
  361. {
  362. if (filesize($file) > 5e6) {
  363. return md5_file($file);
  364. } else {
  365. $s = file_get_contents($file);
  366. if (preg_match('#^[\x09\x0A\x0D\x20-\x7E\x80-\xFF]*+\z#', $s)) {
  367. $s = str_replace("\r\n", "\n", $s);
  368. }
  369. return md5($s);
  370. }
  371. }
  372. /**
  373. * Matches filename against patterns.
  374. * @param string relative path
  375. * @param string[] patterns
  376. * @return bool
  377. */
  378. public static function matchMask($path, array $patterns, $isDir = FALSE)
  379. {
  380. $res = FALSE;
  381. $path = explode('/', ltrim($path, '/'));
  382. foreach ($patterns as $pattern) {
  383. $pattern = strtr($pattern, '\\', '/');
  384. if ($neg = substr($pattern, 0, 1) === '!') {
  385. $pattern = substr($pattern, 1);
  386. }
  387. if (strpos($pattern, '/') === FALSE) { // no slash means base name
  388. if (fnmatch($pattern, end($path), FNM_CASEFOLD)) {
  389. $res = !$neg;
  390. }
  391. continue;
  392. } elseif (substr($pattern, -1) === '/') { // trailing slash means directory
  393. $pattern = trim($pattern, '/');
  394. if (!$isDir && count($path) <= count(explode('/', $pattern))) {
  395. continue;
  396. }
  397. }
  398. $parts = explode('/', ltrim($pattern, '/'));
  399. if (fnmatch(
  400. implode('/', $neg && $isDir ? array_slice($parts, 0, count($path)) : $parts),
  401. implode('/', array_slice($path, 0, count($parts))),
  402. FNM_CASEFOLD | FNM_PATHNAME
  403. )) {
  404. $res = !$neg;
  405. }
  406. }
  407. return $res;
  408. }
  409. private function writeProgress($count, $total, $path, $percent = NULL, $color = NULL)
  410. {
  411. $len = strlen((string) $total);
  412. $s = sprintf("(% {$len}d of %-{$len}d) %s", $count, $total, $path);
  413. if ($percent === NULL) {
  414. $this->logger->log($s, $color);
  415. } else {
  416. $this->logger->progress($s . ' [' . round($percent) . "%]");
  417. }
  418. }
  419. }