diff --git a/admin/database_updates/2.6.8.php b/admin/database_updates/2.6.8.php new file mode 100644 index 000000000..f9db45453 --- /dev/null +++ b/admin/database_updates/2.6.8.php @@ -0,0 +1,20 @@ + Update can now hand an update to cron rather than running it inside the + // request: the page stamps this column and cron/app_update.php takes it and runs + // scripts/update_cli.php in its own process. NULL means nothing is queued, which is why + // the job cannot update an install that did not ask for one. + + mysqli_query($mysqli, "ALTER TABLE `settings` ADD COLUMN IF NOT EXISTS `config_update_queued_at` datetime DEFAULT NULL"); + + // Seeded here as well as by the dispatcher, which only creates rows on its next pass - + // without this, an update queued in the minutes after an upgrade would have no row to + // set cron_job_run_now on and would wait for that pass instead of starting. + mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = 'app_update', cron_job_enabled = 0, cron_job_schedule = 'Daily', cron_job_daily_at = '05:00'"); diff --git a/admin/post/update.php b/admin/post/update.php index 5e28fc130..d999dd1c9 100644 --- a/admin/post/update.php +++ b/admin/post/update.php @@ -2,12 +2,53 @@ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); +/* + * Hands the update to cron instead of running it in this request. scripts/update_cli.php + * replaces the files this request is executing from and then applies the migrations that + * came with them, which is not something to do half way through a page load - and on a host + * where PHP cannot run external commands it is the only way to update the application at all. + * + * The row is written as well as the settings column: config_update_queued_at is what + * cron/app_update.php acts on, and cron_job_run_now is what gets the dispatcher to look + * before the job's own schedule comes round. + */ +if (isset($_GET['queue_update'])) { + + validateCSRFToken(); + + enforceAdminPermission(); + + // The files can be newer than the schema - that is the window this whole page exists to + // close - and the column the queue is written to arrives with a migration + if (!settingsColumnExists($mysqli, 'config_update_queued_at')) { + flashAlert("Apply the database update first - queueing needs a schema change this install has not caught up with yet.", 'error'); + redirect(); + } + + mysqli_query($mysqli, "UPDATE settings SET config_update_queued_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1"); + mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'app_update'"); + + logAudit("App", "Update", "$session_name queued an update to be applied by cron"); + + flashAlert("Update queued - cron will start it within a minute."); + + redirect(); + +} + if (isset($_GET['update'])) { validateCSRFToken(); enforceAdminPermission(); + // Reached only from buttons this install cannot draw without a shell, so anything + // arriving here without one is a crafted request rather than somebody's mistake + if (!shellCommandsAvailable()) { + flashAlert("PHP on this server cannot run Git. Queue the update instead and cron will apply it.", 'error'); + redirect(); + } + // git fetch downloads the latest from the remote without merging or rebasing anything. // The hard reset then throws away every local change and makes the working tree match // the tracked branch exactly. diff --git a/admin/update.php b/admin/update.php index 351358b7d..519475660 100644 --- a/admin/update.php +++ b/admin/update.php @@ -6,32 +6,80 @@ require_once "../includes/database_version.php"; $repo_branch = getRepoBranch(); $remote_ref = escapeshellarg("origin/$repo_branch"); -$updates = checkForUpdates(); +/* + * Everything git can tell us needs a shell. Where PHP cannot run one - shared hosting, a + * hardened php.ini, an FPM pool locked down while the command line is not - the page cannot + * say whether an update is waiting and the web server cannot apply one either, so the + * application half of this page is replaced by the queue, which cron carries out. The + * database half is plain PHP and works everywhere. + */ +$shell_available = shellCommandsAvailable(); -$current_version = $updates->current_version; -$fetch_ok = $updates->result === 0; +$current_version = ''; +$fetch_ok = true; +$updates = null; // Commits sitting between this working tree and the remote branch. Fields are separated // by \x1f rather than having git build the table markup, because a commit subject comes // from outside this install and used to reach the page as unescaped HTML. $pending_commits = []; -$git_log = shell_exec("git log HEAD..$remote_ref --pretty=format:'%h%x1f%ar%x1f%s'"); +if ($shell_available) { -foreach (explode("\n", trim((string) $git_log)) as $commit_line) { + $updates = checkForUpdates(); - if ($commit_line === '') { - continue; - } + $current_version = $updates->current_version; + $fetch_ok = $updates->result === 0; - $commit_fields = explode("\x1f", $commit_line, 3); + $git_log = shell_exec("git log HEAD..$remote_ref --pretty=format:'%h%x1f%ar%x1f%s'"); + + foreach (explode("\n", trim((string) $git_log)) as $commit_line) { + + if ($commit_line === '') { + continue; + } + + $commit_fields = explode("\x1f", $commit_line, 3); + + if (count($commit_fields) === 3) { + $pending_commits[] = $commit_fields; + } - if (count($commit_fields) === 3) { - $pending_commits[] = $commit_fields; } } +/* + * The queue. Maintenance > Update writes config_update_queued_at and asks the dispatcher for + * an immediate run; cron/app_update.php takes the request and runs scripts/update_cli.php in + * its own process. Neither column is in the global settings load - one page needs them. + * + * The column is checked for rather than assumed. This page has to render on an install whose + * files are newer than its schema, because that is the state it exists to get people out of. + */ +$update_queue_available = settingsColumnExists($mysqli, 'config_update_queued_at'); + +$update_queued_at = null; +$cron_last_dispatch_at = null; +$update_job = null; + +if ($update_queue_available) { + + $update_cron_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_queued_at, config_cron_last_dispatch_at FROM settings WHERE company_id = 1")); + + $update_queued_at = $update_cron_row['config_update_queued_at'] ?? null; + $cron_last_dispatch_at = $update_cron_row['config_cron_last_dispatch_at'] ?? null; + + $update_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_last_run_at, cron_job_last_status, cron_job_last_error, cron_job_last_error_at FROM cron_jobs WHERE cron_job_name = 'app_update' LIMIT 1")); + +} + +// Queueing is only worth offering if something is going to pick the request up +$cron_is_running = $update_queue_available + && !empty($config_enable_cron) + && !empty($cron_last_dispatch_at) + && strtotime($cron_last_dispatch_at) > strtotime('-5 minutes'); + // version_compare, not > - "2.6.10" is less than "2.6.9" as a plain string comparison, so // the plain comparison silently stops offering database updates once a minor reaches 10. $db_update_available = version_compare(LATEST_DATABASE_VERSION, CURRENT_DATABASE_VERSION, '>'); @@ -45,7 +93,7 @@ $app_update_available = !empty($pending_commits);
exec and shell_exec disabled, so this page can neither check for
+ application updates nor apply one. Queue an update instead: cron runs it from the command line, which
+ is usually not restricted the same way. Database updates are plain PHP and are unaffected.
+ = escapeHtml(substr((string) $current_version, 0, 7)) ?>= $current_version === '' ? '—' : escapeHtml(substr((string) $current_version, 0, 7)) ?>= escapeHtml($update_job['cron_job_last_error']) ?>+ Recorded = escapeHtml((string) $update_job['cron_job_last_error_at']) ?>. Clear it from + Maintenance > Cron once it has been dealt with. +
- = count($pending_commits) ?> commit= count($pending_commits) === 1 ? '' : 's' ?>
- behind = escapeHtml("origin/$repo_branch") ?>.
-
+ = count($pending_commits) ?> commit= count($pending_commits) === 1 ? '' : 's' ?>
+ behind = escapeHtml("origin/$repo_branch") ?>.
+
+ This server cannot check = escapeHtml("origin/$repo_branch") ?>, so there may
+ or may not be anything waiting. Queueing an update when there is nothing to do is harmless.
+
- Update App runs git pull. Force Update discards every local change and resets
- the files to = escapeHtml("origin/$repo_branch") ?> - use it only when a
- normal update will not apply.
+
+ Update App runs git pull. Force Update discards every local change and resets
+ the files to = escapeHtml("origin/$repo_branch") ?> - use it only when a
+ normal update will not apply. Both run inside this request and stop if PHP runs out of time.
+
+
+ Queue Update hands the job to cron, which runs
+ scripts/update_cli.php in its own process - it updates the files and then the
+ database, with no request timeout, as the user that owns the files. It resets the files to
+ = escapeHtml("origin/$repo_branch") ?>, so local changes to them are lost.
+
+ Cron is not checking in, so a queued update will sit there
+ until it is.
+
+
+ Apply the database update below first. Queueing an update needs a schema
+ change this install has not caught up with yet. From a shell,
+ php scripts/update_cli.php does both in one step.
+