Feature: queued Updates which now eliminates the need for shell_exec from the webui, but if enabled you can still update from the webui the old way too

This commit is contained in:
johnnyq
2026-08-15 16:41:42 -04:00
parent d5ee0bff74
commit 4c4c172f59
8 changed files with 410 additions and 29 deletions

View File

@@ -0,0 +1,20 @@
<?php
/*
* ITFlow - Database update to version 2.6.8 (from 2.6.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Maintenance > 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'");

View File

@@ -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.

View File

@@ -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);
</div>
<div class="card-body">
<?php if (!$fetch_ok) { ?>
<?php if ($shell_available && !$fetch_ok) { ?>
<div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle me-2"></i>Cannot reach the Git remote</h5>
ITFlow updates itself with Git, so nothing below is current until this is fixed.
@@ -59,6 +107,15 @@ $app_update_available = !empty($pending_commits);
</div>
<?php } ?>
<?php if (!$shell_available) { ?>
<div class="alert alert-info">
<h5><i class="fas fa-fw fa-info-circle me-2"></i>This server cannot run Git from the web</h5>
PHP here has <code>exec</code> and <code>shell_exec</code> 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.
</div>
<?php } ?>
<div class="row">
<div class="col-md-3 col-6 mb-3">
<small class="text-secondary text-uppercase">Release</small>
@@ -82,13 +139,35 @@ $app_update_available = !empty($pending_commits);
</div>
<div class="col-md-3 col-6 mb-3">
<small class="text-secondary text-uppercase">Commit</small>
<div class="h5 mb-0"><code><?= escapeHtml(substr((string) $current_version, 0, 7)) ?></code></div>
<div class="h5 mb-0"><code><?= $current_version === '' ? '&mdash;' : escapeHtml(substr((string) $current_version, 0, 7)) ?></code></div>
</div>
</div>
<?php if (!empty($update_queued_at)) { ?>
<div class="alert alert-info">
<i class="fas fa-fw fa-clock me-2"></i>An update was queued at
<strong><?= escapeHtml($update_queued_at) ?></strong>.
<?php if ($cron_is_running) { ?>
Cron will start it within a minute; this page will show the new version once it finishes.
<?php } else { ?>
<strong>Nothing is going to pick it up</strong> - cron has not checked in recently or the master
switch is off. See <a href="cron.php" class="alert-link">Maintenance &gt; Cron</a>.
<?php } ?>
</div>
<?php } ?>
<?php if (!empty($update_job['cron_job_last_error'])) { ?>
<div class="alert alert-warning">
<h5><i class="fas fa-fw fa-exclamation-triangle me-2"></i>The last queued update did not finish</h5>
<pre class="bg-dark text-white p-2 mt-2 mb-2"><?= escapeHtml($update_job['cron_job_last_error']) ?></pre>
Recorded <?= escapeHtml((string) $update_job['cron_job_last_error_at']) ?>. Clear it from
<a href="cron.php" class="alert-link">Maintenance &gt; Cron</a> once it has been dealt with.
</div>
<?php } ?>
<hr>
<?php if (!$app_update_available && !$db_update_available) { ?>
<?php if ($shell_available && !$app_update_available && !$db_update_available) { ?>
<div class="text-center py-3">
<i class="far fa-3x fa-smile-wink text-dark"></i>
@@ -120,24 +199,57 @@ $app_update_available = !empty($pending_commits);
</p>
<?php } ?>
<?php if ($app_update_available) { ?>
<?php if ($app_update_available || !$shell_available) { ?>
<div class="mb-4">
<h6 class="text-uppercase text-secondary">Application files</h6>
<p class="mb-2">
<?= count($pending_commits) ?> commit<?= count($pending_commits) === 1 ? '' : 's' ?>
behind <code><?= escapeHtml("origin/$repo_branch") ?></code>.
</p>
<a class="btn btn-primary confirm-link" href="post.php?update&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-download me-2"></i>Update App
</a>
<a class="btn btn-outline-danger ms-2 confirm-link" href="post.php?update&force_update=1&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-hammer me-2"></i>Force Update
<?php if ($app_update_available) { ?>
<p class="mb-2">
<?= count($pending_commits) ?> commit<?= count($pending_commits) === 1 ? '' : 's' ?>
behind <code><?= escapeHtml("origin/$repo_branch") ?></code>.
</p>
<?php } else { ?>
<p class="mb-2">
This server cannot check <code><?= escapeHtml("origin/$repo_branch") ?></code>, so there may
or may not be anything waiting. Queueing an update when there is nothing to do is harmless.
</p>
<?php } ?>
<?php if ($shell_available) { ?>
<a class="btn btn-primary confirm-link" href="post.php?update&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-download me-2"></i>Update App
</a>
<a class="btn btn-outline-danger ms-2 confirm-link" href="post.php?update&force_update=1&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-hammer me-2"></i>Force Update
</a>
<?php } ?>
<?php if ($update_queue_available) { ?>
<a class="btn btn-dark <?= $shell_available ? 'ms-2' : '' ?> confirm-link" href="post.php?queue_update&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-clock me-2"></i>Queue Update
</a>
<?php } ?>
<p class="text-muted mt-2 mb-0">
<small>
Update App runs <code>git pull</code>. Force Update discards every local change and resets
the files to <code><?= escapeHtml("origin/$repo_branch") ?></code> - use it only when a
normal update will not apply.
<?php if ($shell_available) { ?>
Update App runs <code>git pull</code>. Force Update discards every local change and resets
the files to <code><?= escapeHtml("origin/$repo_branch") ?></code> - use it only when a
normal update will not apply. Both run inside this request and stop if PHP runs out of time.
<?php } ?>
<?php if ($update_queue_available) { ?>
Queue Update hands the job to cron, which runs
<code>scripts/update_cli.php</code> 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
<code><?= escapeHtml("origin/$repo_branch") ?></code>, so local changes to them are lost.
<?php if (!$cron_is_running) { ?>
<strong class="text-warning">Cron is not checking in, so a queued update will sit there
until it is.</strong>
<?php } ?>
<?php } else { ?>
<strong>Apply the database update below first.</strong> Queueing an update needs a schema
change this install has not caught up with yet. From a shell,
<code>php scripts/update_cli.php</code> does both in one step.
<?php } ?>
</small>
</p>
</div>

120
cron/app_update.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
/*
* ITFlow - Application update job
*
* Runs an update queued from Maintenance > Update. It never updates an install on its own:
* the only trigger is config_update_queued_at, which the Update page sets and this job takes
* before it starts anything, so a schedule, a Run Now or a crashed run cannot produce an
* unasked-for update.
*
* The update itself is scripts/update_cli.php in its OWN process. That script replaces the
* files this dispatcher is running from and then applies the database migrations that came
* with them, so it has to be a separate process - see the comment in that file. This job
* only starts it, waits, and records what happened.
*
* Because the files on disk change under the dispatcher, this job ends the dispatch cycle
* after a successful update rather than letting the jobs behind it be loaded half from the
* old release and half from the new one.
*/
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
// Ensure we're running from command line
if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
// Prevent overlapping runs of this script
$cron_lock_script = __FILE__;
require_once "includes/cron_lock.php";
require_once "../config.php";
// Set Timezone
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
$app_update_settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1"));
$config_enable_cron = intval($app_update_settings['config_enable_cron'] ?? 0);
if ($config_enable_cron == 0) {
cronJobStop("Cron: is not enabled\n");
}
// An install whose files are newer than its schema reaches this job before the migration
// that adds the column has run. Nothing can have been queued yet, and asking for it throws
if (!settingsColumnExists($mysqli, 'config_update_queued_at')) {
cronJobStop("The database update has not been applied yet\n");
}
$config_update_queued_at = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_queued_at FROM settings WHERE company_id = 1"))['config_update_queued_at'] ?? null;
if (empty($config_update_queued_at)) {
// Nothing was asked for. Reached by a Run Now from Maintenance > Cron, or by somebody
// putting this job on a schedule - neither is on its own a reason to update an install.
cronJobStop("No update queued\n");
}
/*
* Taken before the update runs, not after. An update that dies half way through - a failed
* migration, a machine losing power mid-checkout - must be looked at rather than retried
* unattended a minute later.
*/
mysqli_query($mysqli, "UPDATE settings SET config_update_queued_at = NULL WHERE company_id = 1");
$app_update_script = realpath(__DIR__ . "/../scripts/update_cli.php");
if ($app_update_script === false) {
throw new Exception("scripts/update_cli.php is missing - the update cannot be run.");
}
if (!function_exists('exec')) {
throw new Exception("PHP on the command line cannot start other processes (exec is disabled), so the update cannot be run.");
}
// Recorded either side of the run so that "did the files actually change" is a commit
// comparison rather than a match on wording. Empty on an install that is not a git checkout,
// where update_cli.php updates the database only and nothing moves underneath us.
$app_update_head_before = trim((string) exec("git rev-parse HEAD 2>/dev/null"));
echo "Running $app_update_script\n";
$app_update_output = [];
exec(escapeshellarg(PHP_BINARY) . " " . escapeshellarg($app_update_script) . " 2>&1", $app_update_output, $app_update_return);
$app_update_text = trim(implode("\n", $app_update_output));
echo $app_update_text . "\n";
$app_update_head_after = trim((string) exec("git rev-parse HEAD 2>/dev/null"));
$app_update_changed = $app_update_head_before !== ''
&& $app_update_head_after !== ''
&& $app_update_head_before !== $app_update_head_after;
/*
* Read by cron/cron.php. The files behind every job after this one have just been replaced,
* and this process is still running the release from before, so the cycle ends here and the
* next minute's dispatch runs the rest against one version of the code.
*/
if ($app_update_changed) {
$cron_dispatch_stop_cycle = true;
}
// The tail is what says why it stopped, and the whole output can run to dozens of lines.
// The very last line alone is often the suggested fix rather than the problem itself
// ("You could try sudo -u ..."), so keep a few of them.
$app_update_lines = array_values(array_filter(array_map('trim', $app_update_output), 'strlen'));
$app_update_summary = implode(" | ", array_slice($app_update_lines, -3));
if ($app_update_return !== 0) {
appNotify("Update", "The queued update failed - $app_update_summary", "/admin/update.php");
logAudit("App", "Update", "Cron ran a queued update which failed: $app_update_summary");
throw new Exception("update_cli.php exited $app_update_return - $app_update_summary");
}
appNotify("Update", "The queued update finished", "/admin/update.php");
logAudit("App", "Update", "Cron applied a queued update");

View File

@@ -22,6 +22,8 @@
* and every job after it in the list.
* - Jobs share one global scope. A job must set the variables it reads rather than
* assuming the state a fresh process would have given it.
* - A job that changes the code on disk must set $cron_dispatch_stop_cycle, so the jobs
* behind it are not loaded half from the old release and half from the new one.
*
* SCHEDULING
*
@@ -192,6 +194,15 @@ if (function_exists('backupDbHoldOpen')) {
// Recording which job was running at the time is the only trace of that left behind.
$cron_dispatch_running = null;
$cron_dispatch_started = null;
/*
* A job may end the cycle after itself by setting this to true. The application update does:
* once it has replaced the files on disk, every job still to come would be loaded into a
* process running the release from before. The next minute's dispatch runs them all against
* one version of the code.
*/
$cron_dispatch_stop_cycle = false;
register_shutdown_function(function () use (&$cron_dispatch_running, &$cron_dispatch_started, $mysqli) {
if ($cron_dispatch_running === null) {
return;
@@ -260,4 +271,9 @@ foreach (cronJobRegistry() as $cron_dispatch_job) {
$cron_dispatch_started = null;
cronLockRelease($cron_dispatch_lock);
if ($cron_dispatch_stop_cycle) {
echo "Cron: '{$cron_dispatch_job['name']}' ended the cycle - the next dispatch picks up the rest.\n";
break;
}
}

3
db.sql
View File

@@ -2306,6 +2306,7 @@ CREATE TABLE `settings` (
`config_backup_retention_days` int(11) NOT NULL DEFAULT 30,
`config_backup_retention_count` int(11) NOT NULL DEFAULT 5,
`config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full',
`config_update_queued_at` datetime DEFAULT NULL,
PRIMARY KEY (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -3150,4 +3151,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-08-09 13:29:14
-- Dump completed on 2026-08-15 16:40:00

View File

@@ -306,8 +306,67 @@ function getRepoBranch(): string
return $branch === '' ? 'master' : $branch;
}
/*
* Whether this PHP can run external commands at all. ITFlow updates itself with git, so the
* update path needs exec() and shell_exec(); hosts that disable them - shared hosting, a
* hardened php.ini, an FPM pool locked down while the CLI is not - can still update through
* cron, which runs under a different php.ini and its own settings.
*
* function_exists() already reports a disabled function as missing. disable_functions is
* read as well because some hardening extensions leave the function defined and refuse the
* call instead, and a fatal on the Update page is a poor way to find that out.
*/
function shellCommandsAvailable(): bool
{
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
foreach (['exec', 'shell_exec'] as $shell_function) {
if (!function_exists($shell_function) || in_array($shell_function, $disabled, true)) {
return false;
}
}
return true;
}
/*
* Whether a settings column exists yet.
*
* Maintenance > Update is the page that APPLIES database updates, so it has to keep working
* against a schema older than the code it is running - the window between the files being
* updated and the database catching up is exactly when somebody opens it. mysqli throws on
* an unknown column, so a page that reads a column newer than the oldest schema it might
* meet dies before it can render the button that fixes it.
*
* Anything else that runs before the migrations have caught up has the same problem, which
* is why this takes the column name rather than answering one question.
*/
function settingsColumnExists($mysqli, string $column): bool
{
$column = escapeSql($column);
$result = mysqli_query($mysqli, "SHOW COLUMNS FROM `settings` LIKE '$column'");
return $result && mysqli_num_rows($result) > 0;
}
function checkForUpdates() {
$updates = new stdClass();
// Nothing here can run without a shell. Reported as a failed check rather than left to
// fatal, because the nightly job calls this too and one host's php.ini must not take
// the whole cron cycle down with it.
if (!shellCommandsAvailable()) {
$updates->output = ["PHP on this server cannot run external commands, so ITFlow cannot check for updates."];
$updates->result = 127;
$updates->current_version = '';
$updates->latest_version = '';
$updates->update_message = "Cannot check for updates";
return $updates;
}
$remote_ref = escapeshellarg("origin/" . getRepoBranch());
// Fetch the latest code changes but don't apply them. stderr is merged in because git
@@ -324,7 +383,6 @@ function checkForUpdates() {
}
$updates = new stdClass();
$updates->output = $output;
$updates->result = $result;
$updates->current_version = $current_version;

View File

@@ -35,6 +35,9 @@
* Every job here checks config_enable_cron in its own header and stops itself when that
* switch is off. It is not a dispatcher-level gate - a new job has to make the check
* itself, and a job that skips it will keep running on an install that thinks cron is off.
*
* Order matters: the dispatcher works down this list. app_update replaces the files every
* job is loaded from, so it stays at the end and ends the cycle behind itself.
*/
function cronJobRegistry(): array
@@ -99,6 +102,16 @@ function cronJobRegistry(): array
'schedule' => 'Daily',
'daily_at' => '03:30',
],
[
'name' => 'app_update',
'label' => 'Application Update',
'script' => 'app_update.php',
'description' => 'Runs an update queued from Maintenance > Update. Does nothing unless one is queued.',
'schedule' => 'Daily',
'daily_at' => '05:00',
'enabled' => 0,
'interval_safe' => false,
],
];
}