mirror of
https://github.com/Combodo/iTop.git
synced 2026-02-12 23:14:18 +01:00
code style last test cleanup review + enhance UI output and display only failed module dependencies real life test cleanup review: add more tests + refacto code review: enhance algo and APIs review: renaming enhance test coverage refactoring renaming + reorder functions/tests compute GetDependencyResolutionFeedback in Module class review2 : renaming things fix rebase + code formatting fix code formatting review changes refactoring: code cleanup/standardization/remove all prototype stuffs refactoring: code cleanup/standardization/remove all prototype stuffs add deps validation to extension ci job fix ci fix ci: test broken when dir to scan did not exist like production-modules fix tests module dependency validation moved in a core folder + cleanup dedicated unit/integration tests forget dependency computation optimization seen as too risky + keep only user friendly sort in case of setup error rebase on develop + split new sort computation apart from modulediscovery revert to previous legacy order + gather new module computation classes in a dedicated folder make validation work (dirty way) + cleanup make setup deterministic: complete dependency order with alphabetical one when 2 module elements are at same position final deps validation bases on DM and PHP classes init in beforeclass + read defined classes/interfaces by module module discovery classes renaming to avoid collision with customer DM definitions read module file data apart from ModuleDiscovery cleanup cleanup fix inconsistent module dependencies fix integration check save tmp work before trying to fetch other wml deps fix module dependencies fix DM filename typo rename ModuleXXX classes by iTopCoreModuleXXX to reduce collisions with extensions add phpdoc + add more tests module dependency optimization - refacto + dependency new sort order module dependency optimization - stop computation when no new dependency is resolved enhance module dependency computation for optimization and admin feedback
394 lines
13 KiB
PHP
Executable File
394 lines
13 KiB
PHP
Executable File
<?php
|
|
|
|
/**
|
|
* Copyright (c) 2010-2024 Combodo SAS
|
|
*
|
|
* This file is part of iTop.
|
|
*
|
|
* iTop is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* iTop is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with iTop. If not, see <http://www.gnu.org/licenses/>
|
|
*
|
|
*/
|
|
|
|
use Combodo\iTop\PhpParser\Evaluation\PhpExpressionEvaluator;
|
|
use Combodo\iTop\Setup\ModuleDependency\Module;
|
|
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReader;
|
|
use Combodo\iTop\Setup\ModuleDiscovery\ModuleFileReaderException;
|
|
|
|
require_once(APPROOT.'setup/modulediscovery/ModuleFileReader.php');
|
|
require_once(__DIR__.'/moduledependency/moduledependencysort.class.inc.php');
|
|
|
|
use Combodo\iTop\Setup\ModuleDependency\ModuleDependencySort;
|
|
|
|
class MissingDependencyException extends CoreException
|
|
{
|
|
/**
|
|
* @see \ModuleDiscovery::OrderModulesByDependencies property init
|
|
* @var array<string, array<string>>
|
|
* module id as key
|
|
* another array as value, containing : 'module' with module info, 'dependencies' with missing dependencies
|
|
*/
|
|
public $aModulesInfo;
|
|
|
|
/**
|
|
* @return string HTML to print to the user the modules impacted
|
|
* @since 2.7.7 3.0.2 3.1.0 N°5090 PR #280
|
|
*/
|
|
public function getHtmlDesc($sHighlightHtmlBegin = null, $sHighlightHtmlEnd = null)
|
|
{
|
|
$sErrorMessage = <<<HTML
|
|
<p>The following modules have unmet dependencies:</p>
|
|
<ul>
|
|
HTML;
|
|
foreach ($this->aModulesInfo as $sModuleId => $aModuleErrors) {
|
|
$sModuleLabel = utils::EscapeHtml($aModuleErrors['module']['label']);
|
|
$sModuleId = utils::EscapeHtml($sModuleId);
|
|
$aModuleMissingDependencies = $aModuleErrors['dependencies'];
|
|
$sErrorMessage .= <<<HTML
|
|
<li><strong>$sModuleLabel</strong> ($sModuleId):
|
|
<ul>
|
|
HTML;
|
|
|
|
foreach ($aModuleMissingDependencies as $sMissingModule) {
|
|
$sMissingModule = utils::EscapeHtml($sMissingModule);
|
|
$sErrorMessage .= "<li>$sMissingModule</li>";
|
|
}
|
|
$sErrorMessage .= <<<HTML
|
|
</ul>
|
|
</li>
|
|
HTML;
|
|
|
|
}
|
|
$sErrorMessage .= '</ul>';
|
|
|
|
return $sErrorMessage;
|
|
}
|
|
}
|
|
|
|
class ModuleDiscovery
|
|
{
|
|
public static $m_aModuleArgs = [
|
|
'label' => 'One line description shown during the interactive setup',
|
|
'dependencies' => 'array of module ids',
|
|
'mandatory' => 'boolean',
|
|
'visible' => 'boolean',
|
|
'datamodel' => 'array of data model files',
|
|
//'dictionary' => 'array of dictionary files', // No longer mandatory, now automated
|
|
'data.struct' => 'array of structural data files',
|
|
'data.sample' => 'array of sample data files',
|
|
'doc.manual_setup' => 'url',
|
|
'doc.more_information' => 'url',
|
|
];
|
|
|
|
// Cache the results and the source directories
|
|
protected static $m_aSearchDirs = null;
|
|
protected static $m_aModules = [];
|
|
protected static $m_aModuleVersionByName = [];
|
|
|
|
// All the entries below are list of file paths relative to the module directory
|
|
protected static $m_aFilesList = ['datamodel', 'webservice', 'dictionary', 'data.struct', 'data.sample'];
|
|
|
|
// ModulePath is used by AddModule to get the path of the module being included (in ListModuleFiles)
|
|
protected static $m_sModulePath = null;
|
|
|
|
private static PhpExpressionEvaluator $oPhpExpressionEvaluator;
|
|
|
|
protected static function SetModulePath($sModulePath)
|
|
{
|
|
self::$m_sModulePath = $sModulePath;
|
|
}
|
|
|
|
/**
|
|
* @param string $sFilePath
|
|
* @param string $sId
|
|
* @param array $aArgs
|
|
*
|
|
* @throws \Exception for missing parameter
|
|
*/
|
|
public static function AddModule($sFilePath, $sId, $aArgs)
|
|
{
|
|
if (is_null($aArgs) || ! is_array($aArgs)) {
|
|
throw new ModuleFileReaderException("Error parsing module file args", 0, null, $sFilePath);
|
|
}
|
|
if (!array_key_exists('itop_version', $aArgs)) {
|
|
// Assume 1.0.2
|
|
$aArgs['itop_version'] = '1.0.2';
|
|
}
|
|
foreach (array_keys(self::$m_aModuleArgs) as $sArgName) {
|
|
if (!array_key_exists($sArgName, $aArgs)) {
|
|
throw new Exception("Module '$sId': missing argument '$sArgName'");
|
|
}
|
|
}
|
|
|
|
$aArgs['root_dir'] = dirname($sFilePath);
|
|
$aArgs['module_file'] = $sFilePath;
|
|
|
|
list($sModuleName, $sModuleVersion) = static::GetModuleName($sId);
|
|
|
|
if (array_key_exists($sModuleName, self::$m_aModuleVersionByName)) {
|
|
if (version_compare($sModuleVersion, self::$m_aModuleVersionByName[$sModuleName]['version'], '>')) {
|
|
// Newer version, let's upgrade
|
|
$sIdToRemove = self::$m_aModuleVersionByName[$sModuleName]['id'];
|
|
unset(self::$m_aModules[$sIdToRemove]);
|
|
|
|
self::$m_aModuleVersionByName[$sModuleName]['version'] = $sModuleVersion;
|
|
self::$m_aModuleVersionByName[$sModuleName]['id'] = $sId;
|
|
} else {
|
|
// Older (or equal) version, let's ignore it
|
|
return;
|
|
}
|
|
} else {
|
|
// First version to be loaded for this module, remember it
|
|
self::$m_aModuleVersionByName[$sModuleName]['version'] = $sModuleVersion;
|
|
self::$m_aModuleVersionByName[$sModuleName]['id'] = $sId;
|
|
}
|
|
|
|
self::$m_aModules[$sId] = $aArgs;
|
|
|
|
// Now keep the relative paths, as provided
|
|
/*
|
|
foreach(self::$m_aFilesList as $sAttribute)
|
|
{
|
|
if (isset(self::$m_aModules[$sId][$sAttribute]))
|
|
{
|
|
// All the items below are list of files, that are relative to the current file
|
|
// being loaded, let's update their path to store path relative to the application directory
|
|
foreach(self::$m_aModules[$sId][$sAttribute] as $idx => $sRelativePath)
|
|
{
|
|
self::$m_aModules[$sId][$sAttribute][$idx] = self::$m_sModulePath.'/'.$sRelativePath;
|
|
}
|
|
}
|
|
}
|
|
*/
|
|
// Populate automatically the list of dictionary files
|
|
$aMatches = [];
|
|
if (preg_match('|^([^/]+)|', $sId, $aMatches)) { // ModuleName = everything before the first forward slash
|
|
$sModuleName = $aMatches[1];
|
|
$sDir = dirname($sFilePath);
|
|
$aDirs = [
|
|
$sDir => self::$m_sModulePath,
|
|
$sDir.'/dictionaries' => self::$m_sModulePath.'/dictionaries',
|
|
];
|
|
foreach ($aDirs as $sRootDir => $sPath) {
|
|
if ($hDir = @opendir($sRootDir)) {
|
|
while (($sFile = readdir($hDir)) !== false) {
|
|
$aMatches = [];
|
|
if (preg_match("/^[^\\.]+.dict.$sModuleName.php$/i", $sFile, $aMatches)) { // Dictionary files named like <Lang>.dict.<ModuleName>.php are loaded automatically
|
|
self::$m_aModules[$sId]['dictionary'][] = $sPath.'/'.$sFile;
|
|
}
|
|
}
|
|
closedir($hDir);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the list of "discovered" modules, ordered based on their (inter) dependencies
|
|
*
|
|
* @param bool $bAbortOnMissingDependency ...
|
|
* @param array $aModulesToLoad List of modules to search for, defaults to all if omitted
|
|
*
|
|
* @return array
|
|
* @throws \MissingDependencyException
|
|
*/
|
|
protected static function GetModules($bAbortOnMissingDependency = false, $aModulesToLoad = null)
|
|
{
|
|
// Order the modules to take into account their inter-dependencies
|
|
return self::OrderModulesByDependencies(self::$m_aModules, $bAbortOnMissingDependency, $aModulesToLoad);
|
|
}
|
|
|
|
/**
|
|
* Arrange an list of modules, based on their (inter) dependencies
|
|
* @param array $aModules The list of modules to process: 'id' => $aModuleInfo
|
|
* @param bool $bAbortOnMissingDependency ...
|
|
* @param array $aModulesToLoad List of modules to search for, defaults to all if omitted
|
|
* @return array
|
|
* @throws \MissingDependencyException
|
|
*/
|
|
public static function OrderModulesByDependencies($aModules, $bAbortOnMissingDependency = false, $aModulesToLoad = null)
|
|
{
|
|
if (is_null($aModulesToLoad)) {
|
|
$aFilteredModules = $aModules;
|
|
} else {
|
|
$aFilteredModules = [];
|
|
foreach ($aModules as $sModuleId => $aModule) {
|
|
$oModule = new Module($sModuleId);
|
|
$sModuleName = $oModule->GetModuleName();
|
|
if (in_array($sModuleName, $aModulesToLoad)) {
|
|
$aFilteredModules[$sModuleId] = $aModule;
|
|
}
|
|
}
|
|
}
|
|
|
|
return ModuleDependencySort::GetInstance()->GetModulesOrderedForInstallation($aFilteredModules, $bAbortOnMissingDependency);
|
|
}
|
|
|
|
private static function GetPhpExpressionEvaluator(): PhpExpressionEvaluator
|
|
{
|
|
if (!isset(static::$oPhpExpressionEvaluator)) {
|
|
static::$oPhpExpressionEvaluator = new PhpExpressionEvaluator([], RunTimeEnvironment::STATIC_CALL_AUTOSELECT_WHITELIST);
|
|
}
|
|
|
|
return static::$oPhpExpressionEvaluator;
|
|
}
|
|
|
|
/**
|
|
* Search (on the disk) for all defined iTop modules, load them and returns the list (as an array)
|
|
* of the possible iTop modules to install
|
|
*
|
|
* @param $aSearchDirs array of directories to search (absolute paths)
|
|
* @param bool $bAbortOnMissingDependency ...
|
|
* @param array $aModulesToLoad List of modules to search for, defaults to all if omitted
|
|
*
|
|
* @return array A big array moduleID => ModuleData
|
|
* @throws \Exception
|
|
*/
|
|
public static function GetAvailableModules($aSearchDirs, $bAbortOnMissingDependency = false, $aModulesToLoad = null)
|
|
{
|
|
if (self::$m_aSearchDirs != $aSearchDirs) {
|
|
self::ResetCache();
|
|
}
|
|
|
|
if (is_null(self::$m_aSearchDirs)) {
|
|
self::$m_aSearchDirs = $aSearchDirs;
|
|
|
|
// Not in cache, let's scan the disk
|
|
foreach ($aSearchDirs as $sSearchDir) {
|
|
$sLookupDir = realpath($sSearchDir);
|
|
if ($sLookupDir == '') {
|
|
throw new Exception("Invalid directory '$sSearchDir'");
|
|
}
|
|
|
|
clearstatcache();
|
|
self::ListModuleFiles(basename($sSearchDir), dirname($sSearchDir));
|
|
}
|
|
return self::GetModules($bAbortOnMissingDependency, $aModulesToLoad);
|
|
} else {
|
|
// Reuse the previous results
|
|
return self::GetModules($bAbortOnMissingDependency, $aModulesToLoad);
|
|
}
|
|
}
|
|
|
|
public static function ResetCache()
|
|
{
|
|
self::$m_aSearchDirs = null;
|
|
self::$m_aModules = [];
|
|
self::$m_aModuleVersionByName = [];
|
|
}
|
|
|
|
/**
|
|
* Helper function to interpret the name of a module
|
|
* @param $sModuleId string Identifier of the module, in the form 'name/version'
|
|
* @return array(name, version)
|
|
*/
|
|
public static function GetModuleName($sModuleId)
|
|
{
|
|
$aMatches = [];
|
|
if (preg_match('!^(.*)/(.*)$!', $sModuleId, $aMatches)) {
|
|
$sName = $aMatches[1];
|
|
$sVersion = $aMatches[2];
|
|
if ($sVersion === "") {
|
|
$sVersion = "1.0.0";
|
|
}
|
|
} else {
|
|
$sName = $sModuleId;
|
|
$sVersion = "1.0.0";
|
|
}
|
|
|
|
return [$sName, $sVersion];
|
|
}
|
|
|
|
/**
|
|
* Helper function to browse a directory and get the modules
|
|
*
|
|
* @param $sRelDir string Directory to start from
|
|
* @param $sRootDir string The root directory path
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
protected static function ListModuleFiles($sRelDir, $sRootDir)
|
|
{
|
|
static $iDummyClassIndex = 0;
|
|
$sDirectory = $sRootDir.'/'.$sRelDir;
|
|
|
|
if ($hDir = opendir($sDirectory)) {
|
|
// This is the correct way to loop over the directory. (according to the documentation)
|
|
while (($sFile = readdir($hDir)) !== false) {
|
|
$aMatches = [];
|
|
if (is_dir($sDirectory.'/'.$sFile)) {
|
|
if (($sFile != '.') && ($sFile != '..') && ($sFile != '.svn') && ($sFile != 'vendor')) {
|
|
self::ListModuleFiles($sRelDir.'/'.$sFile, $sRootDir);
|
|
}
|
|
} elseif (preg_match('/^module\.(.*).php$/i', $sFile, $aMatches)) {
|
|
self::SetModulePath($sRelDir);
|
|
$sModuleFilePath = $sDirectory.'/'.$sFile;
|
|
try {
|
|
$aModuleInfo = ModuleFileReader::GetInstance()->ReadModuleFileInformation($sDirectory.'/'.$sFile);
|
|
SetupWebPage::AddModule($sModuleFilePath, $aModuleInfo[ModuleFileReader::MODULE_INFO_ID], $aModuleInfo[ModuleFileReader::MODULE_INFO_CONFIG]);
|
|
} catch (ModuleFileReaderException $e) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
closedir($hDir);
|
|
} else {
|
|
throw new Exception("Data directory (".$sDirectory.") not found or not readable.");
|
|
}
|
|
}
|
|
} // End of class
|
|
|
|
/** Alias for backward compatibility with old module files in which
|
|
* the declaration of a module invokes SetupWebPage::AddModule()
|
|
* whereas the new form is ModuleDiscovery::AddModule()
|
|
*/
|
|
class SetupWebPage extends ModuleDiscovery
|
|
{
|
|
// For backward compatibility with old modules...
|
|
public static function log_error($sText)
|
|
{
|
|
SetupLog::Error($sText);
|
|
}
|
|
|
|
public static function log_warning($sText)
|
|
{
|
|
SetupLog::Warning($sText);
|
|
}
|
|
|
|
public static function log_info($sText)
|
|
{
|
|
SetupLog::Info($sText);
|
|
}
|
|
|
|
public static function log_ok($sText)
|
|
{
|
|
SetupLog::Ok($sText);
|
|
}
|
|
|
|
public static function log($sText)
|
|
{
|
|
SetupLog::Ok($sText);
|
|
}
|
|
}
|
|
|
|
/** Ugly patch !!!
|
|
* In order to be able to analyse / load several times
|
|
* the same module file, we rename the class (to avoid duplicate class definitions)
|
|
* and we make the class extends the dummy class below in order to "deactivate" completely
|
|
* the class (in case some piece of code enumerate the classes derived from a well known class)
|
|
* Note that this will not work if someone enumerates the classes that implement a given interface
|
|
*/
|
|
class DummyHandler
|
|
{
|
|
}
|