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
This commit is contained in:
odain
2025-02-25 15:37:34 +01:00
parent d8121b563a
commit 24048d2b9c
21 changed files with 1692 additions and 488 deletions

View File

@@ -0,0 +1,146 @@
<?php
namespace Combodo\iTop\Setup\ModuleDependency;
require_once(APPROOT.'/setup/runtimeenv.class.inc.php');
use Combodo\iTop\PhpParser\Evaluation\PhpExpressionEvaluator;
use ModuleFileReaderException;
use RunTimeEnvironment;
/**
* Class that handles a module dependency
* Dependency expression example : (moduleA/123 || moduleB>456)
*/
class DependencyExpression
{
private static PhpExpressionEvaluator $oPhpExpressionEvaluator;
private string $sDependencyExpression;
private bool $bValid = true;
private bool $bResolved = false;
/**
* @var array<string, bool> $aRemainingModuleNamesToResolve
*/
private array $aRemainingModuleNamesToResolve;
/**
* @var array<string, array> $aParamsPerModuleId
*/
private array $aParamsPerModuleId;
public function __construct(string $sDependencyExpression)
{
$this->sDependencyExpression = $sDependencyExpression;
$this->aParamsPerModuleId = [];
$this->aRemainingModuleNamesToResolve = [];
if (preg_match_all('/([^\(\)&| ]+)/', $sDependencyExpression, $aMatches)) {
foreach ($aMatches as $aMatch) {
foreach ($aMatch as $sModuleId) {
if (! array_key_exists($sModuleId, $this->aParamsPerModuleId)) {
// $sModuleId in the dependency string is made of a <name>/<optional_operator><version>
// where the operator is < <= = > >= (by default >=)
$aModuleMatches = [];
if (preg_match('|^([^/]+)/(<?>?=?)([^><=]+)$|', $sModuleId, $aModuleMatches)) {
$sModuleName = $aModuleMatches[1];
$this->aRemainingModuleNamesToResolve[$sModuleName] = true;
$sOperator = $aModuleMatches[2];
if ($sOperator == '') {
$sOperator = '>=';
}
$sExpectedVersion = $aModuleMatches[3];
$this->aParamsPerModuleId[$sModuleId] = [$sModuleName, $sOperator, $sExpectedVersion];
}
}
}
}
} else {
$this->bValid = false;
}
}
private static function GetPhpExpressionEvaluator(): PhpExpressionEvaluator
{
if (!isset(static::$oPhpExpressionEvaluator)) {
static::$oPhpExpressionEvaluator = new PhpExpressionEvaluator([], RunTimeEnvironment::STATIC_CALL_AUTOSELECT_WHITELIST);
}
return static::$oPhpExpressionEvaluator;
}
/**
* Return module names potentially required by current dependency
* @return array
*/
public function GetRemainingModuleNamesToResolve(): array
{
return array_keys($this->aRemainingModuleNamesToResolve);
}
public function IsResolved(): bool
{
return $this->bResolved;
}
/**
* Check if dependency is resolved with current list of module versions
* @param array $aModuleVersions: versions by module names dict
* @param array $aSelectedModules: modules names dict
*
* @return void
*/
public function UpdateModuleResolutionState(array $aModuleVersions, array $aSelectedModules): void
{
if (!$this->bValid) {
return;
}
$aReplacements = [];
foreach ($this->aParamsPerModuleId as $sModuleId => list($sModuleName, $sOperator, $sExpectedVersion)) {
if (array_key_exists($sModuleName, $aModuleVersions)) {
// module is present, check the version
$sCurrentVersion = $aModuleVersions[$sModuleName];
if (version_compare($sCurrentVersion, $sExpectedVersion, $sOperator)) {
if (array_key_exists($sModuleName, $this->aRemainingModuleNamesToResolve)) {
unset($this->aRemainingModuleNamesToResolve[$sModuleName]);
}
$aReplacements[$sModuleId] = '(true)'; // Add parentheses to protect against invalid condition causing
// a function call that results in a runtime fatal error
} else {
$aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing
// a function call that results in a runtime fatal error
}
} else {
// module is not present
$aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing
// a function call that results in a runtime fatal error
}
}
foreach ($this->aRemainingModuleNamesToResolve as $sModuleName => $c) {
if (array_key_exists($sModuleName, $aSelectedModules)) {
// This module is actually a prerequisite
if (!array_key_exists($sModuleName, $aModuleVersions)) {
return;
}
}
}
$bResult = false;
$sBooleanExpr = str_replace(array_keys($aReplacements), array_values($aReplacements), $this->sDependencyExpression);
try {
$bResult = self::GetPhpExpressionEvaluator()->ParseAndEvaluateBooleanExpression($sBooleanExpr);
} catch (ModuleFileReaderException $e) {
//logged already
echo "Failed to parse the boolean Expression = '$sBooleanExpr'<br/>";
}
$this->bResolved = $bResult;
}
public function IsValid(): bool
{
return $this->bValid;
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Combodo\iTop\Setup\ModuleDependency;
require_once(__DIR__.'/dependencyexpression.class.inc.php');
use ModuleDiscovery;
/**
* Class that handles a modules and all its dependencies
*/
class Module
{
private string $sModuleId;
private string $sModuleName;
private string $sVersion;
/**
* @var array<string> $aInitialDependencyExpressions
*/
private array $aInitialDependencyExpressions;
/**
* @var array<string, DependencyExpression> $aRemainingDependenciesToResolve
*/
public array $aRemainingDependenciesToResolve;
public function __construct(string $sModuleId)
{
$this->sModuleId = $sModuleId;
list($this->sModuleName, $this->sVersion) = ModuleDiscovery::GetModuleName($sModuleId);
}
public function IsDependencyExpressionResolved(string $sDependencyExpression): bool
{
return ! array_key_exists($sDependencyExpression, $this->aRemainingDependenciesToResolve);
}
public function GetDependencyResolutionFeedback(): array
{
$aDepsWithIcons = [];
foreach ($this->aInitialDependencyExpressions as $sDependencyExpression) {
if (! $this->IsDependencyExpressionResolved($sDependencyExpression)) {
$aDepsWithIcons[] = '❌ '.$sDependencyExpression;
}
}
return $aDepsWithIcons;
}
/**
* @return string
*/
public function GetModuleName()
{
return $this->sModuleName;
}
/**
* @return string
*/
public function GetVersion()
{
return $this->sVersion;
}
/**
* @return string
*/
public function GetModuleId()
{
return $this->sModuleId;
}
/**
* @param array $aAllDependencyExpressions: list of dependencies (string)
*
* @return void
*/
public function SetDependencies(array $aAllDependencyExpressions): void
{
$this->aInitialDependencyExpressions = $aAllDependencyExpressions;
$this->aRemainingDependenciesToResolve = [];
foreach ($aAllDependencyExpressions as $sDependencyExpression) {
$this->aRemainingDependenciesToResolve[$sDependencyExpression] = new DependencyExpression($sDependencyExpression);
}
}
public function IsResolved(): bool
{
return (0 === count($this->aRemainingDependenciesToResolve));
}
/**
* Check if module dependencies are resolved with current list of module versions
* @param array $aModuleVersions : versions by module names dict
* @param array $aSelectedModules : modules names dict
*
* @return void
*/
public function UpdateModuleResolutionState(array $aModuleVersions, array $aSelectedModules): void
{
$aNextDependencies = [];
foreach ($this->aRemainingDependenciesToResolve as $sDependencyExpression => $oModuleDependency) {
/** @var DependencyExpression $oModuleDependency*/
$oModuleDependency->UpdateModuleResolutionState($aModuleVersions, $aSelectedModules);
if (!$oModuleDependency->IsResolved()) {
$aNextDependencies[$sDependencyExpression] = $oModuleDependency;
}
}
$this->aRemainingDependenciesToResolve = $aNextDependencies;
}
/**
* @return array: list of unique module names
*/
public function GetUnresolvedDependencyModuleNames(): array
{
$aRes = [];
foreach ($this->aRemainingDependenciesToResolve as $sDependencyExpression => $oModuleDependency) {
/** @var DependencyExpression $oModuleDependency */
$aRes = array_merge($aRes, $oModuleDependency->GetRemainingModuleNamesToResolve());
}
return array_unique($aRes);
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace Combodo\iTop\Setup\ModuleDependency;
require_once(__DIR__.'/module.class.inc.php');
use MissingDependencyException;
/**
* Class that sorts module dependencies
*/
class ModuleDependencySort
{
private static ModuleDependencySort $oInstance;
protected function __construct()
{
}
final public static function GetInstance(): ModuleDependencySort
{
if (!isset(static::$oInstance)) {
static::$oInstance = new static();
}
return static::$oInstance;
}
final public static function SetInstance(?ModuleDependencySort $oInstance): void
{
static::$oInstance = $oInstance;
}
/**
* Sort a list of modules, based on their (inter) dependencies
* @param array $aModules The list of modules to process: 'id' => $aModuleInfo
* @param bool $bAbortOnMissingDependency ...
* @return array
* @throws \MissingDependencyException
*/
public function GetModulesOrderedForInstallation($aModules, $bAbortOnMissingDependency = false)
{
// Filter modules to compute
$aUnresolvedDependencyModules = [];
$aModuleNames = [];
foreach ($aModules as $sModuleId => $aModule) {
$oModule = new Module($sModuleId);
$sModuleName = $oModule->GetModuleName();
$oModule->SetDependencies($aModule['dependencies']);
$aUnresolvedDependencyModules[$sModuleId] = $oModule;
$aModuleNames[$sModuleName] = true;
}
// Make sure order is deterministic (alphabtical order)
ksort($aUnresolvedDependencyModules);
//Attempt to resolve module dependencies
$aOrderedModules = [];
$aModuleVersions = [];
$iPreviousUnresolvedCount = -1;
//loop until no dependency is resolved
while ($iPreviousUnresolvedCount !== count($aUnresolvedDependencyModules)) {
$iPreviousUnresolvedCount = count($aUnresolvedDependencyModules);
if ($iPreviousUnresolvedCount === 0) {
break;
}
foreach ($aUnresolvedDependencyModules as $sModuleId => $oModule) {
/** @var Module $oModule */
$oModule->UpdateModuleResolutionState($aModuleVersions, $aModuleNames);
if ($oModule->IsResolved()) {
$aOrderedModules[] = $sModuleId;
$aModuleVersions[$oModule->GetModuleName()] = $oModule->GetVersion();
unset($aUnresolvedDependencyModules[$sModuleId]);
}
}
}
// Report unresolved dependencies
if ($bAbortOnMissingDependency && count($aUnresolvedDependencyModules) > 0) {
$this->SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules);
$aUnresolvedModulesInfo = [];
$aModuleDeps = [];
foreach ($aUnresolvedDependencyModules as $sModuleId => $oModule) {
$aModule = $aModules[$sModuleId];
$aDepsWithIcons = $oModule->GetDependencyResolutionFeedback();
$aModuleDeps[] = "{$aModule['label']} (id: $sModuleId) depends on: ".implode(' + ', $aDepsWithIcons);
$aUnresolvedModulesInfo[$sModuleId] = ['module' => $aModule, 'dependencies' => $aDepsWithIcons];
}
$sMessage = "The following modules have unmet dependencies:\n".implode(",\n", $aModuleDeps);
$oException = new MissingDependencyException($sMessage);
$oException->aModulesInfo = $aUnresolvedModulesInfo;
throw $oException;
}
// Return the ordered list, so that the dependencies are met...
$aResult = [];
foreach ($aOrderedModules as $sId) {
$aResult[$sId] = $aModules[$sId];
}
return $aResult;
}
/**
* This method is key as it sorts modules by their dependencies (topological sort).
* Modules with less dependencies are first.
* When module A depends from module B with same amount of dependencies, moduleB is first.
* This order can deal with
* - cyclic dependencies
* - further versions of same module (name)
*
* @param array $aUnresolvedDependencyModules: dict of Module objects by moduleId key
*
* @return void
*/
protected function SortModulesByCountOfDepencenciesDescending(array &$aUnresolvedDependencyModules): void
{
$aCountDepsByModuleId = [];
$aDependsOnModuleName = [];
foreach ($aUnresolvedDependencyModules as $sModuleId => $oModule) {
/** @var Module $oModule */
$aDependsOnModuleName[$oModule->GetModuleName()] = [];
}
foreach ($aUnresolvedDependencyModules as $sModuleId => $oModule) {
$iInDegreeCounter = 0;
/** @var Module $oModule */
$aUnresolvedDependencyModuleNames = $oModule->GetUnresolvedDependencyModuleNames();
foreach ($aUnresolvedDependencyModuleNames as $sModuleName) {
if (array_key_exists($sModuleName, $aDependsOnModuleName)) {
$aDependsOnModuleName[$sModuleName][] = $sModuleId;
$iInDegreeCounter++;
}
}
//include all modules
$iInDegreeCounterIncludingOutsideModules = count($oModule->GetUnresolvedDependencyModuleNames());
$aCountDepsByModuleId[$sModuleId] = [$iInDegreeCounter, $iInDegreeCounterIncludingOutsideModules, $sModuleId];
}
$aRes = [];
while (count($aUnresolvedDependencyModules) > 0) {
asort($aCountDepsByModuleId);
uasort($aCountDepsByModuleId, function (array $aDeps1, array $aDeps2) {
//compare $iInDegreeCounter
$res = $aDeps1[0] - $aDeps2[0];
if ($res != 0) {
return $res;
}
//compare $iInDegreeCounterIncludingOutsideModules
$res = $aDeps1[1] - $aDeps2[1];
if ($res != 0) {
return $res;
}
//alphabetical order at least
return strcmp($aDeps1[2], $aDeps2[2]);
});
$bOneLoopAtLeast = false;
foreach ($aCountDepsByModuleId as $sModuleId => $iInDegreeCounter) {
$oModule = $aUnresolvedDependencyModules[$sModuleId];
if ($bOneLoopAtLeast && $iInDegreeCounter > 0) {
break;
}
unset($aUnresolvedDependencyModules[$sModuleId]);
unset($aCountDepsByModuleId[$sModuleId]);
$aRes[$sModuleId] = $oModule;
//when 2 versions of the same module (name) below array has been removed already
if (array_key_exists($oModule->GetModuleName(), $aDependsOnModuleName)) {
foreach ($aDependsOnModuleName[$oModule->GetModuleName()] as $sModuleId2) {
if (! array_key_exists($sModuleId2, $aCountDepsByModuleId)) {
continue;
}
$aDepCount = $aCountDepsByModuleId[$sModuleId2];
$iInDegreeCounter = $aDepCount[0] - 1;
$iInDegreeCounterIncludingOutsideModules = $aDepCount[1];
$aCountDepsByModuleId[$sModuleId2] = [$iInDegreeCounter, $iInDegreeCounterIncludingOutsideModules, $sModuleId2];
}
unset($aDependsOnModuleName[$oModule->GetModuleName()]);
}
$bOneLoopAtLeast = true;
}
}
$aUnresolvedDependencyModules = $aRes;
}
}