SymlinkDumper.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. <?php
  2. /*
  3. * This file is part of Packagist.
  4. *
  5. * (c) Jordi Boggiano <j.boggiano@seld.be>
  6. * Nils Adermann <naderman@naderman.de>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Packagist\WebBundle\Package;
  12. use Symfony\Component\Filesystem\Filesystem;
  13. use Composer\Util\Filesystem as ComposerFilesystem;
  14. use Symfony\Bridge\Doctrine\RegistryInterface;
  15. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  16. use Symfony\Component\Finder\Finder;
  17. use Packagist\WebBundle\Entity\Version;
  18. /**
  19. * @author Jordi Boggiano <j.boggiano@seld.be>
  20. */
  21. class SymlinkDumper
  22. {
  23. /**
  24. * Doctrine
  25. * @var RegistryInterface
  26. */
  27. protected $doctrine;
  28. /**
  29. * @var Filesystem
  30. */
  31. protected $fs;
  32. /**
  33. * @var ComposerFilesystem
  34. */
  35. protected $cfs;
  36. /**
  37. * @var string
  38. */
  39. protected $webDir;
  40. /**
  41. * @var string
  42. */
  43. protected $buildDir;
  44. /**
  45. * @var UrlGeneratorInterface
  46. */
  47. protected $router;
  48. /**
  49. * Data cache
  50. * @var array
  51. */
  52. private $rootFile;
  53. /**
  54. * Data cache
  55. * @var array
  56. */
  57. private $listings = array();
  58. /**
  59. * Data cache
  60. * @var array
  61. */
  62. private $individualFiles = array();
  63. /**
  64. * Modified times of individual files
  65. * @var array
  66. */
  67. private $individualFilesMtime = array();
  68. /**
  69. * Stores all the disk writes to be replicated in the second build dir after the symlink has been swapped
  70. * @var array
  71. */
  72. private $writeLog = array();
  73. /**
  74. * Constructor
  75. *
  76. * @param RegistryInterface $doctrine
  77. * @param Filesystem $filesystem
  78. * @param UrlGeneratorInterface $router
  79. * @param string $webDir web root
  80. * @param string $targetDir
  81. */
  82. public function __construct(RegistryInterface $doctrine, Filesystem $filesystem, UrlGeneratorInterface $router, $webDir, $targetDir)
  83. {
  84. $this->doctrine = $doctrine;
  85. $this->fs = $filesystem;
  86. $this->cfs = new ComposerFilesystem;
  87. $this->router = $router;
  88. $this->webDir = realpath($webDir);
  89. $this->buildDir = $targetDir;
  90. }
  91. /**
  92. * Dump a set of packages to the web root
  93. *
  94. * @param array $packageIds
  95. * @param Boolean $force
  96. * @param Boolean $verbose
  97. */
  98. public function dump(array $packageIds, $force = false, $verbose = false)
  99. {
  100. $cleanUpOldFiles = date('i') == 0;
  101. // prepare build dir
  102. $webDir = $this->webDir;
  103. $buildDirA = $this->buildDir.'/a';
  104. $buildDirB = $this->buildDir.'/b';
  105. // initialize
  106. $initialRun = false;
  107. if (!is_dir($buildDirA) || !is_dir($buildDirB)) {
  108. $initialRun = true;
  109. if (!$this->removeDirectory($buildDirA) || !$this->removeDirectory($buildDirB)) {
  110. throw new \RuntimeException('Failed to delete '.$buildDirA.' or '.$buildDirB);
  111. }
  112. $this->fs->mkdir($buildDirA);
  113. $this->fs->mkdir($buildDirB);
  114. }
  115. // set build dir to the not-active one
  116. if (realpath($webDir.'/p') === realpath($buildDirA)) {
  117. $buildDir = realpath($buildDirB);
  118. $oldBuildDir = realpath($buildDirA);
  119. } else {
  120. $buildDir = realpath($buildDirA);
  121. $oldBuildDir = realpath($buildDirB);
  122. }
  123. // copy existing stuff for smooth BC transition
  124. if ($initialRun && !$force) {
  125. if (!file_exists($webDir.'/p') || is_link($webDir.'/p')) {
  126. @rmdir($buildDir);
  127. @rmdir($oldBuildDir);
  128. throw new \RuntimeException('Run this again with --force the first time around to make sure it dumps all packages');
  129. }
  130. if ($verbose) {
  131. echo 'Copying existing files'.PHP_EOL;
  132. }
  133. foreach (array($buildDir, $oldBuildDir) as $dir) {
  134. $this->cloneDir($webDir.'/p', $dir);
  135. }
  136. }
  137. if ($verbose) {
  138. echo 'Web dir is '.$webDir.'/p ('.realpath($webDir.'/p').')'.PHP_EOL;
  139. echo 'Build dir is '.$buildDir.PHP_EOL;
  140. }
  141. // clean the build dir to start over if we are re-dumping everything
  142. if ($force) {
  143. // disable the write log since we copy everything at the end in forced mode
  144. $this->writeLog = false;
  145. if ($verbose) {
  146. echo 'Cleaning up existing files'.PHP_EOL;
  147. }
  148. if (!$this->clearDirectory($buildDir)) {
  149. return false;
  150. }
  151. }
  152. try {
  153. $modifiedIndividualFiles = array();
  154. $total = count($packageIds);
  155. $current = 0;
  156. $step = 50;
  157. while ($packageIds) {
  158. $dumpTime = new \DateTime;
  159. $packages = $this->doctrine->getRepository('PackagistWebBundle:Package')->getPackagesWithVersions(array_splice($packageIds, 0, $step));
  160. if ($verbose) {
  161. echo '['.sprintf('%'.strlen($total).'d', $current).'/'.$total.'] Processing '.$step.' packages'.PHP_EOL;
  162. }
  163. $current += $step;
  164. // prepare packages in memory
  165. foreach ($packages as $package) {
  166. $affectedFiles = array();
  167. $name = strtolower($package->getName());
  168. // clean up versions in individual files
  169. if (file_exists($buildDir.'/'.$name.'.files')) {
  170. $files = json_decode(file_get_contents($buildDir.'/'.$name.'.files'));
  171. foreach ($files as $file) {
  172. if (substr_count($file, '/') > 1) { // handle old .files with p/*/*.json paths
  173. $file = preg_replace('{^p/}', '', $file);
  174. }
  175. $this->loadIndividualFile($buildDir.'/'.$file, $file);
  176. if (isset($this->individualFiles[$file]['packages'][$name])) {
  177. unset($this->individualFiles[$file]['packages'][$name]);
  178. $modifiedIndividualFiles[$file] = true;
  179. }
  180. }
  181. }
  182. // (re)write versions in individual files
  183. foreach ($package->getVersions() as $version) {
  184. foreach (array_slice($version->getNames(), 0, 150) as $versionName) {
  185. if (!preg_match('{^[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_-][A-Za-z0-9_.-]*$}', $versionName) || strpos($versionName, '..')) {
  186. continue;
  187. }
  188. $file = $buildDir.'/'.$versionName.'.json';
  189. $key = $versionName.'.json';
  190. $this->dumpVersionToIndividualFile($version, $file, $key);
  191. $modifiedIndividualFiles[$key] = true;
  192. $affectedFiles[$key] = true;
  193. }
  194. }
  195. // store affected files to clean up properly in the next update
  196. $this->fs->mkdir(dirname($buildDir.'/'.$name));
  197. $this->writeFile($buildDir.'/'.$name.'.files', json_encode(array_keys($affectedFiles)));
  198. // update dump date
  199. $package->setDumpedAt($dumpTime);
  200. $this->doctrine->getManager()->flush($package);
  201. }
  202. unset($packages, $package, $version);
  203. $this->doctrine->getManager()->clear();
  204. if ($current % 250 === 0 || !$packageIds) {
  205. if ($verbose) {
  206. echo 'Dumping individual files'.PHP_EOL;
  207. }
  208. $this->dumpIndividualFiles($buildDir);
  209. }
  210. }
  211. // prepare individual files listings
  212. if ($verbose) {
  213. echo 'Preparing individual files listings'.PHP_EOL;
  214. }
  215. $safeFiles = array();
  216. $individualHashedListings = array();
  217. $finder = Finder::create()->files()->ignoreVCS(true)->name('*.json')->in($buildDir)->depth('1');
  218. foreach ($finder as $file) {
  219. // skip hashed files
  220. if (strpos($file, '$')) {
  221. continue;
  222. }
  223. $key = basename(dirname($file)).'/'.basename($file);
  224. if ($force && !isset($modifiedIndividualFiles[$key])) {
  225. continue;
  226. }
  227. // add hashed provider to listing
  228. $listing = $this->getTargetListing($file);
  229. $hash = hash_file('sha256', $file);
  230. $key = substr($key, 0, -5);
  231. $safeFiles[] = $key.'$'.$hash.'.json';
  232. $this->listings[$listing]['providers'][$key] = array('sha256' => $hash);
  233. $individualHashedListings[$listing] = true;
  234. }
  235. // prepare root file
  236. $rootFile = $buildDir.'/packages.json';
  237. $this->rootFile = array('packages' => array());
  238. $url = $this->router->generate('track_download', array('name' => 'VND/PKG'));
  239. $this->rootFile['notify'] = str_replace('VND/PKG', '%package%', $url);
  240. $this->rootFile['notify-batch'] = $this->router->generate('track_download_batch');
  241. $this->rootFile['providers-url'] = $this->router->generate('home') . 'p/%package%$%hash%.json';
  242. $this->rootFile['search'] = $this->router->generate('search', array('_format' => 'json')) . '?q=%query%';
  243. if ($verbose) {
  244. echo 'Dumping individual listings'.PHP_EOL;
  245. }
  246. // dump listings to build dir
  247. foreach ($individualHashedListings as $listing => $dummy) {
  248. list($listingPath, $hash) = $this->dumpListing($buildDir.'/'.$listing);
  249. $hashedListing = basename($listingPath);
  250. $this->rootFile['provider-includes']['p/'.str_replace($hash, '%hash%', $hashedListing)] = array('sha256' => $hash);
  251. $safeFiles[] = $hashedListing;
  252. }
  253. if ($verbose) {
  254. echo 'Dumping root'.PHP_EOL;
  255. }
  256. $this->dumpRootFile($rootFile);
  257. } catch (\Exception $e) {
  258. // restore files as they were before we started
  259. $this->cloneDir($oldBuildDir, $buildDir);
  260. throw $e;
  261. }
  262. try {
  263. if ($verbose) {
  264. echo 'Putting new files in production'.PHP_EOL;
  265. }
  266. // move away old files for BC update
  267. if ($initialRun && file_exists($webDir.'/p') && !is_link($webDir.'/p')) {
  268. rename($webDir.'/p', $webDir.'/p-old');
  269. }
  270. $this->switchActiveWebDir($webDir, $buildDir);
  271. } catch (\Exception $e) {
  272. @symlink($oldBuildDir, $webDir.'/p');
  273. throw $e;
  274. }
  275. try {
  276. if ($initialRun || !is_link($webDir.'/packages.json') || $force) {
  277. if ($verbose) {
  278. echo 'Writing/linking the packages.json'.PHP_EOL;
  279. }
  280. if (file_exists($webDir.'/packages.json')) {
  281. unlink($webDir.'/packages.json');
  282. }
  283. if (defined('PHP_WINDOWS_VERSION_BUILD')) {
  284. $sourcePath = $buildDir.'/packages.json';
  285. if (!copy($sourcePath, $webDir.'/packages.json')) {
  286. throw new \RuntimeException('Could not copy the packages.json file');
  287. }
  288. } else {
  289. $sourcePath = 'p/packages.json';
  290. if (!symlink($sourcePath, $webDir.'/packages.json')) {
  291. throw new \RuntimeException('Could not symlink the packages.json file');
  292. }
  293. }
  294. }
  295. } catch (\Exception $e) {
  296. $this->switchActiveWebDir($webDir, $oldBuildDir);
  297. throw $e;
  298. }
  299. // clean up old dir if present on BC update
  300. if ($initialRun) {
  301. $this->removeDirectory($webDir.'/p-old');
  302. }
  303. // clean the old build dir if we re-dumped everything
  304. if ($force) {
  305. if ($verbose) {
  306. echo 'Cleaning up old build dir'.PHP_EOL;
  307. }
  308. if (!$this->clearDirectory($oldBuildDir)) {
  309. throw new \RuntimeException('Unrecoverable inconsistent state (old build dir could not be cleared), run with --force again to retry');
  310. }
  311. }
  312. // copy state to old active dir
  313. if ($force) {
  314. if ($verbose) {
  315. echo 'Copying new contents to old build dir to sync up'.PHP_EOL;
  316. }
  317. $this->cloneDir($buildDir, $oldBuildDir);
  318. } else {
  319. if ($verbose) {
  320. echo 'Replaying write log in old build dir'.PHP_EOL;
  321. }
  322. $this->copyWriteLog($buildDir, $oldBuildDir);
  323. }
  324. // clean up old files once an hour
  325. if (!$force && $cleanUpOldFiles) {
  326. if ($verbose) {
  327. echo 'Cleaning up old files'.PHP_EOL;
  328. }
  329. $this->cleanOldFiles($buildDir, $oldBuildDir, $safeFiles);
  330. }
  331. return true;
  332. }
  333. private function switchActiveWebDir($webDir, $buildDir)
  334. {
  335. $newLink = $webDir.'/p-new';
  336. $oldLink = $webDir.'/p';
  337. if (file_exists($newLink)) {
  338. unlink($newLink);
  339. }
  340. if (!symlink($buildDir, $newLink)) {
  341. throw new \RuntimeException('Could not symlink the build dir into the web dir');
  342. }
  343. if (!rename($newLink, $oldLink)) {
  344. throw new \RuntimeException('Could not replace the old symlink with the new one in the web dir');
  345. }
  346. }
  347. private function cloneDir($source, $target)
  348. {
  349. $this->removeDirectory($target);
  350. exec('cp -rpf '.escapeshellarg($source).' '.escapeshellarg($target), $output, $exit);
  351. if (0 !== $exit) {
  352. echo 'Warning, cloning a directory using the php fallback does not keep filemtime, invalid behavior may occur';
  353. $this->fs->mirror($source, $target, null, array('override' => true));
  354. }
  355. }
  356. private function cleanOldFiles($buildDir, $oldBuildDir, $safeFiles)
  357. {
  358. $finder = Finder::create()->directories()->ignoreVCS(true)->in($buildDir);
  359. foreach ($finder as $vendorDir) {
  360. $vendorFiles = Finder::create()->files()->ignoreVCS(true)
  361. ->name('/\$[a-f0-9]+\.json$/')
  362. ->date('until 10minutes ago')
  363. ->in((string) $vendorDir);
  364. foreach ($vendorFiles as $file) {
  365. $key = strtr(str_replace($buildDir.DIRECTORY_SEPARATOR, '', $file), '\\', '/');
  366. if (!in_array($key, $safeFiles, true)) {
  367. unlink((string) $file);
  368. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, (string) $file))) {
  369. unlink($altDirFile);
  370. }
  371. }
  372. }
  373. }
  374. // clean up old provider listings
  375. $finder = Finder::create()->depth(0)->files()->name('provider-*.json')->ignoreVCS(true)->in($buildDir)->date('until 10minutes ago');
  376. foreach ($finder as $provider) {
  377. $key = strtr(str_replace($buildDir.DIRECTORY_SEPARATOR, '', $provider), '\\', '/');
  378. if (!in_array($key, $safeFiles, true)) {
  379. unlink((string) $provider);
  380. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, (string) $provider))) {
  381. unlink($altDirFile);
  382. }
  383. }
  384. }
  385. // clean up old root listings
  386. $finder = Finder::create()->depth(0)->files()->name('packages.json-*')->ignoreVCS(true)->in($buildDir)->date('until 10minutes ago');
  387. foreach ($finder as $rootFile) {
  388. unlink((string) $rootFile);
  389. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, (string) $rootFile))) {
  390. unlink($altDirFile);
  391. }
  392. }
  393. }
  394. private function dumpRootFile($file)
  395. {
  396. // sort all versions and packages to make sha1 consistent
  397. ksort($this->rootFile['packages']);
  398. ksort($this->rootFile['provider-includes']);
  399. foreach ($this->rootFile['packages'] as $package => $versions) {
  400. ksort($this->rootFile['packages'][$package]);
  401. }
  402. if (file_exists($file)) {
  403. rename($file, $file.'-'.time());
  404. }
  405. $this->writeFile($file, json_encode($this->rootFile));
  406. }
  407. private function dumpListing($path)
  408. {
  409. $key = basename($path);
  410. // sort files to make hash consistent
  411. ksort($this->listings[$key]['providers']);
  412. $json = json_encode($this->listings[$key]);
  413. $hash = hash('sha256', $json);
  414. $path = substr($path, 0, -5) . '$' . $hash . '.json';
  415. if (!file_exists($path)) {
  416. $this->writeFile($path, $json);
  417. }
  418. return array($path, $hash);
  419. }
  420. private function loadIndividualFile($path, $key)
  421. {
  422. if (isset($this->individualFiles[$key])) {
  423. return;
  424. }
  425. if (file_exists($path)) {
  426. $this->individualFiles[$key] = json_decode(file_get_contents($path), true);
  427. $this->individualFilesMtime[$key] = filemtime($path);
  428. } else {
  429. $this->individualFiles[$key] = array();
  430. $this->individualFilesMtime[$key] = 0;
  431. }
  432. }
  433. private function dumpIndividualFiles($buildDir)
  434. {
  435. // dump individual files to build dir
  436. foreach ($this->individualFiles as $file => $dummy) {
  437. $this->dumpIndividualFile($buildDir.'/'.$file, $file);
  438. }
  439. $this->individualFiles = array();
  440. $this->individualFilesMtime = array();
  441. }
  442. private function dumpIndividualFile($path, $key)
  443. {
  444. // sort all versions and packages to make sha1 consistent
  445. ksort($this->individualFiles[$key]['packages']);
  446. foreach ($this->individualFiles[$key]['packages'] as $package => $versions) {
  447. ksort($this->individualFiles[$key]['packages'][$package]);
  448. }
  449. $this->fs->mkdir(dirname($path));
  450. $json = json_encode($this->individualFiles[$key]);
  451. $this->writeFile($path, $json, $this->individualFilesMtime[$key]);
  452. // write the hashed provider file
  453. $hashedFile = substr($path, 0, -5) . '$' . hash('sha256', $json) . '.json';
  454. $this->writeFile($hashedFile, $json);
  455. }
  456. private function dumpVersionToIndividualFile(Version $version, $file, $key)
  457. {
  458. $this->loadIndividualFile($file, $key);
  459. $data = $version->toArray();
  460. $data['uid'] = $version->getId();
  461. $this->individualFiles[$key]['packages'][strtolower($version->getName())][$version->getVersion()] = $data;
  462. $timestamp = $version->getReleasedAt() ? $version->getReleasedAt()->getTimestamp() : time();
  463. if (!isset($this->individualFilesMtime[$key]) || $this->individualFilesMtime[$key] < $timestamp) {
  464. $this->individualFilesMtime[$key] = $timestamp;
  465. }
  466. }
  467. private function clearDirectory($path)
  468. {
  469. if (!$this->removeDirectory($path)) {
  470. echo 'Could not remove the build dir entirely, aborting';
  471. return false;
  472. }
  473. $this->fs->mkdir($path);
  474. return true;
  475. }
  476. private function removeDirectory($path)
  477. {
  478. $retries = 5;
  479. do {
  480. if (!$this->cfs->removeDirectory($path)) {
  481. usleep(200);
  482. }
  483. clearstatcache();
  484. } while (is_dir($path) && $retries--);
  485. return !is_dir($path);
  486. }
  487. private function getTargetListingBlocks($now)
  488. {
  489. $blocks = array();
  490. // monday last week
  491. $blocks['latest'] = strtotime('monday last week', $now);
  492. $month = date('n', $now);
  493. $month = ceil($month / 3) * 3 - 2; // 1 for months 1-3, 10 for months 10-12
  494. $block = new \DateTime(date('Y', $now).'-'.$month.'-01'); // 1st day of current trimester
  495. // split last 12 months in 4 trimesters
  496. for ($i=0; $i < 4; $i++) {
  497. $blocks[$block->format('Y-m')] = $block->getTimestamp();
  498. $block->sub(new \DateInterval('P3M'));
  499. }
  500. $year = (int) $block->format('Y');
  501. while ($year >= 2013) {
  502. $blocks[''.$year] = strtotime($year.'-01-01');
  503. $year--;
  504. }
  505. return $blocks;
  506. }
  507. private function getTargetListing($file)
  508. {
  509. static $blocks;
  510. if (!$blocks) {
  511. $blocks = $this->getTargetListingBlocks(time());
  512. }
  513. $mtime = filemtime($file);
  514. foreach ($blocks as $label => $block) {
  515. if ($mtime >= $block) {
  516. return "provider-${label}.json";
  517. }
  518. }
  519. return "provider-archived.json";
  520. }
  521. private function writeFile($path, $contents, $mtime = null)
  522. {
  523. file_put_contents($path, $contents);
  524. if ($mtime !== null) {
  525. touch($path, $mtime);
  526. }
  527. if (is_array($this->writeLog)) {
  528. $this->writeLog[$path] = array($contents, $mtime);
  529. }
  530. }
  531. private function copyWriteLog($from, $to)
  532. {
  533. foreach ($this->writeLog as $path => $op) {
  534. $path = str_replace($from, $to, $path);
  535. $this->fs->mkdir(dirname($path));
  536. file_put_contents($path, $op[0]);
  537. if ($op[1] !== null) {
  538. touch($path, $op[1]);
  539. }
  540. }
  541. }
  542. }