diff --git a/scripts/restore_cli.php b/scripts/restore_cli.php index a4ddc917d..d08b92e70 100644 --- a/scripts/restore_cli.php +++ b/scripts/restore_cli.php @@ -130,7 +130,7 @@ echo "\nDone.\n"; echo "\nNext steps:\n"; echo " 1. Log in with the credentials that were in use when the backup was taken.\n"; echo " 2. If the backup is older than the code in this directory, finish the database update:\n"; -echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n"; +echo " php " . realpath(__DIR__) . "/update_cli.php\n"; echo " 3. Check Maintenance > Cron - the schedule came back with the database.\n"; exit(0); diff --git a/scripts/update_cli.php b/scripts/update_cli.php index 8cc7567e5..463f775c8 100644 --- a/scripts/update_cli.php +++ b/scripts/update_cli.php @@ -23,169 +23,163 @@ if ($currentUser !== $fileOwner) { require_once "../config.php"; require_once "../functions.php"; -// A function to print the help message so that we don't duplicate it -function printHelp() { - echo "Usage: php update_cli.php [options]\n\n"; - echo "Options:\n"; - echo " --help Show this help message.\n"; - echo " --update Update the application only (git pull).\n"; - echo " --force_update Update the application only, discarding local changes: git fetch\n"; - echo " and hard reset to the branch this install tracks.\n"; - echo " --update_db Update the database structure only.\n"; - echo "\nWith no options a full update is performed - the application first, then the\n"; - echo "database. That is the same as running --update --update_db.\n"; +/* + * This script takes no options. It updates the application and then the database, in that + * order, because new code against an old schema is what breaks an install mid-upgrade. + * + * The application update is a hard reset onto the branch this install tracks, so local + * modifications to tracked files are discarded. Untracked files are left alone, which is + * everything an install keeps for itself: config.php, uploads/ and the custom/ directories. + */ + +function printUsage($stream = STDOUT) { + fwrite($stream, "Usage: php update_cli.php\n\n"); + fwrite($stream, "Updates the application to the latest code on the branch this install tracks,\n"); + fwrite($stream, "discarding local changes to tracked files, and then applies any outstanding\n"); + fwrite($stream, "database updates. There are no options.\n"); } -// Define allowed options (removed 'user') -$allowed_options = [ - 'help', - 'update', - 'force_update', - 'update_db' -]; +/* + * Set on the child process this script starts for the database phase - see the comment + * further down. It is not part of the interface, and it is what stops that child from + * starting a child of its own. + */ +$database_phase_only = getenv('ITFLOW_UPDATE_PHASE') === 'database'; -// Parse command-line options -$options = getopt('', ['update', 'force_update', 'update_db', 'help']); +/* + * The switches this script used to take are gone. Anything on the command line is refused + * rather than ignored, so that an old --update_db call from a script or a set of notes + * cannot silently trigger a hard reset of the application instead. + */ +$arguments = array_slice($argv, 1); -// Check for invalid options by comparing argv against allowed options -$argv_copy = $argv; -array_shift($argv_copy); // Remove script name +if (!$database_phase_only && count($arguments) > 0) { -foreach ($argv_copy as $arg) { - if (substr($arg, 0, 2) === '--') { - // Extract the option name (everything after -- and before = if present) - $eqPos = strpos($arg, '='); - if ($eqPos !== false) { - $optName = substr($arg, 2, $eqPos - 2); - } else { - $optName = substr($arg, 2); - } - - // Check if option name is allowed - if (!in_array($optName, $allowed_options)) { - echo "Error: Unrecognized option: $arg\n\n"; - printHelp(); - exit(1); - } + if (in_array($arguments[0], ['--help', '-h', 'help'], true)) { + printUsage(); + exit; } -} -// If "help" is requested, show instructions and exit -if (isset($options['help'])) { - printHelp(); - exit; -} - -// If no recognized options are passed, update everything - the application and then the -// database. The switches stay surgical for anyone who wants one or the other, which is -// what --update_db is for after updating the files from the web UI. -if (count($options) === 0) { - $options['update'] = true; - $options['update_db'] = true; + fwrite(STDERR, "Error: this script takes no options.\n\n"); + printUsage(STDERR); + exit(1); } // Whether the working tree actually moved. Decides how the database phase runs below $app_updated = false; -// If "update" or "force_update" is requested -if (isset($options['update']) || isset($options['force_update'])) { +if (!$database_phase_only) { - // Recorded either side of the update so that "did anything change" is a commit - // comparison rather than a match on git's wording, which varies by version and locale - $head_before = trim((string) exec("git rev-parse HEAD 2>/dev/null")); + /* + * An install that was dropped in as a zip has no .git and cannot be updated this way, + * but its database still can, so the application phase is skipped rather than fatal. + * Checked on the filesystem rather than by asking git, so that a missing git binary + * still reports as the failure it is instead of looking like "not a checkout". + */ + if (!file_exists(dirname(__DIR__) . "/.git")) { - $output = []; + echo "This install is not a git checkout - skipping the application update.\n"; - if (isset($options['force_update'])) { - // Perform a hard reset onto the tracked branch. This named origin/master outright - // until 26.08, which moved any install tracking another branch onto master. - $remote_ref = escapeshellarg("origin/" . getRepoBranch()); + } else { + + $update_branch = getRepoBranch(); + $remote_ref = escapeshellarg("origin/" . $update_branch); + + // Recorded either side of the update so that "did anything change" is a commit + // comparison rather than a match on git's wording, which varies by version and locale + $head_before = trim((string) exec("git rev-parse HEAD 2>/dev/null")); + + // A hard reset throws local modifications away without ever saying what they were + $local_changes = []; + exec("git status --porcelain --untracked-files=no 2>/dev/null", $local_changes); + + if (count($local_changes) > 0) { + echo "Discarding local changes to " . count($local_changes) . " tracked file(s):\n"; + echo " " . implode("\n ", $local_changes) . "\n"; + } + + echo "Updating the application to origin/$update_branch\n"; + + // A hard reset onto the tracked branch. This named origin/master outright until + // 26.08, which moved any install tracking another branch onto master. + $output = []; exec("git fetch --all 2>&1", $output, $return_var); + if ($return_var === 0) { exec("git reset --hard $remote_ref 2>&1", $output, $return_var); } - } else { - // Perform a standard update (git pull) - exec("git pull 2>&1", $output, $return_var); - } - echo implode("\n", $output) . "\n"; + echo implode("\n", $output) . "\n"; - // git reports merge conflicts, a dirty working tree and network failures through its - // exit status. Without this the script printed the error, said "Update successful" - // and exited 0 - and would now go on to update the database as if all was well - if ($return_var !== 0) { - fwrite(STDERR, "Error: the application update failed - see the output above.\n"); - if (isset($options['update_db'])) { - fwrite(STDERR, "The database has been left alone. Fix the problem and run this script again, or run\nphp update_cli.php --update_db on its own to update only the database.\n"); + // git reports a missing remote, a bad ref and network failures through its exit + // status. Without this the script printed the error, said the update succeeded and + // exited 0 - and would now go on to update the database as if all was well + if ($return_var !== 0) { + fwrite(STDERR, "Error: the application update failed - see the output above.\n"); + fwrite(STDERR, "The database has been left alone. Fix the problem and run this script again.\n"); + exit(1); } - exit(1); - } - $head_after = trim((string) exec("git rev-parse HEAD 2>/dev/null")); - $app_updated = $head_before !== '' && $head_after !== '' && $head_before !== $head_after; + $head_after = trim((string) exec("git rev-parse HEAD 2>/dev/null")); + $app_updated = $head_before !== '' && $head_after !== '' && $head_before !== $head_after; - if ($app_updated) { - echo "Update successful\n"; + echo $app_updated ? "Update successful\n" : "The application was already up to date.\n"; } } -// If "update_db" is requested -if (isset($options['update_db'])) { - - /* - * If the application was just updated, this process is still running the code it - * started with - config.php, functions.php and everything functions.php loaded are - * the pre-update copies - while the migration runner and the migration files - * themselves get read fresh off the new tree. A migration calling a helper added in - * the same release then dies with "Call to undefined function". - * - * So hand the database phase to a new process, which loads the new code from the - * start. Its output and its exit status are this script's. A process that did not - * update anything is already running the code on disk and needs none of this. - */ - if ($app_updated && PHP_BINARY !== '' && function_exists('passthru')) { - echo "Running the database update against the updated code\n"; - passthru(escapeshellarg(PHP_BINARY) . " " . escapeshellarg(__FILE__) . " --update_db", $db_return_var); - exit($db_return_var); - } - - require_once "../includes/database_version.php"; - - $latest_db_version = LATEST_DATABASE_VERSION; - - // Fetch the current version from the database - $result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings LIMIT 1"); - $row = $result ? mysqli_fetch_assoc($result) : null; - $old_db_version = trim((string) ($row['config_current_database_version'] ?? '')); - - // No version to work from means no way to tell which migrations have already run, and - // starting from the first one against a partly built database breaks it - if ($old_db_version === '') { - fwrite(STDERR, "Error: could not read the current database version from the settings table.\n"); - fwrite(STDERR, "This install looks incomplete - finish it at /setup before updating the database.\n"); - exit(1); - } - - DEFINE("CURRENT_DATABASE_VERSION", $old_db_version); - - // Run the migrations - populates $database_updates_applied and $database_updates_error - require_once "../admin/database_updates.php"; - - foreach ($database_updates_applied as $applied_version) { - echo "Applied database update $applied_version\n"; - } - - if ($database_updates_error) { - $stopped_at_version = count($database_updates_applied) > 0 ? end($database_updates_applied) : $old_db_version; - fwrite(STDERR, "Error: database update failed at $database_updates_error\n"); - fwrite(STDERR, "The database is at version $stopped_at_version - re-running will resume at the failed update.\n"); - exit(1); - } - - if (count($database_updates_applied) > 0) { - echo "Database updated from version $old_db_version to $latest_db_version.\n"; - } else { - echo "Database is already at the latest version ($latest_db_version). No updates were applied.\n"; - } +/* + * If the application was just updated, this process is still running the code it started + * with - config.php, functions.php and everything functions.php loaded are the pre-update + * copies - while the migration runner and the migration files themselves get read fresh off + * the new tree. A migration calling a helper added in the same release then dies with + * "Call to undefined function". + * + * So hand the database phase to a new process, which loads the new code from the start. Its + * output and its exit status are this script's. A process that did not update anything is + * already running the code on disk and needs none of this. + */ +if ($app_updated && PHP_BINARY !== '' && function_exists('passthru')) { + echo "Running the database update against the updated code\n"; + putenv("ITFLOW_UPDATE_PHASE=database"); + passthru(escapeshellarg(PHP_BINARY) . " " . escapeshellarg(__FILE__), $db_return_var); + exit($db_return_var); +} + +require_once "../includes/database_version.php"; + +$latest_db_version = LATEST_DATABASE_VERSION; + +// Fetch the current version from the database +$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings LIMIT 1"); +$row = $result ? mysqli_fetch_assoc($result) : null; +$old_db_version = trim((string) ($row['config_current_database_version'] ?? '')); + +// No version to work from means no way to tell which migrations have already run, and +// starting from the first one against a partly built database breaks it +if ($old_db_version === '') { + fwrite(STDERR, "Error: could not read the current database version from the settings table.\n"); + fwrite(STDERR, "This install looks incomplete - finish it at /setup before updating the database.\n"); + exit(1); +} + +DEFINE("CURRENT_DATABASE_VERSION", $old_db_version); + +// Run the migrations - populates $database_updates_applied and $database_updates_error +require_once "../admin/database_updates.php"; + +foreach ($database_updates_applied as $applied_version) { + echo "Applied database update $applied_version\n"; +} + +if ($database_updates_error) { + $stopped_at_version = count($database_updates_applied) > 0 ? end($database_updates_applied) : $old_db_version; + fwrite(STDERR, "Error: database update failed at $database_updates_error\n"); + fwrite(STDERR, "The database is at version $stopped_at_version - re-running will resume at the failed update.\n"); + exit(1); +} + +if (count($database_updates_applied) > 0) { + echo "Database updated from version $old_db_version to $latest_db_version.\n"; +} else { + echo "Database is already at the latest version ($latest_db_version). No updates were applied.\n"; }