N°9104 - Be able to trigger cron remotely and asynchronously

This commit is contained in:
odain
2026-03-04 14:54:56 +01:00
parent 14d748aa97
commit fce1348d44
14 changed files with 424 additions and 65 deletions

View File

@@ -687,4 +687,17 @@ abstract class ItopTestCase extends KernelTestCase
return $this->CallUrl($sUrl, $aPostFields, $aCurlOptions, $bXDebugEnabled);
}
/**
* Return a temporary file path. that will be cleaned up by tearDown()
*
* @return string: temporary file path: file prefix include phpunit test method name
*/
public function GetTemporaryFilePath(): string
{
$sPrefix = $this->getName(false);
$sPath = tempnam(sys_get_temp_dir(), $sPrefix);
$this->aFileToClean[] = $sPath;
return $sPath;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Webservices;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use Exception;
use iTopMutex;
use MetaModel;
use utils;
/**
* @group itopRequestMgmt
* @group restApi
* @group defaultProfiles
*/
class CronServiceTest extends ItopDataTestCase
{
protected function setUp(): void
{
parent::setUp(); // TODO: Change the autogenerated stub
$this->RequireOnceItopFile('webservices/CronService.php');
}
public function testIsStarted()
{
$sPath = $this->GetTemporaryFilePath();
file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_starting.log'));
$this->assertEquals(true, \CronService::GetInstance()->IsStarted($sPath));
}
public function testIsFailed_MissingCredentialsFailure()
{
$sPath = $this->GetTemporaryFilePath();
file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_missingcreds_error.log'));
$this->assertEquals("Missing argument 'auth_user'", \CronService::GetInstance()->GetErrorMessage($sPath));
$this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath));
}
public static function ErrorProvider()
{
return [
["Access wrong credentials ('user123')"],
['gabuzomeu'],
];
}
/**
* @dataProvider ErrorProvider
*/
public function testIsFailed_AuthenticationFailure($sError)
{
$sPath = $this->GetTemporaryFilePath();
$sContent = <<<LOG
..
..
ERROR: $sError
LOG;
file_put_contents($sPath, $sContent);
$this->assertEquals($sError, \CronService::GetInstance()->GetErrorMessage($sPath));
$this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath));
}
public static function CannotStartProvider()
{
return [
["cron_alreadyrunning.log", 'Already running...'],
['cron_maintenance.log', 'A maintenance is ongoing'],
['cron_notanadmin.log', 'Access restricted to administrators'],
];
}
/**
* @dataProvider CannotStartProvider
*/
public function testCronCannotStart(string $sLogFile, $sError)
{
$sPath = $this->GetTemporaryFilePath();
file_put_contents($sPath, file_get_contents(__DIR__.'/resources/'.$sLogFile));
$this->assertEquals($sError, \CronService::GetInstance()->GetErrorMessage($sPath));
$this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath));
}
public function testCronRunning()
{
$sPath = $this->GetTemporaryFilePath();
$sContent = <<<LOG
..
..
..
..
Starting: 1772551316 (2026-03-03 16:21:56)
Creating backup: '/var/www/html/iTopLegacy/data/backups/auto/gabuzomeuuninstall-2026-03-03_16_21.tar.gz'
backup: creating tmp dir '/var/www/html/iTopLegacy/data/tmp-backup-1024104636'
backup: adding resource '/var/www/html/iTopLegacy/conf/production/config-itop.php'
backup: adding resource '/var/www/html/iTopLegacy/data/production-modules/'
Starting backup of localhost/gabuzomeuuninstall(suffix:'')
backup: generate data file with command: "mysqldump" --defaults-extra-file="/tmp/itop-mysqldump-CVvm4G" --opt --skip-lock-tables --default-character-set=utf8mb4 --add-drop-database --single-transaction --host='localhost' --user=xxxxx --result-file='/var/www/html/iTopLegacy/data/tmp-backup-1024104636/itop-dump.sql' 'gabuzomeuuninstall'
LOG;
file_put_contents($sPath, $sContent);
$this->assertEquals(null, \CronService::GetInstance()->GetErrorMessage($sPath));
$this->assertEquals(false, \CronService::GetInstance()->IsFailed($sPath));
}
public function testCronStopped()
{
$sPath = $this->GetTemporaryFilePath();
file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_stopped.log'));
$this->assertEquals(null, \CronService::GetInstance()->GetErrorMessage($sPath));
$this->assertEquals(false, \CronService::GetInstance()->IsFailed($sPath));
}
}

View File

@@ -184,6 +184,92 @@ class CronTest extends ItopDataTestCase
$this->assertEquals(static::$sLogin, $sUserLogin);
}
public function testGetCronStatus_FailWhenNotAdmin()
{
$this->AddLoginModeAndSaveConfiguration('url');
$this->CreateUserWithProfiles([self::$aURP_Profiles['REST Services User']]);
$sLogFileName = "crontest_".uniqid().'.log';
$aPostFields = [
'version' => '1.3',
'verbose' => 1,
'debug' => 1,
];
$aGetFields = [
'auth_user' => static::$sLogin,
'auth_pwd' => static::$sPassword,
];
$sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields);
$this->assertEquals('{"message":"Access restricted to administrators"}', $sJSONResult);
}
public function testGetCronStatus_FailWhenLogFileDoesNotExist()
{
$this->AddLoginModeAndSaveConfiguration('url');
$this->CreateUserWithProfiles([self::$aURP_Profiles['Administrator']]);
$aPostFields = [
'version' => '1.3',
'verbose' => 1,
'debug' => 1,
'cron_log_file' => 'gabuzomeu.log',
];
$aGetFields = [
'auth_user' => static::$sLogin,
'auth_pwd' => static::$sPassword,
];
$sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields);
$this->assertEquals('{"message":"Cannot read log file"}', $sJSONResult);
}
public static function GetCronStatusProvider()
{
return [
["cron_alreadyrunning.log", 'Already running...', "error"],
["cron_dummyerror.log", 'Already running...', "error"],
['cron_maintenance.log', 'A maintenance is ongoing', "error"],
['cron_missingcreds_error.log', 'A maintenance is ongoing', "error"],
['cron_notanadmin.log', 'Access restricted to administrators', "error"],
['cron_starting.log', '', "running"],
['cron_stopped.log', '', "stopped"],
];
}
/**
* @dataProvider GetCronStatusProvider
*/
public function testGetCronStatus($sLogFilename, $expectedMsg, $expectedStatus)
{
$sLogFile = APPROOT."log/$sLogFilename";
file_put_contents($sLogFile, file_get_contents(__DIR__.'/resources/'.$sLogFilename));
$this->aFileToClean[] = $sLogFile;
$this->AddLoginModeAndSaveConfiguration('url');
$this->CreateUserWithProfiles([self::$aURP_Profiles['Administrator']]);
$aPostFields = [
'version' => '1.3',
'verbose' => 1,
'debug' => 1,
'cron_log_file' => $sLogFile,
];
$aGetFields = [
'auth_user' => static::$sLogin,
'auth_pwd' => static::$sPassword,
];
$sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields);
$this->assertEquals('{"message":"Cannot read log file"}', $sJSONResult);
}
private function CreateUserWithProfiles(array $aProfileIds): ?string
{
if (count($aProfileIds) > 0) {

View File

@@ -0,0 +1,5 @@
..
..
..
..
Already running...

View File

@@ -0,0 +1,5 @@
..
..
..
..
Error: GABUZOMEU

View File

@@ -0,0 +1,5 @@
..
..
..
..
A maintenance is ongoing

View File

@@ -0,0 +1,12 @@
..
..
..
..
..
..
php /var/www/html/iTop/webservices/cron.php
ERROR: Missing argument 'auth_user'
USAGE:
php cron.php --auth_user=<login> --auth_pwd=<password> [--param_file=<file>] [--verbose=1] [--debug=1] [--status_only=1]

View File

@@ -0,0 +1,5 @@
..
..
..
..
Access restricted to administrators

View File

@@ -0,0 +1,5 @@
..
..
..
..
Starting: 1772551316 (2026-03-03 16:21:56)

View File

@@ -0,0 +1,5 @@
..
..
..
..
Exiting: 1772551316 (2026-03-03 16:21:56)

100
webservices/CronService.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
class CronService
{
public const HELP_MESSAGE = "php cron.php --auth_user=<login> --auth_pwd=<password> [--param_file=<file>] [--verbose=1] [--debug=1] [--status_only=1]";
private static CronService $oInstance;
//used for test only
private ?iTopMutex $oMutex = null;
protected function __construct()
{
}
final public static function GetInstance(): CronService
{
if (!isset(self::$oInstance)) {
self::$oInstance = new CronService();
}
return self::$oInstance;
}
final public static function SetInstance(?CronService $oInstance): void
{
self::$oInstance = $oInstance;
}
private function GetMutex(): iTopMutex
{
if (! is_null($this->oMutex)) {
return $this->oMutex;
}
return new iTopMutex('cron');
}
private function IsMutexLocked(): bool
{
return $this->GetMutex()->IsLocked();
}
public function IsStarted(string $sErrorFile): bool
{
$aLines = utils::ReadTail($sErrorFile);
$sLine = array_shift($aLines);
return (preg_match('/Starting: (.*)$/', $sLine));
}
public function IsStopped(string $sErrorFile): bool
{
$aLines = utils::ReadTail($sErrorFile);
$sLine = array_shift($aLines);
return (preg_match('/Exiting: (.*)$/', $sLine));
}
public function IsFailed(string $sLogFile): bool
{
return ! is_null($this->GetErrorMessage($sLogFile));
}
public function GetErrorMessage(string $sLogFile): ?string
{
$aLines = utils::ReadTail($sLogFile);
$sLine = array_shift($aLines);
if (preg_match('/ERROR: (.*)$/', $sLine, $aMatches)) {
return $aMatches[1];
}
if (preg_match('/Exiting: (.*)$/', $sLine, $aMatches)) {
//stopped
return null;
}
$aStoppedMessages = [
'Already running',
'Access restricted to administrators',
'A maintenance is ongoing',
'Maintenance detected',
'iTop is not yet installed',
];
foreach ($aStoppedMessages as $sMsg) {
if (preg_match("/$sMsg/", $sLine, $aMatches)) {
return $sLine;
}
}
if (false !== strpos($sLine, self::HELP_MESSAGE)) {
$aLines = utils::ReadTail($sLogFile, 5);
foreach ($aLines as $sLine) {
if (preg_match('/ERROR: (.*)$/', $sLine, $aMatches)) {
return $aMatches[1];
}
}
}
return null;
}
}

View File

@@ -6,6 +6,7 @@ require_once(__DIR__.'/../approot.inc.php');
require_once(APPROOT.'/application/application.inc.php');
require_once(APPROOT.'/application/loginwebpage.class.inc.php');
require_once(APPROOT.'/application/startup.inc.php');
require_once(__DIR__.'/CronService.php');
function GetCliCommand(string $sPHPExec, string $sLogFile, array $aCronValues): string
{
@@ -13,20 +14,6 @@ function GetCliCommand(string $sPHPExec, string $sLogFile, array $aCronValues):
return sprintf("$sPHPExec %s/cron.php $sCliParams 2>&1 >>$sLogFile &", __DIR__);
}
function IsErrorLine(string $sLine): bool
{
if (preg_match('/^Access wrong credentials/', $sLine)) {
return true;
}
if (preg_match('/^Access restricted/', $sLine)) {
return true;
}
return false;
}
function ReadParam($sParam, $sDefaultValue = null, $sSanitizationFilter = utils::ENUM_SANITIZATION_FILTER_RAW_DATA)
{
$sValue = utils::ReadParam($sParam, null, true, $sSanitizationFilter);
@@ -37,15 +24,9 @@ function ReadParam($sParam, $sDefaultValue = null, $sSanitizationFilter = utils:
return trim($sValue);
}
function IsCronStartingLine(string $sLine): bool
function LoginAndGetTokenInfo(): array
{
return preg_match('/^Starting: /', $sLine);
}
try {
$oCtx = new ContextTag(ContextTag::TAG_CRON);
\IssueLog::Enable(APPROOT.'log/error.log');
//handle authentication
LoginWebPage::ResetSession();
$iRet = LoginWebPage::DoLogin(false, false, LoginWebPage::EXIT_RETURN);
if ($iRet != LoginWebPage::EXIT_CODE_OK) {
@@ -59,50 +40,61 @@ try {
throw new \Exception("cannot call cron asynchronously via current login mode $sCurrentLoginMode");
}
/** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */
$aTokenInfo = $oLoginFSMExtensionInstance->GetTokenInfo();
return [$sCurrentLoginMode, base64_encode(json_encode($aTokenInfo))];
}
$sCliForLogs = null;
try {
$oCtx = new ContextTag(ContextTag::TAG_CRON);
list($sCurrentLoginMode, $sTokenInfo) = LoginAndGetTokenInfo();
$sLogFilename = ReadParam("cron_log_file", "cron.log");
$sLogFile = APPROOT."log/$sLogFilename";
if (! touch($sLogFile)) {
throw new \Exception("Cannot touch $sLogFile");
}
\IssueLog::Enable($sLogFile);
$aCronValues = [];
foreach ([ 'verbose', 'debug'] as $sParam) {
$value = ReadParam($sParam, false);
$aCronValues[] = "--$sParam=".escapeshellarg($value);
}
/** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */
$aTokenInfo = $oLoginFSMExtensionInstance->GetTokenInfo();
$sTokenInfo = base64_encode(json_encode($aTokenInfo));
$aCronValues[] = "--login_mode=".escapeshellarg($sCurrentLoginMode);
$sLogFilename = ReadParam("cron_log_file", "cron.log");
$sLogFile = APPROOT."log/$sLogFilename";
if (! touch($sLogFile)) {
throw new \Exception("Cannot touch $sLogFile");
}
$sPHPExec = trim(\MetaModel::GetConfig()->Get('php_path'));
$aCronValues[] = "--auth_info=".escapeshellarg('XXXX');
$sCliForLogs = GetCliCommand($sPHPExec, $sLogFile, $aCronValues).PHP_EOL;
\IssueLog::Info("launch cron asynchronously/remotely", null, ['cli' => $sCliForLogs]);
\IssueLog::Info("Launch cron asynchronously", null, ['cli' => $sCliForLogs]);
$aCronValues[] = "--auth_info=".escapeshellarg($sTokenInfo);
$sCli = GetCliCommand($sPHPExec, $sLogFile, $aCronValues);
$process = popen($sCli, 'r');
// $process = proc_open($sCli, 'r');
if (false === $process) {
throw new \Exception("CLI execution issue");
}
/*$aStatus = proc_get_status($process);
if ($aStatus['running']) {
var_dump($aStatus);
throw new \Exception(var_export($aStatus));
}*/
$i = 0;
$bCronStartedOrFailed = true;
while ($i < 20) {
usleep(100000);
while ($aLines = utils::ReadTail($sLogFile)) {
$sLastLine = array_shift($aLines);
if (IsErrorLine($sLastLine) || IsCronStartingLine($sLastLine)) {
//return answer once we are sure cron is starting or did not pass authentication
if (CronService::GetInstance()->IsStarted($sLogFile) || CronService::GetInstance()->IsFailed($sLogFile)) {
//make sure cron is really started or failed before answering by HTTP
$bCronStartedOrFailed = false;
break;
}
usleep(100);
$i++;
}
if ($bCronStartedOrFailed) {
throw new \Exception("Cron execution timeout");
}
http_response_code(200);
@@ -112,12 +104,12 @@ try {
$oP->SetOutputDataOnly(true);
$oP->Output();
} catch (\Exception $e) {
\IssueLog::Error('Cannot run cron', null, ['msg' => $e->getMessage(), 'stack' => $e->getTraceAsString()]);
\IssueLog::Error('Cannot run cron', null, ['msg' => $e->getMessage(), 'stack' => $e->getTraceAsString(), 'cli' => $sCliForLogs ?? '', ]);
http_response_code(500);
$oP = new JsonPage();
$oP->add_header('Access-Control-Allow-Origin: *');
$oP->SetData(["message" => $e->getMessage(), 'cli' => $sCli ?? '', 'msg' => $e->getMessage(), 'stack' => $e->getTraceAsString()]);
$oP->SetData(['msg' => $e->getMessage()]);
$oP->SetOutputDataOnly(true);
$oP->Output();
}

View File

@@ -24,9 +24,11 @@ use Combodo\iTop\Application\WebPage\WebPage;
use Combodo\iTop\Service\InterfaceDiscovery\InterfaceDiscovery;
require_once(__DIR__.'/../approot.inc.php');
require_once(__DIR__.'/CronService.php');
const EXIT_CODE_ERROR = -1;
const EXIT_CODE_FATAL = -2;
// early exit
if (file_exists(READONLY_MODE_FILE)) {
echo "iTop is read-only. Exiting...\n";
@@ -37,7 +39,6 @@ require_once(APPROOT.'/application/application.inc.php');
require_once(APPROOT.'/core/background.inc.php');
IssueLog::Enable(APPROOT.'log/error.log');
$sConfigFile = APPCONF.ITOP_DEFAULT_ENV.'/'.ITOP_CONFIG_FILE;
if (!file_exists($sConfigFile)) {
echo "iTop is not yet installed. Exiting...\n";
@@ -65,7 +66,7 @@ function UsageAndExit($oP)
if ($bModeCLI) {
$oP->p("USAGE:\n");
$oP->p("php cron.php --auth_user=<login> --auth_pwd=<password> [--param_file=<file>] [--verbose=1] [--debug=1] [--status_only=1]\n");
$oP->p(\CronService::HELP_MESSAGE."\n");
} else {
$oP->p("Optional parameters: verbose, param_file, status_only\n");
}
@@ -461,7 +462,6 @@ if ($bIsModeCLI) {
try {
$bVerbose = utils::ReadParam('verbose', false, true /* Allow CLI */);
$bDebug = utils::ReadParam('debug', false, true /* Allow CLI */);
if ($bIsModeCLI) {
utils::UseParamFile();
@@ -485,6 +485,7 @@ try {
if ($oLoginFSMExtensionInstance instanceof iTokenLoginUIExtension) {
$aTokenInfo = json_decode(base64_decode($sTokenInfo), true);
IssueLog::Error("TTTTTTT $sLoginMode TTTTTTTTTTTTTt");
/** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */
$sAuthUser = $oLoginFSMExtensionInstance->GetUserLogin($aTokenInfo);
UserRights::Login($sAuthUser); // Login & set the user's language
@@ -539,7 +540,9 @@ try {
}
} finally {
try {
$oMutex->Unlock();
if (isset($oMutex)) {
$oMutex->Unlock();
}
} catch (Exception $e) {
$oP->p("ERROR: '".$e->getMessage()."'");
if ($bDebug) {

View File

@@ -3,8 +3,8 @@
require_once(__DIR__.'/../approot.inc.php');
require_once(APPROOT.'/application/application.inc.php');
require_once(APPROOT.'/application/startup.inc.php');
require_once(__DIR__.'/CronService.php');
const ERROR_ALREADY_RUNNING = "error_already_running";
const RUNNING = "running";
const STOPPED = "stopped";
const ERROR = "error";
@@ -17,25 +17,28 @@ try {
throw new Exception("Unknown authentication error (retCode=$iRet)", RestResult::UNAUTHORIZED);
}
if (!UserRights::IsAdministrator()) {
throw new Exception("Access restricted to administrators");
}
$sLogFilename = ReadParam("cron_log_file", "cron.log");
$sStatus = RUNNING;
$sMsg = "";
$sLogFile = APPROOT."log/$sLogFilename";
if (! is_file($sLogFile)) {
throw new \Exception("Cannot read log file");
}
$aLines = utils::ReadTail($sLogFile, 2);
$sLastLine = $aLines[1] ?? '';
if (0 === strpos($sLastLine, 'Exiting: ')) {
$sContent = $aLines[0];
if (false !== strpos($sContent, 'Already running')) {
$sStatus = ERROR_ALREADY_RUNNING;
} elseif (preg_match('/ERROR: (.*)\\n/', $sContent, $aMatches)) {
$sMsg = $aMatches[1];
$sStatus = ERROR;
$sMsg = "";
if (CronService::GetInstance()->IsStopped($sLogFile)) {
$sStatus = STOPPED;
} else {
$sErrorMsg = CronService::GetInstance()->GetErrorMessage($sLogFile);
if (is_null($sErrorMsg)) {
$sStatus = RUNNING;
} else {
$sMsg = "$sContent";
$sStatus = STOPPED;
$sMsg = $sErrorMsg;
$sStatus = ERROR;
}
$sStatus = is_null($sMsg) ? RUNNING : ERROR;
}
http_response_code(200);