1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Support\Webapps\ComposerWrapper;
16: use Module\Support\Webapps\PhpWrapper;
17: use Module\Support\Webapps\Traits\PublicRelocatable;
18: use Module\Support\Webapps\VersionFetcher\Github;
19: use Opcenter\Versioning;
20:
21: /**
22: * Drupal drush interface
23: *
24: * @package core
25: */
26: class Drupal_Module extends \Module\Support\Webapps
27: {
28: use PublicRelocatable {
29: getAppRoot as getAppRootReal;
30: }
31:
32: const APP_NAME = 'Drupal';
33:
34: // primary domain document root
35: const DRUPAL_CLI = '/usr/share/pear/drush.phar';
36:
37: const DEFAULT_VERSION_LOCK = 'major';
38:
39: const DRUPAL_COMPATIBILITY = [
40: '8' => '8.x',
41: '9' => '8.4',
42: '10' => '11'
43: ];
44: protected $aclList = array(
45: 'max' => array('sites/*/files')
46: );
47:
48: /**
49: * void __construct(void)
50: *
51: * @ignore
52: */
53: public function __construct()
54: {
55: parent::__construct();
56: }
57:
58: /**
59: * Install WordPress into a pre-existing location
60: *
61: * @param string $hostname domain or subdomain to install WordPress
62: * @param string $path optional path under hostname
63: * @param array $opts additional install options
64: * @return bool
65: */
66: public function install(string $hostname, string $path = '', array $opts = array()): bool
67: {
68: if (isset($opts['version']) && version_compare((string)$opts['version'], '7.33', '<')) {
69: return error("Minimum %(app)s version is %(version)s", ['app' => self::APP_NAME, 'version' => '7.33']);
70: }
71: if (!$this->mysql_enabled()) {
72: return error('%(what)s must be enabled to install %(app)s',
73: ['what' => 'MySQL', 'app' => static::APP_NAME]);
74: }
75:
76: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
77: return false;
78: }
79:
80: $docroot = $this->getDocumentRoot($hostname, $path);
81:
82: // can't fetch translation file from ftp??
83: // don't worry about it for now
84: if (!isset($opts['locale'])) {
85: $opts['locale'] = 'us';
86: }
87:
88: if (!isset($opts['dist'])) {
89: $opts['profile'] = 'standard';
90: $opts['dist'] = 'drupal';
91: if (isset($opts['version'])) {
92: if (strcspn((string)$opts['version'], '.0123456789x')) {
93: return error('invalid version number, %s', $opts['version']);
94: }
95: $opts['dist'] .= '-' . $opts['version'];
96: }
97:
98: } else if (!isset($opts['profile'])) {
99: $opts['profile'] = $opts['dist'];
100: }
101:
102: if (version_compare((string)$opts['version'], '9.0.0', '>=')) {
103: $ret = serial(function () use ($docroot, $opts) {
104: $composer = ComposerWrapper::instantiateContexted(\Auth::context($this->getDocrootUser($docroot),
105: $this->site));
106:
107: $ret = $composer->exec($docroot,
108: 'create-project drupal/recommended-project:' . $opts['version'] . " .");
109: if (!$ret['success']) {
110: return $ret;
111: }
112:
113: return $composer->exec($docroot, 'require drush/drush');
114: });
115:
116: if (!$ret['success']) {
117: return error("Failed to install %(app)s: %(err)s", [
118: 'app' => static::APP_NAME,
119: 'err' => coalesce($ret['stderr'], $ret['stdout'])
120: ]);
121: }
122: if (null === ($docroot = $this->remapPublic($hostname, $path, 'web'))) {
123: // it's more reasonable to fail at this stage, but let's try to complete
124: return error("Failed to remap %(app)s to %(subdir)/, manually remap from `%(docroot)s' - %(app)s setup is incomplete!", [
125: 'app' => static::APP_NAME,
126: 'subdir' => 'web',
127: 'docroot' => $this->getDocumentRoot($hostname, $path),
128: ]);
129: }
130: } else {
131: $cmd = 'dl %(dist)s';
132:
133: $tmpdir = '/tmp/drupal' . crc32((string)\Util_PHP::random_int());
134: $args = array(
135: 'tempdir' => $tmpdir,
136: 'path' => $docroot,
137: 'dist' => $opts['dist']
138: );
139: /**
140: * drupal expects destination dir to exist
141: * move /tmp/<RANDOM NAME>/drupal to <DOCROOT> instead
142: * of downloading to <DOCROOT>/drupal and moving everything down 1
143: */
144: $this->file_create_directory($tmpdir);
145: $ret = $this->_exec('/tmp', $cmd . ' --drupal-project-rename --destination=%(tempdir)s -q', $args);
146:
147: if (!$ret['success']) {
148: return error('failed to download Drupal - out of space? Error: `%s\'',
149: coalesce($ret['stderr'], $ret['stdout'])
150: );
151: }
152:
153: if ($this->file_exists($docroot)) {
154: $this->file_delete($docroot, true);
155: }
156:
157: $this->file_purge();
158: $ret = $this->file_rename($tmpdir . '/drupal', $docroot);
159: $this->file_delete($tmpdir, true);
160: if (!$ret) {
161: return error("failed to move Drupal install to `%s'", $docroot);
162: }
163: }
164:
165: if (isset($opts['site-email']) && !preg_match(Regex::EMAIL, $opts['site-email'])) {
166: return error("invalid site email `%s' provided", $opts['site-email']);
167: }
168:
169: if (!isset($opts['site-email'])) {
170: // default to active domain, hope it's valid!
171: if (false === strpos($hostname, '.')) {
172: $hostname .= '.' . $this->domain;
173: }
174: $split = $this->web_split_host($hostname);
175: if (!$this->email_address_exists('postmaster', $split['domain'])) {
176: if (!$this->email_transport_exists($split['domain'])) {
177: warn("email is not configured for domain `%s', messages sent from installation may " .
178: 'be unrespondable', $split['domain']);
179: } else if ($this->email_add_alias('postmaster', $split['domain'], $opts['email'])) {
180: info("created `postmaster@%s' address for Drupal mailings that " .
181: "will forward to `%s'", $split['domain'], $opts['email']);
182: } else {
183: warn("failed to create Drupal postmaster address `postmaster@%s', messages " .
184: 'sent from installation may be unrespondable', $split['domain']);
185: }
186: }
187: $opts['site-email'] = 'postmaster@' . $split['domain'];
188: }
189:
190: $db = \Module\Support\Webapps\DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
191: if (!$db->create()) {
192: return false;
193: }
194:
195: $proto = 'mysql';
196: if (!empty($opts['version']) && version_compare((string)$opts['version'], '7.0', '<')) {
197: $proto = 'mysqli';
198: }
199: $dburi = $proto . '://' . $db->username . ':' .
200: $db->password . '@' . $db->hostname . '/' . $db->database;
201:
202: if (!isset($opts['title'])) {
203: $opts['title'] = 'A Random Drupal Install';
204: }
205:
206: $autogenpw = false;
207: if (!isset($opts['password'])) {
208: $autogenpw = true;
209: $opts['password'] = \Opcenter\Auth\Password::generate();
210: info("autogenerated password `%s'", $opts['password']);
211: }
212:
213: info("setting admin user to `%s'", $opts['user']);
214:
215: $xtra = array(
216: "install_configure_form.update_status_module='array(FALSE,FALSE)'"
217: );
218: // drush reqs name if dist not drupal otherwise
219: // getPath() on null error
220:
221: if ($opts['dist'] === 'drupal') {
222: $dist = '';
223: } else {
224: $dist = $opts['dist'];
225: }
226: $args = array(
227: 'dist' => $dist,
228: 'profile' => $opts['profile'],
229: 'dburi' => $dburi,
230: 'account-name' => $opts['user'],
231: 'account-pass' => $opts['password'],
232: 'account-mail' => $opts['email'],
233: 'locale' => $opts['locale'],
234: 'site-mail' => $opts['site-email'],
235: 'title' => $opts['title'],
236: 'xtraopts' => implode(' ', $xtra)
237: );
238:
239: $approot = $this->getAppRoot($hostname, $path);
240: $ret = $this->_exec($approot,
241: 'site-install %(profile)s -q --db-url=%(dburi)s --account-name=%(account-name)s ' .
242: '--account-pass=%(account-pass)s -y --account-mail=%(account-mail)s ' .
243: '--site-mail=%(site-mail)s --site-name=%(title)s %(xtraopts)s', $args);
244:
245: if (!$ret['success']) {
246: info('removing temporary files');
247: $this->file_delete($docroot, true);
248: $db->rollback();
249:
250: return error('failed to install Drupal: %s', coalesce($ret['stderr'], $ret['stdout']));
251: }
252: // by default, let's only open up ACLs to the bare minimum
253: $this->file_touch($docroot . '/.htaccess');
254: $this->removeInvalidDirectives($docroot, 'sites/default/files/');
255:
256: /**
257: * Make sure RewriteBase is present, move to Webapps?
258: */
259: $this->fixRewriteBase($docroot, $path);
260:
261: $this->_postInstallTrustedHost($opts['version'], $hostname, $docroot);
262:
263: if (!empty($opts['ssl'])) {
264: // @todo force redirect to HTTPS
265: }
266:
267: $this->notifyInstalled($hostname, $path, $opts);
268:
269: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
270: ['app' => static::APP_NAME, 'email' => $opts['email']]);
271: }
272:
273: /**
274: * @inheritDoc
275: */
276: protected function mapFilesFromList(array $files, string $approot): array
277: {
278: // remap for Drupal 9.x installed via Composer
279: if ($this->file_exists("$approot/web/sites")) {
280: $approot .= "/web";
281: }
282: return parent::mapFilesFromList($files, $approot);
283: }
284:
285: /**
286: * App root relocated in Drupal 9.x
287: * @param string $hostname
288: * @param string $path
289: * @return int
290: */
291: protected function getAppRootDepth(string $hostname, string $path = ''): int
292: {
293: $approot = dirname($this->web_normalize_path($hostname, $path));
294: return $this->file_exists("$approot/web/index.php") ? 1 : 0;
295: }
296:
297:
298: /**
299: * Look for manifest presence in v3.5+
300: *
301: * @param string $path
302: * @return string
303: */
304: private function assertCliTypeFromInstall(string $path): string
305: {
306: return $this->file_exists($path . '/Composer/Composer.php') || $this->file_exists($path . '/vendor/drupal/core-composer-scaffold') ? '999.999.999' : '8.9999.99999';
307: }
308:
309: private function cliFromVersion(string $version, string $poolVersion = null): string
310: {
311: $selections = [
312: dirname(self::DRUPAL_CLI) . '/drush-8.4.11.phar',
313: 'vendor/bin/drush'
314: ];
315: $choice = version_compare($version, '9.0.0', '<') ? 0 : 1;
316: if ($poolVersion && version_compare($poolVersion, '7.1.0', '<')) {
317: return $selections[0];
318: }
319:
320: return $selections[$choice];
321: }
322:
323: private function _exec(?string $path, $cmd, array $args = array())
324: {
325: $wrapper = PhpWrapper::instantiateContexted($this->getAuthContext());
326: $drush = $this->cliFromVersion($args['version'] ?? $this->assertCliTypeFromInstall($path),
327: $this->php_pool_version_from_path((string)$path));
328: $ret = $wrapper->exec($path, $drush . ' ' . $cmd, $args);
329:
330: if (0 === strncmp((string)coalesce($ret['stderr'], $ret['stdout']), 'Error:', 6)) {
331: // move stdout to stderr on error for consistency
332: $ret['success'] = false;
333: if (!$ret['stderr']) {
334: $ret['stderr'] = $ret['stdout'];
335: }
336: }
337:
338: return $ret;
339: }
340:
341: /**
342: * Get installed version
343: *
344: * @param string $hostname
345: * @param string $path
346: * @return null|string version number
347: */
348: public function get_version(string $hostname, string $path = ''): ?string
349: {
350:
351: if (!$this->valid($hostname, $path)) {
352: return null;
353: }
354: $docroot = $this->getAppRoot($hostname, $path);
355:
356: return $this->_getVersion($docroot);
357: }
358:
359: /**
360: * Location is a valid WP install
361: *
362: * @param string $hostname or $docroot
363: * @param string $path
364: * @return bool
365: */
366: public function valid(string $hostname, string $path = ''): bool
367: {
368: if ($hostname[0] === '/') {
369: $docroot = $hostname;
370: } else {
371: $docroot = $this->getAppRoot($hostname, $path);
372: if (!$docroot) {
373: return false;
374: }
375: }
376:
377: return $this->file_exists($docroot . '/sites/default')
378: || $this->file_exists($docroot . '/sites/all')
379: /* Drupal 9.0+ */
380: || $this->file_exists($docroot . '/vendor/drupal/core-composer-scaffold');
381: }
382:
383: /**
384: * Get version using exact docroot
385: *
386: * @param $docroot
387: * @return string
388: */
389: protected function _getVersion($docroot): ?string
390: {
391: $ret = $this->_exec($docroot, 'status --format=json');
392: if (!$ret['success']) {
393: return null;
394: }
395:
396: $output = json_decode($ret['stdout'], true);
397: return $output['drupal-version'] ?? null;
398: }
399:
400: /**
401: * Add trusted_host_patterns if necessary
402: *
403: * @param $version
404: * @param $hostname
405: * @param $docroot
406: * @return bool
407: */
408: private function _postInstallTrustedHost($version, $hostname, $docroot): bool
409: {
410: if (\Opcenter\Versioning::compare((string)$version, '8.0', '<')) {
411: return true;
412: }
413: $file = $docroot . '/sites/default/settings.php';
414: $content = $this->file_get_file_contents($file);
415: if (!$content) {
416: return error('unable to add trusted_host_patterns configuration - cannot get ' .
417: "Drupal configuration for `%s'", $hostname);
418: }
419: $content .= "\n\n" .
420: '/** in the event the domain name changes, trust site configuration */' . "\n" .
421: '$settings["trusted_host_patterns"] = array(' . "\n" .
422: "\t" . "'^(www\.)?' . " . 'str_replace(".", "\\\\.", $_SERVER["DOMAIN"]) . ' . "'$'" . "\n" .
423: ');' . "\n";
424:
425: return $this->file_put_file_contents($file, $content);
426: }
427:
428: /**
429: * Install and activate plugin
430: *
431: * @param string $hostname domain or subdomain of wp install
432: * @param string $path optional path component of wp install
433: * @param string $plugin plugin name
434: * @param string $version optional plugin version
435: * @return bool
436: */
437: public function install_plugin(string $hostname, string $path, string $plugin, string $version = ''): bool
438: {
439: $docroot = $this->getAppRoot($hostname, $path);
440: if (!$docroot) {
441: return error('invalid Drupal location');
442: }
443: $dlplugin = $plugin;
444: if ($version) {
445: if (false === strpos($version, '-')) {
446: // Drupal seems to like <major>-x naming conventions
447: $version .= '-x';
448: }
449: $dlplugin .= '-' . $version;
450: }
451: $args = array($plugin);
452: $ret = $this->_exec($docroot, 'pm-download -y %s', $args);
453: if (!$ret['success']) {
454: return error("failed to install plugin `%s': %s", $plugin, $ret['stderr']);
455: }
456:
457: if (!$this->enable_plugin($hostname, $path, $plugin)) {
458: return warn("downloaded plugin `%s' but failed to activate: %s", $plugin, $ret['stderr']);
459: }
460: info("installed plugin `%s'", $plugin);
461:
462: return true;
463: }
464:
465: public function enable_plugin(string $hostname, ?string $path, $plugin)
466: {
467: $docroot = $this->getAppRoot($hostname, $path);
468: if (!$docroot) {
469: return error('invalid Drupal location');
470: }
471: $ret = $this->_exec($docroot, 'pm-enable -y %s', array($plugin));
472: if (!$ret) {
473: return error("failed to enable plugin `%s': %s", $plugin, $ret['stderr']);
474: }
475:
476: return true;
477: }
478:
479: /**
480: * Uninstall a plugin
481: *
482: * @param string $hostname
483: * @param string $path
484: * @param string $plugin plugin name
485: * @param bool|string $force delete even if plugin activated
486: * @return bool
487: */
488: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
489: {
490: $docroot = $this->getAppRoot($hostname, $path);
491: if (!$docroot) {
492: return error('invalid Drupal location');
493: }
494:
495: $args = array($plugin);
496:
497: if ($this->plugin_active($hostname, $path, $plugin)) {
498: if (!$force) {
499: return error("plugin `%s' is active, disable first");
500: }
501: $this->disable_plugin($hostname, $path, $plugin);
502: }
503:
504: $cmd = 'pm-uninstall %s';
505:
506: $ret = $this->_exec($docroot, $cmd, $args);
507:
508: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
509: return error("failed to uninstall plugin `%s': %s", $plugin, $ret['stderr']);
510: }
511: info("uninstalled plugin `%s'", $plugin);
512:
513: return true;
514: }
515:
516: public function plugin_active(string $hostname, ?string $path, $plugin)
517: {
518: $docroot = $this->getAppRoot($hostname, (string)$path);
519: if (!$docroot) {
520: return error('invalid Drupal location');
521: }
522: $plugin = $this->plugin_status($hostname, (string)$path, $plugin);
523:
524: return $plugin['status'] === 'enabled';
525: }
526:
527: public function plugin_status(string $hostname, string $path = '', string $plugin = null): ?array
528: {
529: $docroot = $this->getAppRoot($hostname, $path);
530: if (!$docroot) {
531: return error('invalid Drupal location');
532: }
533: $cmd = 'pm-info --format=json %(plugin)s';
534: $ret = $this->_exec($docroot, $cmd, ['plugin' => $plugin]);
535: if (!$ret['success']) {
536: return null;
537: }
538: $plugins = [];
539: foreach (json_decode($ret['stdout'], true) as $name => $meta) {
540: $plugins[$name] = [
541: 'version' => $meta['version'],
542: 'next' => null,
543: 'current' => true,
544: 'max' => $meta['version']
545: ];
546: }
547:
548: return $plugin ? array_pop($plugins) : $plugins;
549: }
550:
551: public function disable_plugin($hostname, ?string $path, $plugin)
552: {
553: $docroot = $this->getAppRoot($hostname, (string)$path);
554: if (!$docroot) {
555: return error('invalid Drupal location');
556: }
557: $ret = $this->_exec($docroot, 'pm-disable -y %s', array($plugin));
558: if (!$ret) {
559: return error("failed to disable plugin `%s': %s", $plugin, $ret['stderr']);
560: }
561: info("disabled plugin `%s'", $plugin);
562:
563: return true;
564: }
565:
566: /**
567: * Recovery mode to disable all plugins
568: *
569: * @param string $hostname subdomain or domain of WP
570: * @param string $path optional path
571: * @return bool
572: */
573: public function disable_all_plugins(string $hostname, string $path = ''): bool
574: {
575: $docroot = $this->getAppRoot($hostname, $path);
576: if (!$docroot) {
577: return error('failed to determine path');
578: }
579: $plugins = array();
580: $installed = $this->list_all_plugins($hostname, $path);
581: if (!$installed) {
582: return true;
583: }
584: foreach ($installed as $plugin => $info) {
585: if (strtolower($info['status']) !== 'enabled') {
586: continue;
587: }
588: $this->disable_plugin($hostname, $path, $plugin);
589: $plugins[] = $info['name'];
590:
591: }
592: if ($plugins) {
593: info("disabled plugins: `%s'", implode(',', $plugins));
594: }
595:
596: return true;
597: }
598:
599: public function list_all_plugins($hostname, $path = '', $status = '')
600: {
601: $docroot = $this->getAppRoot($hostname, $path);
602: if (!$docroot) {
603: return error('invalid Drupal location');
604: }
605: if ($status) {
606: $status = strtolower($status);
607: $status = '--status=' . $status;
608: }
609: $ret = $this->_exec($docroot, 'pm-list --format=json --no-core %s', array($status));
610: if (!$ret['success']) {
611: return error('failed to enumerate plugins: %s', $ret['stderr']);
612: }
613:
614: return json_decode($ret['stdout'], true);
615: }
616:
617: /**
618: * Uninstall Drupal from a location
619: *
620: * @param string $hostname
621: * @param string $path
622: * @param string $delete
623: * @return bool
624: * @internal param string $deletefiles remove all files under docroot
625: */
626: public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
627: {
628: return parent::uninstall($hostname, $path, $delete);
629: }
630:
631: /**
632: * Get database configuration for a blog
633: *
634: * @param string $hostname domain or subdomain of Drupal
635: * @param string $path optional path
636: * @return array|bool
637: */
638: public function db_config(string $hostname, string $path = '')
639: {
640: $docroot = $this->getDocumentRoot($hostname, $path);
641: if (!$docroot) {
642: return error('failed to determine Drupal');
643: }
644: $code = 'include("./sites/default/settings.php"); $conf = $databases["default"]["default"]; print serialize(array("user" => $conf["username"], "password" => $conf["password"], "db" => $conf["database"], "prefix" => $conf["prefix"], "host" => $conf["host"]));';
645: $cmd = 'cd %(path)s && php -r %(code)s';
646: $ret = $this->pman_run($cmd, array('path' => $docroot, 'code' => $code));
647:
648: if (!$ret['success']) {
649: return error("failed to obtain Drupal configuration for `%s'", $docroot);
650: }
651:
652: return \Util_PHP::unserialize(trim($ret['stdout']));
653: }
654:
655: private function _extractBranch($version)
656: {
657: if (substr($version, -2) === '.x') {
658: return $version;
659: }
660: $pos = strpos($version, '.');
661: if (false === $pos) {
662: // sent major alone
663: return $version . '.x';
664: }
665: $newver = substr($version, 0, $pos);
666:
667: return $newver . '.x';
668: }
669:
670: /**
671: * Get all current major versions
672: *
673: * @return array
674: */
675: private function _getVersions(): array
676: {
677: $key = 'drupal.versions';
678: $cache = Cache_Super_Global::spawn();
679: if (false !== ($ver = $cache->get($key))) {
680: return (array)$ver;
681: }
682: // 8.7.11+
683: $versions = (new Github)->setMode('tags')->fetch('drupal/drupal', fn($v) => str_contains($v['version'], '-') ? false : null);
684: $cache->set($key, $versions, 43200);
685:
686: return $versions;
687: }
688:
689: /**
690: * Change WP admin credentials
691: *
692: * $fields is a hash whose indices match password
693: *
694: * @param string $hostname
695: * @param string $path
696: * @param array $fields password only field supported for now
697: * @return bool
698: */
699: public function change_admin(string $hostname, string $path, array $fields): bool
700: {
701: $docroot = $this->getAppRoot($hostname, $path);
702: if (!$docroot) {
703: return warn('failed to change administrator information');
704: }
705: $admin = $this->get_admin($hostname, $path);
706:
707: if (!$admin) {
708: return error('cannot determine admin of Drupal install');
709: }
710:
711: $args = array(
712: 'user' => $admin
713: );
714:
715: if (isset($fields['password'])) {
716: $args['password'] = $fields['password'];
717: $ret = $this->_exec($docroot, 'user-password --password=%(password)s %(user)s', $args);
718: if (!$ret['success']) {
719: return error("failed to update password for user `%s': %s", $admin, $ret['stderr']);
720: }
721: }
722:
723: return true;
724: }
725:
726: /**
727: * Get the primary admin for a Drupal instance
728: *
729: * @param string $hostname
730: * @param string $path
731: * @return null|string admin or false on failure
732: */
733: public function get_admin(string $hostname, string $path = ''): ?string
734: {
735: $docroot = $this->getAppRoot($hostname, $path);
736: $ret = $this->_exec($docroot, 'user-information 1 --format=json');
737: if (!$ret['success']) {
738: warn('failed to enumerate Drupal administrative users');
739:
740: return null;
741: }
742: $tmp = json_decode($ret['stdout'], true);
743: if (!$tmp) {
744: return null;
745: }
746: $tmp = array_pop($tmp);
747:
748: return $tmp['name'];
749: }
750:
751: /**
752: * Update core, plugins, and themes atomically
753: *
754: * @param string $hostname subdomain or domain
755: * @param string $path optional path under hostname
756: * @param string $version
757: * @return bool
758: */
759: public function update_all(string $hostname, string $path = '', string $version = null): bool
760: {
761: $ret = ($this->update($hostname, $path, $version) && $this->update_plugins($hostname, $path))
762: || error('failed to update all components');
763:
764: parent::setInfo($this->getDocumentRoot($hostname, $path), [
765: 'version' => $this->get_version($hostname, $path),
766: 'failed' => !$ret
767: ]);
768:
769: return $ret;
770: }
771:
772: /**
773: * Update Drupal to latest version
774: *
775: * @param string $hostname domain or subdomain under which WP is installed
776: * @param string $path optional subdirectory
777: * @param string $version
778: * @return bool
779: */
780: public function update(string $hostname, string $path = '', string $version = null): bool
781: {
782: $approot = $this->getAppRoot($hostname, $path);
783: if (!$approot) {
784: return error('update failed');
785: }
786: if ($this->isLocked($approot)) {
787: return error('Drupal is locked - remove lock file from `%s\' and try again', $approot);
788: }
789:
790: $oldVersion = $this->get_version($hostname, $path);
791: if ($version) {
792: if (!is_scalar($version) || strcspn($version, '.0123456789x-')) {
793: return error('invalid version number, %s', $version);
794: }
795: $current = $this->_extractBranch($version);
796: } else {
797: $current = $this->_extractBranch($this->get_version($hostname, $path));
798: $version = Versioning::nextVersion(
799: $this->get_versions(),
800: $oldVersion
801: );
802: }
803:
804: if (version_compare($oldVersion, '9.0.0', '<') && version_compare($version, '9.0.0', '>=')) {
805: // moves to drush as Composer package
806: return error("No automatic upgrade path exists from pre-9.0 to 9.0+");
807: }
808:
809: $docroot = $this->getDocumentRoot($hostname, $path);
810: // save .htaccess
811: $htaccess = $docroot . DIRECTORY_SEPARATOR . '.htaccess';
812: if ($this->file_exists($htaccess) && !$this->file_move($htaccess, $htaccess . '.bak', true)) {
813: return error('upgrade failure: failed to save copy of original .htaccess');
814: }
815: $this->file_purge();
816: $this->_setMaintenance($approot, true, $current);
817:
818: if (version_compare($version, '9.0.0', '<')) {
819: $cmd = 'pm-update drupal-%(version)s -y';
820: $args = array('version' => $version);
821: $ret = $this->_exec($approot, $cmd, $args);
822: if ($ret['success']) {
823: $this->_exec($approot, 'cache-build');
824: }
825:
826: } else {
827: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
828: $ret = $composer->exec($approot, "update 'drupal/core-*' -W --with='drupal/core-recommended:$version'");
829: if ($ret['success']) {
830: $ret = $this->_exec($approot, "updatedb");
831: }
832:
833: if ($ret['success']) {
834: $this->_exec($approot, "cache:rebuild");
835: }
836: }
837:
838: $this->file_purge();
839: $this->_setMaintenance($approot, false, $current);
840:
841: if ($this->file_exists($htaccess . '.bak') && !$this->file_move($htaccess . '.bak', $htaccess, true)
842: && ($this->file_purge() || true)
843: ) {
844: warn("failed to rename backup `%s/.htaccess.bak' to .htaccess", $approot);
845: }
846:
847: parent::setInfo($approot, [
848: 'version' => $this->get_version($hostname, $path) ?? $version,
849: 'failed' => !$ret['success']
850: ]);
851: $this->fortify($hostname, $path, array_get($this->getOptions($approot), 'fortify') ?: 'max');
852:
853: if (!$ret['success']) {
854: return error('failed to update Drupal: %s', coalesce($ret['stderr'], $ret['stdout']));
855: }
856:
857: return $ret['success'];
858: }
859:
860: public function isLocked(string $docroot): bool
861: {
862: return file_exists($this->domain_fs_path() . $docroot . DIRECTORY_SEPARATOR .
863: '.drush-lock-update');
864: }
865:
866: /**
867: * Set Drupal maintenance mode before/after update
868: *
869: * @param $docroot
870: * @param $mode
871: * @param null $version
872: * @return bool
873: */
874: private function _setMaintenance($docroot, $mode, $version = null)
875: {
876: if (null === $version) {
877: $version = $this->_getVersion($docroot);
878: }
879: $version = explode('.', $version, 2);
880: if ((int)$version[0] >= 8) {
881: $maintenancecmd = 'sset system.maintenance_mode %(mode)d';
882: $cachecmd = 'cr';
883: } else {
884: $maintenancecmd = 'vset --exact maintenance_mode %(mode)d';
885: $cachecmd = 'cache-clear all';
886: }
887:
888: $ret = $this->_exec($docroot, $maintenancecmd, array('mode' => (int)$mode));
889: if (!$ret['success']) {
890: warn('failed to set maintenance mode');
891: }
892: $ret = $this->_exec($docroot, $cachecmd);
893: if (!$ret['success']) {
894: warn('failed to rebuild cache');
895: }
896:
897: return true;
898: }
899:
900: /**
901: * Update Drupal plugins and themes
902: *
903: * @param string $hostname domain or subdomain
904: * @param string $path optional path within host
905: * @param array $plugins
906: * @return bool
907: */
908: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
909: {
910: $docroot = $this->getAppRoot($hostname, $path);
911: if (version_compare($this->get_version($hostname, $path), '9.0', '>=')) {
912: return debug("Individual plugin updates no longer supported");
913: }
914: if (!$docroot) {
915: return error('update failed');
916: }
917: $cmd = 'pm-update -y --check-disabled --no-core';
918:
919: $args = array();
920: if ($plugins) {
921: for ($i = 0, $n = count($plugins); $i < $n; $i++) {
922: $plugin = $plugins[$i];
923: $version = null;
924: if (isset($plugin['version'])) {
925: $version = $plugin['version'];
926: }
927: if (isset($plugin['name'])) {
928: $plugin = $plugin['name'];
929: }
930:
931: $name = 'p' . $i;
932: $cmd .= ' %(' . $name . ')s';
933: $args[$name] = $plugin . ($version ? '-' . $version : '');
934: }
935: }
936:
937: $ret = $this->_exec($docroot, $cmd, $args);
938: if (!$ret['success']) {
939: /**
940: * NB: "Command pm-update needs a higher bootstrap level"...
941: * Use an older version of Drush to bring the version up
942: * to use the latest drush
943: */
944: return error("plugin update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
945: }
946:
947: return $ret['success'];
948: }
949:
950: public function _housekeeping()
951: {
952: $versions = [
953: 'drush-8.4.11.phar' => null
954: ];
955:
956: foreach ($versions as $full => $short) {
957: $src = resource_path('storehouse') . '/' . $full;
958: $dest = \Opcenter\Php::PEAR_HOME. '/' . ($short ?? $full);
959: if (is_file($dest) && sha1_file($src) === sha1_file($dest)) {
960: continue;
961: }
962:
963: copy($src, $dest);
964: chmod($dest, 0755);
965: info('Copied %(src)s to %(dest)s', ['src' => $full, 'dest' => $dest]);
966: }
967:
968: return true;
969: }
970:
971: /**
972: * Get all available Drupal versions
973: *
974: * @return array
975: */
976: public function get_versions(): array
977: {
978: $versions = $this->_getVersions();
979:
980: return array_column($versions, 'version');
981: }
982:
983: /**
984: * Update WordPress themes
985: *
986: * @param string $hostname subdomain or domain
987: * @param string $path optional path under hostname
988: * @param array $themes
989: * @return bool
990: */
991: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
992: {
993: return false;
994: }
995:
996: private function _getCommand()
997: {
998: return 'php ' . self::DRUPAL_CLI;
999: }
1000: }
1001: