3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
13 * The extension manager handles installing, disabling enabling, and
14 * uninstalling extensions.
16 * You should obtain a singleton of this class via
18 * $manager = CRM_Extension_Manager::singleton()->getManager();
21 * @copyright CiviCRM LLC https://civicrm.org/licensing
23 class CRM_Extension_Manager
{
25 * The extension is fully installed and enabled.
27 const STATUS_INSTALLED
= 'installed';
30 * The extension config has been applied to database but deactivated.
32 const STATUS_DISABLED
= 'disabled';
35 * The extension code is visible, but nothing has been applied to DB
37 const STATUS_UNINSTALLED
= 'uninstalled';
40 * The extension code is not locally accessible
42 const STATUS_UNKNOWN
= 'unknown';
45 * The extension is installed but the code is not accessible
47 const STATUS_INSTALLED_MISSING
= 'installed-missing';
50 * The extension was installed and is now disabled; the code is not accessible
52 const STATUS_DISABLED_MISSING
= 'disabled-missing';
55 * @var CRM_Extension_Container_Interface
57 * Note: Treat as private. This is only public to facilitate debugging.
59 public $fullContainer;
64 * @var CRM_Extension_Container_Basic|false
66 * Note: Treat as private. This is only public to facilitate debugging.
68 public $defaultContainer;
73 * @var CRM_Extension_Mapper
75 * Note: Treat as private. This is only public to facilitate debugging.
84 * Format is (typeName => CRM_Extension_Manager_Interface)
86 * Note: Treat as private. This is only public to facilitate debugging.
95 * Format is (extensionKey => statusConstant)
97 * Note: Treat as private. This is only public to facilitate debugging.
102 * Live process(es) per extension.
108 * ['operation' => 'install|enable|uninstall|disable', 'phase' => 'queued|live|completed'
114 * The inner array is a stack, so the most recent current operation is the
115 * last entry. As this manager handles multiple extensions at once, here's
116 * the flow for an install operation.
118 * $manager->install(['ext1', 'ext2']);
121 * 1. { ext1: ['install'], ext2: ['install'] }
122 * 2. { ext1: ['install', 'installing'], ext2: ['install'] }
123 * 3. { ext1: ['install'], ext2: ['install', 'installing'] }
124 * 4. { ext1: ['install'], ext2: ['install'] }
127 protected $processes = [];
132 * @param CRM_Extension_Container_Interface $fullContainer
133 * @param CRM_Extension_Container_Basic|FALSE $defaultContainer
134 * @param CRM_Extension_Mapper $mapper
135 * @param array $typeManagers
137 public function __construct(CRM_Extension_Container_Interface
$fullContainer, $defaultContainer, CRM_Extension_Mapper
$mapper, $typeManagers) {
138 $this->fullContainer
= $fullContainer;
139 $this->defaultContainer
= $defaultContainer;
140 $this->mapper
= $mapper;
141 $this->typeManagers
= $typeManagers;
145 * Install or upgrade the code for an extension -- and perform any
146 * necessary database changes (eg replacing extension metadata).
148 * This only works if the extension is stored in the default container.
150 * @param string $tmpCodeDir
151 * Path to a local directory containing a copy of the new (inert) code.
152 * @throws CRM_Extension_Exception
154 public function replace($tmpCodeDir) {
155 if (!$this->defaultContainer
) {
156 throw new CRM_Extension_Exception("Default extension container is not configured");
159 $newInfo = CRM_Extension_Info
::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR
. CRM_Extension_Info
::FILENAME
);
160 $oldStatus = $this->getStatus($newInfo->key
);
162 // find $tgtPath, $oldInfo, $typeManager
163 switch ($oldStatus) {
164 case self
::STATUS_UNINSTALLED
:
165 case self
::STATUS_INSTALLED
:
166 case self
::STATUS_DISABLED
:
167 // There is an old copy of the extension. Try to install in the same place -- but it must go somewhere in the default-container
169 list ($oldInfo, $typeManager) = $this->_getInfoTypeHandler($newInfo->key
);
170 $tgtPath = $this->fullContainer
->getPath($newInfo->key
);
171 if (!CRM_Utils_File
::isChildPath($this->defaultContainer
->getBaseDir(), $tgtPath)) {
172 // force installation in the default-container
174 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
175 CRM_Core_Session
::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', [
182 case self
::STATUS_INSTALLED_MISSING
:
183 case self
::STATUS_DISABLED_MISSING
:
184 // the extension does not exist in any container; we're free to put it anywhere
185 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
187 list ($oldInfo, $typeManager) = $this->_getMissingInfoTypeHandler($newInfo->key
);
190 case self
::STATUS_UNKNOWN
:
191 // the extension does not exist in any container; we're free to put it anywhere
192 $tgtPath = $this->defaultContainer
->getBaseDir() . DIRECTORY_SEPARATOR
. $newInfo->key
;
193 $oldInfo = $typeManager = NULL;
197 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
201 switch ($oldStatus) {
202 case self
::STATUS_UNINSTALLED
:
203 case self
::STATUS_UNKNOWN
:
204 // There are no DB records to worry about, so we'll just put the files in place
205 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
206 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
210 case self
::STATUS_INSTALLED
:
211 case self
::STATUS_INSTALLED_MISSING
:
212 case self
::STATUS_DISABLED
:
213 case self
::STATUS_DISABLED_MISSING
:
214 // There are DB records; coordinate the file placement with the DB updates
215 $typeManager->onPreReplace($oldInfo, $newInfo);
216 if (!CRM_Utils_File
::replaceDir($tmpCodeDir, $tgtPath)) {
217 throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath");
219 $this->_updateExtensionEntry($newInfo);
220 $typeManager->onPostReplace($oldInfo, $newInfo);
224 throw new CRM_Extension_Exception("Cannot install or enable extension: {$newInfo->key}");
228 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
232 * Add records of the extension to the database -- and enable it
234 * @param string|array $keys
235 * One or more extension keys.
236 * @param string $mode install|enable
237 * @throws CRM_Extension_Exception
239 public function install($keys, $mode = 'install') {
240 $keys = (array) $keys;
241 $origStatuses = $this->getStatuses();
243 // TODO: to mitigate the risk of crashing during installation, scan
244 // keys/statuses/types before doing anything
246 // Check compatibility
248 foreach ($keys as $key) {
249 if ($this->isIncompatible($key)) {
250 $incompatible[] = $key;
254 throw new CRM_Extension_Exception('Cannot install incompatible extension: ' . implode(', ', $incompatible));
257 // Keep state for these operations.
258 $this->addProcess($keys, $mode);
260 foreach ($keys as $key) {
261 /** @var CRM_Extension_Info $info */
262 /** @var CRM_Extension_Manager_Base $typeManager */
263 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
265 switch ($origStatuses[$key]) {
266 case self
::STATUS_INSTALLED
:
267 // ok, nothing to do. As such the status of this process is no longer
268 // 'install' install was the intent, which might have resulted in
269 // changes but these changes will not be happening, so processes that
270 // are sensitive to installs (like the managed entities reconcile
271 // operation) should not assume that these changes have happened.
272 $this->popProcess([$key]);
275 case self
::STATUS_DISABLED
:
277 $this->addProcess([$key], 'enabling');
278 $typeManager->onPreEnable($info);
279 $this->_setExtensionActive($info, 1);
280 $typeManager->onPostEnable($info);
282 // A full refresh would be preferrable but very slow. This at least allows
283 // later extensions to access classes from earlier extensions.
284 $this->statuses
= NULL;
285 $this->mapper
->refresh();
287 $this->popProcess([$key]);
290 case self
::STATUS_UNINSTALLED
:
292 $this->addProcess([$key], 'installing');
293 $typeManager->onPreInstall($info);
294 $this->_createExtensionEntry($info);
295 $typeManager->onPostInstall($info);
297 // A full refresh would be preferrable but very slow. This at least allows
298 // later extensions to access classes from earlier extensions.
299 $this->statuses
= NULL;
300 $this->mapper
->refresh();
302 $this->popProcess([$key]);
305 case self
::STATUS_UNKNOWN
:
307 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
311 $this->statuses
= NULL;
312 $this->mapper
->refresh();
313 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
315 $schema = new CRM_Logging_Schema();
316 $schema->fixSchemaDifferences();
318 foreach ($keys as $key) {
320 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
322 switch ($origStatuses[$key]) {
323 case self
::STATUS_INSTALLED
:
327 case self
::STATUS_DISABLED
:
331 case self
::STATUS_UNINSTALLED
:
333 $this->addProcess([$key], 'installing');
334 $typeManager->onPostPostInstall($info);
335 $this->popProcess([$key]);
338 case self
::STATUS_UNKNOWN
:
340 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
344 // All processes for these keys
345 $this->popProcess($keys);
349 * Add records of the extension to the database -- and enable it
352 * List of extension keys.
353 * @throws CRM_Extension_Exception
355 public function enable($keys) {
356 $this->install($keys, 'enable');
360 * Disable extension without removing record from db.
362 * @param string|array $keys
363 * One or more extension keys.
364 * @throws CRM_Extension_Exception
366 public function disable($keys) {
367 $keys = (array) $keys;
368 $origStatuses = $this->getStatuses();
370 // TODO: to mitigate the risk of crashing during installation, scan
371 // keys/statuses/types before doing anything
374 $disableRequirements = $this->findDisableRequirements($keys);
375 // This munges order, but makes it comparable.
376 sort($disableRequirements);
377 if ($keys !== $disableRequirements) {
378 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
381 $this->addProcess($keys, 'disable');
383 foreach ($keys as $key) {
384 switch ($origStatuses[$key]) {
385 case self
::STATUS_INSTALLED
:
386 $this->addProcess([$key], 'disabling');
388 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
389 $typeManager->onPreDisable($info);
390 $this->_setExtensionActive($info, 0);
391 $typeManager->onPostDisable($info);
392 $this->popProcess([$key]);
395 case self
::STATUS_INSTALLED_MISSING
:
397 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
398 $typeManager->onPreDisable($info);
399 $this->_setExtensionActive($info, 0);
400 $typeManager->onPostDisable($info);
403 case self
::STATUS_DISABLED
:
404 case self
::STATUS_DISABLED_MISSING
:
405 case self
::STATUS_UNINSTALLED
:
407 // Remove the 'disable' process as we're not doing that.
408 $this->popProcess([$key]);
411 case self
::STATUS_UNKNOWN
:
413 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
417 $this->statuses
= NULL;
418 $this->mapper
->refresh();
419 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
421 $this->popProcess($keys);
425 * Remove all database references to an extension.
427 * @param string|array $keys
428 * One or more extension keys.
429 * @throws CRM_Extension_Exception
431 public function uninstall($keys) {
432 $keys = (array) $keys;
433 $origStatuses = $this->getStatuses();
435 // TODO: to mitigate the risk of crashing during installation, scan
436 // keys/statuses/types before doing anything
438 $this->addProcess($keys, 'uninstall');
440 foreach ($keys as $key) {
441 switch ($origStatuses[$key]) {
442 case self
::STATUS_INSTALLED
:
443 case self
::STATUS_INSTALLED_MISSING
:
444 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
446 case self
::STATUS_DISABLED
:
447 $this->addProcess([$key], 'uninstalling');
449 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
450 $typeManager->onPreUninstall($info);
451 $this->_removeExtensionEntry($info);
452 $typeManager->onPostUninstall($info);
455 case self
::STATUS_DISABLED_MISSING
:
457 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
458 $typeManager->onPreUninstall($info);
459 $this->_removeExtensionEntry($info);
460 $typeManager->onPostUninstall($info);
463 case self
::STATUS_UNINSTALLED
:
465 // remove the 'uninstall' process since we're not doing that.
466 $this->popProcess([$key]);
469 case self
::STATUS_UNKNOWN
:
471 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
475 $this->statuses
= NULL;
476 $this->mapper
->refresh();
477 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
478 $this->popProcess($keys);
482 * Determine the status of an extension.
487 * constant self::STATUS_*
489 public function getStatus($key) {
490 $statuses = $this->getStatuses();
491 if (array_key_exists($key, $statuses)) {
492 return $statuses[$key];
495 return self
::STATUS_UNKNOWN
;
500 * Check if a given extension is incompatible with this version of CiviCRM
505 public function isIncompatible($key) {
506 $info = CRM_Extension_System
::getCompatibilityInfo();
507 return $info[$key] ??
FALSE;
511 * Determine the status of all extensions.
514 * ($key => status_constant)
516 public function getStatuses() {
517 if (!is_array($this->statuses
)) {
518 $compat = CRM_Extension_System
::getCompatibilityInfo();
520 $this->statuses
= [];
522 foreach ($this->fullContainer
->getKeys() as $key) {
523 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
527 SELECT full_name, is_active
528 FROM civicrm_extension
530 $dao = CRM_Core_DAO
::executeQuery($sql);
531 while ($dao->fetch()) {
533 $path = $this->fullContainer
->getPath($dao->full_name
);
534 $codeExists = !empty($path) && is_dir($path);
536 catch (CRM_Extension_Exception
$e) {
539 if (!empty($compat[$dao->full_name
]['force-uninstall'])) {
540 $this->statuses
[$dao->full_name
] = self
::STATUS_UNINSTALLED
;
542 elseif ($dao->is_active
) {
543 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
546 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
550 return $this->statuses
;
553 public function refresh() {
554 $this->statuses
= NULL;
555 // and, indirectly, defaultContainer
556 $this->fullContainer
->refresh();
557 $this->mapper
->refresh();
561 * Return current processes for given extension.
563 * @param String $key extension key
567 public function getActiveProcesses(string $key) :Array {
568 return $this->processes
[$key] ??
[];
572 * Determine if the extension specified is currently involved in an install
573 * or enable process. Just sugar code to make things more readable.
575 * @param String $key extension key
579 public function extensionIsBeingInstalledOrEnabled($key) :bool {
580 foreach ($this->getActiveProcesses($key) as $process) {
581 if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) {
588 // ----------------------
591 * Find the $info and $typeManager for a $key
595 * @throws CRM_Extension_Exception
597 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
599 private function _getInfoTypeHandler($key) {
601 $info = $this->mapper
->keyToInfo($key);
602 if (array_key_exists($info->type
, $this->typeManagers
)) {
603 return [$info, $this->typeManagers
[$info->type
]];
606 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
611 * Find the $info and $typeManager for a $key
615 * @throws CRM_Extension_Exception
617 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
619 private function _getMissingInfoTypeHandler($key) {
620 $info = $this->createInfoFromDB($key);
622 if (array_key_exists($info->type
, $this->typeManagers
)) {
623 return [$info, $this->typeManagers
[$info->type
]];
626 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
630 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
635 * @param CRM_Extension_Info $info
639 private function _createExtensionEntry(CRM_Extension_Info
$info) {
640 $dao = new CRM_Core_DAO_Extension();
641 $dao->label
= $info->label
;
642 $dao->name
= $info->name
;
643 $dao->full_name
= $info->key
;
644 $dao->type
= $info->type
;
645 $dao->file
= $info->file
;
647 return (bool) ($dao->insert());
651 * @param CRM_Extension_Info $info
655 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
656 $dao = new CRM_Core_DAO_Extension();
657 $dao->full_name
= $info->key
;
658 if ($dao->find(TRUE)) {
659 $dao->label
= $info->label
;
660 $dao->name
= $info->name
;
661 $dao->full_name
= $info->key
;
662 $dao->type
= $info->type
;
663 $dao->file
= $info->file
;
665 return (bool) ($dao->update());
668 return $this->_createExtensionEntry($info);
673 * @param CRM_Extension_Info $info
675 * @throws CRM_Extension_Exception
677 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
678 $dao = new CRM_Core_DAO_Extension();
679 $dao->full_name
= $info->key
;
680 if ($dao->find(TRUE)) {
681 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
682 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
685 throw new CRM_Extension_Exception("Failed to remove extension entry");
687 } // else: post-condition already satisified
691 * @param CRM_Extension_Info $info
694 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
695 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
696 1 => [$isActive, 'Integer'],
697 2 => [$info->key
, 'String'],
702 * Auto-generate a place-holder for a missing extension using info from
706 * @return CRM_Extension_Info|NULL
708 public function createInfoFromDB($key) {
709 $dao = new CRM_Core_DAO_Extension();
710 $dao->full_name
= $key;
711 if ($dao->find(TRUE)) {
712 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
721 * Build a list of extensions to install, in an order that will satisfy dependencies.
724 * List of extensions to install.
725 * @param \CRM_Extension_Info $info
726 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
729 * List of extension keys, including dependencies, in order of installation.
730 * @throws \CRM_Extension_Exception
731 * @throws \MJS\TopSort\CircularDependencyException
732 * @throws \MJS\TopSort\ElementNotFoundException
734 public function findInstallRequirements($keys, $info = NULL) {
735 // Use our passed in info, or get the local versions
737 $infos[$info->key
] = $info;
740 $infos = $this->mapper
->getAllInfos();
742 // array(string $key).
743 $todoKeys = array_unique($keys);
744 // array(string $key => 1);
746 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
748 while (!empty($todoKeys)) {
749 $key = array_shift($todoKeys);
750 if (isset($doneKeys[$key])) {
755 /** @var CRM_Extension_Info $info */
756 $info = @$infos[$key];
758 if ($info && $info->requires
) {
759 $sorter->add($key, $info->requires
);
760 $todoKeys = array_merge($todoKeys, $info->requires
);
763 $sorter->add($key, []);
766 return $sorter->sort();
770 * Build a list of extensions to remove, in an order that will satisfy dependencies.
773 * List of extensions to install.
775 * List of extension keys, including dependencies, in order of removal.
777 public function findDisableRequirements($keys) {
779 self
::STATUS_INSTALLED
,
780 self
::STATUS_INSTALLED_MISSING
,
782 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
783 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
784 $todoKeys = array_unique($keys);
786 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
788 while (!empty($todoKeys)) {
789 $key = array_shift($todoKeys);
790 if (isset($doneKeys[$key])) {
795 if (isset($revMap[$key])) {
796 $requiredBys = CRM_Utils_Array
::collect('key',
797 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
798 $sorter->add($key, $requiredBys);
799 $todoKeys = array_merge($todoKeys, $requiredBys);
802 $sorter->add($key, []);
805 return $sorter->sort();
809 * Provides way to set processes property for phpunit tests - not for general use.
813 public function setProcessesForTesting(array $processes) {
814 $this->processes
= $processes;
819 * @param $filterStatuses
822 protected function filterInfosByStatus($infos, $filterStatuses) {
824 foreach ($infos as $k => $v) {
825 if (in_array($this->getStatus($v->key
), $filterStatuses)) {
833 * Add a process to the stacks for the extensions.
835 * @param array $keys extensionKey
836 * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling
838 protected function addProcess(array $keys, string $process) {
839 foreach ($keys as $key) {
840 $this->processes
[$key][] = $process;
845 * Pop the top op from the stacks for the extensions.
847 * @param array $keys extensionKey
849 protected function popProcess(array $keys) {
850 foreach ($keys as $key) {
851 if (!empty($this->process
[$key])) {
852 array_pop($this->process
[$key]);