Files
iTop/synchro/synchro_import.php
Pierre Goiffon b7136c0b7a N°2214 Add PHP check in CLI scripts
It is quite common that the PHP interpreter that is launched in CLI is different that the one used by the webserver. So iTop code launched by CLI could run in a context that doesn't meet iTop requirements !

This adds in the following scripts the same control that is done on the setup wizard first step :
* cron.php
* backup, check-backup
* export, exportv2
* bulk import
* synchro-exec, synchro-import

If the check throws at least one error then the script is stopped with an appropriate message, and a log is made (IssueLog, Error level, CLI channel)

(cherry picked from commit c768e18e2b : no risk taken for 2.7.1, so cherry picked for 2.8.0)
2020-06-15 15:20:17 +02:00

798 lines
22 KiB
PHP

<?php
/**
* Copyright (C) 2013-2019 Combodo SARL
*
* 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
*/
//
// Known limitations
// - reconciliation is made on the column primary_key
//
if (!defined('__DIR__'))
{
/** @noinspection DirectoryConstantCanBeUsedInspection */
define('__DIR__', dirname(__FILE__));
}
require_once __DIR__.'/../approot.inc.php';
require_once APPROOT.'/application/application.inc.php';
require_once APPROOT.'/application/webpage.class.inc.php';
require_once APPROOT.'/application/csvpage.class.inc.php';
require_once APPROOT.'/application/clipage.class.inc.php';
require_once APPROOT.'/application/startup.inc.php';
class ExchangeException extends Exception
{
}
$aPageParams = array
(
'auth_user' => array
(
'mandatory' => true,
'modes' => 'cli',
'default' => null,
'description' => 'login (must have enough rights to create objects of the given class)',
),
'auth_pwd' => array
(
'mandatory' => true,
'modes' => 'cli',
'default' => null,
'description' => 'password',
),
'data_source_id' => array
(
'mandatory' => true,
'modes' => 'http,cli',
'default' => null,
'description' => 'Synchro data source id',
),
'csvdata' => array
(
'mandatory' => true,
'modes' => 'http',
'default' => null,
'description' => 'data',
),
'csvfile' => array
(
'mandatory' => true,
'modes' => 'cli',
'default' => '',
'description' => 'local data file, replaces csvdata if specified',
),
'synchronize' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => '1',
'description' => 'If set to 1, then the synchronization will be executed right after the data load',
),
'charset' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => 'UTF-8',
'description' => 'Character set encoding of the CSV data: UTF-8, ISO-8859-1, WINDOWS-1251, WINDOWS-1252, ISO-8859-15',
),
'date_format' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => 'Y-m-d H:i:s',
'description' => 'Input date format (used both for dates and datetimes) - Examples: Y-m-d, d/m/Y (Europe) - no transformation is applied if the argument is omitted. (note: old format specification using %Y %m %d is also supported for backward compatibility)',
),
'separator' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => ';',
'description' => 'column separator in CSV data (1 char, or \'tab\')',
),
'qualifier' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => '"',
'description' => 'test qualifier in CSV data',
),
'output' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => 'summary',
'description' => '[retcode] to return the count of lines in error, [summary] to return a concise report, [details] to get a detailed report (each line listed)',
),
'max_chunk_size' => array
(
'mandatory' => false,
'modes' => 'cli',
'default' => '0',
'description' => 'Limit on the count of records that can be loaded at once while performing the synchronization',
),
/*
'reportlevel' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => 'errors|warnings|created|changed|unchanged',
'description' => 'combination of flags to limit the detailed output',
),
*/
'simulate' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => '0',
'description' => 'If set to 1, then the load will not be executed, but the expected report will be produced',
),
'comment' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => '',
'description' => 'Comment to be added into the change log',
),
'no_stop_on_import_error' => array
(
'mandatory' => false,
'modes' => 'http,cli',
'default' => '0',
'description' => 'Don\'t stop the import in case of SQL import error. By default the import will stop at the first error (and rollback all changes). If this flag is set to 1 the import will continue anyway',
),
);
function UsageAndExit($oP)
{
global $aPageParams;
$bModeCLI = utils::IsModeCLI();
$oP->p("USAGE:\n");
foreach ($aPageParams as $sParam => $aParamData)
{
$aModes = explode(',', $aParamData['modes']);
if ($bModeCLI)
{
if (in_array('cli', $aModes, false))
{
$sDesc = $aParamData['description'].', '.($aParamData['mandatory'] ? 'mandatory' : 'optional, defaults to ['.$aParamData['default'].']');
$oP->p("$sParam = $sDesc");
}
}
else
{
if (in_array('http', $aModes, false))
{
$sDesc = $aParamData['description'].', '.($aParamData['mandatory'] ? 'mandatory' : 'optional, defaults to ['.$aParamData['default'].']');
$oP->p("$sParam = $sDesc");
}
}
}
$oP->output();
exit;
}
function ReadParam($oP, $sParam, $sSanitizationFilter = 'parameter')
{
global $aPageParams;
assert(isset($aPageParams[$sParam]));
assert(!$aPageParams[$sParam]['mandatory']);
$sValue = utils::ReadParam($sParam, $aPageParams[$sParam]['default'], true /* Allow CLI */, $sSanitizationFilter);
return trim($sValue);
}
function ReadMandatoryParam($oP, $sParam, $sSanitizationFilter)
{
global $aPageParams;
assert(isset($aPageParams[$sParam]));
assert($aPageParams[$sParam]['mandatory']);
$sValue = utils::ReadParam($sParam, null, true /* Allow CLI */, $sSanitizationFilter);
if ($sValue === null)
{
$oP->p("ERROR: Missing argument '$sParam'\n");
UsageAndExit($oP);
}
return trim($sValue);
}
function ChangeDateFormat($sProposedDate, $sFormat, $bDateOnly)
{
if ($sProposedDate === '')
{
// An empty string means 'reset'
return '';
}
// Convert to a valid MySQL datetime
$oFormat = new DateTimeFormat($sFormat);
$sRegExpr = $oFormat->ToRegExpr('/');
if (!preg_match($sRegExpr, $sProposedDate))
{
return false;
}
$oDate = $oFormat->Parse($sProposedDate);
if ($oDate !== null)
{
$oTargetFormat = $bDateOnly ? AttributeDate::GetInternalFormat() : AttributeDateTime::GetInternalFormat();
$sDate = $oDate->format($oTargetFormat);
return $sDate;
}
return false;
}
class CLILikeWebPage extends WebPage
{
public function add_comment($sText)
{
$this->add('#'.$sText."<br/>\n");
}
}
/////////////////////////////////
// Main program
if (utils::IsModeCLI())
{
$oP = new CLIPage(Dict::S('TitleSynchroExecution'));
SetupUtils::CheckPhpAndExtensionsForCli($oP, -2);
}
else
{
$oP = new CLILikeWebPage(Dict::S('TitleSynchroExecution'));
}
try
{
utils::UseParamFile();
}
catch (Exception $e)
{
$oP->p("Error: ".$e->GetMessage());
$oP->output();
exit -2;
}
if (utils::IsModeCLI())
{
// Next steps:
// specific arguments: 'csvfile'
//
$sAuthUser = ReadMandatoryParam($oP, 'auth_user', 'raw_data');
$sAuthPwd = ReadMandatoryParam($oP, 'auth_pwd', 'raw_data');
$sCsvFile = ReadMandatoryParam($oP, 'csvfile', 'raw_data');
if (UserRights::CheckCredentials($sAuthUser, $sAuthPwd))
{
UserRights::Login($sAuthUser); // Login & set the user's language
}
else
{
$oP->p("Access restricted or wrong credentials ('$sAuthUser')");
$oP->output();
exit -1;
}
if (!is_readable($sCsvFile))
{
$oP->p("Input file could not be found or could not be read: '$sCsvFile'");
$oP->output();
exit -1;
}
$sCSVData = file_get_contents($sCsvFile);
}
else
{
require_once APPROOT.'/application/loginwebpage.class.inc.php';
LoginWebPage::DoLogin(); // Check user rights and prompt if needed
$sCSVData = utils::ReadPostedParam('csvdata', '', 'raw_data');
}
try
{
//////////////////////////////////////////////////
//
// Read parameters
//
$iDataSourceId = ReadMandatoryParam($oP, 'data_source_id', 'raw_data');
$sSynchronize = ReadParam($oP, 'synchronize');
$sSep = ReadParam($oP, 'separator', 'raw_data');
$sQualifier = ReadParam($oP, 'qualifier', 'raw_data');
$sCharSet = ReadParam($oP, 'charset', 'raw_data');
$sDateTimeFormat = ReadParam($oP, 'date_format', 'raw_data');
if ($sDateTimeFormat === '')
{
$sDateTimeFormat = 'Y-m-d H:i:s'; // By default use the SQL date & time format
}
if (strpos($sDateTimeFormat, '%') !== false)
{
$sDateTimeFormat = utils::DateTimeFormatToPHP($sDateTimeFormat);
}
$oDateTimeFormat = new DateTimeFormat($sDateTimeFormat);
$sDateFormat = $oDateTimeFormat->ToDateFormat(); // Keep only the date part
$sOutput = ReadParam($oP, 'output');
// $sReportLevel = ReadParam($oP, 'reportlevel');
$sSimulate = ReadParam($oP, 'simulate');
$sComment = ReadParam($oP, 'comment', 'raw_data');
$sNoStopOnImportError = ReadParam($oP, 'no_stop_on_import_error');
if (strtolower(trim($sSep)) === 'tab')
{
$sSep = "\t";
}
// Saving script launch datetime : if we're doing both import AND exec phases (--synchronize=1 parameter)
// then exec phase will need this !
$oLoadStartDate = SynchroExecution::GetDataBaseCurrentDateTime();
// Note about date formatting: These MySQL settings are read-only... and in fact unused :-(
// SET SESSION date_format = '%d/%m/%Y';
// SET SESSION datetime_format = '%d/%m/%Y %H:%i:%s';
// Therefore, we have to allow users to transform the format according to a given specification: date_format
//////////////////////////////////////////////////
//
// Statistics
//
$iCountErrors = 0;
$iCountCreations = 0;
$iCountUpdates = 0;
//////////////////////////////////////////////////
//
// Check parameters format/consistency
//
if (strlen($sCSVData) === 0)
{
throw new ExchangeException('Missing data - at least one line is expected');
}
/** @var \SynchroDataSource $oDataSource */
$oDataSource = MetaModel::GetObject('SynchroDataSource', $iDataSourceId, false);
if ($oDataSource === null)
{
throw new ExchangeException("Unknown data source id: '$iDataSourceId'");
}
$sClass = $oDataSource->GetTargetClass();
if (strlen($sSep) > 1)
{
throw new ExchangeException("Separator is limited to one character, found '$sSep'");
}
if (strlen($sQualifier) > 1)
{
throw new ExchangeException("Text qualifier is limited to one character, found '$sQualifier'");
}
if (!in_array($sOutput, array('retcode', 'summary', 'details')))
{
throw new ExchangeException("Unknown output format: '$sOutput'");
}
/*
$aReportLevels = explode('|', $sReportLevel);
foreach($aReportLevels as $sLevel)
{
if (!in_array($sLevel, explode('|', 'errors|warnings|created|changed|unchanged')))
{
throw new ExchangeException("Unknown level in reporting level: '$sLevel'");
}
}
*/
if ($sSimulate === '1')
{
$bSimulate = true;
}
else
{
$bSimulate = false;
}
if ($sSynchronize === '1')
{
$bSynchronize = true;
}
else
{
$bSynchronize = false;
}
//////////////////////////////////////////////////
//
// Parse first line, check attributes, analyse the request
//
if ($sCharSet === 'UTF-8')
{
$sUTF8Data = $sCSVData;
}
else
{
$sUTF8Data = iconv($sCharSet, 'UTF-8//IGNORE//TRANSLIT', $sCSVData);
}
$oCSVParser = new CSVParser($sUTF8Data, $sSep, $sQualifier, MetaModel::GetConfig()->Get('max_execution_time_per_loop'));
$aInputColumns = $oCSVParser->ListFields();
$iColCount = count($aInputColumns);
// Check columns
$aColumns = $oDataSource->GetSQLColumns();
$aDateColumns = array();
foreach (MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef)
{
if ($oAttDef instanceof AttributeDate)
{
$aDateColumns[$sAttCode] = 'DATE';
}
elseif ($oAttDef instanceof AttributeDateTime)
{
$aDateColumns[$sAttCode] = 'DATETIME';
}
}
$aIsDateToTransform = array();
$aDateToTransformReport = array();
foreach ($aInputColumns as $iFieldId => $sInputColumn)
{
if (array_key_exists($sInputColumn, $aDateColumns))
{
$aIsDateToTransform[$iFieldId] = $aDateColumns[$sInputColumn]; // either DATE or DATETIME
$aDateToTransformReport[] = $sInputColumn;
}
else
{
$aIsDateToTransform[$iFieldId] = false;
}
if ($sInputColumn === 'primary_key')
{
$iPrimaryKeyCol = $iFieldId;
continue;
}
if (!array_key_exists($sInputColumn, $aColumns))
{
throw new ExchangeException("Unknown column '$sInputColumn' (class: '$sClass')");
}
}
if (!isset($iPrimaryKeyCol))
{
throw new ExchangeException("Missing reconciliation column 'primary_key'");
}
//////////////////////////////////////////////////
//
// Go for parsing and interpretation
//
try
{
if ($sOutput === 'details')
{
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment(' Import phase');
$oP->add_comment('------------------------------------------------------------');
}
if ($bSimulate)
{
CMDBSource::Query('START TRANSACTION');
}
$aData = $oCSVParser->ToArray();
$iLineCount = count($aData);
$sTable = $oDataSource->GetDataTable();
// Prepare insert columns
$sInsertColumns = '`'.implode('`, `', $aInputColumns).'`';
$iPreviousTimeLimit = ini_get('max_execution_time');
$iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop');
$oMutex = new iTopMutex('synchro_import_'.$oDataSource->GetKey());
$oMutex->Lock();
set_time_limit($iLoopTimeLimit);
foreach ($aData as $iRow => $aRow)
{
$sReconciliationCondition = '`primary_key` = '.CMDBSource::Quote($aRow[$iPrimaryKeyCol]);
$sSelect = "SELECT COUNT(*) FROM `$sTable` WHERE $sReconciliationCondition";
$aRes = CMDBSource::QueryToArray($sSelect);
$iCount = (int)$aRes[0]['COUNT(*)'];
if ($iCount === 0)
{
// No record... create it
//
$iCountCreations++;
if ($sOutput === 'details')
{
$oP->add("$iRow: New entry, reconciliation: '$sReconciliationCondition'\n");
}
$aValues = array(); // Used to build the insert query
foreach ($aRow as $iCol => $value)
{
if ($value === null)
{
$aValues[] = 'NULL';
}
elseif ($aIsDateToTransform[$iCol] !== false)
{
$bDateOnly = false;
$sFormat = $sDateTimeFormat;
if ($aIsDateToTransform[$iCol] === 'DATE')
{
$bDateOnly = true;
$sFormat = $sDateFormat;
}
$sDate = ChangeDateFormat($value, $sFormat, $bDateOnly);
if ($sDate === false)
{
$aValues[] = CMDBSource::Quote('');
if ($sOutput === 'details')
{
$oP->add("$iRow: Wrong format for {$aIsDateToTransform[$iCol]} column $iCol: '$value' does not match the expected format: '$sFormat' (column skipped)\n");
}
}
else
{
$aValues[] = CMDBSource::Quote($sDate);
}
}
else
{
$aValues[] = CMDBSource::Quote($value);
}
}
$sValues = implode(', ', $aValues);
$sInsert = "INSERT INTO `$sTable` ($sInsertColumns) VALUES ($sValues)";
try
{
CMDBSource::Query($sInsert);
}
catch (Exception $e)
{
if ($sNoStopOnImportError === '1')
{
$iCountErrors++;
$oP->add("$iRow: Import error '".$e->getMessage()."' (continuing)...\n");
}
else // Fatal error
{
throw $e;
}
}
}
elseif ($iCount === 1)
{
// Found a match... update it
//
$iCountUpdates++;
if ($sOutput === 'details')
{
$oP->add("$iRow: Update entry, reconciliation: '$sReconciliationCondition'\n");
}
$aValuePairs = array(); // Used to build the update query
foreach ($aRow as $iCol => $value)
{
// Skip reconciliation column
if ($iCol === $iPrimaryKeyCol)
{
continue;
}
$sCol = $aInputColumns[$iCol];
if ($aIsDateToTransform[$iCol] !== false)
{
$bDateOnly = false;
$sFormat = $sDateTimeFormat;
if ($aIsDateToTransform[$iCol] === 'DATE')
{
$bDateOnly = true;
$sFormat = $sDateFormat;
}
$sDate = ChangeDateFormat($value, $sFormat, $bDateOnly);
if ($sDate === false)
{
if ($sOutput === 'details')
{
$oP->add("$iRow: Wrong format for {$aIsDateToTransform[$iCol]} column $iCol: '$value' does not match the expected format: '$sFormat' (column skipped)\n");
}
}
else
{
$aValuePairs[] = "`$sCol` = ".CMDBSource::Quote($sDate);
}
}
else
{
$aValuePairs[] = "`$sCol` = ".CMDBSource::Quote($aRow[$iCol]);
}
}
$sValuePairs = implode(', ', $aValuePairs);
$sUpdateQuery = "UPDATE `$sTable` SET $sValuePairs WHERE $sReconciliationCondition";
try
{
CMDBSource::Query($sUpdateQuery);
}
catch (Exception $e)
{
if ($sNoStopOnImportError === '1')
{
$iCountErrors++;
$oP->add("$iRow: Import error '".$e->getMessage()."' (continuing)...\n");
}
else // Fatal error
{
throw $e;
}
}
}
else
{
// Too many records... ambiguity
//
$iCountErrors++;
$oP->add("$iRow: Error - Failed to reconcile, found $iCount rows having '$sReconciliationCondition'\n");
}
}
$oMutex->Unlock();
set_time_limit($iPreviousTimeLimit);
if (($sOutput === 'summary') || ($sOutput === 'details'))
{
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment(' Import phase summary');
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment('Data Source: '.$iDataSourceId);
$oP->add_comment('Synchronize: '.($bSynchronize ? '1' : '0'));
$oP->add_comment('Class: '.$sClass);
$oP->add_comment('Separator: '.$sSep);
$oP->add_comment('Qualifier: '.$sQualifier);
$oP->add_comment('Charset Encoding:'.$sCharSet);
if (strlen($sDateTimeFormat) > 0)
{
$aDateTimeColumns = array();
$aDateColumns = array();
foreach ($aIsDateToTransform as $iCol => $sType)
{
if ($sType !== false)
{
$sCol = $aInputColumns[$iCol];
if ($sType === 'DATE')
{
$aDateColumns[] = $sCol;
}
else
{
$aDateTimeColumns[] = $sCol;
}
}
}
$sFormatedDateTimeColumns = (count($aDateTimeColumns) > 0) ? ', applied to columns ['.implode(', ',
$aDateTimeColumns).']' : '';
$sFormatedDateColumns = (count($aDateColumns) > 0) ? ', applied to columns ['.implode(', ', $aDateColumns).']' : '';
$oP->add_comment("Date and time format: '$sDateTimeFormat' $sFormatedDateTimeColumns");
$oP->add_comment("Date only format: '$sDateFormat' $sFormatedDateColumns");
}
else
{
// shall never get there
$oP->add_comment('Date format: <none>');
}
$oP->add_comment('Data Size: '.strlen($sCSVData));
$oP->add_comment('Data Lines: '.$iLineCount);
$oP->add_comment('Columns: '.implode(', ', $aInputColumns));
$oP->add_comment('Output format: '.$sOutput);
// $oP->add_comment("Report level: ".$sReportLevel);
$oP->add_comment('Simulate: '.($bSimulate ? '1' : '0'));
$oP->add_comment('Change tracking comment: '.$sComment);
$oP->add_comment('Issues (before synchro): '.$iCountErrors);
// $oP->add_comment("Warnings: ".$iCountWarnings);
$oP->add_comment('Created (before synchro): '.$iCountCreations);
$oP->add_comment('Updated (before synchro): '.$iCountUpdates);
}
//////////////////////////////////////////////////
//
// Synchronize
//
if ($bSynchronize)
{
$oSynchroExec = new SynchroExecution($oDataSource, $oLoadStartDate);
$oStatLog = $oSynchroExec->Process();
if ($sOutput === 'details')
{
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment(' Synchronization phase');
$oP->add_comment('------------------------------------------------------------');
$iCount = 0;
foreach ($oStatLog->GetTraces() as $sMessage)
{
$iCount++;
$oP->add_comment($sMessage);
}
if ($iCount === 0)
{
$oP->add_comment(' No traces. (To activate the traces set "synchro_trace" => true in the configuration file)');
}
}
if ($oStatLog->Get('status') === 'error')
{
$oP->p('ERROR: '.$oStatLog->Get('last_error'));
}
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment(' Synchronization phase summary');
$oP->add_comment('------------------------------------------------------------');
$oP->add_comment('Replicas: '.$oStatLog->Get('stats_nb_replica_total'));
$oP->add_comment('Replicas touched since last synchro: '.$oStatLog->Get('stats_nb_replica_seen'));
$oP->add_comment('Objects deleted: '.$oStatLog->Get('stats_nb_obj_deleted'));
$oP->add_comment('Objects deletion errors: '.$oStatLog->Get('stats_nb_obj_deleted_errors'));
$oP->add_comment('Objects obsoleted: '.$oStatLog->Get('stats_nb_obj_obsoleted'));
$oP->add_comment('Objects obsolescence errors: '.$oStatLog->Get('stats_nb_obj_obsoleted_errors'));
$oP->add_comment('Objects created: '.$oStatLog->Get('stats_nb_obj_created').' ('.$oStatLog->Get('stats_nb_obj_created_warnings').' warnings)');
$oP->add_comment('Objects creation errors: '.$oStatLog->Get('stats_nb_obj_created_errors'));
$oP->add_comment('Objects updated: '.$oStatLog->Get('stats_nb_obj_updated').' ('.$oStatLog->Get('stats_nb_obj_updated_warnings')." warnings)");
$oP->add_comment('Objects update errors: '.$oStatLog->Get('stats_nb_obj_updated_errors'));
$oP->add_comment('Objects reconciled (updated): '.$oStatLog->Get('stats_nb_obj_new_updated').' ('.$oStatLog->Get('stats_nb_obj_new_updated_warnings').' warnings)');
$oP->add_comment('Objects reconciled (unchanged): '.$oStatLog->Get('stats_nb_obj_new_unchanged').' ('.$oStatLog->Get('stats_nb_obj_new_updated_warnings').' warnings)');
$oP->add_comment('Objects reconciliation errors: '.$oStatLog->Get('stats_nb_replica_reconciled_errors'));
$oP->add_comment('Replica disappeared, no action taken: '.$oStatLog->Get('stats_nb_replica_disappeared_no_action'));
}
}
catch (Exception $e)
{
if ($bSimulate)
{
CMDBSource::Query('ROLLBACK');
}
throw $e;
}
if ($bSimulate)
{
CMDBSource::Query('ROLLBACK');
}
//////////////////////////////////////////////////
//
// Summary of settings and results
//
if ($sOutput === 'retcode')
{
$oP->add($iCountErrors);
}
}
catch (ExchangeException $e)
{
$oP->add_comment($e->getMessage());
}
catch (Exception $e)
{
$oP->add_comment((string)$e);
}
$oP->output();