diff --git a/.docker/try-rollback-offer.sh b/.docker/try-rollback-offer.sh new file mode 100644 index 0000000000..4167d18326 --- /dev/null +++ b/.docker/try-rollback-offer.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Upgrades a 2.1 database, marks the run as one that did not finish, and then +# asks the upgrader to put the database back with --rollback. +# +# .docker/try-rollback-offer.sh --engine postgresql --baseline ../SMF-2.1/.docker/baseline/artifacts/2.1.7-1/small/postgres.sql +# +# This exists to exercise the offer the upgrader makes when it finds a run that +# stopped part way. Killing a real upgrade at the right moment is a race, and +# what is being tested here is the offer rather than the interruption, which is +# covered by interrupt-upgrade.sh. +set -euo pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname -- "${BASH_SOURCE[0]}")/upgrade-readings.sh" + +ENGINE='' +BASELINE='' +OUT="$DOCKER_DIR/offer" + +while [ $# -gt 0 ]; do + case "$1" in + --engine) ENGINE="$2"; shift 2 ;; + --baseline) BASELINE="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$ENGINE" ] || die 'need --engine' +[ -n "$BASELINE" ] || die 'need --baseline' + +cd "$BOARD_DIR" +mkdir -p "$OUT" +OUT=$(cd -- "$OUT" && pwd) + +log "${ENGINE}: emptying the database" +"$DOCKER_DIR/reset.sh" --engine "$ENGINE" >/dev/null + +log "${ENGINE}: loading $(basename -- "$BASELINE")" +load_baseline "$ENGINE" "$BASELINE" + +log "${ENGINE}: upgrading" +UPGRADE_ARGS='--backup' run_upgrade "$ENGINE" "$OUT/upgrade-${ENGINE}.log" \ + || die "${ENGINE}: the upgrade failed -- $OUT/upgrade-${ENGINE}.log" + +log "${ENGINE}: upgraded to SMF $(installed_version "$ENGINE")" +snapshot "$ENGINE" upgraded "$OUT" + +# What a killed process leaves behind is a run with no finishing time on it. +# Setting that here is the difference between testing the offer and testing +# how well a kill can be timed. +log "${ENGINE}: marking the run as one that did not finish" + +if [ "$ENGINE" = 'mysql' ]; then + docker compose exec -T -e MYSQL_PWD="$DB_PASSWORD" mysql \ + mysql -u"$DB_USER" -D "$DB_NAME" -e "UPDATE ${DB_PREFIX}migration_runs SET time_finished = 0;" +else + docker compose exec -T postgres \ + psql -q -U "$DB_USER" -d "$DB_NAME" -c "UPDATE ${DB_PREFIX}migration_runs SET time_finished = 0;" +fi + +log "${ENGINE}: asking the upgrader to put it back" +rm -f "$BOARD_DIR/install.php" +cp "$BOARD_DIR/other/upgrade.php" "$BOARD_DIR/upgrade.php" + +status=0 +docker compose exec -T web php upgrade.php --rollback > "$OUT/rollback-${ENGINE}.log" 2>&1 || status=$? + +rm -f "$BOARD_DIR/upgrade.php" + +printf '\n' +cat "$OUT/rollback-${ENGINE}.log" +printf '\n' + +log "${ENGINE}: the forum is now SMF $(installed_version "$ENGINE")" + +exit "$status" diff --git a/Languages/en_US/Maintenance.php b/Languages/en_US/Maintenance.php index 21dceeb9f5..9487d382c9 100644 --- a/Languages/en_US/Maintenance.php +++ b/Languages/en_US/Maintenance.php @@ -405,6 +405,14 @@ // Upgrade options $txt['upgrade_areyouready'] = 'Before the upgrade gets underway, please review the options below and press "Continue" when you are ready to begin.'; $txt['upgrade_backup_table'] = 'Backup SMF tables in your database using the prefix {0}'; +$txt['upgrade_rollback_title'] = 'An unfinished upgrade'; +$txt['upgrade_rollback_offer'] = 'An upgrade from {version} was started {date} and did not finish. You can carry on with it, or put the database back the way it was before it started. Only the database is put back: the files on disk are left alone.'; +$txt['upgrade_rollback_button'] = 'Put the database back'; +$txt['upgrade_rollback_done'] = 'The database has been put back the way it was before the upgrade started. The files on disk are still {version}, so the forum will not run until you either start the upgrade again or put your old files back from a backup.'; +$txt['log_rollback_starting'] = 'Putting the database back to {version}'; +$txt['log_rollback_done'] = 'Put back {count} things.'; +$txt['log_rollback_failed'] = 'Nothing was put back: {error}'; +$txt['log_rollback_refused'] = 'The database refused: {statement}'; $txt['upgrade_maintenance'] = 'Put the forum into maintenance mode during upgrade.'; $txt['upgrade_maintenance_title'] = 'Maintenance Title:'; $txt['upgrade_maintenance_message'] = 'Maintenance Message:'; diff --git a/Sources/Db/APIs/PostgreSQL.php b/Sources/Db/APIs/PostgreSQL.php index e3c39760ab..75fb7ece35 100644 --- a/Sources/Db/APIs/PostgreSQL.php +++ b/Sources/Db/APIs/PostgreSQL.php @@ -975,14 +975,18 @@ public function backup_table(string $table, string $backup_table): object|bool ); } - /** - * @todo Should we create backups of sequences as well? - */ + // The copy takes the columns and their types. It does not take their + // defaults, because a default is copied as the expression it is written + // with: a column fed by a sequence would arrive pointing at the live + // table's sequence, leaving the backup as an object that sequence + // cannot be dropped without and drawing ids from it if anything were + // inserted here. What each column defaults to is kept by the upgrader + // alongside the rest of the table's definition, which is a fuller + // record than a copy of the defaults would have been. $result = $this->query( 'CREATE TABLE {raw:backup_table} ( LIKE {raw:table} - INCLUDING DEFAULTS )', [ 'backup_table' => $backup_table, diff --git a/Sources/Db/Schema/v3_0/MigrationData.php b/Sources/Db/Schema/v3_0/MigrationData.php new file mode 100644 index 0000000000..2ff620f3ee --- /dev/null +++ b/Sources/Db/Schema/v3_0/MigrationData.php @@ -0,0 +1,110 @@ +name = 'migration_data'; + + $this->columns = [ + 'id_entry' => new Column( + name: 'id_entry', + type: 'int', + unsigned: true, + not_null: true, + auto: true, + ), + 'id_run' => new Column( + name: 'id_run', + type: 'varchar', + size: 36, + not_null: true, + default: '', + ), + 'migration' => new Column( + name: 'migration', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'data_type' => new Column( + name: 'data_type', + type: 'varchar', + size: 30, + not_null: true, + default: '', + ), + 'data_key' => new Column( + name: 'data_key', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), + 'data' => new Column( + name: 'data', + type: 'mediumtext', + not_null: true, + ), + 'time_added' => new Column( + name: 'time_added', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_entry', + ], + ], + ), + 'idx_run' => new DbIndex( + name: 'idx_run', + columns: [ + [ + 'name' => 'id_run', + ], + [ + 'name' => 'data_type', + ], + ], + ), + ]; + } +} diff --git a/Sources/Db/Schema/v3_0/MigrationRuns.php b/Sources/Db/Schema/v3_0/MigrationRuns.php new file mode 100644 index 0000000000..ca9d437e09 --- /dev/null +++ b/Sources/Db/Schema/v3_0/MigrationRuns.php @@ -0,0 +1,137 @@ +name = 'migration_runs'; + + $this->columns = [ + 'id_run' => new Column( + name: 'id_run', + type: 'varchar', + size: 36, + not_null: true, + default: '', + ), + 'version_from' => new Column( + name: 'version_from', + type: 'varchar', + size: 20, + not_null: true, + default: '', + ), + 'version_to' => new Column( + name: 'version_to', + type: 'varchar', + size: 20, + not_null: true, + default: '', + ), + 'step' => new Column( + name: 'step', + type: 'smallint', + unsigned: true, + not_null: true, + default: 0, + ), + 'substep' => new Column( + name: 'substep', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + 'substep_start' => new Column( + name: 'substep_start', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + 'id_member' => new Column( + name: 'id_member', + type: 'int', + unsigned: true, + not_null: true, + default: 0, + ), + 'time_started' => new Column( + name: 'time_started', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + 'time_updated' => new Column( + name: 'time_updated', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + 'time_finished' => new Column( + name: 'time_finished', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + 'time_rolled_back' => new Column( + name: 'time_rolled_back', + type: 'bigint', + unsigned: true, + not_null: true, + default: 0, + ), + ]; + + $this->indexes = [ + 'primary' => new DbIndex( + type: 'primary', + columns: [ + [ + 'name' => 'id_run', + ], + ], + ), + 'idx_time_finished' => new DbIndex( + name: 'idx_time_finished', + columns: [ + [ + 'name' => 'time_finished', + ], + ], + ), + ]; + } +} diff --git a/Sources/Maintenance/MigrationData.php b/Sources/Maintenance/MigrationData.php new file mode 100644 index 0000000000..6795a3ab14 --- /dev/null +++ b/Sources/Maintenance/MigrationData.php @@ -0,0 +1,463 @@ +insert( + 'insert', + '{db_prefix}migration_data', + [ + 'id_run' => 'string-36', + 'migration' => 'string-255', + 'data_type' => 'string-30', + 'data_key' => 'string-255', + 'data' => 'string', + 'time_added' => 'int', + ], + [ + [$run, $migration, $type, $key, $data, time()], + ], + ['id_entry'], + ); + + return true; + } + + /** + * Reads one entry back. + * + * @param string $run The run that wrote it. + * @param string $type What kind of thing it is. + * @param string $key What it is about. + * @return string|null The thing, or null if this run never recorded it. + */ + public static function get(string $run, string $type, string $key): ?string + { + if (!self::exists()) { + return null; + } + + $request = Db::$db->query( + 'SELECT data + FROM {db_prefix}migration_data + WHERE id_run = {string:run} + AND data_type = {string:type} + AND data_key = {string:key} + LIMIT 1', + [ + 'run' => $run, + 'type' => $type, + 'key' => $key, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return $row === false || $row === null ? null : (string) $row['data']; + } + + /** + * Reads back everything one run recorded of a kind. + * + * @param string $run The run that wrote them. + * @param string $type What kind of thing they are. + * @return array The things, keyed by what each is about. + */ + public static function all(string $run, string $type): array + { + if (!self::exists()) { + return []; + } + + $entries = []; + + $request = Db::$db->query( + 'SELECT data_key, data + FROM {db_prefix}migration_data + WHERE id_run = {string:run} + AND data_type = {string:type} + ORDER BY data_key', + [ + 'run' => $run, + 'type' => $type, + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $entries[$row['data_key']] = (string) $row['data']; + } + + Db::$db->free_result($request); + + return $entries; + } + + /** + * Removes one entry. + * + * @param string $run The run that wrote it. + * @param string $type What kind of thing it is. + * @param string $key What it is about. + */ + public static function forget(string $run, string $type, string $key): void + { + if (!self::exists()) { + return; + } + + Db::$db->query( + 'DELETE FROM {db_prefix}migration_data + WHERE id_run = {string:run} + AND data_type = {string:type} + AND data_key = {string:key}', + [ + 'run' => $run, + 'type' => $type, + 'key' => $key, + ], + ); + } + + /** + * The run that is under way, if there is one. + * + * A run is under way until something says it finished, so a process that + * was killed leaves its row behind and the next attempt finds it. That is + * what makes a restart the same run rather than a new one, and it is why + * this is asked of the database rather than of the progress data in + * Settings.php, which the command line never writes. + * + * A run that has been undone is finished business whatever its finishing + * time says. Upgrading again after a rollback is a new attempt on a + * database that has been put back, and it needs a backup and a set of + * definitions of its own rather than the ones describing a state that has + * already been restored. + * + * @return string The run's id, or an empty string if none is open. + */ + public static function currentRun(): string + { + if (!self::exists()) { + return ''; + } + + $request = Db::$db->query( + 'SELECT id_run + FROM {db_prefix}migration_runs + WHERE time_finished = {int:unfinished} + AND time_rolled_back = {int:not_undone} + ORDER BY time_started DESC + LIMIT 1', + [ + 'unfinished' => 0, + 'not_undone' => 0, + ], + ); + + $row = Db::$db->fetch_assoc($request); + Db::$db->free_result($request); + + return $row === false || $row === null ? '' : (string) $row['id_run']; + } + + /** + * Opens a run. + * + * @param string $run The id to give it. + * @param string $from The version the forum is on now. + * @param int $member Who started it, if that is known. + * @return bool Whether it was recorded. + */ + public static function startRun(string $run, string $from, int $member = 0): bool + { + if (!self::exists()) { + return false; + } + + Db::$db->insert( + 'ignore', + '{db_prefix}migration_runs', + [ + 'id_run' => 'string-36', + 'version_from' => 'string-20', + 'id_member' => 'int', + 'time_started' => 'int', + 'time_updated' => 'int', + ], + [ + [$run, $from, $member, time(), time()], + ], + ['id_run'], + ); + + return true; + } + + /** + * Records how far a run has got. + * + * The upgrader keeps its place in the query string, which is gone the + * moment the process is. This is the copy that outlives it. + * + * @param string $run The run. + * @param int $step Which step it is on. + * @param int $substep Which substep of that step. + * @param int $start How far into the substep. + */ + public static function recordPosition(string $run, int $step, int $substep, int $start): void + { + if ($run === '' || !self::exists()) { + return; + } + + Db::$db->query( + 'UPDATE {db_prefix}migration_runs + SET step = {int:step}, + substep = {int:substep}, + substep_start = {int:start}, + time_updated = {int:now} + WHERE id_run = {string:run}', + [ + 'step' => $step, + 'substep' => $substep, + 'start' => $start, + 'now' => time(), + 'run' => $run, + ], + ); + } + + /** + * Closes a run, so that the next one is a new one. + * + * @param string $run The run. + * @param string $to The version the forum is on now. + */ + public static function finishRun(string $run, string $to): void + { + if ($run === '' || !self::exists()) { + return; + } + + Db::$db->query( + 'UPDATE {db_prefix}migration_runs + SET version_to = {string:to}, + time_updated = {int:now}, + time_finished = {int:now} + WHERE id_run = {string:run}', + [ + 'to' => $to, + 'now' => time(), + 'run' => $run, + ], + ); + } + + /** + * Throws away runs that recorded nothing. + * + * Asking the upgrader to put a database back opens a run of its own, since + * writing any setting asks which run is under way. That run copies nothing + * never reaches the backup step, so it describes no table, and leaving it + * open would have the next upgrade take it up as unfinished work and pass + * over the backup it never made. Having noted a setting or two on the way + * is not enough to make it a run worth keeping. + */ + public static function discardEmptyRuns(): void + { + if (!self::exists()) { + return; + } + + Db::$db->query( + 'DELETE FROM {db_prefix}migration_runs + WHERE time_finished = {int:unfinished} + AND id_run NOT IN ( + SELECT id_run + FROM {db_prefix}migration_data + WHERE data_type = {string:definition} + )', + [ + 'unfinished' => 0, + 'definition' => self::TYPE_DEFINITION, + ], + ); + } + + /** + * Notes that a run has been undone. + * + * The row stays where it is. What the run did is still worth knowing about + * after it has been put back, and a forum that was upgraded and rolled back + * is a different thing from one that was never upgraded. + * + * @param string $run The run that was undone. + */ + public static function recordRollback(string $run): void + { + if ($run === '' || !self::exists()) { + return; + } + + Db::$db->query( + 'UPDATE {db_prefix}migration_runs + SET time_rolled_back = {int:now} + WHERE id_run = {string:run}', + [ + 'now' => time(), + 'run' => $run, + ], + ); + } + + /** + * Whether there is anywhere to record this. + * + * The table arrives with 3.0, so anything asking before it has been created + * has nowhere to write. A migration that cannot record something carries on + * rather than ending the upgrade, so this is asked rather than left to the + * query to discover. + * + * Only a yes is remembered. A no is the answer until the table is made, and + * the run that makes it is usually the one asking. + * + * @return bool Whether the table is there. + */ + public static function exists(): bool + { + static $exists = false; + + if ($exists) { + return true; + } + + $tables = Db::$db->list_tables(); + $prefix = self::prefix(); + + return $exists = \in_array($prefix . 'migration_data', $tables) + && \in_array($prefix . 'migration_runs', $tables); + } + + /** + * The table prefix, without the database in front of it. + * + * On MySQL the prefix can name the database as well, as `smf`.smf_, while + * list_tables() answers with bare names. Anything comparing one against the + * other has to take the database off first, or it never finds a table that + * is sitting right there. + * + * @return string The prefix on its own. + */ + public static function prefix(): string + { + return preg_match('~^`(.+?)`\.(.+?)$~', Db::$db->prefix, $match) !== 0 ? $match[2] : Db::$db->prefix; + } + + /** + * Makes the table if it is not there yet. + * + * The upgrader reaches the backup step before it reaches the migrations + * that build the 3.0 schema, so anything wanting to record what a table + * looked like beforehand has to ask for this first. + * + * @return bool Whether there is a table to write to now. + */ + public static function ensure(): bool + { + if (self::exists()) { + return true; + } + + (new MigrationDataTable())->normalize(); + (new MigrationRunsTable())->normalize(); + + return self::exists(); + } +} diff --git a/Sources/Maintenance/MigrationRollback.php b/Sources/Maintenance/MigrationRollback.php new file mode 100644 index 0000000000..e44db6fcc8 --- /dev/null +++ b/Sources/Maintenance/MigrationRollback.php @@ -0,0 +1,516 @@ +error = 'that run recorded nothing to put back'; + + return false; + } + + $backed_up = MigrationData::all($run, MigrationData::TYPE_BACKUP); + + // The rows are what makes this worth doing. Without them the tables + // would come back empty, which is worse than leaving things alone. + $missing = array_diff(array_keys($definitions), array_keys($backed_up)); + + if ($missing !== []) { + $this->error = 'no backup was taken of ' . implode(', ', \array_slice($missing, 0, 5)); + + return false; + } + + // The recorded SQL is the database's own account of itself, and the + // checks that keep a query from being assembled out of user input have + // nothing to look at here: they refuse the quoting a CREATE TABLE is + // full of, and the semicolons inside a function body. The installer and + // the migrations turn them off around their own DDL for the same + // reason. + $checking = Db::$db->disableQueryCheck; + Db::$db->disableQueryCheck = true; + + // A prefix that names the database, as `smf`.smf_ does, means nothing + // ever selected one: every query says which database it means. The + // recorded SQL does not, since the upgrader wrote it while the prefix + // was a plain one, so the database has to be chosen before any of it + // will run at all. + $database = $this->database(); + + if ($database !== '') { + Db::$db->select($database); + } + + foreach ($this->routines($run) as $name => $sql) { + $this->execute($sql); + $this->log[] = 'function ' . $name; + } + + foreach ($definitions as $table => $sql) { + $this->execute($sql); + $this->refill($table); + $this->log[] = 'table ' . $table; + } + + foreach ($this->added($definitions) as $table) { + Db::$db->drop_table($table); + $this->log[] = 'dropped ' . $table; + } + + // After the tables, since a table the upgrade added can have an index + // built over a function it added alongside it. + foreach ($this->addedRoutines($run) as $name => $sql) { + $this->execute($sql); + $this->log[] = 'dropped function ' . $name; + } + + Db::$db->disableQueryCheck = $checking; + + $this->restoreSettings($run); + + MigrationData::recordRollback($run); + + return true; + } + + /** + * The runs that could be undone, newest first. + * + * @return array Rows from the migration_runs table. + */ + public function candidates(): array + { + if (!MigrationData::exists()) { + return []; + } + + $runs = []; + + $request = Db::$db->query( + 'SELECT id_run, version_from, version_to, time_started, time_finished + FROM {db_prefix}migration_runs + WHERE time_rolled_back = {int:never} + ORDER BY time_started DESC', + [ + 'never' => 0, + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $runs[] = $row; + } + + Db::$db->free_result($request); + + return $runs; + } + + /** + * The run worth offering to undo, if there is one. + * + * An upgrade that finished is not offered: putting a working forum back is + * something an admin should have to go looking for, not something the + * upgrader suggests. One that stopped part way is the other case entirely, + * and the admin standing in front of it has two ways out -- carry on, or + * put things back as they were. + * + * Only a run that took a backup can be offered, since nothing else has the + * rows to put back. + * + * @return array|null The run, or null if there is nothing to offer. + */ + public function unfinished(): ?array + { + foreach ($this->candidates() as $run) { + if ( + (int) $run['time_finished'] !== 0 + || MigrationData::all($run['id_run'], MigrationData::TYPE_BACKUP) === [] + ) { + continue; + } + + return $run; + } + + return null; + } + + /****************** + * Internal methods + ******************/ + + /** + * The functions a run recorded. + * + * @param string $run The run. + * @return array The SQL that creates each, keyed by its signature. + */ + private function routines(string $run): array + { + // A routine recorded by name alone is one the run found but could not + // write down. Its name still counts as having been there, which is + // what keeps it off the list of things the upgrade added, but there is + // no SQL to put back. + return array_filter(MigrationData::all($run, MigrationData::TYPE_ROUTINE)); + } + + /** + * The routines the upgrade added, newest kind first. + * + * An aggregate is dropped before the plain functions are, since it is + * built out of one of them and PostgreSQL will not let the parts go while + * something is made of them. + * + * @param string $run The run. + * @return array The DROP statements, keyed by signature. + */ + private function addedRoutines(string $run): array + { + if (Db::$db->title !== POSTGRE_TITLE) { + return []; + } + + $recorded = MigrationData::all($run, MigrationData::TYPE_ROUTINE); + + if ($recorded === []) { + return []; + } + + $drops = []; + + $request = Db::$db->query( + 'SELECT p.oid::regprocedure AS signature, p.prokind + FROM pg_proc AS p + INNER JOIN pg_namespace AS n ON (n.oid = p.pronamespace) + WHERE n.nspname = {string:schema} + ORDER BY p.prokind = {string:plain}, signature', + [ + 'schema' => 'public', + 'plain' => 'f', + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + if (isset($recorded[$row['signature']])) { + continue; + } + + $drops[$row['signature']] = 'DROP ' . ($row['prokind'] === 'a' ? 'AGGREGATE' : 'FUNCTION') . ' ' . $row['signature']; + } + + Db::$db->free_result($request); + + return $drops; + } + + /** + * The database the prefix names, if it names one. + * + * @return string The database's name, or an empty string if the prefix is + * a plain one and a database has already been chosen. + */ + private function database(): string + { + return preg_match('~^`(.+?)`\.~', Db::$db->prefix, $match) !== 0 ? $match[1] : ''; + } + + /** + * Tables that are here now and were not when the run started. + * + * The upgrader's own tables are not among them however this is counted: + * they are where the answer is being read from, and a rollback that took + * them with it could not record that it had happened. + * + * @param array $definitions What the run recorded, keyed by table name. + * @return array Names of the tables to drop, with the prefix on them. + */ + private function added(array $definitions): array + { + $added = []; + + foreach (Db::$db->list_tables() as $table) { + if ( + isset($definitions[$table]) + || str_starts_with($table, 'backup_') + || str_starts_with($table, MigrationData::prefix() . 'migration_') + || !str_starts_with($table, MigrationData::prefix()) + ) { + continue; + } + + $added[] = $table; + } + + return $added; + } + + /** + * Puts a table's rows back from its backup. + * + * @param string $table Name of the table, with the prefix on it. + */ + private function refill(string $table): void + { + if (Db::$db->list_tables(false, 'backup_' . $table) === []) { + return; + } + + Db::$db->query( + 'INSERT INTO {raw:table} + SELECT * FROM {raw:backup}', + [ + 'table' => $table, + 'backup' => 'backup_' . $table, + 'db_error_skip' => true, + ], + ); + } + + /** + * Puts the settings in Settings.php back. + * + * A setting the run found missing is taken out again rather than written + * as an empty one, which is what the note beside it is for. + * + * @param string $run The run. + */ + private function restoreSettings(string $run): void + { + $settings = MigrationData::all($run, MigrationData::TYPE_SETTING); + + if ($settings === []) { + return; + } + + $put_back = []; + $remove = []; + + foreach ($settings as $name => $noted) { + $noted = json_decode($noted, true); + + if (!\is_array($noted)) { + continue; + } + + if (empty($noted['set'])) { + $remove[] = $name; + } else { + $put_back[$name] = $noted['value']; + } + } + + if ($put_back !== []) { + Config::updateSettingsFile($put_back); + $this->log[] = 'settings ' . implode(', ', array_keys($put_back)); + } + + if ($remove !== []) { + Config::updateSettingsFile(array_fill_keys($remove, null), rebuild: true); + $this->log[] = 'removed ' . implode(', ', $remove); + } + } + + /** + * Runs SQL that may be more than one statement. + * + * A recorded definition is a small script rather than a single statement, + * and the database layer takes one at a time, so it is split here. + * + * @param string $sql The SQL to run. + */ + private function execute(string $sql): void + { + foreach ($this->statements($sql) as $statement) { + // These are whole statements rather than something built around + // values, so the checks that keep a query from being assembled out + // of user input have nothing to look at here and reject the + // quoting a CREATE TABLE is full of. + $result = Db::$db->query( + $statement, + [ + 'security_override' => true, + 'db_error_skip' => true, + ], + ); + + // The errors are skipped so that one statement failing does not + // end the whole thing, which would leave a forum half put back. + // Skipped is not the same as unnoticed, though: what did not run + // is the difference between a rollback and the appearance of one. + if ($result === false && !$this->positionSequence($statement)) { + $this->failures[] = preg_replace('~\s+~', ' ', substr($statement, 0, 120)); + } + } + } + + /** + * Puts a sequence where a CREATE SEQUENCE would have started it. + * + * Dropping a table does not drop the sequence feeding it, so a sequence + * being put back is nearly always already there and asking for it again is + * refused. What the statement was for is the number it would have started + * at, and that can still be had. + * + * @param string $statement The statement that was refused. + * @return bool Whether this was a CREATE SEQUENCE that has now been dealt + * with another way. + */ + private function positionSequence(string $statement): bool + { + if (preg_match('~^CREATE SEQUENCE ([^\s]+) START WITH (\d+)~i', trim($statement), $match) !== 1) { + return false; + } + + // The third argument says the value has not been handed out yet, so + // the next id is the one the statement asked to start at. + $result = Db::$db->query( + 'SELECT setval({string:sequence}, {int:start}, false)', + [ + 'sequence' => $match[1], + 'start' => (int) $match[2], + 'db_error_skip' => true, + ], + ); + + return $result !== false; + } + + /** + * Splits SQL into the statements it is made of. + * + * Quoting has to be respected while splitting: a function body is one + * string, and the semicolons inside it end nothing. + * + * @param string $sql The SQL. + * @return array The statements, without the semicolons between them. + */ + private function statements(string $sql): array + { + $statements = []; + $current = ''; + $quote = ''; + $length = \strlen($sql); + + for ($i = 0; $i < $length; $i++) { + $char = $sql[$i]; + + if ($quote !== '') { + // Inside a dollar quoted string, only its own tag ends it. + if ($quote[0] === '$') { + if (substr($sql, $i, \strlen($quote)) === $quote) { + $current .= $quote; + $i += \strlen($quote) - 1; + $quote = ''; + + continue; + } + } elseif ($char === '\\' && $i + 1 < $length) { + $current .= $char . $sql[++$i]; + + continue; + } elseif ($char === $quote) { + $quote = ''; + } + + $current .= $char; + + continue; + } + + if ($char === "'" || $char === '"' || $char === '`') { + $quote = $char; + } elseif ($char === '$' && preg_match('~^\$[A-Za-z_]*\$~', substr($sql, $i), $matches) === 1) { + $quote = $matches[0]; + $current .= $quote; + $i += \strlen($quote) - 1; + + continue; + } elseif ($char === ';') { + if (trim($current) !== '') { + $statements[] = trim($current); + } + + $current = ''; + + continue; + } + + $current .= $char; + } + + if (trim($current) !== '') { + $statements[] = trim($current); + } + + return $statements; + } +} diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index 2b3a633535..160001fb6c 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -25,6 +25,8 @@ use SMF\Maintenance\GenericSubStep; use SMF\Maintenance\Maintenance; use SMF\Maintenance\Migration; +use SMF\Maintenance\MigrationData; +use SMF\Maintenance\MigrationRollback; use SMF\Maintenance\Step; use SMF\Maintenance\Utf8ConverterStep; use SMF\QueryString; @@ -36,6 +38,7 @@ use SMF\User; use SMF\UserDataset; use SMF\Utils; +use SMF\Uuid; /** * Upgrade tool. @@ -204,6 +207,23 @@ class Upgrade extends ToolsBase implements ToolsInterface ], ]; + /** + * @var array + * + * Settings that are not recorded before being changed. The upgrade's own + * progress data is its bookkeeping and means nothing afterwards; the rest + * are things that should not be sitting in a database table, since a copy + * of the database password inside the database would be in every dump + * taken from then on. + */ + public const UNRECORDED_SETTINGS = [ + 'maintenance_tool_progress', + 'db_passwd', + 'db_user', + 'image_proxy_secret', + 'auth_secret', + ]; + /******************* * Public properties *******************/ @@ -333,6 +353,25 @@ class Upgrade extends ToolsBase implements ToolsInterface */ protected string $start_smf_version = ''; + /** + * @var string + * + * Identifies this upgrade, and stays the same when it is started again + * after being interrupted. What a migration records against it therefore + * describes the database as this upgrade found it, not as a later attempt + * found it half changed. Read through getRunId(), which knows where it + * lives. + */ + protected string $id_run = ''; + + /** + * @var bool + * + * Whether the database's functions have been looked at yet. They are the + * same for every table, so they are read once rather than per table. + */ + protected bool $routines_recorded = false; + /** * @var null|string * @@ -875,6 +914,18 @@ public function upgradeOptions(): bool Utils::$context['sm_stats_configured'] = !empty(Config::$modSettings['allow_sm_stats']) || !empty(Config::$modSettings['enable_sm_stats']); + // An upgrade that stopped part way leaves the admin with two ways out. + // Carrying on is the one the rest of this page is about; putting the + // database back as it was is the other, and is only worth offering when + // there is a backup to put back. + $rollback = new MigrationRollback(); + + Utils::$context['rollback_offer'] = $rollback->unfinished(); + + if (!empty($_POST['rollback']) && Utils::$context['rollback_offer'] !== null) { + return $this->rollBackUpgrade($rollback, Utils::$context['rollback_offer']); + } + // If we've not submitted then we're done. if (!Sapi::isCLI() && empty($_POST['upcont'])) { Utils::$context['continue'] = true; @@ -889,6 +940,11 @@ public function upgradeOptions(): bool Db::load(); Db::$db->setSqlMode('strict'); + // The admin has pressed Continue, so the upgrade is underway and the + // run it belongs to starts here. Opening it before anything is written + // is what gives the settings this step changes somewhere to be recorded. + $this->getRunId(); + $file_settings = []; $db_settings = []; @@ -1052,10 +1108,16 @@ public function backupDatabase(): bool $tables = Db::$db->list_tables($db, $filter); - // Filter out backup tables. - $table_names = array_filter($tables, function ($table) { - return !str_starts_with($table, 'backup_'); - }); + // Filter out backup tables, and the upgrader's own bookkeeping. A copy + // of the record of what was copied is of no use to anybody putting a + // forum back, and restoring it would put back an older account of the + // run doing the restoring. + // array_values because the substep is used as an index into this list, + // and array_filter leaves a hole where each name it dropped had been. + $table_names = array_values(array_filter($tables, function ($table) { + return !str_starts_with($table, 'backup_') + && !str_starts_with($table, MigrationData::prefix() . 'migration_'); + })); Maintenance::$total_substeps = \count($table_names); @@ -1300,6 +1362,12 @@ public function finalize(): bool $this->updateSettingsFile($file_settings); + // The run closes after the last thing the upgrade writes, so that + // db_character_set and db_mb4 are recorded while it is still open. + // Whatever upgrades this forum next is a different run, and records + // what it finds rather than reading the notes this one left. + MigrationData::finishRun($this->getRunId(), SMF_VERSION); + // We're done! $this->logProgress(Lang::getTxt('log_upgrade_complete', file: 'Maintenance')); Maintenance::$overall_percent = 100; @@ -1380,13 +1448,305 @@ public function backupRecommended(): bool */ public function doBackupTable($table): bool { - return Db::$db->backup_table($table, 'backup_' . $table); + $run = $this->getRunId(); + + // backup_table() drops the backup before it writes it, so a run that + // is started again would replace a copy of the database as it was with + // a copy of it half migrated. The copy this run already made is the + // one worth having. + if ($run !== '' && MigrationData::get($run, MigrationData::TYPE_BACKUP, $table) !== null) { + return true; + } + + $this->recordRoutines(); + $this->recordDefinition($table); + + if (Db::$db->backup_table($table, 'backup_' . $table) === false) { + return false; + } + + if ($run !== '') { + MigrationData::save($run, static::class, MigrationData::TYPE_BACKUP, $table, (string) time()); + } + + return true; + } + + /** + * Writes settings to Settings.php, noting what they held first. + * + * @param array $config_vars The settings to write. + * @param bool|null $keep_quotes Whether to keep quotes in the values. + * @param bool $rebuild Whether to rebuild the file from scratch. + * @return bool Whether the file was written. + */ + public function updateSettingsFile(array $config_vars, ?bool $keep_quotes = null, bool $rebuild = false): bool + { + $this->recordSettings(array_keys($config_vars)); + + return parent::updateSettingsFile($config_vars, $keep_quotes, $rebuild); } /****************** * Internal methods ******************/ + /** + * Undoes an upgrade that stopped part way, and stops. + * + * Whatever happens, this does not carry on into the rest of the upgrade. + * An admin who asked for the database to be put back did not ask for it to + * be upgraded again straight afterwards. + * + * @param MigrationRollback $rollback The thing that does the work. + * @param array $run The run being undone. + * @return bool Always false, since the upgrade is not going any further. + */ + private function rollBackUpgrade(MigrationRollback $rollback, array $run): bool + { + $this->logProgress(Lang::getTxt('log_rollback_starting', ['version' => $run['version_from']], file: 'Maintenance')); + + Db::load(); + + if (!$rollback->rollback($run['id_run'])) { + Maintenance::$fatal_error = Lang::getTxt('log_rollback_failed', ['error' => $rollback->error], file: 'Maintenance'); + + return false; + } + + foreach ($rollback->failures as $failure) { + Maintenance::$warnings[] = Lang::getTxt('log_rollback_refused', ['statement' => $failure], file: 'Maintenance'); + } + + $this->logProgress(Lang::getTxt('log_rollback_done', ['count' => \count($rollback->log)], file: 'Maintenance')); + + // This process started while the forum was on the version it has just + // been taken off, and the progress data would say so on the way out. + // The next upgrade would read that, believe the work was already done + // and skip the migrations the database now needs again. The run knows + // what the forum was on before it touched anything, which is what it is + // on again now; the copy in Config::$modSettings was read before the + // rollback and still names the version that has just gone. + $this->start_smf_version = str_replace(' ', '.', strtolower((string) $run['version_from'])); + + $this->updateSettingsFile(['maintenance_tool_progress' => '']); + + // Asking for a rollback opened a run of its own, which copied nothing. + // Left open, the next upgrade would take it up as unfinished work. + MigrationData::discardEmptyRuns(); + + Utils::$context['rollback_done'] = true; + Utils::$context['continue'] = false; + + return false; + } + + /** + * Records what the settings being written held beforehand. + * + * A database put back to the shape it had is not a forum that works if + * Settings.php still describes the one it was upgraded to: db_character_set + * and db_mb4 in particular say what the database is, and after a rollback + * they would be saying it about a database that no longer exists. + * + * Only the settings the upgrade is about to change are recorded, and only + * the first time each is touched, so this is a note of what to put back + * rather than a copy of the file. Settings that are nobody else's business + * are left out: Settings.php holds the database password, and a copy of it + * inside the database would be in every dump taken from then on. + * + * @param array $names Names of the settings about to be written. + */ + private function recordSettings(array $names): void + { + // A run of its own is no use here. The settings are put back beside the + // tables the run copied, so one that copied nothing has nothing to put + // them back into, and the last thing an upgrade should leave behind is + // a run that was opened by the act of finishing. + $run = $this->getRunId(false); + + if ($run === '') { + return; + } + + // Read the file as it stands rather than as it stood when the request + // began. An upgrade writes Settings.php more than once, and the default + // refuses a file touched since TIME_START -- which, from the second + // write onwards, is a file this upgrade wrote itself. + $current = Config::getCurrentSettings(@filemtime(SMF_SETTINGS_FILE) ?: null); + + if (!\is_array($current)) { + return; + } + + foreach ($names as $name) { + if ( + \in_array($name, self::UNRECORDED_SETTINGS) + || MigrationData::get($run, MigrationData::TYPE_SETTING, $name) !== null + ) { + continue; + } + + MigrationData::save( + $run, + static::class, + MigrationData::TYPE_SETTING, + $name, + (string) json_encode([ + 'set' => \array_key_exists($name, $current), + 'value' => $current[$name] ?? null, + ]), + ); + } + } + + /** + * Records the functions the database held before the migrations reach it. + * + * A table's definition is not enough on its own. An index can be built over + * an expression rather than a column -- members has one over + * indexable_month_day(birthdate) -- and the SQL that rebuilds the table + * names the function without saying what it is. Recording them together is + * what makes the pair worth keeping. + * + * Only PostgreSQL has anything to record here. MySQL is given none of its + * own, and the functions SMF adds to PostgreSQL are the ones in the public + * schema, since everything the server ships with lives in pg_catalog. + */ + private function recordRoutines(): void + { + if ($this->routines_recorded || Db::$db->title !== POSTGRE_TITLE) { + return; + } + + $this->routines_recorded = true; + + $run = $this->getRunId(); + + if ($run === '' || MigrationData::all($run, MigrationData::TYPE_ROUTINE) !== []) { + return; + } + + // Every routine is named, because the names are what says which ones + // the upgrade went on to add. Only a plain function can be written + // down though: pg_get_functiondef() refuses an aggregate, and an + // aggregate SMF did not create is one it has no business rebuilding. + $definitions = []; + + $request = Db::$db->query( + 'SELECT p.oid::regprocedure AS signature, pg_get_functiondef(p.oid) AS definition + FROM pg_proc AS p + INNER JOIN pg_namespace AS n ON (n.oid = p.pronamespace) + WHERE n.nspname = {string:schema} + AND p.prokind = {string:plain}', + [ + 'schema' => 'public', + 'plain' => 'f', + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $definitions[$row['signature']] = $row['definition']; + } + + Db::$db->free_result($request); + + $request = Db::$db->query( + 'SELECT p.oid::regprocedure AS signature + FROM pg_proc AS p + INNER JOIN pg_namespace AS n ON (n.oid = p.pronamespace) + WHERE n.nspname = {string:schema} + ORDER BY signature', + [ + 'schema' => 'public', + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + MigrationData::save( + $run, + static::class, + MigrationData::TYPE_ROUTINE, + $row['signature'], + $definitions[$row['signature']] ?? '', + ); + } + + Db::$db->free_result($request); + } + + /** + * Records what a table looked like before the migrations reach it. + * + * The backup holds the rows. This holds the shape they were in: the SQL + * that would build the table again, with its indexes, its keys and, on + * PostgreSQL, a sequence of its own. Without it a backup table is a set of + * columns and nothing else, which is not enough to put a forum back. + * + * Only the first pass of a run records anything. A run that was + * interrupted and started again reaches this a second time, over a + * database the migrations have already changed, and what it would write + * then is not what the admin wanted a copy of. + * + * @param string $table Name of the table, with the prefix on it. + */ + private function recordDefinition(string $table): void + { + $run = $this->getRunId(); + + if ($run === '') { + return; + } + + if (MigrationData::get($run, MigrationData::TYPE_DEFINITION, $table) !== null) { + return; + } + + MigrationData::save( + $run, + static::class, + MigrationData::TYPE_DEFINITION, + $table, + Db::$db->table_sql($table), + ); + } + + /** + * What identifies this upgrade, making one if there is not one yet. + * + * A run stays open until something says it finished, so a process that was + * killed leaves its row behind and this finds it again. The progress data + * in Settings.php could not do this: it is written by preExit(), which the + * command line reaches only once the upgrade has finished, and which a + * killed process never reaches at all. + * + * @return string The run's id. + */ + private function getRunId(bool $start = true): string + { + if ($this->id_run !== '') { + return $this->id_run; + } + + if (!MigrationData::ensure()) { + return ''; + } + + $run = MigrationData::currentRun(); + + if ($run === '' && !$start) { + return ''; + } + + if ($run === '') { + $run = (string) Uuid::create(); + + MigrationData::startRun($run, $this->start_smf_version, $this->user['id'] ?? 0); + } + + return $this->id_run = $run; + } + /** * Prepare the configuration to handle support with some older installs. */ @@ -1679,6 +2039,16 @@ private function performSubsteps(array $substeps, int $offset = 0, ?int $total = while (Maintenance::getCurrentSubStep() - $offset < \count($substeps)) { $substep = $substeps[Maintenance::getCurrentSubStep() - $offset]; + // Where this run has got to, somewhere a killed process cannot + // take with it. The step and substep themselves live in the query + // string, which goes when the request does. + MigrationData::recordPosition( + $this->getRunId(), + Maintenance::getCurrentStep(), + Maintenance::getCurrentSubStep(), + Maintenance::getCurrentStart(), + ); + $this->logProgress(' +++ ' . $substep->name, true); // If this is not a canidate for us to execute, skip it. diff --git a/Themes/default/UpgradeTemplate.php b/Themes/default/UpgradeTemplate.php index 93350854ca..bb2c32c7bc 100644 --- a/Themes/default/UpgradeTemplate.php +++ b/Themes/default/UpgradeTemplate.php @@ -19,6 +19,7 @@ use SMF\Lang; use SMF\Maintenance\Maintenance; use SMF\Sapi; +use SMF\Time; use SMF\Utils; /** @@ -306,6 +307,31 @@ public static function upgradeOptions(): void return; } + // An upgrade that stopped part way is the only time putting the database + // back is offered. The admin is standing in front of a half upgraded + // forum and has two ways out of it. + if (!empty(Utils::$context['rollback_done'])) { + echo ' +