From d1e91087b9de75d6a048bb4fd8ea4ce304ca25c2 Mon Sep 17 00:00:00 2001 From: odain Date: Fri, 9 Jan 2026 08:28:01 +0100 Subject: [PATCH] =?UTF-8?q?N=C2=B08764=20-=20Halt=20setup=20if=20database?= =?UTF-8?q?=20is=20not=20compatible=20with=20an=20uninstallation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup/applicationinstaller.class.inc.php | 111 +++++++++++++++--- .../ModelReflectionSerializer.php | 27 ++++- setup/feature_removal/SetupAudit.php | 32 +++-- setup/wizardsteps.class.inc.php | 13 -- .../setup/feature_removal/SetupAuditTest.php | 20 +++- 5 files changed, 156 insertions(+), 47 deletions(-) diff --git a/setup/applicationinstaller.class.inc.php b/setup/applicationinstaller.class.inc.php index 377998b05..064066a1e 100644 --- a/setup/applicationinstaller.class.inc.php +++ b/setup/applicationinstaller.class.inc.php @@ -17,9 +17,13 @@ // You should have received a copy of the GNU Affero General Public License // along with iTop. If not, see +use Combodo\iTop\Setup\FeatureRemoval\ModelReflectionSerializer; +use Combodo\iTop\Setup\FeatureRemoval\SetupAudit; + require_once(APPROOT.'setup/parameters.class.inc.php'); require_once(APPROOT.'setup/xmldataloader.class.inc.php'); require_once(APPROOT.'setup/backup.class.inc.php'); +require_once APPROOT.'setup/feature_removal/SetupAudit.php'; /** * The base class for the installation process. @@ -258,6 +262,7 @@ class ApplicationInstaller $sExtensionDir = $this->oParams->Get('extensions_dir', 'extensions'); $aMiscOptions = $this->oParams->Get('options', []); $aRemovedExtensionCodes = $this->oParams->Get('removed_extensions', []); + $sForceUninstall = $this->oParams->Get('force-uninstall', ''); $bUseSymbolicLinks = null; if ((isset($aMiscOptions['symlinks']) && $aMiscOptions['symlinks'])) { @@ -274,29 +279,36 @@ class ApplicationInstaller $aSelectedModules, $sSourceDir, $sExtensionDir, + $sForceUninstall, $bUseSymbolicLinks ); + $aResult = [ + 'status' => self::OK, + 'message' => '', + 'next-step' => 'setup-audit', + 'next-step-label' => 'Checking data consistency with the new data model', + 'percentage-completed' => 40, + ]; + break; + + case 'setup-audit': + $sForceUninstall = $this->oParams->Get('force-uninstall', ''); + $this->DoSetupAudit($sForceUninstall); $aResult = [ 'status' => self::OK, 'message' => '', 'next-step' => 'db-schema', 'next-step-label' => 'Updating database schema', - 'percentage-completed' => 40, + 'percentage-completed' => 50, ]; break; case 'db-schema': $aSelectedModules = $this->oParams->Get('selected_modules', []); - $aParamValues = $this->oParams->GetParamForConfigArray(); - $bOldAddon = $this->oParams->Get('old_addon', false); - $sUrl = $this->oParams->Get('url', ''); $this->DoUpdateDBSchema( - $aSelectedModules, - $aParamValues, - $bOldAddon, - $sUrl + $aSelectedModules ); $aResult = [ @@ -487,6 +499,7 @@ class ApplicationInstaller * @param array $aSelectedModules * @param string $sSourceDir * @param string $sExtensionDir + * @param string $sForceUninstall * @param boolean $bUseSymbolicLinks * * @return void @@ -495,8 +508,14 @@ class ApplicationInstaller * * @since 3.1.0 N°2013 added the aParamValues param */ - protected function DoCompile($aRemovedExtensionCodes, $aSelectedModules, $sSourceDir, $sExtensionDir, $bUseSymbolicLinks = null) + protected function DoCompile($aRemovedExtensionCodes, $aSelectedModules, $sSourceDir, $sExtensionDir, $sForceUninstall, $bUseSymbolicLinks = null) { + /** + * @since 3.2.0 move the ContextTag init at the very beginning of the method + * @noinspection PhpUnusedLocalVariableInspection + */ + $oContextTag = new ContextTag(ContextTag::TAG_SETUP); + SetupLog::Info("Compiling data model."); require_once(APPROOT.'setup/modulediscovery.class.inc.php'); @@ -528,7 +547,20 @@ class ApplicationInstaller if (!is_dir($sSourcePath)) { throw new Exception("Failed to find the source directory '$sSourcePath', please check the rights of the web server"); } + $bIsAlreadyInMaintenanceMode = SetupUtils::IsInMaintenanceMode(); + if ($sForceUninstall === "checked") { + //audit required + SetupLog::Info(__METHOD__, null, ['force-uninstall' => $sForceUninstall]); + if ($bIsAlreadyInMaintenanceMode) { + //required to read DM before calling SaveModelInfo + SetupUtils::ExitMaintenanceMode(); + $bIsAlreadyInMaintenanceMode = false; + } + + $this->SaveModelInfo($sEnvironment); + } + if (($sEnvironment == 'production') && !$bIsAlreadyInMaintenanceMode) { $sConfigFilePath = utils::GetConfigFilePath($sEnvironment); if (is_file($sConfigFilePath)) { @@ -620,23 +652,70 @@ class ApplicationInstaller } } + private function GetModelInfoPath(): string + { + return APPROOT.'data/beforecompilation_modelinfo.json'; + } + + private function SaveModelInfo(string $sEnvironment): void + { + $aModelInfo = ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($sEnvironment); + $sModelInfoPath = $this->GetModelInfoPath(); + file_put_contents($sModelInfoPath, json_encode($aModelInfo)); + } + + private function GetPreviousModelInfo(string $sEnvironment): array + { + $sContent = file_get_contents($this->GetModelInfoPath()); + $aModelInfo = json_decode($sContent, true); + + if (false === $aModelInfo) { + throw new \Exception("Could not read (before compilation) previous model to audit data"); + } + + return $aModelInfo; + } + + protected function DoSetupAudit(string $sForceUninstall) + { + /** + * @since 3.2.0 move the ContextTag init at the very beginning of the method + * @noinspection PhpUnusedLocalVariableInspection + */ + $oContextTag = new ContextTag(ContextTag::TAG_SETUP); + + $aParamValues = $this->oParams->GetParamForConfigArray(); + $sMode = $aParamValues['mode']; + if ($sMode !== "upgrade") { + return; + } + + if ($sForceUninstall !== "checked") { + SetupLog::Info("Setup data audit disabled (force-uninstall)"); + return; + } else { + SetupLog::Info(__METHOD__, null, ['force-uninstall' => $sForceUninstall]); + } + + $sTargetEnvironment = $this->GetTargetEnv(); + $aPreviousCompilationModelInfo = $this->GetPreviousModelInfo($sTargetEnvironment); + + $oSetupAudit = new SetupAudit($sTargetEnvironment, $sTargetEnvironment); + $oSetupAudit->ComputeClasses($aPreviousCompilationModelInfo); + } + /** * @param $aSelectedModules - * @param $sModulesDir - * @param $aParamValues - * @param string $sTargetEnvironment - * @param bool $bOldAddon - * @param string $sAppRootUrl * * @throws \ConfigException * @throws \CoreException * @throws \MySQLException */ - protected function DoUpdateDBSchema($aSelectedModules, $aParamValues, $bOldAddon = false, $sAppRootUrl = '') + protected function DoUpdateDBSchema($aSelectedModules) { $sTargetEnvironment = $this->GetTargetEnv(); $sModulesDir = $this->GetTargetDir(); - + $aParamValues = $this->oParams->GetParamForConfigArray(); /** * @since 3.2.0 move the ContextTag init at the very beginning of the method * @noinspection PhpUnusedLocalVariableInspection diff --git a/setup/feature_removal/ModelReflectionSerializer.php b/setup/feature_removal/ModelReflectionSerializer.php index 5aff4dcdd..2468c963b 100644 --- a/setup/feature_removal/ModelReflectionSerializer.php +++ b/setup/feature_removal/ModelReflectionSerializer.php @@ -2,8 +2,12 @@ namespace Combodo\iTop\Setup\FeatureRemoval; +use ContextTag; use CoreException; use Exception; +use IssueLog; +use SetupLog; +use Utils; class ModelReflectionSerializer { @@ -29,27 +33,38 @@ class ModelReflectionSerializer public function GetModelFromEnvironment(string $sEnv): array { - \IssueLog::Info(__METHOD__, null, ['env' => $sEnv]); - $sPHPExec = trim(\MetaModel::GetConfig()->Get('php_path')); + IssueLog::Info(__METHOD__, null, ['env' => $sEnv]); + $sPHPExec = trim(Utils::GetConfig()->Get('php_path')); $sOutput = ""; $iRes = 0; + exec(sprintf("$sPHPExec %s/get_model_reflection.php --env='%s'", __DIR__, $sEnv), $sOutput, $iRes); if ($iRes != 0) { - \IssueLog::Error("Cannot get classes", null, ['env' => $sEnv, 'code' => $iRes, "output" => $sOutput]); + $this->LogErrorWithProperLogger("Cannot get classes", null, ['env' => $sEnv, 'code' => $iRes, "output" => $sOutput]); throw new CoreException("Cannot get classes"); } $aClasses = json_decode($sOutput[0] ?? null, true); if (false === $aClasses) { - \IssueLog::Error("Invalid JSON", null, ["output" => $sOutput]); + $this->LogErrorWithProperLogger("Invalid JSON", null, ['env' => $sEnv, "output" => $sOutput]); throw new Exception("cannot get classes"); } if (!is_array($aClasses)) { - \IssueLog::Error("not an array", null, ["classes" => $aClasses]); - throw new Exception("cannot get classes"); + $this->LogErrorWithProperLogger("not an array", null, ['env' => $sEnv, "classes" => $aClasses, "output" => $sOutput]); + throw new Exception("cannot get classes from $sEnv"); } return $aClasses; } + + //could be shared with others in log APIs ? + private function LogErrorWithProperLogger($sMessage, $sChannel = null, $aContext = []): void + { + if (ContextTag::Check(ContextTag::TAG_SETUP)) { + SetupLog::Error($sMessage, $sChannel, $aContext); + } else { + IssueLog::Error($sMessage, $sChannel, $aContext); + } + } } diff --git a/setup/feature_removal/SetupAudit.php b/setup/feature_removal/SetupAudit.php index 76831a663..f90a29c7d 100644 --- a/setup/feature_removal/SetupAudit.php +++ b/setup/feature_removal/SetupAudit.php @@ -16,21 +16,34 @@ class SetupAudit private string $sEnvBeforeExtensionRemoval; private string $sEnvAfterExtensionRemoval; - private array $aClassesBeforeRemoval; - private array $aClassesAfterRemoval; - private array $aRemovedClasses; - private array $aFinalClassesRemoved; + private bool $bClassesInitialized = false; + private array $aClassesBeforeRemoval = []; + private array $aClassesAfterRemoval = []; + private array $aRemovedClasses = []; + private array $aFinalClassesRemoved = []; public function __construct(string $sEnvBeforeExtensionRemoval, string $sEnvAfterExtensionRemoval = DryRemovalRuntimeEnvironment::DRY_REMOVAL_AUDIT_ENV) { $this->sEnvBeforeExtensionRemoval = $sEnvBeforeExtensionRemoval; $this->sEnvAfterExtensionRemoval = $sEnvAfterExtensionRemoval; + } + + public function ComputeClasses(array $aClassesBeforeRemoval = null) + { + if ($this->bClassesInitialized) { + return; + } $sCurrentEnvt = MetaModel::GetEnvironment(); - if ($sCurrentEnvt === $this->sEnvBeforeExtensionRemoval) { - $this->aClassesBeforeRemoval = MetaModel::GetClasses(); + + if (is_null($aClassesBeforeRemoval)) { + if ($sCurrentEnvt === $this->sEnvBeforeExtensionRemoval) { + $this->aClassesBeforeRemoval = MetaModel::GetClasses(); + } else { + $this->aClassesBeforeRemoval = ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($this->sEnvBeforeExtensionRemoval); + } } else { - $this->aClassesBeforeRemoval = ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($this->sEnvBeforeExtensionRemoval); + $this->aClassesBeforeRemoval = $aClassesBeforeRemoval; } if ($sCurrentEnvt === $this->sEnvAfterExtensionRemoval) { @@ -39,8 +52,7 @@ class SetupAudit $this->aClassesAfterRemoval = ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($this->sEnvAfterExtensionRemoval); } - $this->aRemovedClasses = []; - $this->aFinalClassesRemoved = []; + $this->bClassesInitialized = true; } /*public function SetSelectedExtensions(Config $oConfig, array $aSelectedExtensions) @@ -56,6 +68,8 @@ class SetupAudit public function GetRemovedClasses(): array { + $this->ComputeClasses(); + if (count($this->aRemovedClasses) == 0) { if (count($this->aClassesBeforeRemoval) == 0) { return $this->aRemovedClasses; diff --git a/setup/wizardsteps.class.inc.php b/setup/wizardsteps.class.inc.php index 12db0dadd..a97b680c2 100644 --- a/setup/wizardsteps.class.inc.php +++ b/setup/wizardsteps.class.inc.php @@ -50,7 +50,6 @@ require_once(APPROOT.'setup/applicationinstaller.class.inc.php'); require_once(APPROOT.'setup/parameters.class.inc.php'); require_once(APPROOT.'core/mutex.class.inc.php'); require_once(APPROOT.'setup/extensionsmap.class.inc.php'); -require_once APPROOT.'setup/feature_removal/SetupAudit.php'; /** * First step of the iTop Installation Wizard: Welcome screen, requirements @@ -2178,18 +2177,6 @@ class WizStepSummary extends WizardStep $this->bDependencyCheck = true; try { SetupUtils::AnalyzeInstallation($this->oWizard, true, $aSelectedModules); - - /*$sInstallMode = utils::ReadParam('install_mode'); - \SetupLog::Info(__METHOD__, null, ['$sInstallMode' => $sInstallMode]); - //if ($sInstallMode === "upgrade") { - $aExtensions = json_decode($this->oWizard->GetParameter('selected_extensions'), true); - $oSetupAudit = new SetupAudit([]); - - $oConfig = SetupUtils::GetConfig($this->oWizard); - $oSetupAudit->SetSelectedExtensions($oConfig, $aExtensions); - //$oSetupAudit->AuditExtensionsCleanupRules(true); - //} - */ } catch (MissingDependencyException $e) { $this->bDependencyCheck = false; $this->sDependencyIssue = $e->getHtmlDesc(); diff --git a/tests/php-unit-tests/unitary-tests/setup/feature_removal/SetupAuditTest.php b/tests/php-unit-tests/unitary-tests/setup/feature_removal/SetupAuditTest.php index 841c5ff3c..68707358c 100644 --- a/tests/php-unit-tests/unitary-tests/setup/feature_removal/SetupAuditTest.php +++ b/tests/php-unit-tests/unitary-tests/setup/feature_removal/SetupAuditTest.php @@ -3,10 +3,12 @@ namespace Combodo\iTop\Test\UnitTest\Setup\FeatureRemoval; use Combodo\iTop\Setup\FeatureRemoval\DryRemovalRuntimeEnvironment; +use Combodo\iTop\Setup\FeatureRemoval\ModelReflectionSerializer; use Combodo\iTop\Setup\FeatureRemoval\SetupAudit; use Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase; use Combodo\iTop\Test\UnitTest\Service\UnitTestRunTimeEnvironment; use Exception; +use MetaModel; class SetupAuditTest extends ItopCustomDatamodelTestCase { @@ -52,7 +54,7 @@ class SetupAuditTest extends ItopCustomDatamodelTestCase $oDryRemovalRuntimeEnvt->Prepare($this->GetTestEnvironment(), ['nominal_ext1', 'finalclass_ext2']); $oDryRemovalRuntimeEnvt->CompileFrom($this->GetTestEnvironment()); - $oSetupAudit = new SetupAudit(\MetaModel::GetEnvironment()); + $oSetupAudit = new SetupAudit(MetaModel::GetEnvironment()); $expected = [ "Feature1Module1MyClass", @@ -67,13 +69,25 @@ class SetupAuditTest extends ItopCustomDatamodelTestCase $this->assertEqualsCanonicalizing($expected, $oSetupAudit->GetIssues()); } + public function testGetRemovedClassesFromSetupWizard() + { + $sEnv = MetaModel::GetEnvironment(); + + $aClassesAfterRemoval = ModelReflectionSerializer::GetInstance()->GetModelFromEnvironment($sEnv); + $aClassesAfterRemoval[] = "GabuZomeu"; + + $oSetupAudit = new SetupAudit($sEnv, $sEnv); + $oSetupAudit->ComputeClasses($aClassesAfterRemoval); + $this->assertEquals(["GabuZomeu"], $oSetupAudit->GetRemovedClasses()); + } + public function testGetIssues() { $sUID = "AuditExtensionsCleanupRules_".uniqid(); $oOrg = $this->CreateOrganization($sUID); $this->createObject('FinalClassFeature1Module1MyFinalClassFromLocation', ['org_id' => $oOrg->GetKey(), 'name' => $sUID, 'name2' => uniqid()]); - $oSetupAudit = new SetupAudit(\MetaModel::GetEnvironment()); + $oSetupAudit = new SetupAudit(MetaModel::GetEnvironment()); $aRemovedClasses = [ "Feature1Module1MyClass", "FinalClassFeature1Module1MyClass", @@ -99,7 +113,7 @@ class SetupAuditTest extends ItopCustomDatamodelTestCase $this->createObject('FinalClassFeature1Module1MyFinalClassFromLocation', ['org_id' => $oOrg->GetKey(), 'name' => $sUID, 'name2' => uniqid()]); $this->createObject('FinalClassFeature2Module1MyFinalClassFromLocation', ['org_id' => $oOrg->GetKey(), 'name' => $sUID, 'name2' => uniqid()]); - $oSetupAudit = new SetupAudit(\MetaModel::GetEnvironment()); + $oSetupAudit = new SetupAudit(MetaModel::GetEnvironment()); $aRemovedClasses = [ "Feature1Module1MyClass", "FinalClassFeature1Module1MyClass",