Commit bbdabde7 authored by Cédric Anne's avatar Cédric Anne Committed by Johan Cwiklinski
Browse files

Store timezone usage state in config file; add a command to enable it

parent 6682ec9a
......@@ -127,6 +127,7 @@ The present file will list all changes made to the project; according to the
- `Config::displayCheckExtensions()`
- `Config::getCache()`
- `DBMysql::affected_rows()`
- `DBMysql::areTimezonesAvailable()`
- `DBMysql::data_seek()`
- `DBMysql::fetch_array()`
- `DBMysql::fetch_assoc()`
......@@ -138,6 +139,7 @@ The present file will list all changes made to the project; according to the
- `DBMysql::insert_id()`
- `DBMysql::isMySQLStrictMode()`
- `DBMysql::list_fields()`
- `DBMysql::notTzMigrated()`
- `DBMysql::num_fields()`
- `DbUtils::getRealQueryForTreeItem()`
- `Dropdown::getDropdownNetpoint()`
......
......@@ -479,13 +479,10 @@ class Central extends CommonGLPI {
count($myisam_tables)
);
}
if ($DB->areTimezonesAvailable()) {
$not_tstamp = $DB->notTzMigrated();
if ($not_tstamp > 0) {
$messages['warnings'][] = sprintf(__('%1$s columns are not compatible with timezones usage.'), $not_tstamp)
. ' '
. sprintf(__('Run the "php bin/console %1$s" command to migrate them.'), 'glpi:migration:timestamps');
}
if ($DB->use_timezones && ($not_tstamp = $DB->getTzIncompatibleTables()->count()) > 0) {
$messages['warnings'][] = sprintf(__('%1$s columns are not compatible with timezones usage.'), $not_tstamp)
. ' '
. sprintf(__('Run the "php bin/console %1$s" command to migrate them.'), 'glpi:migration:timestamps');
}
if (($non_utf8mb4_tables = $DB->getNonUtf8mb4Tables()->count()) > 0) {
$messages['warnings'][] = sprintf(__('%1$s tables not migrated to utf8mb4 collation.'), $non_utf8mb4_tables)
......
......@@ -1297,9 +1297,7 @@ class Config extends CommonDBTM {
echo "</td>";
echo "<td><label for='dropdown_timezone$rand'>" . __('Timezone') . "</label></td>";
echo "<td>";
$tz_warning = '';
$tz_available = $DB->areTimezonesAvailable($tz_warning);
if ($tz_available) {
if ($DB->use_timezones) {
$timezones = $DB->getTimezones();
Dropdown::showFromArray(
'timezone',
......@@ -1310,8 +1308,9 @@ class Config extends CommonDBTM {
]
);
} else {
echo "<img src=\"{$CFG_GLPI['root_doc']}/pics/warning_min.png\">";
echo $tz_warning;
echo __('Timezone usage has not been activated.')
. ' '
. sprintf(__('Run the "php bin/console %1$s" command to activate it.'), 'glpi:database:enable_timezones');
}
echo "<tr class='tab_bg_2'><td><label for='dropdown_default_central_tab$rand'>".__('Default central tab')."</label></td>";
......
......@@ -41,6 +41,7 @@ use DBConnection;
use DBmysql;
use Glpi\Console\AbstractCommand;
use Glpi\Console\Command\ForceNoPluginsOptionCommandInterface;
use Glpi\System\Requirement\DbTimezones;
use mysqli;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Helper\Table;
......@@ -282,6 +283,8 @@ abstract class AbstractConfigureCommand extends AbstractCommand implements Force
DBConnection::setConnectionCharset($mysqli, $use_utf8mb4);
$use_timezones = $this->checkTimezonesAvailability($mysqli);
$db_name = $mysqli->real_escape_string($db_name);
$output->writeln(
......@@ -293,6 +296,7 @@ abstract class AbstractConfigureCommand extends AbstractCommand implements Force
$db_user,
$db_pass,
$db_name,
$use_timezones,
$log_deprecation_warnings,
$use_utf8mb4,
$allow_myisam
......@@ -315,6 +319,7 @@ abstract class AbstractConfigureCommand extends AbstractCommand implements Force
$db_user,
$db_pass,
$db_name,
$use_timezones,
$log_deprecation_warnings,
$use_utf8mb4,
$allow_myisam
......@@ -324,16 +329,18 @@ abstract class AbstractConfigureCommand extends AbstractCommand implements Force
$dbuser,
$dbpassword,
$dbdefault,
$use_timezones,
$log_deprecation_warnings,
$use_utf8mb4,
$allow_myisam
) {
$this->dbhost = $dbhost;
$this->dbuser = $dbuser;
$this->dbpassword = $dbpassword;
$this->dbdefault = $dbdefault;
$this->use_utf8mb4 = $use_utf8mb4;
$this->allow_myisam = $allow_myisam;
$this->dbhost = $dbhost;
$this->dbuser = $dbuser;
$this->dbpassword = $dbpassword;
$this->dbdefault = $dbdefault;
$this->use_timezones = $use_timezones;
$this->use_utf8mb4 = $use_utf8mb4;
$this->allow_myisam = $allow_myisam;
$this->log_deprecation_warnings = $log_deprecation_warnings;
......@@ -431,4 +438,55 @@ abstract class AbstractConfigureCommand extends AbstractCommand implements Force
new ConfirmationQuestion(__('Do you want to continue?') . ' [Yes/no]', true)
);
}
/**
* Check timezones availability and return availability state.
*
* @param mysqli $mysqli
*
* @return bool
*/
private function checkTimezonesAvailability(mysqli $mysqli): bool {
$db = new class($mysqli) extends DBmysql {
public function __construct($dbh) {
$this->dbh = $dbh;
}
};
$timezones_requirement = new DbTimezones($db);
if (!$timezones_requirement->isValidated()) {
$message = __('Timezones usage cannot be activated due to following errors:');
foreach ($timezones_requirement->getValidationMessages() as $validation_message) {
$message .= "\n - " . $validation_message;
}
$this->output->writeln(
'<comment>' . $message . '</comment>',
OutputInterface::VERBOSITY_QUIET
);
if ($this->input->getOption('no-interaction')) {
$message = sprintf(
__('Fix them and run the "php bin/console %1$s" command to enable timezones.'),
'glpi:database:enable_timezones'
);
$this->output->writeln('<comment>' . $message . '</comment>', OutputInterface::VERBOSITY_QUIET);
} else {
/** @var \Symfony\Component\Console\Helper\QuestionHelper $question_helper */
$question_helper = $this->getHelper('question');
$continue = $question_helper->ask(
$this->input,
$this->output,
new ConfirmationQuestion(__('Do you want to continue?') . ' [Yes/no]', true)
);
if (!$continue) {
throw new \Glpi\Console\Exception\EarlyExitException(
'<comment>' . __('Configuration aborted.') . '</comment>',
self::ABORTED_BY_USER
);
}
}
}
return $timezones_requirement->isValidated();
}
}
<?php
/**
* ---------------------------------------------------------------------
* GLPI - Gestionnaire Libre de Parc Informatique
* Copyright (C) 2015-2021 Teclib' and contributors.
*
* http://glpi-project.org
*
* based on GLPI - Gestionnaire Libre de Parc Informatique
* Copyright (C) 2003-2014 by the INDEPNET Development Team.
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* GLPI is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* GLPI 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with GLPI. If not, see <http://www.gnu.org/licenses/>.
* ---------------------------------------------------------------------
*/
namespace Glpi\Console\Database;
if (!defined('GLPI_ROOT')) {
die("Sorry. You can't access this file directly");
}
use DBConnection;
use Glpi\Console\AbstractCommand;
use Glpi\System\Requirement\DbTimezones;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class EnableTimezonesCommand extends AbstractCommand {
/**
* Error code returned if DB configuration file cannot be updated.
*
* @var integer
*/
const ERROR_UNABLE_TO_UPDATE_CONFIG = 1;
/**
* Error code returned if prerequisites are missing.
*
* @var integer
*/
const ERROR_MISSING_PREREQUISITES = 2;
/**
* Error code returned if some tables are still using datetime field type.
*
* @var integer
*/
const ERROR_TIMESTAMP_FIELDS_REQUIRED = 3;
protected function configure() {
parent::configure();
$this->setName('glpi:database:enable_timezones');
$this->setAliases(['db:enable_timezones']);
$this->setDescription(__('Enable timezones usage.'));
}
protected function execute(InputInterface $input, OutputInterface $output) {
$timezones_requirement = new DbTimezones($this->db);
if (!$timezones_requirement->isValidated()) {
$message = __('Timezones usage cannot be activated due to following errors:');
foreach ($timezones_requirement->getValidationMessages() as $validation_message) {
$message .= "\n - " . $validation_message;
}
throw new \Glpi\Console\Exception\EarlyExitException(
'<error>' . $message . '</error>',
self::ERROR_MISSING_PREREQUISITES
);
}
if (($datetime_count = $this->db->getTzIncompatibleTables()->count()) > 0) {
$message = sprintf(__('%1$s columns are not still using datetime field type.'), $datetime_count)
. ' '
. sprintf(__('Run the "php bin/console %1$s" command to migrate them.'), 'glpi:migration:timestamps');
throw new \Glpi\Console\Exception\EarlyExitException(
'<error>' . $message . '</error>',
self::ERROR_TIMESTAMP_FIELDS_REQUIRED
);
}
if (!DBConnection::updateConfigProperty(DBConnection::PROPERTY_USE_TIMEZONES, true)) {
throw new \Glpi\Console\Exception\EarlyExitException(
'<error>' . __('Unable to update DB configuration file.') . '</error>',
self::ERROR_UNABLE_TO_UPDATE_CONFIG
);
}
$output->writeln('<info>' . __('Timezone usage has been enabled.') . '</info>');
return 0; // Success
}
}
......@@ -36,8 +36,9 @@ if (!defined('GLPI_ROOT')) {
die("Sorry. You can't access this file directly");
}
use DBConnection;
use Glpi\Console\AbstractCommand;
use QueryExpression;
use Glpi\System\Requirement\DbTimezones;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
......@@ -45,6 +46,20 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
class TimestampsCommand extends AbstractCommand {
/**
* Error code returned when failed to migrate one table.
*
* @var integer
*/
const ERROR_TABLE_MIGRATION_FAILED = 1;
/**
* Error code returned if DB configuration file cannot be updated.
*
* @var integer
*/
const ERROR_UNABLE_TO_UPDATE_CONFIG = 2;
protected function configure() {
parent::configure();
......@@ -56,36 +71,7 @@ class TimestampsCommand extends AbstractCommand {
//convert db
// we are going to update datetime types to timestamp type
$tbl_iterator = $this->db->request([
'SELECT' => ['information_schema.columns.table_name as TABLE_NAME'],
'DISTINCT' => true,
'FROM' => 'information_schema.columns',
'INNER JOIN' => [
'information_schema.tables' => [
'FKEY' => [
'information_schema.tables' => 'table_name',
'information_schema.columns' => 'table_name',
[
'AND' => [
'information_schema.tables.table_schema' => new QueryExpression(
$this->db->quoteName('information_schema.columns.table_schema')
),
]
],
]
]
],
'WHERE' => [
'information_schema.columns.table_schema' => $this->db->dbdefault,
'information_schema.columns.table_name' => ['LIKE', 'glpi\_%'],
'information_schema.columns.data_type' => 'datetime',
'information_schema.tables.table_type' => 'BASE TABLE',
],
'ORDER' => [
'information_schema.columns.table_name'
]
]);
$tbl_iterator = $this->db->getTzIncompatibleTables();
$output->writeln(
sprintf(
......@@ -96,130 +82,158 @@ class TimestampsCommand extends AbstractCommand {
if ($tbl_iterator->count() === 0) {
$output->writeln('<info>' . __('No migration needed.') . '</info>');
return 0; // Success
}
if (!$input->getOption('no-interaction')) {
// Ask for confirmation (unless --no-interaction)
/** @var \Symfony\Component\Console\Helper\QuestionHelper $question_helper */
$question_helper = $this->getHelper('question');
$run = $question_helper->ask(
$input,
$output,
new ConfirmationQuestion(__('Do you want to continue?') . ' [Yes/no]', true)
);
if (!$run) {
$output->writeln(
'<comment>' . __('Migration aborted.') . '</comment>',
OutputInterface::VERBOSITY_VERBOSE
} else {
if (!$input->getOption('no-interaction')) {
// Ask for confirmation (unless --no-interaction)
/** @var \Symfony\Component\Console\Helper\QuestionHelper $question_helper */
$question_helper = $this->getHelper('question');
$run = $question_helper->ask(
$input,
$output,
new ConfirmationQuestion(__('Do you want to continue?') . ' [Yes/no]', true)
);
return 0;
if (!$run) {
$output->writeln(
'<comment>' . __('Migration aborted.') . '</comment>',
OutputInterface::VERBOSITY_VERBOSE
);
return 0;
}
}
}
$progress_bar = new ProgressBar($output, $tbl_iterator->count());
$progress_bar->start();
foreach ($tbl_iterator as $table) {
$progress_bar->advance(1);
$tablealter = ''; // init by default
// get accurate info from information_schema to perform correct alter
$col_iterator = $this->db->request([
'SELECT' => [
'table_name AS TABLE_NAME',
'column_name AS COLUMN_NAME',
'column_default AS COLUMN_DEFAULT',
'column_comment AS COLUMN_COMMENT',
'is_nullable AS IS_NULLABLE',
],
'FROM' => 'information_schema.columns',
'WHERE' => [
'table_schema' => $this->db->dbdefault,
'table_name' => $table['TABLE_NAME'],
'data_type' => 'datetime'
]
]);
foreach ($col_iterator as $column) {
$nullable = false;
$default = null;
//check if nullable
if ('YES' === $column['IS_NULLABLE']) {
$nullable = true;
}
$progress_bar = new ProgressBar($output, $tbl_iterator->count());
$progress_bar->start();
foreach ($tbl_iterator as $table) {
$progress_bar->advance(1);
$tablealter = ''; // init by default
// get accurate info from information_schema to perform correct alter
$col_iterator = $this->db->request([
'SELECT' => [
'table_name AS TABLE_NAME',
'column_name AS COLUMN_NAME',
'column_default AS COLUMN_DEFAULT',
'column_comment AS COLUMN_COMMENT',
'is_nullable AS IS_NULLABLE',
],
'FROM' => 'information_schema.columns',
'WHERE' => [
'table_schema' => $this->db->dbdefault,
'table_name' => $table['TABLE_NAME'],
'data_type' => 'datetime'
]
]);
foreach ($col_iterator as $column) {
$nullable = false;
$default = null;
//check if nullable
if ('YES' === $column['IS_NULLABLE']) {
$nullable = true;
}
//guess default value
if (is_null($column['COLUMN_DEFAULT']) && !$nullable) { // no default
// Prevent MySQL/MariaDB to force "default current_timestamp on update current_timestamp"
// as "on update current_timestamp" could be a real problem on fields like "date_creation".
$default = "CURRENT_TIMESTAMP";
} else if ((is_null($column['COLUMN_DEFAULT']) || strtoupper($column['COLUMN_DEFAULT']) == 'NULL') && $nullable) {
$default = "NULL";
} else if (!is_null($column['COLUMN_DEFAULT']) && strtoupper($column['COLUMN_DEFAULT']) != 'NULL') {
if (preg_match('/^current_timestamp(\(\))?$/i', $column['COLUMN_DEFAULT']) === 1) {
$default = $column['COLUMN_DEFAULT'];
} else if ($column['COLUMN_DEFAULT'] < '1970-01-01 00:00:01') {
// Prevent default value to be out of range (lower to min possible value)
$defaultDate = new \DateTime('1970-01-01 00:00:01', new \DateTimeZone('UTC'));
$defaultDate->setTimezone(new \DateTimeZone(date_default_timezone_get()));
$default = $this->db->quoteValue($defaultDate->format("Y-m-d H:i:s"));
} else if ($column['COLUMN_DEFAULT'] > '2038-01-19 03:14:07') {
// Prevent default value to be out of range (greater to max possible value)
$defaultDate = new \DateTime('2038-01-19 03:14:07', new \DateTimeZone('UTC'));
$defaultDate->setTimezone(new \DateTimeZone(date_default_timezone_get()));
$default = $this->db->quoteValue($defaultDate->format("Y-m-d H:i:s"));
//guess default value
if (is_null($column['COLUMN_DEFAULT']) && !$nullable) { // no default
// Prevent MySQL/MariaDB to force "default current_timestamp on update current_timestamp"
// as "on update current_timestamp" could be a real problem on fields like "date_creation".
$default = "CURRENT_TIMESTAMP";
} else if ((is_null($column['COLUMN_DEFAULT']) || strtoupper($column['COLUMN_DEFAULT']) == 'NULL') && $nullable) {
$default = "NULL";
} else if (!is_null($column['COLUMN_DEFAULT']) && strtoupper($column['COLUMN_DEFAULT']) != 'NULL') {
if (preg_match('/^current_timestamp(\(\))?$/i', $column['COLUMN_DEFAULT']) === 1) {
$default = $column['COLUMN_DEFAULT'];
} else if ($column['COLUMN_DEFAULT'] < '1970-01-01 00:00:01') {
// Prevent default value to be out of range (lower to min possible value)
$defaultDate = new \DateTime('1970-01-01 00:00:01', new \DateTimeZone('UTC'));
$defaultDate->setTimezone(new \DateTimeZone(date_default_timezone_get()));
$default = $this->db->quoteValue($defaultDate->format("Y-m-d H:i:s"));
} else if ($column['COLUMN_DEFAULT'] > '2038-01-19 03:14:07') {
// Prevent default value to be out of range (greater to max possible value)
$defaultDate = new \DateTime('2038-01-19 03:14:07', new \DateTimeZone('UTC'));
$defaultDate->setTimezone(new \DateTimeZone(date_default_timezone_get()));
$default = $this->db->quoteValue($defaultDate->format("Y-m-d H:i:s"));
} else {
$default = $this->db->quoteValue($column['COLUMN_DEFAULT']);
}
}
//build alter
$tablealter .= "\n\t MODIFY COLUMN ".$this->db->quoteName($column['COLUMN_NAME'])." TIMESTAMP";
if ($nullable) {
$tablealter .= " NULL";
} else {
$default = $this->db->quoteValue($column['COLUMN_DEFAULT']);
$tablealter .= " NOT NULL";
}
if ($default !== null) {
$tablealter .= " DEFAULT $default";
}
if ($column['COLUMN_COMMENT'] != '') {
$tablealter .= " COMMENT '".$this->db->escape($column['COLUMN_COMMENT'])."'";
}
$tablealter .= ",";
}
$tablealter = rtrim($tablealter, ",");
//build alter
$tablealter .= "\n\t MODIFY COLUMN ".$this->db->quoteName($column['COLUMN_NAME'])." TIMESTAMP";
if ($nullable) {
$tablealter .= " NULL";
} else {
$tablealter .= " NOT NULL";
}
if ($default !== null) {
$tablealter .= " DEFAULT $default";
}
if ($column['COLUMN_COMMENT'] != '') {
$tablealter .= " COMMENT '".$this->db->escape($column['COLUMN_COMMENT'])."'";
// apply alter to table
$query = "ALTER TABLE " . $this->db->quoteName($table['TABLE_NAME']) . " " . $tablealter.";\n";
$this->writelnOutputWithProgressBar(
'<comment>' . sprintf(__('Running %s'), $query) . '</comment>',
$progress_bar,
OutputInterface::VERBOSITY_VERBOSE
);
$result = $this->db->query($query);
if (false === $result) {
$message = sprintf(
__('Update of `%s` failed with message "(%s) %s".'),
$table['TABLE_NAME'],
$this->db->errno(),
$this->db->error()
);
$this->writelnOutputWithProgressBar(
'<error>' . $message . '</error>',
$progress_bar,
OutputInterface::VERBOSITY_QUIET
);
return self::ERROR_TABLE_MIGRATION_FAILED;
}
$tablealter .= ",";
}
$tablealter = rtrim($tablealter, ",");
// apply alter to table
$query = "ALTER TABLE " . $this->db->quoteName($table['TABLE_NAME']) . " " . $tablealter.";\n";
$this->writelnOutputWithProgressBar(
'<comment>' . sprintf(__('Running %s'), $query) . '</comment>',
$progress_bar,
OutputInterface::VERBOSITY_VERBOSE
);
$result = $this->db->query($query);
if (false === $result) {
$message = sprintf(
__('Update of `%s` failed with message "(%s) %s".'),
$table['TABLE_NAME'],
$this->db->errno(),
$this->db->error()
$progress_bar->finish();
$this->output->write(PHP_EOL);
}
$timezones_requirement = new DbTimezones($this->db);
if ($timezones_requirement->isValidated()) {
if (!DBConnection::updateConfigProperty(DBConnection::PROPERTY_USE_TIMEZONES, true)) {
throw new \Glpi\Console\Exception\EarlyExitException(
'<error>' . __('Unable to update DB configuration file.') . '</error>',
self::ERROR_UNABLE_TO_UPDATE_CONFIG
);
$this->writelnOutputWithProgressBar(
'<error>' . $message . '</error>',
$progress_bar,
}
} else {
$output->writeln(
'<error>' . __('Timezones usage cannot be activated due to following errors:') . '</error>',
OutputInterface::VERBOSITY_QUIET
);
foreach ($timezones_requirement->getValidationMessages() as $validation_message) {
$output->writeln(
'<error> - ' . $validation_message . '</error>',