fix(installer): preserve upgrade state and propagate failures (#8266)

* fix(installer): preserve upgrade state and propagate failures

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs(changelog): record installer recovery fixes for #8266

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(security): refresh installer sink inventories

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): retain merge precedence for browser parameters

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): defer debug settings until the process lock is held

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): enforce table conversion failures and postconditions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(installer): refresh SQL audit baseline after clause refactor

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-10-11 17:08:12 -04:00
1 parent 8ebc7e8a1a
commit 06c32ab13f
15 files changed
+445 -162

No files matched your search

+1
View File
@@ -0,0 +1 @@
Preserve authentication migration data and retryable upgrade versions on failures, propagate CLI and Composer errors, correct table conversion preconditions and engine/row-format changes, preserve requested CLI debug levels, and serialize installer settings changes across CLI and background workers
+14 -8
View File
@@ -110,7 +110,7 @@ if (cacti_sizeof($parms)) {
break;
case '--installer':
$installer = true;
require_once(__DIR__ . '../install/functions.php');
require_once(__DIR__ . '/../install/functions.php');
break;
case '--version':
@@ -146,7 +146,7 @@ if (!($innodb || $utf8 || $latin)) {
print_or_log($installer, 'ERROR: Must select either UTF8, LATIN1 or InnoDB conversion.' . PHP_EOL . PHP_EOL);
display_help();
exit;
exit(1);
}
if (!$local) {
@@ -198,7 +198,7 @@ if ($innodb) {
if (cacti_strtolower($file_per_table['Value']) != 'on') {
print_or_log($installer, 'innodb_file_per_table not enabled');
exit;
exit(1);
}
}
@@ -232,6 +232,7 @@ if (cacti_sizeof($tables)) {
$canInnoDB = true;
} elseif ($table_data['ENGINE'] == 'Aria') {
$canConvert = true;
$canInnoDB = true;
}
}
@@ -259,19 +260,24 @@ if (cacti_sizeof($tables)) {
if ($table_data['TABLE_ROWS'] < $size || $force) {
print_or_log($installer, "Converting Table > '$table'");
$sql = '';
$clauses = [];
if ($dynamic) {
$clauses[] = 'ROW_FORMAT=Dynamic';
}
if ($utf8) {
$sql .= ' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
$clauses[] = 'CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
} elseif ($latin) {
$sql .= ' CONVERT TO CHARACTER SET latin1';
$clauses[] = 'CONVERT TO CHARACTER SET latin1';
}
if ($innodb && $canInnoDB) {
$sql .= (strlen($sql) ? ',' : '') . ' ENGINE=Innodb';
$clauses[] = 'ENGINE=InnoDB';
}
$status = db_execute("ALTER TABLE `$table`" . ($dynamic ? ' ROW_FORMAT=Dynamic, ' : '') . $sql);
$sql = implode(', ', $clauses);
$status = db_execute("ALTER TABLE `$table` $sql");
if ($status === false) {
$conversion_failed = true;
+105 -86
View File
@@ -34,6 +34,7 @@ $options = ['Runtime' => 'Cli'];
$should_install = false;
$force_install = false;
$log_options = [];
display_version();
@@ -73,10 +74,10 @@ if (cacti_sizeof($parms)) {
if ($tmplevel !== false) {
$level = $tmplevel;
} else {
$level = log_install_level($logname, POLLER_VERBOSITY_DEBUG) + 1;
$level = ($log_options[$logname] ?? log_install_level($logname, POLLER_VERBOSITY_DEBUG)) + 1;
}
$level = log_install_level_sanitize($level);
set_config_option($logname, $level);
$level = log_install_level_sanitize($level);
$log_options[$logname] = $level;
break;
case '--version':
@@ -197,98 +198,114 @@ if (cacti_sizeof($parms)) {
}
}
db_execute("DELETE FROM settings WHERE name like 'log_install%' or name = 'install_eula'");
include_once(CACTI_PATH_LIBRARY . '/api_automation.php');
include_once(CACTI_PATH_LIBRARY . '/api_automation_tools.php');
include_once(CACTI_PATH_LIBRARY . '/api_data_source.php');
include_once(CACTI_PATH_LIBRARY . '/api_device.php');
include_once(CACTI_PATH_LIBRARY . '/data_query.php');
include_once(CACTI_PATH_LIBRARY . '/import.php');
include_once(CACTI_PATH_LIBRARY . '/installer.php');
require_once(CACTI_PATH_LIBRARY . '/poller.php');
include_once(CACTI_PATH_LIBRARY . '/utility.php');
$options['Step'] = Installer::STEP_INSTALL_CONFIRM;
if (!register_process_start('install', 'master', 0, 86400)) {
print 'An Installation was already in progress' . PHP_EOL;
$results = ['Step' => $options['Step']];
$update_char = 'o';
debug_install_array('Options', $options);
$installer = new Installer($options);
$results = $installer->jsonSerialize();
debug_install_array('Result', $results);
process_install_errors($results);
$install_mode = 'no';
switch ($installer->getMode()) {
case Installer::MODE_INSTALL:
$install_mode = 'INSTALL CORE';
break;
case Installer::MODE_POLLER:
$install_mode = 'INSTALL POLLER';
break;
case Installer::MODE_UPGRADE:
$install_mode = 'UPGRADE';
break;
case Installer::MODE_DOWNGRADE:
$install_mode = 'DOWNGRADE';
break;
exit(1);
}
log_install_always('cli', 'Installer prepared for ' . $install_mode . ' action');
$message = '';
try {
db_execute("DELETE FROM settings WHERE name like 'log_install%' or name = 'install_eula'");
if ($installer->getStep() == Installer::STEP_INSTALL_CONFIRM && $should_install) {
$time = '';
if ($force_install) {
$time = '-b';
foreach ($log_options as $logname => $level) {
set_config_option($logname, $level);
}
log_install_always('cli', 'Starting installation...');
Installer::beginInstall($time, $installer);
log_install_always('cli', 'Finished installation...');
include_once(CACTI_PATH_LIBRARY . '/api_automation.php');
include_once(CACTI_PATH_LIBRARY . '/api_automation_tools.php');
include_once(CACTI_PATH_LIBRARY . '/api_data_source.php');
include_once(CACTI_PATH_LIBRARY . '/api_device.php');
include_once(CACTI_PATH_LIBRARY . '/data_query.php');
include_once(CACTI_PATH_LIBRARY . '/import.php');
include_once(CACTI_PATH_LIBRARY . '/installer.php');
include_once(CACTI_PATH_LIBRARY . '/utility.php');
$options['Step'] = Installer::STEP_INSTALL_CONFIRM;
$results = ['Step' => $options['Step']];
$update_char = 'o';
debug_install_array('Options', $options);
$installer = new Installer($options);
$results = $installer->jsonSerialize();
debug_install_array('Result', $results);
process_install_errors($results);
$install_mode = 'no';
switch ($installer->getMode()) {
case Installer::MODE_INSTALL:
$install_mode = 'INSTALL CORE';
break;
case Installer::MODE_POLLER:
$install_mode = 'INSTALL POLLER';
break;
case Installer::MODE_UPGRADE:
$install_mode = 'UPGRADE';
break;
case Installer::MODE_DOWNGRADE:
$install_mode = 'DOWNGRADE';
break;
}
log_install_always('cli', 'Installer prepared for ' . $install_mode . ' action');
$message = '';
if ($installer->getStep() == Installer::STEP_INSTALL_CONFIRM && $should_install) {
$time = '';
if ($force_install) {
$time = '-b';
}
log_install_always('cli', 'Starting installation...');
Installer::beginInstall($time, $installer);
log_install_always('cli', 'Finished installation...');
}
$step = $installer->getStep();
$exitCode = 0;
log_install_high('cli','getStep(): ' . $step);
switch ($installer->getStep()) {
case Installer::STEP_INSTALL:
log_install_always('cli', 'An Installation was already in progress');
$exitCode = 1;
break;
case Installer::STEP_INSTALL_CONFIRM:
log_install_always('cli', 'No errors were detected. Install not performed as --install not specified');
break;
case Installer::STEP_ERROR:
log_install_always('cli', 'One or more errors occurred during install, please refer to log files');
process_install_errors(['Errors'=>$installer->getErrors()]);
$exitCode = 1;
break;
case Installer::STEP_COMPLETE:
log_install_always('cli', 'Installation has now completed, you may launch the web console');
break;
default:
log_install_always('cli', 'Unexpected step (' . $installer->getStep() . ')');
$exitCode = 1;
break;
}
print PHP_EOL;
} finally {
unregister_process('install', 'master', 0);
}
$step = $installer->getStep();
$exitCode = 0;
log_install_high('cli','getStep(): ' . $step);
switch ($installer->getStep()) {
case Installer::STEP_INSTALL:
log_install_always('cli', 'An Installation was already in progress');
$exitCode = 1;
break;
case Installer::STEP_INSTALL_CONFIRM:
log_install_always('cli', 'No errors were detected. Install not performed as --install not specified');
break;
case Installer::STEP_ERROR:
log_install_always('cli', 'One or more errors occurred during install, please refer to log files');
process_install_errors(['Errors'=>$installer->getErrors()]);
$exitCode = 1;
break;
case Installer::STEP_COMPLETE:
log_install_always('cli', 'Installation has now completed, you may launch the web console');
break;
default:
log_install_always('cli', 'Unexpected step (' . $installer->getStep() . ')');
$exitCode = 1;
break;
}
print PHP_EOL;
exit($exitCode);
/**
@@ -443,6 +460,8 @@ function process_install_errors(array $results) : void {
print PHP_EOL . 'Unable to continue as ' . $count . ' issue' . ($count == 1 ? '' : 's') . ' in ' . $sections . ' section' . ($sections == 1 ? '' : 's') . ' were found.' . PHP_EOL;
// exit does not execute the caller's finally block.
unregister_process('install', 'master', 0);
exit(1);
}
}
+62 -41
View File
@@ -128,72 +128,93 @@ if (cacti_version_compare($old_cacti_version,CACTI_VERSION,'=')) {
exit_version_error($old_cacti_version, 'Unable to identify version, cannot upgrade.');
}
print 'Upgrading from v' . get_cacti_version_text(false, $old_cacti_version) . PHP_EOL;
if (!register_process_start('install', 'master', 0, 86400)) {
exit_error('An installation or upgrade is already in progress.');
}
$prev_cacti_version = $old_cacti_version;
$orig_cacti_version = get_cacti_db_version();
$upgrade_failed = false;
// loop through versions from old version to the current, performing updates for each version in the chain
foreach ($cacti_version_codes as $cacti_upgrade_version => $hash_code) {
// skip versions old than the database version
if (cacti_version_compare($old_cacti_version, $cacti_upgrade_version, '>=')) {
continue;
}
try {
print 'Upgrading from v' . get_cacti_version_text(false, $old_cacti_version) . PHP_EOL;
// construct version upgrade include path
$upgrade_file = CACTI_PATH_INSTALL . '/upgrades/' . str_replace('.', '_', $cacti_upgrade_version) . '.php';
$upgrade_function = 'upgrade_to_' . str_replace('.', '_', $cacti_upgrade_version);
$prev_cacti_version = $old_cacti_version;
$orig_cacti_version = get_cacti_db_version();
// check for upgrade version file, then include, check for function and execute
if (file_exists($upgrade_file)) {
print 'Performing Database Upgrade' . PHP_EOL;
print ' - from v' . $prev_cacti_version . ' (DB ' . $orig_cacti_version . ')' . PHP_EOL;
print ' to v' . $cacti_upgrade_version . PHP_EOL;
include($upgrade_file);
if (function_exists($upgrade_function)) {
call_user_func($upgrade_function);
$status = db_install_errors($cacti_upgrade_version);
} else {
$status = DB_STATUS_ERROR;
print 'Error: upgrade function (' . $upgrade_function . ') not found' . PHP_EOL;
// loop through versions from old version to the current, performing updates for each version in the chain
foreach ($cacti_version_codes as $cacti_upgrade_version => $hash_code) {
// skip versions old than the database version
if (cacti_version_compare($old_cacti_version, $cacti_upgrade_version, '>=')) {
continue;
}
if ($status == DB_STATUS_ERROR) {
// construct version upgrade include path
$upgrade_file = CACTI_PATH_INSTALL . '/upgrades/' . str_replace('.', '_', $cacti_upgrade_version) . '.php';
$upgrade_function = 'upgrade_to_' . str_replace('.', '_', $cacti_upgrade_version);
// check for upgrade version file, then include, check for function and execute
if (file_exists($upgrade_file)) {
print 'Performing Database Upgrade' . PHP_EOL;
print ' - from v' . $prev_cacti_version . ' (DB ' . $orig_cacti_version . ')' . PHP_EOL;
print ' to v' . $cacti_upgrade_version . PHP_EOL;
include($upgrade_file);
if (function_exists($upgrade_function)) {
call_user_func($upgrade_function);
$status = db_install_errors($cacti_upgrade_version);
} else {
$status = DB_STATUS_ERROR;
print 'Error: upgrade function (' . $upgrade_function . ') not found' . PHP_EOL;
}
if ($status <= DB_STATUS_WARNING) {
$upgrade_failed = true;
break;
}
if (cacti_version_compare($orig_cacti_version, $cacti_upgrade_version, '<')) {
if (!db_execute_prepared('UPDATE version SET cacti = ?', [$cacti_upgrade_version])) {
$upgrade_failed = true;
break;
}
$orig_cacti_version = $cacti_upgrade_version;
}
$prev_cacti_version = $cacti_upgrade_version;
}
if (!db_execute_prepared('UPDATE version SET cacti = ?', [$cacti_upgrade_version])) {
$upgrade_failed = true;
break;
}
if (cacti_version_compare($orig_cacti_version, $cacti_upgrade_version, '<')) {
db_execute_prepared('UPDATE version SET cacti = ?', [$cacti_upgrade_version]);
if (cacti_version_compare(CACTI_VERSION, $cacti_upgrade_version, '=')) {
$upgrade_failed = !db_execute_prepared('UPDATE version SET cacti = ?', [CACTI_VERSION_FULL]);
$orig_cacti_version = $cacti_upgrade_version;
break;
}
$prev_cacti_version = $cacti_upgrade_version;
}
db_execute_prepared('UPDATE version SET cacti = ?', [$cacti_upgrade_version]);
if (cacti_version_compare(CACTI_VERSION, $cacti_upgrade_version, '=')) {
db_execute_prepared('UPDATE version SET cacti = ?', [CACTI_VERSION_FULL]);
break;
}
} finally {
unregister_process('install', 'master', 0);
}
print PHP_EOL;
exit($upgrade_failed ? 1 : 0);
function exit_error(string $text) : void {
print "ERROR: $text" . PHP_EOL;
exit;
exit(1);
}
function exit_version_error(string $old_cacti_version, string $text) : void {
exit_error($text . PHP_EOL . ' - from: v' . get_cacti_version_text(false, $old_cacti_version) . PHP_EOL . ' to: v' . CACTI_VERSION_BRIEF_FULL);
}
function db_install_errors(string $cacti_version) : string {
function db_install_errors(string $cacti_version) : int {
global $database_upgrade_status, $debug, $database_statuses;
$error_status = DB_STATUS_SKIPPED;
+10 -1
View File
@@ -29,6 +29,15 @@ ob_start();
define('IN_CACTI_INSTALL', 1);
chdir(__DIR__ . '/../');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
header('Content-Type: application/json');
print json_encode(['error' => 'Method not allowed']);
exit;
}
// set the json variable for request validation handling
include_once('lib/functions.php');
include_once('lib/html_utility.php');
@@ -62,7 +71,7 @@ if (isrv('data') && gnrv('data')) {
$json_level = log_install_level('json',POLLER_VERBOSITY_NONE);
log_install_high('json','Start: ' . clean_up_lines(json_encode($initialData)));
$initialData = array_merge(['Runtime' => 'Web'], $initialData);
$initialData = array_merge($initialData, ['Runtime' => 'Web']);
if (isset($initialData['step']) && $initialData['step'] == Installer::STEP_TEST_REMOTE) {
$json = install_test_remote_database_connection();
+17 -6
View File
@@ -951,20 +951,31 @@ function login_providers_convert_1_3_0() : void {
return;
}
$domains = db_fetch_assoc('SELECT * FROM user_domains');
$domains = is_array($domains) ? $domains : [];
$result = db_install_fetch_assoc('SELECT * FROM user_domains');
if ($result['status'] !== DB_STATUS_SUCCESS || !is_array($result['data'])) {
return;
}
$domains = $result['data'];
$migration_failed = false;
foreach ($domains as $domain) {
$parameters = [];
if (db_table_exists('user_domains_ldap')) {
$ldap = db_fetch_row_prepared('SELECT *
$result = db_install_fetch_row('SELECT *
FROM user_domains_ldap
WHERE domain_id = ?',
[$domain['domain_id']]);
$ldap = is_array($ldap) ? $ldap : [];
if ($result['status'] !== DB_STATUS_SUCCESS || !is_array($result['data'])) {
$migration_failed = true;
continue;
}
$ldap = $result['data'];
if (cacti_sizeof($ldap)) {
$parameters = [
@@ -1031,9 +1042,9 @@ function login_providers_convert_1_3_0() : void {
return;
}
$migrated_count = db_fetch_cell('SELECT COUNT(*) FROM login_providers');
$result = db_install_fetch_cell('SELECT COUNT(*) FROM login_providers');
if ((int) $migrated_count < cacti_sizeof($domains)) {
if ($result['status'] !== DB_STATUS_SUCCESS || (int) $result['data'] < cacti_sizeof($domains)) {
return;
}
+17 -10
View File
@@ -3666,11 +3666,11 @@ class Installer implements JsonSerializable {
putenv('COMPOSER_HOME=' . sys_get_temp_dir() . '/cacti-composer');
}
$composer = cacti_escapeshellcmd(str_replace('\\', '/', $composer));
$base = cacti_escapeshellarg(CACTI_PATH_BASE);
$check = shell_exec("$composer install --dry-run --no-interaction --working-dir=$base 2>&1");
$command = [$composer, 'install', '--no-interaction', '--working-dir=' . CACTI_PATH_BASE];
$result = $this->runCommand([...$command, '--dry-run']);
$check = implode(PHP_EOL, $result['output']);
if ($check === null) {
if ($result['exitCode'] !== 0) {
log_install_always('', __('WARNING: Composer could not be executed. Run \'composer install\' from the Cacti directory manually and check its output.'));
return;
@@ -3711,13 +3711,13 @@ class Installer implements JsonSerializable {
log_install_always('', __('Refreshing Composer dependencies in include/vendor.'));
$output = shell_exec("$composer install --no-interaction --no-progress --working-dir=$base 2>&1");
$result = $this->runCommand([...$command, '--no-progress']);
if ($output !== null) {
log_install_debug('composer', $output);
if ($result['output'] !== []) {
log_install_debug('composer', implode(PHP_EOL, $result['output']));
}
if (!is_file(CACTI_PATH_INCLUDE . '/vendor/autoload.php')) {
if ($result['exitCode'] !== 0 || !is_file(CACTI_PATH_INCLUDE . '/vendor/autoload.php')) {
log_install_always('', __('WARNING: Composer refresh did not complete. Run \'composer install\' from the Cacti directory manually.'));
} else {
log_install_always('', __('Composer dependencies refreshed.'));
@@ -4237,7 +4237,14 @@ class Installer implements JsonSerializable {
return ['exitCode' => 127, 'output' => [__('Unable to execute the configured PHP binary or CLI script')]];
}
$command = [$phpBinary, '-q', $script, ...$arguments];
return $this->runCommand([$phpBinary, '-q', $script, ...$arguments]);
}
/**
* @param list<string> $command
* @return array{exitCode: int, output: array<int, string>}
*/
private function runCommand(array $command) : array {
$pipes = [];
$errorFile = tmpfile();
@@ -4393,7 +4400,7 @@ class Installer implements JsonSerializable {
$failure = $ver_status;
}
if ($failure == DB_STATUS_ERROR) {
if ($failure <= DB_STATUS_WARNING) {
break;
}
}
@@ -0,0 +1,35 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
*/
require_once CACTI_PATH_INSTALL . '/functions.php';
require_once CACTI_PATH_LIBRARY . '/installer.php';
test('installer commands preserve failed exit status and both output streams', function () {
$installer = (new ReflectionClass(Installer::class))->newInstanceWithoutConstructor();
$result = (new ReflectionMethod(Installer::class, 'runCommand'))->invoke($installer, [
PHP_BINARY,
'-r',
'fwrite(STDOUT, $argv[1] . PHP_EOL); fwrite(STDERR, "download failed\n"); exit(7);',
'literal argument; $(echo unexpected)',
]);
expect($result['exitCode'])->toBe(7)
->and($result['output'])->toContain('literal argument; $(echo unexpected)', 'download failed');
});
test('installer rejects unsupported request methods before loading application state', function () {
$installer = (new ReflectionClass(Installer::class))->newInstanceWithoutConstructor();
$result = (new ReflectionMethod(Installer::class, 'runCommand'))->invoke($installer, [
PHP_BINARY,
'-r',
'$_SERVER["REQUEST_METHOD"] = "GET"; register_shutdown_function(function () { echo " status=" . http_response_code(); }); require $argv[1];',
CACTI_PATH_INSTALL . '/step_json.php',
]);
expect($result['exitCode'])->toBe(0)
->and(implode(PHP_EOL, $result['output']))->toContain('Method not allowed', 'status=405');
});
@@ -119,7 +119,7 @@ test('installer command-line adapters propagate failures and release locks', fun
->and($installer)->toContain("['bypass_shell' => true]")
->and($import)->toContain('if (file_exists($filename) && !unlink($filename)) {')
->and($utility)->toContain('is_file($config_file) && is_readable($config_file)')
->and(substr_count($installer, 'shell_exec('))->toBe(4)
->and(substr_count($installer, 'shell_exec('))->toBe(2)
->and($installer)->not->toContain("cacti_escapeshellarg(CACTI_PATH_CLI . '/add_tree.php')")
->and($installer)->not->toContain("cacti_escapeshellarg(CACTI_PATH_CLI . '/convert_tables.php')");
});
@@ -44,7 +44,7 @@ test('successful installer json responses roll the csrf token forward', function
->and($stepJson)->toContain("header('Cache-Control: no-store')")
->and($stepJson)->toContain("header('X-Content-Type-Options: nosniff')")
->and(strpos($stepJson, "\$response['csrfMagicToken'] = csrf_get_tokens();"))
->toBeLessThan(strpos($stepJson, "header('Content-Type: application/json')"));
->toBeLessThan(strrpos($stepJson, "header('Content-Type: application/json')"));
});
test('installer retries csrf timeouts once for every json action', function () use ($root) {
+144
View File
@@ -0,0 +1,144 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
*/
// This fixture mutates schema and migration files in a disposable Docker copy.
if (getenv('CACTI_INSTALLER_FAILURE_FIXTURE') !== '1') {
fwrite(STDERR, "Run through installer_resilience_docker.sh only.\n");
exit(1);
}
require __DIR__ . '/../../include/cli_check.php';
require_once CACTI_PATH_INSTALL . '/functions.php';
require_once CACTI_PATH_LIBRARY . '/installer.php';
class InstallerFailureStatement extends PDOStatement {
public static string $failQuery = '';
public function execute(?array $params = null) : bool {
$query = implode(' ', preg_split('/\s+/', trim($this->queryString)) ?: []);
if (self::$failQuery !== '' && str_starts_with($query, self::$failQuery)) {
throw new PDOException('Injected installer fixture read failure', 1142);
}
return parent::execute($params);
}
}
function installer_fixture_check(bool $condition, string $message) : void {
if (!$condition) {
throw new RuntimeException($message);
}
}
$scenario = $_SERVER['argv'][1] ?? '';
$key = "$database_hostname:$database_port:$database_default";
$pdo = $database_sessions[$key];
$version = db_fetch_cell('SELECT cacti FROM version');
$backups = [];
try {
if (in_array($scenario, ['domain-read', 'ldap-read', 'count-read'], true)) {
require_once CACTI_PATH_INSTALL . '/upgrades/1_3_0.php';
$pdo->exec('CREATE TABLE user_domains (domain_id INT PRIMARY KEY, domain_name VARCHAR(64), type INT, enabled VARCHAR(2), debug VARCHAR(2), defdomain INT, user_id INT)');
$pdo->exec("INSERT INTO user_domains VALUES (987, 'Installer fixture', 1, 'on', '', 0, 0)");
$pdo->exec('CREATE TABLE user_domains_ldap (domain_id INT PRIMARY KEY)');
// LDAP reads fail before accessing the deliberately minimal fixture row.
if ($scenario === 'ldap-read') {
$pdo->exec('INSERT INTO user_domains_ldap VALUES (987)');
}
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [InstallerFailureStatement::class]);
InstallerFailureStatement::$failQuery = match ($scenario) {
'domain-read' => 'SELECT * FROM user_domains',
'ldap-read' => 'SELECT * FROM user_domains_ldap',
'count-read' => 'SELECT COUNT(*) FROM login_providers',
};
$database_upgrade_status = [];
$cacti_upgrade_version = '1.3.0';
login_providers_convert_1_3_0();
InstallerFailureStatement::$failQuery = '';
installer_fixture_check(count($pdo->query("SHOW TABLES LIKE 'user_domains%'")->fetchAll()) === 2, 'Failed read destroyed legacy authentication tables');
installer_fixture_check(in_array(DB_STATUS_ERROR, array_column($database_upgrade_status['1.3.0'] ?? [], 'status'), true), 'Failed read was not recorded in migration status');
installer_fixture_check((int) $pdo->query('SELECT COUNT(*) FROM user_domains')->fetchColumn() === 1, 'Legacy authentication row was lost');
} elseif (in_array($scenario, ['upgrade-warning', 'cli-failure'], true)) {
foreach (['1_2_29', '1_2_30'] as $target) {
$path = CACTI_PATH_INSTALL . '/upgrades/' . $target . '.php';
$backups[$path] = is_file($path) ? file_get_contents($path) : false;
}
file_put_contents(CACTI_PATH_INSTALL . '/upgrades/1_2_29.php', '<?php /* Fixture: missing migration function. */');
file_put_contents(CACTI_PATH_INSTALL . '/upgrades/1_2_30.php', '<?php function upgrade_to_1_2_30(): void { db_install_execute("SELECT 1"); }');
db_execute("UPDATE version SET cacti = '1.2.28'");
if ($scenario === 'upgrade-warning') {
$cacti_version_codes = array_intersect_key($cacti_version_codes, array_flip(['1.2.29', '1.2.30']));
$reflection = new ReflectionClass(Installer::class);
$installer = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('old_cacti_version')->setValue($installer, '1.2.28');
$error = $reflection->getMethod('upgradeDatabase')->invoke($installer);
installer_fixture_check($error !== '', 'Incomplete migration reported success');
} else {
$output = [];
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(CACTI_PATH_CLI . '/upgrade_database.php') . ' --forcever=1.2.28 2>&1', $output, $status);
installer_fixture_check($status !== 0, 'CLI migration failure returned success');
installer_fixture_check(str_contains(implode(PHP_EOL, $output), 'not found'), 'CLI fixture did not exercise the missing migration');
}
installer_fixture_check(db_fetch_cell('SELECT cacti FROM version') === '1.2.28', 'Incomplete migration advanced the recorded version');
} elseif ($scenario === 'composer-failure') {
$composer = tempnam(sys_get_temp_dir(), 'installer-composer-');
installer_fixture_check($composer !== false, 'Could not create Composer fixture');
file_put_contents($composer, '#!' . PHP_BINARY . PHP_EOL . '<?php if (in_array("--dry-run", $argv, true)) { echo "Package operations: 1 install, 0 updates, 0 removals\n"; exit(0); } echo "download failed\n"; exit(7);');
chmod($composer, 0700);
set_config_option('path_composer', $composer);
$logPath = read_config_option('path_cactilog');
if ($logPath === '') {
$logPath = CACTI_PATH_LOG . '/cacti.log';
}
$offset = is_file($logPath) ? filesize($logPath) : 0;
$reflection = new ReflectionClass(Installer::class);
$installer = $reflection->newInstanceWithoutConstructor();
$reflection->getMethod('refreshVendorDependencies')->invoke($installer);
$log = substr(file_get_contents($logPath), $offset);
installer_fixture_check(str_contains($log, 'Composer refresh did not complete'), 'Failed Composer command did not log a warning');
installer_fixture_check(!str_contains($log, 'Composer dependencies refreshed.'), 'Failed Composer command reported success');
unlink($composer);
} elseif ($scenario === 'installer-lock') {
installer_fixture_check(register_process_start('install', 'master', 0, 86400), 'Could not register lock fixture');
set_config_option('install_eula', 'lock-owner-fixture');
set_config_option('log_install_json', '3');
try {
$output = [];
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(CACTI_PATH_CLI . '/upgrade_database.php') . ' --forcever=1.2.28 2>&1', $output, $status);
installer_fixture_check($status !== 0 && str_contains(implode(PHP_EOL, $output), 'already in progress'), 'CLI upgrader ignored an active installer');
$arguments = ['--accept-eula', '--install', '--force', '--debug=json:5', '--path=php_binary:' . PHP_BINARY, '--path=rrdtool:/usr/bin/rrdtool', '--path=snmpwalk:/usr/bin/snmpwalk', '--path=snmpget:/usr/bin/snmpget', '--path=snmpbulkwalk:/usr/bin/snmpbulkwalk', '--path=snmpgetnext:/usr/bin/snmpgetnext', '--path=fping:/usr/bin/fping'];
$output = [];
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(CACTI_PATH_CLI . '/install_cacti.php') . ' ' . implode(' ', array_map('escapeshellarg', $arguments)) . ' 2>&1', $output, $status);
installer_fixture_check($status !== 0 && str_contains(implode(PHP_EOL, $output), 'already in progress'), 'CLI installer ignored an active installer');
installer_fixture_check(db_fetch_cell("SELECT value FROM settings WHERE name = 'install_eula'") === 'lock-owner-fixture', 'Blocked CLI installer overwrote the active installer settings');
installer_fixture_check(db_fetch_cell("SELECT value FROM settings WHERE name = 'log_install_json'") === '3', 'Blocked CLI installer overwrote the active installer logging settings');
} finally {
unregister_process('install', 'master', 0);
}
} else {
throw new RuntimeException('Unknown fixture scenario');
}
print "PASS: $scenario" . PHP_EOL;
} finally {
InstallerFailureStatement::$failQuery = '';
foreach ($backups as $path => $contents) {
if ($contents === false) {
unlink($path);
} else {
file_put_contents($path, $contents);
}
}
db_execute_prepared('UPDATE version SET cacti = ?', [$version]);
}
+36 -1
View File
@@ -167,8 +167,34 @@ EXPECTED_TABLES="$(grep -c '^CREATE TABLE' cacti.sql)"
printf 'Scenario: fresh CLI installation\n'
reset_database "$REPO_DIR/cacti.sql"
run_installer
run_installer --debug=json:3
assert_complete_install "$EXPECTED_TABLES"
[ "$(db_cacti -e "SELECT value FROM settings WHERE name = 'log_install_json';")" = '3' ] || fail 'installer reset the requested debug level'
printf 'Scenario: table conversion rejects missing conversion options\n'
if docker exec "$WEB_CONTAINER" php /var/www/html/cacti/cli/convert_tables.php; then
fail 'table converter returned success without a conversion option'
fi
printf 'Scenario: table conversion rejects disabled file-per-table\n'
db_root -e 'SET GLOBAL innodb_file_per_table = OFF;'
if docker exec "$WEB_CONTAINER" php /var/www/html/cacti/cli/convert_tables.php --innodb --table=host; then
fail 'table converter returned success with file-per-table disabled'
fi
db_root -e 'SET GLOBAL innodb_file_per_table = ON;'
printf 'Scenario: installer logging mode loads the conversion helpers\n'
docker exec "$WEB_CONTAINER" php /var/www/html/cacti/cli/convert_tables.php --installer --utf8 --table=host
printf 'Scenario: row-format-only conversion does not require a charset change\n'
db_cacti -e 'CREATE TABLE installer_conversion_fixture (id int unsigned NOT NULL PRIMARY KEY) ENGINE=InnoDB ROW_FORMAT=Compact DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;'
docker exec "$WEB_CONTAINER" php /var/www/html/cacti/cli/convert_tables.php --innodb --dynamic --table=installer_conversion_fixture
[ "$(db_cacti -e "SELECT ROW_FORMAT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'installer_conversion_fixture';")" = 'Dynamic' ] || fail 'row-format-only conversion did not convert the fixture table'
printf 'Scenario: Aria conversion reaches the requested InnoDB engine\n'
db_cacti -e 'ALTER TABLE installer_conversion_fixture ENGINE=Aria;'
docker exec "$WEB_CONTAINER" php /var/www/html/cacti/cli/convert_tables.php --innodb --utf8 --table=installer_conversion_fixture
[ "$(db_cacti -e "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'installer_conversion_fixture';")" = 'InnoDB' ] || fail 'table converter left the fixture table using Aria'
printf 'Scenario: 1.2.22 to %s CLI upgrade\n' "$CACTI_VERSION"
reset_database "$OLD_SCHEMA"
@@ -195,6 +221,7 @@ VERSION_STATUS=$?
set -e
printf '%s\n' "$VERSION_OUTPUT"
[ "$VERSION_STATUS" -ne 0 ] || fail 'installer ignored a rejected version write'
[ "$(db_cacti -e "SELECT COUNT(*) FROM processes WHERE tasktype = 'install' AND taskname = 'master';")" -eq 0 ] || fail 'failed installer retained its process registration'
[ "$(db_cacti -e 'SELECT cacti FROM version;')" = 'new_install' ] || fail 'failed version transaction did not roll back'
printf 'Scenario: incomplete schema fails closed in CLI mode\n'
@@ -206,6 +233,7 @@ CLI_STATUS=$?
set -e
printf '%s\n' "$CLI_OUTPUT"
[ "$CLI_STATUS" -ne 0 ] || fail 'CLI installer accepted an incomplete schema'
[ "$(db_cacti -e "SELECT COUNT(*) FROM processes WHERE tasktype = 'install' AND taskname = 'master';")" -eq 0 ] || fail 'schema validation failure retained its process registration'
grep -q 'user_auth_row_cache' <<< "$CLI_OUTPUT" || fail 'CLI failure did not identify the missing table'
[ "$(db_cacti -e 'SELECT cacti FROM version;')" = 'new_install' ] || fail 'failed install advanced the version row'
@@ -224,4 +252,11 @@ printf '%s\n' "$BACKGROUND_OUTPUT"
[ "$BACKGROUND_STATUS" -ne 0 ] || fail 'background installer accepted an incomplete schema'
[ "$(db_cacti -e "SELECT value FROM settings WHERE name = 'install_step';")" = '99' ] || fail 'background failure did not preserve STEP_ERROR'
for scenario in domain-read ldap-read count-read upgrade-warning cli-failure composer-failure installer-lock; do
printf 'Scenario: %s\n' "$scenario"
reset_database "$REPO_DIR/cacti.sql"
docker exec -e CACTI_INSTALLER_FAILURE_FIXTURE=1 "$WEB_CONTAINER" \
php /var/www/html/cacti/tests/e2e/installer_failure_paths.php "$scenario"
done
printf 'PASS installer unit/integration Docker matrix\n'
@@ -44,8 +44,6 @@ cmd_exec ./lib/functions.php return exec('whoami') ?: '';
cmd_exec ./lib/functions.php * On Windows, exec()/shell_exec()/popen() route through cmd.exe, which ignores
cmd_exec ./lib/installer.php $output = shell_exec(
cmd_exec ./lib/installer.php $output = shell_exec(cacti_escapeshellcmd(str_replace('\\', '/', $path)) . ' --version 2>&1');
cmd_exec ./lib/installer.php $check = shell_exec("$composer install --dry-run --no-interaction --working-dir=$base 2>&1");
cmd_exec ./lib/installer.php $output = shell_exec("$composer install --no-interaction --no-progress --working-dir=$base 2>&1");
cmd_exec ./lib/installer.php $process = proc_open(
cmd_exec ./lib/ping.php $result = shell_exec('ping -6 -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
cmd_exec ./lib/ping.php $result = shell_exec('ping -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true) . ' 2>&1');
@@ -113,7 +111,7 @@ cmd_exec ./support.php $snmp_version = (string) shell_exec(cacti_escapeshell
cmd_exec ./support.php exec(cacti_escapeshellcmd(read_config_option('path_spine')) . ' --version', $out_array);
dynamic_include ./cli/audit_database.php require_once($plugin . '/includes/database.php');
dynamic_include ./cli/audit_database.php require_once($plugin . '/setup.php');
dynamic_include ./cli/upgrade_database.php include($upgrade_file);
dynamic_include ./cli/upgrade_database.php include($upgrade_file);
dynamic_include ./include/global.php require_once($vendor_autoload);
dynamic_include ./include/global_languages.php require_once($providerFull);
dynamic_include ./index.php require_once($file);
Can't render this file because it contains an unexpected character in line 22 and column 251.
@@ -44,8 +44,6 @@ cmd_exec ./lib/functions.php return exec('whoami') ?: '';
cmd_exec ./lib/functions.php * On Windows, exec()/shell_exec()/popen() route through cmd.exe, which ignores
cmd_exec ./lib/installer.php $output = shell_exec(
cmd_exec ./lib/installer.php $output = shell_exec(cacti_escapeshellcmd(str_replace('\\', '/', $path)) . ' --version 2>&1');
cmd_exec ./lib/installer.php $check = shell_exec("$composer install --dry-run --no-interaction --working-dir=$base 2>&1");
cmd_exec ./lib/installer.php $output = shell_exec("$composer install --no-interaction --no-progress --working-dir=$base 2>&1");
cmd_exec ./lib/installer.php $process = proc_open(
cmd_exec ./lib/ping.php $result = shell_exec('ping -6 -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true));
cmd_exec ./lib/ping.php $result = shell_exec('ping -W ' . ceil($this->timeout / 1000) . ' -c ' . $this->retries . ' -p ' . $pattern . ' ' . cacti_escapeshellarg_cmd($this->host['hostname'], true, true) . ' 2>&1');
@@ -118,7 +116,7 @@ deserialize ./lib/functions.php return unserialize($strobj, ['allowed_classes'
deserialize ./lib/reports.php $items = unserialize($unstripped, ['allowed_classes' => false]);
dynamic_include ./cli/audit_database.php require_once($plugin . '/includes/database.php');
dynamic_include ./cli/audit_database.php require_once($plugin . '/setup.php');
dynamic_include ./cli/upgrade_database.php include($upgrade_file);
dynamic_include ./cli/upgrade_database.php include($upgrade_file);
dynamic_include ./include/global.php require_once($vendor_autoload);
dynamic_include ./include/global_languages.php require_once($providerFull);
dynamic_include ./index.php require_once($file);
Can't render this file because it contains an unexpected character in line 22 and column 251.
@@ -7,7 +7,6 @@
"cli/add_tree.php": 3,
"cli/analyze_database.php": 2,
"cli/audit_database.php": 7,
"cli/convert_tables.php": 1,
"cli/fix_mediumint.php": 1,
"cli/repair_database.php": 1,
"cli/repair_graphs.php": 3,