SymlinkDumper.php 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912
  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. use Packagist\WebBundle\Entity\Package;
  19. use Doctrine\DBAL\Connection;
  20. use Packagist\WebBundle\HealthCheck\MetadataDirCheck;
  21. use Predis\Client;
  22. use Graze\DogStatsD\Client as StatsDClient;
  23. /**
  24. * @author Jordi Boggiano <j.boggiano@seld.be>
  25. */
  26. class SymlinkDumper
  27. {
  28. /**
  29. * Doctrine
  30. * @var RegistryInterface
  31. */
  32. protected $doctrine;
  33. /**
  34. * @var Filesystem
  35. */
  36. protected $fs;
  37. /**
  38. * @var ComposerFilesystem
  39. */
  40. protected $cfs;
  41. /**
  42. * @var string
  43. */
  44. protected $webDir;
  45. /**
  46. * @var string
  47. */
  48. protected $buildDir;
  49. /**
  50. * @var UrlGeneratorInterface
  51. */
  52. protected $router;
  53. /**
  54. * @var Client
  55. */
  56. protected $redis;
  57. /**
  58. * Data cache
  59. * @var array
  60. */
  61. private $rootFile;
  62. /**
  63. * Data cache
  64. * @var array
  65. */
  66. private $listings = array();
  67. /**
  68. * Data cache
  69. * @var array
  70. */
  71. private $individualFiles = array();
  72. /**
  73. * Data cache
  74. * @var array
  75. */
  76. private $individualFilesV2 = array();
  77. /**
  78. * Modified times of individual files
  79. * @var array
  80. */
  81. private $individualFilesMtime = array();
  82. /**
  83. * Stores all the disk writes to be replicated in the second build dir after the symlink has been swapped
  84. * @var array
  85. */
  86. private $writeLog = array();
  87. /**
  88. * Generate compressed files.
  89. * @var int 0 disabled, 9 maximum.
  90. */
  91. private $compress;
  92. /**
  93. * @var array
  94. */
  95. private $awsMeta;
  96. /**
  97. * @var StatsDClient
  98. */
  99. private $statsd;
  100. /**
  101. * Constructor
  102. *
  103. * @param RegistryInterface $doctrine
  104. * @param Filesystem $filesystem
  105. * @param UrlGeneratorInterface $router
  106. * @param string $webDir web root
  107. * @param string $targetDir
  108. * @param int $compress
  109. */
  110. public function __construct(RegistryInterface $doctrine, Filesystem $filesystem, UrlGeneratorInterface $router, Client $redis, $webDir, $targetDir, $compress, $awsMetadata, StatsDClient $statsd)
  111. {
  112. $this->doctrine = $doctrine;
  113. $this->fs = $filesystem;
  114. $this->cfs = new ComposerFilesystem;
  115. $this->router = $router;
  116. $this->webDir = realpath($webDir);
  117. $this->buildDir = $targetDir;
  118. $this->compress = $compress;
  119. $this->redis = $redis;
  120. $this->awsMeta = $awsMetadata;
  121. $this->statsd = $statsd;
  122. }
  123. /**
  124. * Dump a set of packages to the web root
  125. *
  126. * @param array $packageIds
  127. * @param Boolean $force
  128. * @param Boolean $verbose
  129. */
  130. public function dump(array $packageIds, $force = false, $verbose = false)
  131. {
  132. if (!MetadataDirCheck::isMetadataStoreMounted($this->awsMeta)) {
  133. throw new \RuntimeException('Metadata store not mounted, can not dump metadata');
  134. }
  135. // prepare build dir
  136. $webDir = $this->webDir;
  137. $buildDirA = $this->buildDir.'/a';
  138. $buildDirB = $this->buildDir.'/b';
  139. $buildDirV2 = $this->buildDir.'/p2';
  140. // initialize
  141. $initialRun = false;
  142. if (!is_dir($buildDirA) || !is_dir($buildDirB)) {
  143. $initialRun = true;
  144. if (!$this->removeDirectory($buildDirA) || !$this->removeDirectory($buildDirB)) {
  145. throw new \RuntimeException('Failed to delete '.$buildDirA.' or '.$buildDirB);
  146. }
  147. $this->fs->mkdir($buildDirA);
  148. $this->fs->mkdir($buildDirB);
  149. }
  150. if (!is_dir($buildDirV2)) {
  151. $this->fs->mkdir($buildDirV2);
  152. }
  153. // set build dir to the not-active one
  154. if (realpath($webDir.'/p') === realpath($buildDirA)) {
  155. $buildDir = realpath($buildDirB);
  156. $oldBuildDir = realpath($buildDirA);
  157. } else {
  158. $buildDir = realpath($buildDirA);
  159. $oldBuildDir = realpath($buildDirB);
  160. }
  161. $buildDirV2 = realpath($buildDirV2);
  162. // copy existing stuff for smooth BC transition
  163. if ($initialRun && !$force) {
  164. if (!file_exists($webDir.'/p') || is_link($webDir.'/p')) {
  165. @rmdir($buildDir);
  166. @rmdir($oldBuildDir);
  167. throw new \RuntimeException('Run this again with --force the first time around to make sure it dumps all packages');
  168. }
  169. if ($verbose) {
  170. echo 'Copying existing files'.PHP_EOL;
  171. }
  172. foreach (array($buildDir, $oldBuildDir) as $dir) {
  173. $this->cloneDir($webDir.'/p', $dir);
  174. }
  175. }
  176. if ($verbose) {
  177. echo 'Web dir is '.$webDir.'/p ('.realpath($webDir.'/p').')'.PHP_EOL;
  178. echo 'Build dir is '.$buildDir.PHP_EOL;
  179. echo 'Build v2 dir is '.$buildDirV2.PHP_EOL;
  180. }
  181. // clean the build dir to start over if we are re-dumping everything
  182. if ($force) {
  183. // disable the write log since we copy everything at the end in forced mode
  184. $this->writeLog = false;
  185. if ($verbose) {
  186. echo 'Cleaning up existing files'.PHP_EOL;
  187. }
  188. if (!$this->clearDirectory($buildDir)) {
  189. return false;
  190. }
  191. }
  192. $dumpTimeUpdates = [];
  193. $versionRepo = $this->doctrine->getRepository('PackagistWebBundle:Version');
  194. try {
  195. $modifiedIndividualFiles = array();
  196. $modifiedV2Files = array();
  197. $total = count($packageIds);
  198. $current = 0;
  199. $step = 50;
  200. while ($packageIds) {
  201. $dumpTime = new \DateTime;
  202. $packages = $this->doctrine->getRepository('PackagistWebBundle:Package')->getPackagesWithVersions(array_splice($packageIds, 0, $step));
  203. if ($verbose) {
  204. echo '['.sprintf('%'.strlen($total).'d', $current).'/'.$total.'] Processing '.$step.' packages'.PHP_EOL;
  205. }
  206. $current += $step;
  207. // prepare packages in memory
  208. foreach ($packages as $package) {
  209. // skip spam packages in the dumper in case we do a forced full dump and prevent them from being dumped for a little while
  210. if ($package->isAbandoned() && $package->getReplacementPackage() === 'spam/spam') {
  211. $dumpTimeUpdates['2100-01-01 00:00:00'][] = $package->getId();
  212. continue;
  213. }
  214. $affectedFiles = array();
  215. $name = strtolower($package->getName());
  216. // clean up versions in individual files
  217. if (file_exists($buildDir.'/'.$name.'.files')) {
  218. $files = json_decode(file_get_contents($buildDir.'/'.$name.'.files'));
  219. foreach ($files as $file) {
  220. if (substr_count($file, '/') > 1) { // handle old .files with p/*/*.json paths
  221. $file = preg_replace('{^p/}', '', $file);
  222. }
  223. $this->loadIndividualFile($buildDir.'/'.$file, $file);
  224. if (isset($this->individualFiles[$file]['packages'][$name])) {
  225. unset($this->individualFiles[$file]['packages'][$name]);
  226. $modifiedIndividualFiles[$file] = true;
  227. }
  228. }
  229. }
  230. // (re)write versions in individual files
  231. $versionIds = [];
  232. foreach ($package->getVersions() as $version) {
  233. $versionIds[] = $version->getId();
  234. }
  235. $versionData = $versionRepo->getVersionData($versionIds);
  236. foreach ($package->getVersions() as $version) {
  237. foreach (array_slice($version->getNames($versionData), 0, 150) as $versionName) {
  238. if (!preg_match('{^[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_-][A-Za-z0-9_.-]*$}', $versionName) || strpos($versionName, '..')) {
  239. continue;
  240. }
  241. $file = $buildDir.'/'.$versionName.'.json';
  242. $key = $versionName.'.json';
  243. $this->dumpVersionToIndividualFile($version, $file, $key, $versionData);
  244. $modifiedIndividualFiles[$key] = true;
  245. $affectedFiles[$key] = true;
  246. }
  247. }
  248. // dump v2 format
  249. $key = $name.'.json';
  250. $keyDev = $name.'~dev.json';
  251. $this->dumpPackageToV2File($package, $versionData, $key, $keyDev);
  252. $modifiedV2Files[$key] = true;
  253. $modifiedV2Files[$keyDev] = true;
  254. // store affected files to clean up properly in the next update
  255. $this->fs->mkdir(dirname($buildDir.'/'.$name));
  256. $this->writeFileNonAtomic($buildDir.'/'.$name.'.files', json_encode(array_keys($affectedFiles)));
  257. $dumpTimeUpdates[$dumpTime->format('Y-m-d H:i:s')][] = $package->getId();
  258. }
  259. unset($packages, $package, $version);
  260. $this->doctrine->getManager()->clear();
  261. if ($current % 250 === 0 || !$packageIds) {
  262. if ($verbose) {
  263. echo 'Dumping individual files'.PHP_EOL;
  264. }
  265. $this->dumpIndividualFiles($buildDir);
  266. $this->dumpIndividualFilesV2($buildDirV2);
  267. }
  268. }
  269. // prepare individual files listings
  270. if ($verbose) {
  271. echo 'Preparing individual files listings'.PHP_EOL;
  272. }
  273. $individualHashedListings = array();
  274. $finder = Finder::create()->files()->ignoreVCS(true)->name('*.json')->in($buildDir)->depth('1');
  275. foreach ($finder as $file) {
  276. // skip hashed files
  277. if (strpos($file, '$')) {
  278. continue;
  279. }
  280. $key = basename(dirname($file)).'/'.basename($file);
  281. if ($force && !isset($modifiedIndividualFiles[$key])) {
  282. continue;
  283. }
  284. // add hashed provider to listing
  285. $listing = $this->getTargetListing($file);
  286. $hash = hash_file('sha256', $file);
  287. $key = substr($key, 0, -5);
  288. $this->listings[$listing]['providers'][$key] = array('sha256' => $hash);
  289. $individualHashedListings[$listing] = true;
  290. }
  291. // prepare root file
  292. $rootFile = $buildDir.'/packages.json';
  293. $this->rootFile = array('packages' => array());
  294. $url = $this->router->generate('track_download', ['name' => 'VND/PKG'], UrlGeneratorInterface::ABSOLUTE_URL);
  295. $this->rootFile['notify'] = str_replace('VND/PKG', '%package%', $url);
  296. $this->rootFile['notify-batch'] = $this->router->generate('track_download_batch', [], UrlGeneratorInterface::ABSOLUTE_URL);
  297. $this->rootFile['providers-url'] = $this->router->generate('home', []) . 'p/%package%$%hash%.json';
  298. $this->rootFile['metadata-url'] = $this->router->generate('home', []) . 'p2/%package%.json';
  299. $this->rootFile['search'] = $this->router->generate('search', ['_format' => 'json'], UrlGeneratorInterface::ABSOLUTE_URL) . '?q=%query%&type=%type%';
  300. $this->rootFile['providers-api'] = str_replace('VND/PKG', '%package%', $this->router->generate('view_providers', ['name' => 'VND/PKG', '_format' => 'json'], UrlGeneratorInterface::ABSOLUTE_URL));
  301. if ($verbose) {
  302. echo 'Dumping individual listings'.PHP_EOL;
  303. }
  304. // dump listings to build dir
  305. foreach ($individualHashedListings as $listing => $dummy) {
  306. list($listingPath, $hash) = $this->dumpListing($buildDir.'/'.$listing);
  307. $hashedListing = basename($listingPath);
  308. $this->rootFile['provider-includes']['p/'.str_replace($hash, '%hash%', $hashedListing)] = array('sha256' => $hash);
  309. }
  310. if ($verbose) {
  311. echo 'Dumping root'.PHP_EOL;
  312. }
  313. $this->dumpRootFile($rootFile);
  314. } catch (\Exception $e) {
  315. // restore files as they were before we started
  316. $this->cloneDir($oldBuildDir, $buildDir);
  317. throw $e;
  318. }
  319. try {
  320. if ($verbose) {
  321. echo 'Putting new files in production'.PHP_EOL;
  322. }
  323. if (!file_exists($webDir.'/p2') && !@symlink($buildDirV2, $webDir.'/p2')) {
  324. echo 'Warning: Could not symlink the build dir v2 into the web dir';
  325. throw new \RuntimeException('Could not symlink the build dir v2 into the web dir');
  326. }
  327. // move away old files for BC update
  328. if ($initialRun && file_exists($webDir.'/p') && !is_link($webDir.'/p')) {
  329. rename($webDir.'/p', $webDir.'/p-old');
  330. }
  331. $this->switchActiveWebDir($webDir, $buildDir);
  332. } catch (\Exception $e) {
  333. @symlink($oldBuildDir, $webDir.'/p');
  334. throw $e;
  335. }
  336. try {
  337. if ($initialRun || !is_link($webDir.'/packages.json') || $force) {
  338. if ($verbose) {
  339. echo 'Writing/linking the packages.json'.PHP_EOL;
  340. }
  341. if (file_exists($webDir.'/packages.json')) {
  342. unlink($webDir.'/packages.json');
  343. }
  344. if (file_exists($webDir.'/packages.json.gz')) {
  345. unlink($webDir.'/packages.json.gz');
  346. }
  347. if (defined('PHP_WINDOWS_VERSION_BUILD')) {
  348. $sourcePath = $buildDir.'/packages.json';
  349. if (!copy($sourcePath, $webDir.'/packages.json')) {
  350. throw new \RuntimeException('Could not copy the packages.json file');
  351. }
  352. } else {
  353. $sourcePath = 'p/packages.json';
  354. if (!symlink($sourcePath, $webDir.'/packages.json')) {
  355. throw new \RuntimeException('Could not symlink the packages.json file');
  356. }
  357. if ($this->compress && !symlink($sourcePath.'.gz', $webDir.'/packages.json.gz')) {
  358. throw new \RuntimeException('Could not symlink the packages.json.gz file');
  359. }
  360. }
  361. }
  362. } catch (\Exception $e) {
  363. $this->switchActiveWebDir($webDir, $oldBuildDir);
  364. throw $e;
  365. }
  366. // clean up old dir if present on BC update
  367. if ($initialRun) {
  368. $this->removeDirectory($webDir.'/p-old');
  369. }
  370. // clean the old build dir if we re-dumped everything
  371. if ($force) {
  372. if ($verbose) {
  373. echo 'Cleaning up old build dir'.PHP_EOL;
  374. }
  375. if (!$this->clearDirectory($oldBuildDir)) {
  376. throw new \RuntimeException('Unrecoverable inconsistent state (old build dir could not be cleared), run with --force again to retry');
  377. }
  378. }
  379. // copy state to old active dir
  380. if ($force) {
  381. if ($verbose) {
  382. echo 'Copying new contents to old build dir to sync up'.PHP_EOL;
  383. }
  384. $this->cloneDir($buildDir, $oldBuildDir);
  385. } else {
  386. if ($verbose) {
  387. echo 'Replaying write log in old build dir'.PHP_EOL;
  388. }
  389. $this->copyWriteLog($buildDir, $oldBuildDir);
  390. }
  391. if ($verbose) {
  392. echo 'Updating package dump times'.PHP_EOL;
  393. }
  394. $maxDumpTime = 0;
  395. foreach ($dumpTimeUpdates as $dt => $ids) {
  396. $retries = 5;
  397. // retry loop in case of a lock timeout
  398. while ($retries--) {
  399. try {
  400. $this->doctrine->getManager()->getConnection()->executeQuery(
  401. 'UPDATE package SET dumpedAt=:dumped WHERE id IN (:ids)',
  402. [
  403. 'ids' => $ids,
  404. 'dumped' => $dt,
  405. ],
  406. ['ids' => Connection::PARAM_INT_ARRAY]
  407. );
  408. break;
  409. } catch (\Exception $e) {
  410. if (!$retries) {
  411. throw $e;
  412. }
  413. sleep(2);
  414. }
  415. }
  416. if ($dt !== '2100-01-01 00:00:00') {
  417. $maxDumpTime = max($maxDumpTime, strtotime($dt));
  418. }
  419. }
  420. if ($maxDumpTime !== 0) {
  421. $this->redis->set('last_metadata_dump_time', $maxDumpTime + 1);
  422. // make sure no next dumper has a chance to start and dump things within the same second as $maxDumpTime
  423. // as in updatedSince we will return the updates from the next second only (the +1 above) to avoid serving the same updates twice
  424. if (time() === $maxDumpTime) {
  425. sleep(1);
  426. }
  427. }
  428. $this->statsd->increment('packagist.metadata_dump');
  429. return true;
  430. }
  431. private function switchActiveWebDir($webDir, $buildDir)
  432. {
  433. $newLink = $webDir.'/p-new';
  434. $oldLink = $webDir.'/p';
  435. if (file_exists($newLink)) {
  436. unlink($newLink);
  437. }
  438. if (!symlink($buildDir, $newLink)) {
  439. echo 'Warning: Could not symlink the build dir into the web dir';
  440. throw new \RuntimeException('Could not symlink the build dir into the web dir');
  441. }
  442. if (!rename($newLink, $oldLink)) {
  443. echo 'Warning: Could not replace the old symlink with the new one in the web dir';
  444. throw new \RuntimeException('Could not replace the old symlink with the new one in the web dir');
  445. }
  446. }
  447. private function cloneDir($source, $target)
  448. {
  449. $this->removeDirectory($target);
  450. exec('cp -rpf '.escapeshellarg($source).' '.escapeshellarg($target), $output, $exit);
  451. if (0 !== $exit) {
  452. echo 'Warning, cloning a directory using the php fallback does not keep filemtime, invalid behavior may occur';
  453. $this->fs->mirror($source, $target, null, array('override' => true));
  454. }
  455. }
  456. public function gc()
  457. {
  458. // build up array of safe files
  459. $safeFiles = [];
  460. $rootFile = $this->webDir.'/packages.json';
  461. if (!file_exists($rootFile) || !is_dir($this->buildDir.'/a')) {
  462. return;
  463. }
  464. $rootJson = json_decode(file_get_contents($rootFile), true);
  465. foreach ($rootJson['provider-includes'] as $listing => $opts) {
  466. $listing = str_replace('%hash%', $opts['sha256'], $listing);
  467. $safeFiles[basename($listing)] = true;
  468. $listingJson = json_decode(file_get_contents($this->webDir.'/'.$listing), true);
  469. foreach ($listingJson['providers'] as $pkg => $opts) {
  470. $provPath = $pkg.'$'.$opts['sha256'].'.json';
  471. $safeFiles[$provPath] = true;
  472. }
  473. }
  474. $buildDirs = [realpath($this->buildDir.'/a'), realpath($this->buildDir.'/b')];
  475. shuffle($buildDirs);
  476. $this->cleanOldFiles($buildDirs[0], $buildDirs[1], $safeFiles);
  477. }
  478. private function cleanOldFiles($buildDir, $oldBuildDir, $safeFiles)
  479. {
  480. $time = (time() - 86400) * 10000;
  481. $this->redis->set('metadata-oldest', $time);
  482. $this->redis->zremrangebyscore('metadata-dumps', 0, $time-1);
  483. $this->redis->zremrangebyscore('metadata-deletes', 0, $time-1);
  484. $finder = Finder::create()->directories()->ignoreVCS(true)->in($buildDir);
  485. foreach ($finder as $vendorDir) {
  486. $vendorFiles = Finder::create()->files()->ignoreVCS(true)
  487. ->name('/\$[a-f0-9]+\.json$/')
  488. ->date('until 10minutes ago')
  489. ->in((string) $vendorDir);
  490. foreach ($vendorFiles as $file) {
  491. $key = strtr(str_replace($buildDir.DIRECTORY_SEPARATOR, '', $file), '\\', '/');
  492. if (!isset($safeFiles[$key])) {
  493. unlink((string) $file);
  494. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, (string) $file))) {
  495. unlink($altDirFile);
  496. }
  497. }
  498. }
  499. }
  500. // clean up old provider listings
  501. $finder = Finder::create()->depth(0)->files()->name('provider-*.json')->ignoreVCS(true)->in($buildDir)->date('until 10minutes ago');
  502. foreach ($finder as $provider) {
  503. $key = strtr(str_replace($buildDir.DIRECTORY_SEPARATOR, '', $provider), '\\', '/');
  504. if (!isset($safeFiles[$key])) {
  505. $path = (string) $provider;
  506. unlink($path);
  507. if (file_exists($path.'.gz')) {
  508. unlink($path.'.gz');
  509. }
  510. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, $path))) {
  511. unlink($altDirFile);
  512. if (file_exists($altDirFile.'.gz')) {
  513. unlink($altDirFile.'.gz');
  514. }
  515. }
  516. }
  517. }
  518. // clean up old root listings
  519. $finder = Finder::create()->depth(0)->files()->name('packages.json-*')->ignoreVCS(true)->in($buildDir)->date('until 10minutes ago');
  520. foreach ($finder as $rootFile) {
  521. $path = (string) $rootFile;
  522. unlink($path);
  523. if (file_exists($path.'.gz')) {
  524. unlink($path.'.gz');
  525. }
  526. if (file_exists($altDirFile = str_replace($buildDir, $oldBuildDir, $path))) {
  527. unlink($altDirFile);
  528. if (file_exists($altDirFile.'.gz')) {
  529. unlink($altDirFile.'.gz');
  530. }
  531. }
  532. }
  533. }
  534. private function dumpRootFile($file)
  535. {
  536. // sort all versions and packages to make sha1 consistent
  537. ksort($this->rootFile['packages']);
  538. ksort($this->rootFile['provider-includes']);
  539. foreach ($this->rootFile['packages'] as $package => $versions) {
  540. ksort($this->rootFile['packages'][$package]);
  541. }
  542. $json = json_encode($this->rootFile, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  543. $time = time();
  544. $this->writeFile($file, $json, $time);
  545. if ($this->compress) {
  546. $this->writeFile($file . '.gz', gzencode($json, $this->compress), $time);
  547. }
  548. }
  549. private function dumpListing($path)
  550. {
  551. $key = basename($path);
  552. // sort files to make hash consistent
  553. ksort($this->listings[$key]['providers']);
  554. $json = json_encode($this->listings[$key], JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  555. $hash = hash('sha256', $json);
  556. $path = substr($path, 0, -5) . '$' . $hash . '.json';
  557. $time = time();
  558. if (!file_exists($path)) {
  559. $this->writeFile($path, $json, $time);
  560. if ($this->compress) {
  561. $this->writeFile($path . '.gz', gzencode($json, $this->compress), $time);
  562. }
  563. }
  564. return array($path, $hash);
  565. }
  566. private function loadIndividualFile($path, $key)
  567. {
  568. if (isset($this->individualFiles[$key])) {
  569. return;
  570. }
  571. if (file_exists($path)) {
  572. $this->individualFiles[$key] = json_decode(file_get_contents($path), true);
  573. $this->individualFilesMtime[$key] = filemtime($path);
  574. } else {
  575. $this->individualFiles[$key] = array();
  576. $this->individualFilesMtime[$key] = 0;
  577. }
  578. }
  579. private function dumpIndividualFiles($buildDir)
  580. {
  581. // dump individual files to build dir
  582. foreach ($this->individualFiles as $file => $dummy) {
  583. $this->dumpIndividualFile($buildDir.'/'.$file, $file);
  584. }
  585. $this->individualFiles = array();
  586. $this->individualFilesMtime = array();
  587. }
  588. private function dumpIndividualFile($path, $key)
  589. {
  590. // sort all versions and packages to make sha1 consistent
  591. ksort($this->individualFiles[$key]['packages']);
  592. foreach ($this->individualFiles[$key]['packages'] as $package => $versions) {
  593. ksort($this->individualFiles[$key]['packages'][$package]);
  594. }
  595. $this->fs->mkdir(dirname($path));
  596. $json = json_encode($this->individualFiles[$key], JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  597. $this->writeFile($path, $json, $this->individualFilesMtime[$key]);
  598. // write the hashed provider file
  599. $hashedFile = substr($path, 0, -5) . '$' . hash('sha256', $json) . '.json';
  600. $this->writeFile($hashedFile, $json);
  601. }
  602. private function dumpVersionToIndividualFile(Version $version, $file, $key, $versionData)
  603. {
  604. $this->loadIndividualFile($file, $key);
  605. $data = $version->toArray($versionData);
  606. $data['uid'] = $version->getId();
  607. $this->individualFiles[$key]['packages'][strtolower($version->getName())][$version->getVersion()] = $data;
  608. $timestamp = $version->getReleasedAt() ? $version->getReleasedAt()->getTimestamp() : time();
  609. if (!isset($this->individualFilesMtime[$key]) || $this->individualFilesMtime[$key] < $timestamp) {
  610. $this->individualFilesMtime[$key] = $timestamp;
  611. }
  612. }
  613. private function dumpIndividualFilesV2($dir)
  614. {
  615. // dump individual files to build dir
  616. foreach ($this->individualFilesV2 as $key => $data) {
  617. $path = $dir . '/' . $key;
  618. $this->fs->mkdir(dirname($path));
  619. $json = json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  620. $this->writeV2File($path, $json);
  621. }
  622. $this->individualFilesV2 = array();
  623. }
  624. private function dumpPackageToV2File(Package $package, $versionData, string $packageKey, string $packageKeyDev)
  625. {
  626. $versions = $package->getVersions();
  627. if (is_object($versions)) {
  628. $versions = $versions->toArray();
  629. }
  630. usort($versions, Package::class.'::sortVersions');
  631. $tags = [];
  632. $branches = [];
  633. foreach ($versions as $version) {
  634. if ($version->isDevelopment()) {
  635. $branches[] = $version;
  636. } else {
  637. $tags[] = $version;
  638. }
  639. }
  640. $this->dumpVersionsToV2File($package, $tags, $versionData, $packageKey);
  641. $this->dumpVersionsToV2File($package, $branches, $versionData, $packageKeyDev);
  642. }
  643. private function dumpVersionsToV2File(Package $package, $versions, $versionData, string $packageKey)
  644. {
  645. $minifiedVersions = [];
  646. $lastKnownVersionData = null;
  647. foreach ($versions as $version) {
  648. $versionArray = $version->toV2Array($versionData);
  649. if (!$lastKnownVersionData) {
  650. $lastKnownVersionData = $versionArray;
  651. $minifiedVersions[] = $versionArray;
  652. continue;
  653. }
  654. $minifiedVersion = [];
  655. // add any changes from the previous version
  656. foreach ($versionArray as $key => $val) {
  657. if (!isset($lastKnownVersionData[$key]) || $lastKnownVersionData[$key] !== $val) {
  658. $minifiedVersion[$key] = $val;
  659. $lastKnownVersionData[$key] = $val;
  660. }
  661. }
  662. // store any deletions from the previous version for keys missing in current one
  663. foreach ($lastKnownVersionData as $key => $val) {
  664. if (!isset($versionArray[$key])) {
  665. $minifiedVersion[$key] = "__unset";
  666. unset($lastKnownVersionData[$key]);
  667. }
  668. }
  669. $minifiedVersions[] = $minifiedVersion;
  670. }
  671. $this->individualFilesV2[$packageKey]['packages'][strtolower($package->getName())] = $minifiedVersions;
  672. $this->individualFilesV2[$packageKey]['minified'] = 'composer/2.0';
  673. }
  674. private function clearDirectory($path)
  675. {
  676. if (!$this->removeDirectory($path)) {
  677. echo 'Could not remove the build dir entirely, aborting';
  678. return false;
  679. }
  680. $this->fs->mkdir($path);
  681. return true;
  682. }
  683. private function removeDirectory($path)
  684. {
  685. $retries = 5;
  686. do {
  687. if (!$this->cfs->removeDirectory($path)) {
  688. usleep(200);
  689. }
  690. clearstatcache();
  691. } while (is_dir($path) && $retries--);
  692. return !is_dir($path);
  693. }
  694. private function getTargetListingBlocks($now)
  695. {
  696. $blocks = array();
  697. // monday last week
  698. $blocks['latest'] = strtotime('monday last week', $now);
  699. $month = date('n', $now);
  700. $month = ceil($month / 3) * 3 - 2; // 1 for months 1-3, 10 for months 10-12
  701. $block = new \DateTime(date('Y', $now).'-'.$month.'-01'); // 1st day of current trimester
  702. // split last 12 months in 4 trimesters
  703. for ($i=0; $i < 4; $i++) {
  704. $blocks[$block->format('Y-m')] = $block->getTimestamp();
  705. $block->sub(new \DateInterval('P3M'));
  706. }
  707. $year = (int) $block->format('Y');
  708. while ($year >= 2013) {
  709. $blocks[''.$year] = strtotime($year.'-01-01');
  710. $year--;
  711. }
  712. return $blocks;
  713. }
  714. private function getTargetListing($file)
  715. {
  716. static $blocks;
  717. if (!$blocks) {
  718. $blocks = $this->getTargetListingBlocks(time());
  719. }
  720. $mtime = filemtime($file);
  721. foreach ($blocks as $label => $block) {
  722. if ($mtime >= $block) {
  723. return "provider-${label}.json";
  724. }
  725. }
  726. return "provider-archived.json";
  727. }
  728. private function writeFile($path, $contents, $mtime = null)
  729. {
  730. file_put_contents($path.'.tmp', $contents);
  731. if ($mtime !== null) {
  732. touch($path.'.tmp', $mtime);
  733. }
  734. rename($path.'.tmp', $path);
  735. if (is_array($this->writeLog)) {
  736. $this->writeLog[$path] = array($contents, $mtime);
  737. }
  738. }
  739. private function writeV2File($path, $contents)
  740. {
  741. if (file_exists($path) && file_get_contents($path) === $contents) {
  742. return;
  743. }
  744. // get time before file_put_contents to be sure we return a time at least as old as the filemtime, if it is older it doesn't matter
  745. $timestamp = round(microtime(true)*10000);
  746. file_put_contents($path.'.tmp', $contents);
  747. rename($path.'.tmp', $path);
  748. if (!preg_match('{/([^/]+/[^/]+?(~dev)?)\.json$}', $path, $match)) {
  749. throw new \LogicException('Could not match package name from '.$path);
  750. }
  751. $this->redis->zadd('metadata-dumps', $timestamp, $match[1]);
  752. }
  753. private function writeFileNonAtomic($path, $contents)
  754. {
  755. file_put_contents($path, $contents);
  756. if (is_array($this->writeLog)) {
  757. $this->writeLog[$path] = array($contents, null);
  758. }
  759. }
  760. private function copyWriteLog($from, $to)
  761. {
  762. foreach ($this->writeLog as $path => $op) {
  763. $path = str_replace($from, $to, $path);
  764. $this->fs->mkdir(dirname($path));
  765. file_put_contents($path, $op[0]);
  766. if ($op[1] !== null) {
  767. touch($path, $op[1]);
  768. }
  769. }
  770. }
  771. }