Files
iTop/setup/modulediscovery.class.inc.php
odain 24048d2b9c N°8724 - Enhance setup feedback in case of module dependency issue (#700)
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
2025-11-26 19:23:26 +01:00

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
{
}