Merge branch 'master' into next
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / PackageInstallationScheduler.class.php
1 <?php
2 declare(strict_types=1);
3 namespace wcf\system\package;
4 use wcf\data\package\update\server\PackageUpdateServer;
5 use wcf\data\package\update\PackageUpdate;
6 use wcf\data\package\Package;
7 use wcf\data\package\PackageCache;
8 use wcf\system\database\util\PreparedStatementConditionBuilder;
9 use wcf\system\exception\HTTPUnauthorizedException;
10 use wcf\system\exception\SystemException;
11 use wcf\system\io\File;
12 use wcf\system\WCF;
13 use wcf\util\FileUtil;
14 use wcf\util\HTTPRequest;
15
16 /**
17 * Contains business logic related to preparation of package installations.
18 *
19 * @author Alexander Ebert
20 * @copyright 2001-2018 WoltLab GmbH
21 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
22 * @package WoltLabSuite\Core\System\Package
23 */
24 class PackageInstallationScheduler {
25 /**
26 * stack of package installations / updates
27 * @var array
28 */
29 protected $packageInstallationStack = [];
30
31 /**
32 * list of package update servers
33 * @var PackageUpdateServer[]
34 */
35 protected $packageUpdateServers = [];
36
37 /**
38 * list of packages to update or install
39 * @var array
40 */
41 protected $selectedPackages = [];
42
43 /**
44 * virtual package versions
45 * @var array
46 */
47 protected $virtualPackageVersions = [];
48
49 /**
50 * Creates a new instance of PackageInstallationScheduler
51 *
52 * @param string[] $selectedPackages
53 */
54 public function __construct(array $selectedPackages) {
55 $this->selectedPackages = $selectedPackages;
56 $this->packageUpdateServers = PackageUpdateServer::getActiveUpdateServers();
57 }
58
59 /**
60 * Builds the stack of package installations / updates.
61 *
62 * @param boolean $validateInstallInstructions
63 */
64 public function buildPackageInstallationStack($validateInstallInstructions = false) {
65 foreach ($this->selectedPackages as $package => $version) {
66 $this->tryToInstallPackage($package, $version, true, $validateInstallInstructions);
67 }
68 }
69
70 /**
71 * Tries to install a new package. Checks the virtual package version list.
72 *
73 * @param string $package package identifier
74 * @param string $minversion preferred package version
75 * @param boolean $installOldVersion true, if you want to install the package in the given minversion and not in the newest version
76 * @param boolean $validateInstallInstructions
77 */
78 protected function tryToInstallPackage($package, $minversion = '', $installOldVersion = false, $validateInstallInstructions = false) {
79 // check virtual package version
80 if (isset($this->virtualPackageVersions[$package])) {
81 if (!empty($minversion) && Package::compareVersion($this->virtualPackageVersions[$package], $minversion, '<')) {
82 $stackPosition = -1;
83 // remove installation of older version
84 foreach ($this->packageInstallationStack as $key => $value) {
85 if ($value['package'] == $package) {
86 $stackPosition = $key;
87 break;
88 }
89 }
90
91 // install newer version
92 $this->installPackage($package, ($installOldVersion ? $minversion : ''), $stackPosition, $validateInstallInstructions);
93 }
94 }
95 else {
96 // check if package is already installed
97 $packageID = PackageCache::getInstance()->getPackageID($package);
98 if ($packageID === null) {
99 // package is missing -> install
100 $this->installPackage($package, ($installOldVersion ? $minversion : ''), -1, $validateInstallInstructions);
101 }
102 else {
103 $package = PackageCache::getInstance()->getPackage($packageID);
104 if (!empty($minversion) && Package::compareVersion($package->packageVersion, $minversion, '<')) {
105 $this->updatePackage($packageID, ($installOldVersion ? $minversion : ''));
106 }
107 }
108 }
109 }
110
111 /**
112 * Installs a new package.
113 *
114 * @param string $package package identifier
115 * @param string $version package version
116 * @param integer $stackPosition
117 * @param boolean $validateInstallInstructions
118 */
119 protected function installPackage($package, $version = '', $stackPosition = -1, $validateInstallInstructions = false) {
120 // get package update versions
121 $packageUpdateVersions = PackageUpdateDispatcher::getInstance()->getPackageUpdateVersions($package, $version);
122
123 // resolve requirements
124 $this->resolveRequirements($packageUpdateVersions[0]['packageUpdateVersionID']);
125
126 // download package
127 $download = $this->downloadPackage($package, $packageUpdateVersions, $validateInstallInstructions);
128
129 // add to stack
130 $data = [
131 'packageName' => $packageUpdateVersions[0]['packageName'],
132 'packageVersion' => $packageUpdateVersions[0]['packageVersion'],
133 'package' => $package,
134 'packageID' => 0,
135 'archive' => $download,
136 'action' => 'install'
137 ];
138 if ($stackPosition == -1) $this->packageInstallationStack[] = $data;
139 else $this->packageInstallationStack[$stackPosition] = $data;
140
141 // update virtual versions
142 $this->virtualPackageVersions[$package] = $packageUpdateVersions[0]['packageVersion'];
143 }
144
145 /**
146 * Resolves the package requirements of an package update.
147 * Starts the installation or update to higher version of required packages.
148 *
149 * @param integer $packageUpdateVersionID
150 */
151 protected function resolveRequirements($packageUpdateVersionID) {
152 // resolve requirements
153 $requiredPackages = [];
154 $requirementsCache = [];
155 $sql = "SELECT *
156 FROM wcf".WCF_N."_package_update_requirement
157 WHERE packageUpdateVersionID = ?";
158 $statement = WCF::getDB()->prepareStatement($sql);
159 $statement->execute([$packageUpdateVersionID]);
160 while ($row = $statement->fetchArray()) {
161 $requiredPackages[] = $row['package'];
162 $requirementsCache[] = $row;
163 }
164
165 if (!empty($requiredPackages)) {
166 // find installed packages
167 $conditions = new PreparedStatementConditionBuilder();
168 $conditions->add("package IN (?)", [$requiredPackages]);
169
170 $installedPackages = [];
171 $sql = "SELECT packageID, package, packageVersion
172 FROM wcf".WCF_N."_package
173 ".$conditions;
174 $statement = WCF::getDB()->prepareStatement($sql);
175 $statement->execute($conditions->getParameters());
176 while ($row = $statement->fetchArray()) {
177 if (!isset($installedPackages[$row['package']])) $installedPackages[$row['package']] = [];
178 $installedPackages[$row['package']][$row['packageID']] = (isset($this->virtualPackageVersions[$row['packageID']]) ? $this->virtualPackageVersions[$row['packageID']] : $row['packageVersion']);
179 }
180
181 // check installed / missing packages
182 foreach ($requirementsCache as $row) {
183 if (isset($installedPackages[$row['package']])) {
184 // package already installed -> check version
185 // sort multiple instances by version number
186 uasort($installedPackages[$row['package']], [Package::class, 'compareVersion']);
187
188 $packageID = 0;
189 foreach ($installedPackages[$row['package']] as $packageID => $packageVersion) {
190 if (empty($row['minversion']) || Package::compareVersion($row['minversion'], $packageVersion, '<=')) {
191 continue 2;
192 }
193 }
194
195 // package version too low -> update necessary
196 $this->updatePackage($packageID, $row['minversion']);
197 }
198 else {
199 $this->tryToInstallPackage($row['package'], $row['minversion']);
200 }
201 }
202 }
203 }
204
205 /**
206 * Tries to download a package from available update servers.
207 *
208 * @param string $package package identifier
209 * @param array $packageUpdateVersions package update versions
210 * @param boolean $validateInstallInstructions
211 * @return string tmp filename of a downloaded package
212 * @throws PackageUpdateUnauthorizedException
213 * @throws SystemException
214 */
215 protected function downloadPackage($package, $packageUpdateVersions, $validateInstallInstructions = false) {
216 // get download from cache
217 if ($filename = $this->getCachedDownload($package, $packageUpdateVersions[0]['packageVersion'])) {
218 return $filename;
219 }
220
221 // download file
222 foreach ($packageUpdateVersions as $packageUpdateVersion) {
223 // get auth data
224 $authData = $this->getAuthData($packageUpdateVersion);
225
226 if ($packageUpdateVersion['filename']) {
227 $request = new HTTPRequest(
228 $packageUpdateVersion['filename'],
229 (!empty($authData) ? ['auth' => $authData] : []),
230 [
231 'apiVersion' => PackageUpdate::API_VERSION
232 ]
233 );
234 }
235 else {
236 // create request
237 $request = new HTTPRequest(
238 $this->packageUpdateServers[$packageUpdateVersion['packageUpdateServerID']]->getDownloadURL(),
239 (!empty($authData) ? ['auth' => $authData] : []),
240 [
241 'apiVersion' => PackageUpdate::API_VERSION,
242 'packageName' => $packageUpdateVersion['package'],
243 'packageVersion' => $packageUpdateVersion['packageVersion']
244 ]
245 );
246 }
247
248 try {
249 $request->execute();
250 }
251 catch (HTTPUnauthorizedException $e) {
252 throw new PackageUpdateUnauthorizedException($request, $this->packageUpdateServers[$packageUpdateVersion['packageUpdateServerID']], $packageUpdateVersion);
253 }
254
255 $response = $request->getReply();
256
257 // check response
258 if ($response['statusCode'] != 200) {
259 throw new SystemException(WCF::getLanguage()->getDynamicVariable('wcf.acp.package.error.downloadFailed', ['__downloadPackage' => $package]) . ' ('.$response['body'].')');
260 }
261
262 // write content to tmp file
263 $filename = FileUtil::getTemporaryFilename('package_');
264 $file = new File($filename);
265 $file->write($response['body']);
266 $file->close();
267 unset($response['body']);
268
269 // test package
270 $archive = new PackageArchive($filename);
271 $archive->openArchive();
272
273 // check install instructions
274 if ($validateInstallInstructions) {
275 $installInstructions = $archive->getInstallInstructions();
276 if (empty($installInstructions)) {
277 throw new SystemException("Package '" . $archive->getLocalizedPackageInfo('packageName') . "' (" . $archive->getPackageInfo('name') . ") does not contain valid installation instructions.");
278 }
279 }
280
281 $archive->getTar()->close();
282
283 // cache download in session
284 PackageUpdateDispatcher::getInstance()->cacheDownload($package, $packageUpdateVersion['packageVersion'], $filename);
285
286 return $filename;
287 }
288
289 return false;
290 }
291
292 /**
293 * Returns a list of excluded packages.
294 *
295 * @return array
296 */
297 public function getExcludedPackages() {
298 $excludedPackages = [];
299
300 if (!empty($this->packageInstallationStack)) {
301 $packageInstallations = [];
302 $packageIdentifier = [];
303 foreach ($this->packageInstallationStack as $packageInstallation) {
304 $packageInstallation['newVersion'] = ($packageInstallation['action'] == 'update' ? $packageInstallation['toVersion'] : $packageInstallation['packageVersion']);
305 $packageInstallations[] = $packageInstallation;
306 $packageIdentifier[] = $packageInstallation['package'];
307 }
308
309 // check exclusions of the new packages
310 // get package update ids
311 $conditions = new PreparedStatementConditionBuilder();
312 $conditions->add("package IN (?)", [$packageIdentifier]);
313
314 $sql = "SELECT packageUpdateID, package
315 FROM wcf".WCF_N."_package_update
316 ".$conditions;
317 $statement = WCF::getDB()->prepareStatement($sql);
318 $statement->execute($conditions->getParameters());
319 while ($row = $statement->fetchArray()) {
320 foreach ($packageInstallations as $key => $packageInstallation) {
321 if ($packageInstallation['package'] == $row['package']) {
322 $packageInstallations[$key]['packageUpdateID'] = $row['packageUpdateID'];
323 }
324 }
325 }
326
327 // get exclusions of the new packages
328 // build conditions
329 $conditions = '';
330 $statementParameters = [];
331 foreach ($packageInstallations as $packageInstallation) {
332 if (!empty($conditions)) $conditions .= ' OR ';
333 $conditions .= "(packageUpdateID = ? AND packageVersion = ?)";
334 $statementParameters[] = $packageInstallation['packageUpdateID'];
335 $statementParameters[] = $packageInstallation['newVersion'];
336 }
337
338 $sql = "SELECT package.*, package_update_exclusion.*,
339 package_update.packageUpdateID,
340 package_update.package
341 FROM wcf".WCF_N."_package_update_exclusion package_update_exclusion
342 LEFT JOIN wcf".WCF_N."_package_update_version package_update_version
343 ON (package_update_version.packageUpdateVersionID = package_update_exclusion.packageUpdateVersionID)
344 LEFT JOIN wcf".WCF_N."_package_update package_update
345 ON (package_update.packageUpdateID = package_update_version.packageUpdateID)
346 LEFT JOIN wcf".WCF_N."_package package
347 ON (package.package = package_update_exclusion.excludedPackage)
348 WHERE package_update_exclusion.packageUpdateVersionID IN (
349 SELECT packageUpdateVersionID
350 FROM wcf".WCF_N."_package_update_version
351 WHERE ".$conditions."
352 )
353 AND package.package IS NOT NULL";
354 $statement = WCF::getDB()->prepareStatement($sql);
355 $statement->execute($statementParameters);
356 while ($row = $statement->fetchArray()) {
357 foreach ($packageInstallations as $key => $packageInstallation) {
358 if ($packageInstallation['package'] == $row['package']) {
359 if (!isset($packageInstallations[$key]['excludedPackages'])) {
360 $packageInstallations[$key]['excludedPackages'] = [];
361 }
362 $packageInstallations[$key]['excludedPackages'][$row['excludedPackage']] = ['package' => $row['excludedPackage'], 'version' => $row['excludedPackageVersion']];
363
364 // check version
365 if (!empty($row['excludedPackageVersion'])) {
366 if (Package::compareVersion($row['packageVersion'], $row['excludedPackageVersion'], '<')) {
367 continue;
368 }
369 }
370
371 $excludedPackages[] = [
372 'package' => $row['package'],
373 'packageName' => $packageInstallations[$key]['packageName'],
374 'packageVersion' => $packageInstallations[$key]['newVersion'],
375 'action' => $packageInstallations[$key]['action'],
376 'conflict' => 'newPackageExcludesExistingPackage',
377 'existingPackage' => $row['excludedPackage'],
378 'existingPackageName' => WCF::getLanguage()->get($row['packageName']),
379 'existingPackageVersion' => $row['packageVersion']
380 ];
381 }
382 }
383 }
384
385 // check excluded packages of the existing packages
386 $conditions = new PreparedStatementConditionBuilder();
387 $conditions->add("excludedPackage IN (?)", [$packageIdentifier]);
388
389 $sql = "SELECT package.*, package_exclusion.*
390 FROM wcf".WCF_N."_package_exclusion package_exclusion
391 LEFT JOIN wcf".WCF_N."_package package
392 ON (package.packageID = package_exclusion.packageID)
393 ".$conditions;
394 $statement = WCF::getDB()->prepareStatement($sql);
395 $statement->execute($conditions->getParameters());
396 while ($row = $statement->fetchArray()) {
397 foreach ($packageInstallations as $key => $packageInstallation) {
398 if ($packageInstallation['package'] == $row['excludedPackage']) {
399 if (!empty($row['excludedPackageVersion'])) {
400 // check version
401 if (Package::compareVersion($packageInstallation['newVersion'], $row['excludedPackageVersion'], '<')) {
402 continue;
403 }
404
405 // search exclusing package in stack
406 foreach ($packageInstallations as $packageUpdate) {
407 if ($packageUpdate['packageID'] == $row['packageID']) {
408 // check new exclusions
409 if (!isset($packageUpdate['excludedPackages']) || !isset($packageUpdate['excludedPackages'][$row['excludedPackage']]) || (!empty($packageUpdate['excludedPackages'][$row['excludedPackage']]['version']) && Package::compareVersion($packageInstallation['newVersion'], $packageUpdate['excludedPackages'][$row['excludedPackage']]['version'], '<'))) {
410 continue 2;
411 }
412 }
413 }
414 }
415
416 $excludedPackages[] = [
417 'package' => $row['excludedPackage'],
418 'packageName' => $packageInstallation['packageName'],
419 'packageVersion' => $packageInstallation['newVersion'],
420 'action' => $packageInstallation['action'],
421 'conflict' => 'existingPackageExcludesNewPackage',
422 'existingPackage' => $row['package'],
423 'existingPackageName' => WCF::getLanguage()->get($row['packageName']),
424 'existingPackageVersion' => $row['packageVersion']
425 ];
426 }
427 }
428 }
429 }
430
431 return $excludedPackages;
432 }
433
434 /**
435 * Returns the stack of package installations.
436 *
437 * @return array
438 */
439 public function getPackageInstallationStack() {
440 return $this->packageInstallationStack;
441 }
442
443 /**
444 * Updates an existing package.
445 *
446 * @param integer $packageID
447 * @param string $version
448 */
449 protected function updatePackage($packageID, $version) {
450 // get package info
451 $package = PackageCache::getInstance()->getPackage($packageID);
452
453 // get current package version
454 $packageVersion = $package->packageVersion;
455 if (isset($this->virtualPackageVersions[$packageID])) {
456 $packageVersion = $this->virtualPackageVersions[$packageID];
457 // check virtual package version
458 if (Package::compareVersion($packageVersion, $version, '>=')) {
459 // virtual package version is greater than requested version
460 // skip package update
461 return;
462 }
463 }
464
465 // get highest version of the required major release
466 if (preg_match('/(\d+\.\d+\.)/', $version, $match)) {
467 $sql = "SELECT DISTINCT packageVersion
468 FROM wcf".WCF_N."_package_update_version
469 WHERE packageUpdateID IN (
470 SELECT packageUpdateID
471 FROM wcf".WCF_N."_package_update
472 WHERE package = ?
473 )
474 AND packageVersion LIKE ?";
475 $statement = WCF::getDB()->prepareStatement($sql);
476 $statement->execute([
477 $package->package,
478 $match[1].'%'
479 ]);
480 $packageVersions = $statement->fetchAll(\PDO::FETCH_COLUMN);
481
482 if (count($packageVersions) > 1) {
483 // sort by version number
484 usort($packageVersions, [Package::class, 'compareVersion']);
485
486 // get highest version
487 $version = array_pop($packageVersions);
488 }
489 }
490
491 // get all fromversion
492 $fromversions = [];
493 $sql = "SELECT puv.packageVersion, puf.fromversion
494 FROM wcf".WCF_N."_package_update_fromversion puf
495 LEFT JOIN wcf".WCF_N."_package_update_version puv
496 ON (puv.packageUpdateVersionID = puf.packageUpdateVersionID)
497 WHERE puf.packageUpdateVersionID IN (
498 SELECT packageUpdateVersionID
499 FROM wcf".WCF_N."_package_update_version
500 WHERE packageUpdateID IN (
501 SELECT packageUpdateID
502 FROM wcf".WCF_N."_package_update
503 WHERE package = ?
504 )
505 )";
506 $statement = WCF::getDB()->prepareStatement($sql);
507 $statement->execute([$package->package]);
508 while ($row = $statement->fetchArray()) {
509 if (!isset($fromversions[$row['packageVersion']])) $fromversions[$row['packageVersion']] = [];
510 $fromversions[$row['packageVersion']][$row['fromversion']] = $row['fromversion'];
511 }
512
513 // sort by version number
514 uksort($fromversions, [Package::class, 'compareVersion']);
515
516 // find shortest update thread
517 $updateThread = $this->findShortestUpdateThread($package->package, $fromversions, $packageVersion, $version);
518
519 // process update thread
520 foreach ($updateThread as $fromversion => $toVersion) {
521 $packageUpdateVersions = PackageUpdateDispatcher::getInstance()->getPackageUpdateVersions($package->package, $toVersion);
522
523 // resolve requirements
524 $this->resolveRequirements($packageUpdateVersions[0]['packageUpdateVersionID']);
525
526 // download package
527 $download = $this->downloadPackage($package->package, $packageUpdateVersions);
528
529 // add to stack
530 $this->packageInstallationStack[] = [
531 'packageName' => $package->getName(),
532 'fromversion' => $fromversion,
533 'toVersion' => $toVersion,
534 'package' => $package->package,
535 'packageID' => $packageID,
536 'archive' => $download,
537 'action' => 'update'
538 ];
539
540 // update virtual versions
541 $this->virtualPackageVersions[$packageID] = $toVersion;
542 }
543 }
544
545 /**
546 * Determines intermediate update steps using a backtracking algorithm in case there is no direct upgrade possible.
547 *
548 * @param string $package package identifier
549 * @param array $fromversions list of all fromversions
550 * @param string $currentVersion current package version
551 * @param string $newVersion new package version
552 * @return array list of update steps (old version => new version, old version => new version, ...)
553 * @throws SystemException
554 */
555 protected function findShortestUpdateThread($package, $fromversions, $currentVersion, $newVersion) {
556 if (!isset($fromversions[$newVersion])) {
557 throw new SystemException("An update of package ".$package." from version ".$currentVersion." to ".$newVersion." is not supported.");
558 }
559
560 // find direct update
561 foreach ($fromversions[$newVersion] as $fromversion) {
562 if (Package::checkFromversion($currentVersion, $fromversion)) {
563 return [$currentVersion => $newVersion];
564 }
565 }
566
567 // find intermediate update
568 $packageVersions = array_keys($fromversions);
569 $updateThreadList = [];
570 foreach ($fromversions[$newVersion] as $fromversion) {
571 $innerUpdateThreadList = [];
572 // find matching package versions
573 foreach ($packageVersions as $packageVersion) {
574 if (Package::checkFromversion($packageVersion, $fromversion) && Package::compareVersion($packageVersion, $currentVersion, '>') && Package::compareVersion($packageVersion, $newVersion, '<')) {
575 $innerUpdateThreadList[] = $this->findShortestUpdateThread($package, $fromversions, $currentVersion, $packageVersion) + [$packageVersion => $newVersion];
576 }
577 }
578
579 if (!empty($innerUpdateThreadList)) {
580 // sort by length
581 usort($innerUpdateThreadList, [$this, 'compareUpdateThreadLists']);
582
583 // add to thread list
584 $updateThreadList[] = array_shift($innerUpdateThreadList);
585 }
586 }
587
588 if (empty($updateThreadList)) {
589 throw new SystemException("An update of package ".$package." from version ".$currentVersion." to ".$newVersion." is not supported.");
590 }
591
592 // sort by length
593 usort($updateThreadList, [$this, 'compareUpdateThreadLists']);
594
595 // take shortest
596 return array_shift($updateThreadList);
597 }
598
599 /**
600 * Compares the length of two updates threads.
601 *
602 * @param array $updateThreadListA
603 * @param array $updateThreadListB
604 * @return integer
605 */
606 protected function compareUpdateThreadLists($updateThreadListA, $updateThreadListB) {
607 $countA = count($updateThreadListA);
608 $countB = count($updateThreadListB);
609
610 if ($countA < $countB) return -1;
611 if ($countA > $countB) return 1;
612
613 return 0;
614 }
615
616 /**
617 * Returns the filename of downloads stored in session or null if no stored downloads exist.
618 *
619 * @param string $package package identifier
620 * @param string $version package version
621 * @return string|boolean
622 */
623 protected function getCachedDownload($package, $version) {
624 $cachedDownloads = WCF::getSession()->getVar('cachedPackageUpdateDownloads');
625 if (isset($cachedDownloads[$package.'@'.$version]) && @file_exists($cachedDownloads[$package.'@'.$version])) {
626 return $cachedDownloads[$package.'@'.$version];
627 }
628
629 return false;
630 }
631
632 /**
633 * Returns stored auth data the update server with given data.
634 *
635 * @param array $data
636 * @return array
637 */
638 protected function getAuthData(array $data) {
639 $updateServer = new PackageUpdateServer(null, $data);
640 return $updateServer->getAuthData();
641 }
642 }