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 switch ($origStatuses[$key]) {
386 case self
::STATUS_INSTALLED
:
387 $this->addProcess([$key], 'disabling');
389 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
390 $typeManager->onPreDisable($info);
391 $this->_setExtensionActive($info, 0);
392 $typeManager->onPostDisable($info);
393 $this->popProcess([$key]);
396 case self
::STATUS_INSTALLED_MISSING
:
398 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
399 $typeManager->onPreDisable($info);
400 $this->_setExtensionActive($info, 0);
401 $typeManager->onPostDisable($info);
404 case self
::STATUS_DISABLED
:
405 case self
::STATUS_DISABLED_MISSING
:
406 case self
::STATUS_UNINSTALLED
:
408 // Remove the 'disable' process as we're not doing that.
409 $this->popProcess([$key]);
412 case self
::STATUS_UNKNOWN
:
414 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
418 $this->statuses
= NULL;
419 $this->mapper
->refresh();
420 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
422 $this->popProcess($keys);
426 * Remove all database references to an extension.
428 * @param string|array $keys
429 * One or more extension keys.
430 * @throws CRM_Extension_Exception
432 public function uninstall($keys) {
433 $keys = (array) $keys;
434 $origStatuses = $this->getStatuses();
436 // TODO: to mitigate the risk of crashing during installation, scan
437 // keys/statuses/types before doing anything
439 $this->addProcess($keys, 'uninstall');
441 foreach ($keys as $key) {
442 switch ($origStatuses[$key]) {
443 case self
::STATUS_INSTALLED
:
444 case self
::STATUS_INSTALLED_MISSING
:
445 throw new CRM_Extension_Exception("Cannot uninstall extension; disable it first: $key");
447 case self
::STATUS_DISABLED
:
448 $this->addProcess([$key], 'uninstalling');
450 list ($info, $typeManager) = $this->_getInfoTypeHandler($key);
451 $typeManager->onPreUninstall($info);
452 $this->_removeExtensionEntry($info);
453 $typeManager->onPostUninstall($info);
456 case self
::STATUS_DISABLED_MISSING
:
458 list ($info, $typeManager) = $this->_getMissingInfoTypeHandler($key);
459 $typeManager->onPreUninstall($info);
460 $this->_removeExtensionEntry($info);
461 $typeManager->onPostUninstall($info);
464 case self
::STATUS_UNINSTALLED
:
466 // remove the 'uninstall' process since we're not doing that.
467 $this->popProcess([$key]);
470 case self
::STATUS_UNKNOWN
:
472 throw new CRM_Extension_Exception("Cannot disable unknown extension: $key");
476 $this->statuses
= NULL;
477 $this->mapper
->refresh();
478 CRM_Core_Invoke
::rebuildMenuAndCaches(TRUE);
479 $this->popProcess($keys);
483 * Determine the status of an extension.
488 * constant self::STATUS_*
490 public function getStatus($key) {
491 $statuses = $this->getStatuses();
492 if (array_key_exists($key, $statuses)) {
493 return $statuses[$key];
496 return self
::STATUS_UNKNOWN
;
501 * Check if a given extension is incompatible with this version of CiviCRM
506 public function isIncompatible($key) {
507 $info = CRM_Extension_System
::getCompatibilityInfo();
508 return $info[$key] ??
FALSE;
512 * Determine the status of all extensions.
515 * ($key => status_constant)
517 public function getStatuses() {
518 if (!is_array($this->statuses
)) {
519 $compat = CRM_Extension_System
::getCompatibilityInfo();
521 $this->statuses
= [];
523 foreach ($this->fullContainer
->getKeys() as $key) {
524 $this->statuses
[$key] = self
::STATUS_UNINSTALLED
;
528 SELECT full_name, is_active
529 FROM civicrm_extension
531 $dao = CRM_Core_DAO
::executeQuery($sql);
532 while ($dao->fetch()) {
534 $path = $this->fullContainer
->getPath($dao->full_name
);
535 $codeExists = !empty($path) && is_dir($path);
537 catch (CRM_Extension_Exception
$e) {
540 if (!empty($compat[$dao->full_name
]['force-uninstall'])) {
541 $this->statuses
[$dao->full_name
] = self
::STATUS_UNINSTALLED
;
543 elseif ($dao->is_active
) {
544 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_INSTALLED
: self
::STATUS_INSTALLED_MISSING
;
547 $this->statuses
[$dao->full_name
] = $codeExists ? self
::STATUS_DISABLED
: self
::STATUS_DISABLED_MISSING
;
551 return $this->statuses
;
554 public function refresh() {
555 $this->statuses
= NULL;
556 // and, indirectly, defaultContainer
557 $this->fullContainer
->refresh();
558 $this->mapper
->refresh();
562 * Return current processes for given extension.
564 * @param String $key extension key
568 public function getActiveProcesses(string $key) :Array {
569 return $this->processes
[$key] ??
[];
573 * Determine if the extension specified is currently involved in an install
574 * or enable process. Just sugar code to make things more readable.
576 * @param String $key extension key
580 public function extensionIsBeingInstalledOrEnabled($key) :bool {
581 foreach ($this->getActiveProcesses($key) as $process) {
582 if (in_array($process, ['install', 'installing', 'enable', 'enabling'])) {
589 // ----------------------
592 * Find the $info and $typeManager for a $key
596 * @throws CRM_Extension_Exception
598 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
600 private function _getInfoTypeHandler($key) {
602 $info = $this->mapper
->keyToInfo($key);
603 if (array_key_exists($info->type
, $this->typeManagers
)) {
604 return [$info, $this->typeManagers
[$info->type
]];
607 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
612 * Find the $info and $typeManager for a $key
616 * @throws CRM_Extension_Exception
618 * [CRM_Extension_Info, CRM_Extension_Manager_Interface]
620 private function _getMissingInfoTypeHandler($key) {
621 $info = $this->createInfoFromDB($key);
623 if (array_key_exists($info->type
, $this->typeManagers
)) {
624 return [$info, $this->typeManagers
[$info->type
]];
627 throw new CRM_Extension_Exception("Unrecognized extension type: " . $info->type
);
631 throw new CRM_Extension_Exception("Failed to reconstruct missing extension: " . $key);
636 * @param CRM_Extension_Info $info
640 private function _createExtensionEntry(CRM_Extension_Info
$info) {
641 $dao = new CRM_Core_DAO_Extension();
642 $dao->label
= $info->label
;
643 $dao->name
= $info->name
;
644 $dao->full_name
= $info->key
;
645 $dao->type
= $info->type
;
646 $dao->file
= $info->file
;
648 return (bool) ($dao->insert());
652 * @param CRM_Extension_Info $info
656 private function _updateExtensionEntry(CRM_Extension_Info
$info) {
657 $dao = new CRM_Core_DAO_Extension();
658 $dao->full_name
= $info->key
;
659 if ($dao->find(TRUE)) {
660 $dao->label
= $info->label
;
661 $dao->name
= $info->name
;
662 $dao->full_name
= $info->key
;
663 $dao->type
= $info->type
;
664 $dao->file
= $info->file
;
666 return (bool) ($dao->update());
669 return $this->_createExtensionEntry($info);
674 * @param CRM_Extension_Info $info
676 * @throws CRM_Extension_Exception
678 private function _removeExtensionEntry(CRM_Extension_Info
$info) {
679 $dao = new CRM_Core_DAO_Extension();
680 $dao->full_name
= $info->key
;
681 if ($dao->find(TRUE)) {
682 if (CRM_Core_BAO_Extension
::del($dao->id
)) {
683 CRM_Core_Session
::setStatus(ts('Selected option value has been deleted.'), ts('Deleted'), 'success');
686 throw new CRM_Extension_Exception("Failed to remove extension entry");
688 } // else: post-condition already satisified
692 * @param CRM_Extension_Info $info
695 private function _setExtensionActive(CRM_Extension_Info
$info, $isActive) {
696 CRM_Core_DAO
::executeQuery('UPDATE civicrm_extension SET is_active = %1 where full_name = %2', [
697 1 => [$isActive, 'Integer'],
698 2 => [$info->key
, 'String'],
703 * Auto-generate a place-holder for a missing extension using info from
707 * @return CRM_Extension_Info|NULL
709 public function createInfoFromDB($key) {
710 $dao = new CRM_Core_DAO_Extension();
711 $dao->full_name
= $key;
712 if ($dao->find(TRUE)) {
713 $info = new CRM_Extension_Info($dao->full_name
, $dao->type
, $dao->name
, $dao->label
, $dao->file
);
722 * Build a list of extensions to install, in an order that will satisfy dependencies.
725 * List of extensions to install.
726 * @param \CRM_Extension_Info $info
727 * An extension info object that we should use instead of our local versions (eg. when checking for upgradeability).
730 * List of extension keys, including dependencies, in order of installation.
731 * @throws \CRM_Extension_Exception
732 * @throws \MJS\TopSort\CircularDependencyException
733 * @throws \MJS\TopSort\ElementNotFoundException
735 public function findInstallRequirements($keys, $info = NULL) {
736 // Use our passed in info, or get the local versions
738 $infos[$info->key
] = $info;
741 $infos = $this->mapper
->getAllInfos();
743 // array(string $key).
744 $todoKeys = array_unique($keys);
745 // array(string $key => 1);
747 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
749 while (!empty($todoKeys)) {
750 $key = array_shift($todoKeys);
751 if (isset($doneKeys[$key])) {
756 /** @var CRM_Extension_Info $info */
757 $info = @$infos[$key];
759 if ($info && $info->requires
) {
760 $sorter->add($key, $info->requires
);
761 $todoKeys = array_merge($todoKeys, $info->requires
);
764 $sorter->add($key, []);
767 return $sorter->sort();
771 * Build a list of extensions to remove, in an order that will satisfy dependencies.
774 * List of extensions to install.
776 * List of extension keys, including dependencies, in order of removal.
778 public function findDisableRequirements($keys) {
780 self
::STATUS_INSTALLED
,
781 self
::STATUS_INSTALLED_MISSING
,
783 $installedInfos = $this->filterInfosByStatus($this->mapper
->getAllInfos(), $INSTALLED);
784 $revMap = CRM_Extension_Info
::buildReverseMap($installedInfos);
785 $todoKeys = array_unique($keys);
787 $sorter = new \MJS\TopSort\Implementations\
FixedArraySort();
789 while (!empty($todoKeys)) {
790 $key = array_shift($todoKeys);
791 if (isset($doneKeys[$key])) {
796 if (isset($revMap[$key])) {
797 $requiredBys = CRM_Utils_Array
::collect('key',
798 $this->filterInfosByStatus($revMap[$key], $INSTALLED));
799 $sorter->add($key, $requiredBys);
800 $todoKeys = array_merge($todoKeys, $requiredBys);
803 $sorter->add($key, []);
806 return $sorter->sort();
810 * Provides way to set processes property for phpunit tests - not for general use.
814 public function setProcessesForTesting(array $processes) {
815 $this->processes
= $processes;
820 * @param $filterStatuses
823 protected function filterInfosByStatus($infos, $filterStatuses) {
825 foreach ($infos as $k => $v) {
826 if (in_array($this->getStatus($v->key
), $filterStatuses)) {
834 * Add a process to the stacks for the extensions.
836 * @param array $keys extensionKey
837 * @param string $process one of: install|uninstall|enable|disable|installing|uninstalling|enabling|disabling
839 protected function addProcess(array $keys, string $process) {
840 foreach ($keys as $key) {
841 $this->processes
[$key][] = $process;
846 * Pop the top op from the stacks for the extensions.
848 * @param array $keys extensionKey
850 protected function popProcess(array $keys) {
851 foreach ($keys as $key) {
852 if (!empty($this->process
[$key])) {
853 array_pop($this->process
[$key]);