From 4c4c172f594f42857255cf6086a92b662d39eecf Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 15 Aug 2026 16:41:42 -0400 Subject: [PATCH] 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 --- admin/database_updates/2.6.8.php | 20 ++++ admin/post/update.php | 41 ++++++++ admin/update.php | 166 ++++++++++++++++++++++++++----- cron/app_update.php | 120 ++++++++++++++++++++++ cron/cron.php | 16 +++ db.sql | 3 +- functions/app.php | 60 ++++++++++- includes/cron_jobs.php | 13 +++ 8 files changed, 410 insertions(+), 29 deletions(-) create mode 100644 admin/database_updates/2.6.8.php create mode 100644 cron/app_update.php 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);
- +
Cannot reach the Git remote
ITFlow updates itself with Git, so nothing below is current until this is fixed. @@ -59,6 +107,15 @@ $app_update_available = !empty($pending_commits);
+ +
+
This server cannot run Git from the web
+ PHP here has 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. +
+ +
Release @@ -82,13 +139,35 @@ $app_update_available = !empty($pending_commits);
Commit -
+
+ +
+ An update was queued at + . + + Cron will start it within a minute; this page will show the new version once it finishes. + + Nothing is going to pick it up - cron has not checked in recently or the master + switch is off. See Maintenance > Cron. + +
+ + + +
+
The last queued update did not finish
+
+ Recorded . Clear it from + Maintenance > Cron once it has been dealt with. +
+ +
- +
@@ -120,24 +199,57 @@ $app_update_available = !empty($pending_commits);

- +
Application files
-

- commit - behind . -

- - Update App - - - Force Update + +

+ commit + behind . +

+ +

+ This server cannot check , so there may + or may not be anything waiting. Queueing an update when there is nothing to do is harmless. +

+ + + +
+ Update App + + + Force Update + + + + + + Queue Update + +

- Update App runs git pull. Force Update discards every local change and resets - the files to - 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 - 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 + , 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. +

diff --git a/cron/app_update.php b/cron/app_update.php new file mode 100644 index 000000000..c8bcb2161 --- /dev/null +++ b/cron/app_update.php @@ -0,0 +1,120 @@ + 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"); diff --git a/cron/cron.php b/cron/cron.php index 35b45ce19..596ca3b52 100644 --- a/cron/cron.php +++ b/cron/cron.php @@ -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; + } } diff --git a/db.sql b/db.sql index c63684e88..af7c4f855 100644 --- a/db.sql +++ b/db.sql @@ -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 diff --git a/functions/app.php b/functions/app.php index b835e97be..7fa3f3e29 100644 --- a/functions/app.php +++ b/functions/app.php @@ -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; diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php index 00aea2a37..125ce152a 100644 --- a/includes/cron_jobs.php +++ b/includes/cron_jobs.php @@ -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, + ], ]; }