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_System::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 if (!CRM_Core_Config
::isUpgradeMode()) {
314 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
316 $schema = new CRM_Logging_Schema();
317 $schema->fixSchemaDifferences();
319 foreach ($keys as $key) {
321 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
323 switch ($origStatuses[$key]) {
324 case self
::STATUS_INSTALLED
:
328 case self
::STATUS_DISABLED
:
332 case self
::STATUS_UNINSTALLED
:
334 $this->addProcess([$key], 'installing');
335 $typeManager->onPostPostInstall($info);
336 $this->popProcess([$key]);
339 case self
::STATUS_UNKNOWN
:
341 throw new CRM_Extension_Exception("Cannot install or enable extension: $key");
345 // All processes for these keys
346 $this->popProcess($keys);
350 * Add records of the extension to the database -- and enable it
353 * List of extension keys.
354 * @throws CRM_Extension_Exception
356 public function enable($keys) {
357 $this->install($keys, 'enable');
361 * Disable extension without removing record from db.
363 * @param string|array $keys
364 * One or more extension keys.
365 * @throws CRM_Extension_Exception
367 public function disable($keys) {
368 $keys = (array) $keys;
369 $origStatuses = $this->getStatuses();
371 // TODO: to mitigate the risk of crashing during installation, scan
372 // keys/statuses/types before doing anything
375 $disableRequirements = $this->findDisableRequirements($keys);
376 // This munges order, but makes it comparable.
377 sort($disableRequirements);
378 if ($keys !== $disableRequirements) {
379 throw new CRM_Extension_Exception_DependencyException("Cannot disable extension due to dependencies. Consider disabling all these: " . implode(',', $disableRequirements));
382 $this->addProcess($keys, 'disable');
384 foreach ($keys as $key) {
385 if (isset($origStatuses[$key])) {
386 switch ($origStatuses[$key]) {
387 case self
::STATUS_INSTALLED
:
388 $this->addProcess([$key], 'disabling');
390 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
391 $typeManager->onPreDisable($info);
392 $this->_setExtensionActive($info, 0);
393 $typeManager->onPostDisable($info);
394 $this->popProcess([$key]);
397 case self
::STATUS_INSTALLED_MISSING
:
399 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
400 $typeManager->onPreDisable($info);
401 $this->_setExtensionActive($info, 0);
402 $typeManager->onPostDisable($info);
405 case self
::STATUS_DISABLED
:
406 case self
::STATUS_DISABLED_MISSING
:
407 case self
::STATUS_UNINSTALLED
:
409 // Remove the 'disable' process as we're not doing that.
410 $this->popProcess([$key]);
413 case self
::STATUS_UNKNOWN
:
415 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
419 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
423 $this->statuses
= NULL;
424 $this->mapper
->refresh();
425 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
427 $this->popProcess($keys);
431 * Remove all database references to an extension.
433 * @param string|array $keys
434 * One or more extension keys.
435 * @throws CRM_Extension_Exception
437 public function uninstall($keys) {
438 $keys = (array) $keys;
439 $origStatuses = $this->getStatuses();
441 // TODO: to mitigate the risk of crashing during installation, scan
442 // keys/statuses/types before doing anything
444 $this->addProcess($keys, 'uninstall');
446 foreach ($keys as $key) {
447 switch ($origStatuses[$key]) {
448 case self
::STATUS_INSTALLED
:
449 case self
::STATUS_INSTALLED_MISSING
:
450 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
452 case self
::STATUS_DISABLED
:
453 $this->addProcess([$key], 'uninstalling');
455 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
456 $typeManager->onPreUninstall($info);
457 $this->_removeExtensionEntry($info);
458 $typeManager->onPostUninstall($info);
461 case self
::STATUS_DISABLED_MISSING
:
463 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
464 $typeManager->onPreUninstall($info);
465 $this->_removeExtensionEntry($info);
466 $typeManager->onPostUninstall($info);
469 case self
::STATUS_UNINSTALLED
:
471 // remove the 'uninstall' process since we're not doing that.
472 $this->popProcess([$key]);
475 case self
::STATUS_UNKNOWN
:
477 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
481 $this->statuses
= NULL;
482 $this->mapper
->refresh();
483 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
484 $this->popProcess($keys);
488 * Determine the status of an extension.
493 * constant self::STATUS_*
495 public function getStatus($key) {
496 $statuses = $this->getStatuses();
497 if (array_key_exists($key, $statuses)) {
498 return $statuses[$key];
501 return self
::STATUS_UNKNOWN
;
506 * Check if a given extension is incompatible with this version of CiviCRM
511 public function isIncompatible($key) {
512 $info = CRM_Extension_System
::getCompatibilityInfo();
513 return $info[$key] ??
FALSE;
517 * Determine the status of all extensions.
520 * ($key => status_constant)
522 public function getStatuses() {
523 if (!is_array($this->statuses
)) {
524 $compat = CRM_Extension_System
::getCompatibilityInfo();
526 $this->statuses
= [];
528 foreach ($this->fullContainer
->getKeys() as $key) {
529 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
533 SELECT full_name, is_active
534 FROM civicrm_extension
536 $dao = CRM_Core_DAO
::executeQuery($sql);
537 while ($dao->fetch()) {
539 $path = $this->fullContainer
->getPath($dao->full_name
);
540 $codeExists = !empty($path) && is_dir($path);
542 catch (CRM_Extension_Exception
$e) {
545 if (!empty($compat[$dao->full_name
]['force-uninstall'])) {
546 $this->statuses
[$dao->full_name
] = self
::STATUS_UNINSTALLED
;
548 elseif ($dao->is_active
) {
549 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
552 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
556 return $this->statuses
;
559 public function refresh() {
560 $this->statuses
= NULL;
561 // and, indirectly, defaultContainer
562 $this->fullContainer
->refresh();
563 $this->mapper
->refresh();
567 * Return current processes for given extension.
569 * @param string $key extension key
573 public function getActiveProcesses(string $key) :Array {
574 return $this->processes
[$key] ??
[];
578 * Determine if the extension specified is currently involved in an install
579 * or enable process. Just sugar code to make things more readable.
581 * @param string $key extension key
585 public function extensionIsBeingInstalledOrEnabled($key) :bool {
586 foreach ($this->getActiveProcesses($key) as $process) {
587 if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) {
594 // ----------------------
597 * Find the $info and $typeManager for a $key
601 * @throws CRM_Extension_Exception
603 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
605 private function _getInfoTypeHandler($key) {
607 $info = $this->mapper
->keyToInfo($key);
608 if (array_key_exists($info->type
, $this->typeManagers
)) {
609 return [$info, $this->typeManagers
[$info->type
]];
612 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
617 * Find the $info and $typeManager for a $key
621 * @throws CRM_Extension_Exception
623 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
625 private function _getMissingInfoTypeHandler($key) {
626 $info = $this->createInfoFromDB($key);
628 if (array_key_exists($info->type
, $this->typeManagers
)) {
629 return [$info, $this->typeManagers
[$info->type
]];
632 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
636 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
641 * @param CRM_Extension_Info $info
645 private function _createExtensionEntry(CRM_Extension_Info
$info) {
646 $dao = new CRM_Core_DAO_Extension();
647 $dao->label
= $info->label
;
648 $dao->name
= $info->name
;
649 $dao->full_name
= $info->key
;
650 $dao->type
= $info->type
;
651 $dao->file
= $info->file
;
653 return (bool) ($dao->insert());
657 * @param CRM_Extension_Info $info
661 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
662 $dao = new CRM_Core_DAO_Extension();
663 $dao->full_name
= $info->key
;
664 if ($dao->find(TRUE)) {
665 $dao->label
= $info->label
;
666 $dao->name
= $info->name
;
667 $dao->full_name
= $info->key
;
668 $dao->type
= $info->type
;
669 $dao->file
= $info->file
;
671 return (bool) ($dao->update());
674 return $this->_createExtensionEntry($info);
679 * @param CRM_Extension_Info $info
681 * @throws CRM_Extension_Exception
683 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
684 $dao = new CRM_Core_DAO_Extension();
685 $dao->full_name
= $info->key
;
686 if ($dao->find(TRUE)) {
687 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
688 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
691 throw new CRM_Extension_Exception("Failed to remove extension entry");
693 } // else: post-condition already satisified
697 * @param CRM_Extension_Info $info
700 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
701 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
702 1 => [$isActive, 'Integer'],
703 2 => [$info->key
, 'String'],
708 * Auto-generate a place-holder for a missing extension using info from
712 * @return CRM_Extension_Info|NULL
714 public function createInfoFromDB($key) {
715 $dao = new CRM_Core_DAO_Extension();
716 $dao->full_name
= $key;
717 if ($dao->find(TRUE)) {
718 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
727 * Build a list of extensions to install, in an order that will satisfy dependencies.
730 * List of extensions to install.
731 * @param \CRM_Extension_Info $info
732 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
735 * List of extension keys, including dependencies, in order of installation.
736 * @throws \CRM_Extension_Exception
737 * @throws \MJS\TopSort\CircularDependencyException
738 * @throws \MJS\TopSort\ElementNotFoundException
740 public function findInstallRequirements($keys, $info = NULL) {
741 // Use our passed in info, or get the local versions
743 $infos[$info->key
] = $info;
746 $infos = $this->mapper
->getAllInfos();
748 // array(string $key).
749 $todoKeys = array_unique($keys);
750 // array(string $key => 1);
752 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
754 while (!empty($todoKeys)) {
755 $key = array_shift($todoKeys);
756 if (isset($doneKeys[$key])) {
761 /** @var CRM_Extension_Info $info */
762 $info = @$infos[$key];
764 if ($info && $info->requires
) {
765 $sorter->add($key, $info->requires
);
766 $todoKeys = array_merge($todoKeys, $info->requires
);
769 $sorter->add($key, []);
772 return $sorter->sort();
776 * Build a list of extensions to remove, in an order that will satisfy dependencies.
779 * List of extensions to install.
781 * List of extension keys, including dependencies, in order of removal.
783 public function findDisableRequirements($keys) {
785 self
::STATUS_INSTALLED
,
786 self
::STATUS_INSTALLED_MISSING
,
788 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
789 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
790 $todoKeys = array_unique($keys);
792 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
794 while (!empty($todoKeys)) {
795 $key = array_shift($todoKeys);
796 if (isset($doneKeys[$key])) {
801 if (isset($revMap[$key])) {
802 $requiredBys = CRM_Utils_Array
::collect('key',
803 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
804 $sorter->add($key, $requiredBys);
805 $todoKeys = array_merge($todoKeys, $requiredBys);
808 $sorter->add($key, []);
811 return $sorter->sort();
815 * Provides way to set processes property for phpunit tests - not for general use.
819 public function setProcessesForTesting(array $processes) {
820 $this->processes
= $processes;
825 * @param $filterStatuses
828 protected function filterInfosByStatus($infos, $filterStatuses) {
830 foreach ($infos as $k => $v) {
831 if (in_array($this->getStatus($v->key
), $filterStatuses)) {
839 * Add a process to the stacks for the extensions.
841 * @param array $keys extensionKey
842 * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling
844 protected function addProcess(array $keys, string $process) {
845 foreach ($keys as $key) {
846 $this->processes
[$key][] = $process;
851 * Pop the top op from the stacks for the extensions.
853 * @param array $keys extensionKey
855 protected function popProcess(array $keys) {
856 foreach ($keys as $key) {
857 if (!empty($this->process
[$key])) {
858 array_pop($this->process
[$key]);