fix: installer postflight honest about partial/failed install
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 37s
Generic: Project CI / Lint & Validate (pull_request) Successful in 33s
Universal: PR Check / Validate PR (pull_request) Failing after 28s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 32s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled

Joomla logs a failed child extension but still runs the package
postflight, so the 'installed successfully' message could appear on a
broken install. In postflight, before the success/next-steps messages:

- verify every bundled child declared in the package manifest actually
  registered in #__extensions (match element+type, and folder=group for
  plugins), and
- verify the component's schema tables (parsed dynamically from the
  installed install.mysql.sql) exist in the DB.

If anything is missing, enqueue an error listing it and RETURN before the
success message. Fail-open (try/catch -> nothing missing) so a transient
manifest/DB/IO glitch can't fail a good install. Unconditional
housekeeping (download-key restore, menu fixes) still runs regardless.
Names escaped with htmlspecialchars; table/child lists derived
dynamically, never hard-coded.

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
This commit is contained in:
2026-07-06 10:49:49 -05:00
parent 0d832e60e9
commit 6b4f98869a
2 changed files with 157 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@
- The backend controller, Web Services API controller, and legacy `cli/mokosuitebackup.php` now run backups through the shared `BackupRunner` (gaining the normalized complete/warning/fail status) instead of instantiating `BackupEngine` directly.
### Fixed
- Package installer is now **honest about success**: the postflight no longer prints "installed successfully" / next-steps when the install actually failed or only partially completed. Joomla logs a failed child extension but still runs the package postflight, so the postflight now (a) verifies every bundled child declared in the manifest actually registered in `#__extensions` (matched by element + type, and folder/group for plugins) and (b) verifies the component's schema tables — derived dynamically from the installed `install.mysql.sql` — actually exist. If anything is missing it enqueues an error listing what's missing and stops before the success message. Fail-open: any manifest/DB/IO glitch is treated as "nothing missing" so a transient error never turns a good install into a false failure. Unconditional housekeeping (e.g. download-key restore) still runs regardless.
- Pre-update/uninstall backup no longer **white-screens** the update on large sites. The synchronous backup that runs inside the extension update/uninstall request now raises `max_execution_time`/`memory_limit` (and `ignore_user_abort`) like the web-cron path, so it can't exhaust the request's default limits mid-backup. (Core Joomla updates additionally get a full-screen backup screen — see below.)
- Archive names for **CLI/console-triggered backups** no longer come out as `joomla.invalid_…`. The `[HOST]` placeholder took `$_SERVER['HTTP_HOST']` verbatim, but Joomla's console fills that with the reserved sentinel host `joomla.invalid`; the resolver now treats that (like empty/`localhost`) as unusable and falls back to the configured `live_site` host, then the system hostname. (Set the site's *live_site* to get the exact domain in CLI-built names.)
- Standalone restore script generation no longer aborts backups with `str_replace() expects at least 3 arguments, 2 given`. `MokoRestore::generateStandaloneScript()` had a `str_replace()` call (the "Backup Archive" pre-check rewrite) that was missing its `$php` subject argument, so **every** standalone-mode backup fatally errored while "Generating standalone restore.php…" — the archive still finalized and uploaded, but no `restore.php` was ever produced (the true root cause behind #226). (#226)
+156
View File
@@ -188,6 +188,35 @@ class Pkg_MokoSuiteBackupInstallerScript
PREPARE/EXECUTE migration that MySQL 8 rejected in Joomla's installer). */
$this->dropLegacyRemoteColumns();
/* Honesty gate: Joomla logs a failed child extension but still runs the
package postflight, so verify every bundled child actually registered
before claiming success. If any are missing, say so and do NOT print the
success / next-steps messages. (All housekeeping above runs regardless.)
Fail-open — a manifest/DB glitch returns no missing children. */
$problems = $this->findMissingPackageChildren($parent);
foreach ($this->findMissingComponentTables() as $table) {
$problems[] = 'table ' . $table;
}
if (!empty($problems)) {
$safe = array_map(
static fn($m) => htmlspecialchars($m, ENT_QUOTES, 'UTF-8'),
$problems
);
$detail = count($safe) > 3
? count($safe) . ' bundled extensions/tables are missing'
: 'missing: ' . implode(', ', $safe);
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteBackup did not install correctly</strong> — ' . $detail
. '. Review the installation log and re-install.',
'error'
);
return;
}
/* Install completion notice (install and update) */
$this->installSuccessful();
@@ -208,6 +237,133 @@ class Pkg_MokoSuiteBackupInstallerScript
}
}
/**
* Verify every bundled child extension declared in the package manifest
* actually registered in #__extensions. Joomla logs a failed child but does
* not fail the package, so this answers "did the package really install?"
* honestly. Returns human-readable labels for any missing children (empty =
* all present). Matching mirrors extension uniqueness: element + type, and
* for plugins also folder (group), since plugins are only unique that way.
*
* Fail-open: any manifest/DB error returns [] so a transient glitch can never
* turn a good install into a false failure.
*
* @param InstallerAdapter $parent Package installer adapter
*
* @return string[] Labels of missing children (empty when everything installed)
*/
private function findMissingPackageChildren(InstallerAdapter $parent): array
{
try {
$manifest = $parent->getParent()->getManifest();
if (!$manifest instanceof \SimpleXMLElement
|| !isset($manifest->files) || !isset($manifest->files->file)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$missing = [];
foreach ($manifest->files->file as $file) {
$attrs = $file->attributes();
$element = isset($attrs['id']) ? (string) $attrs['id'] : '';
$extType = isset($attrs['type']) ? (string) $attrs['type'] : '';
if ($element === '' || $extType === '') {
continue;
}
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element))
->where($db->quoteName('type') . ' = ' . $db->quote($extType));
$group = '';
/* Plugins are only unique by element + folder (group). */
if ($extType === 'plugin' && isset($attrs['group'])) {
$group = (string) $attrs['group'];
if ($group !== '') {
$query->where($db->quoteName('folder') . ' = ' . $db->quote($group));
}
}
$db->setQuery($query);
if ((int) $db->loadResult() === 0) {
$label = $extType . ' "' . $element . '"';
if ($group !== '') {
$label .= ' (' . $group . ')';
}
$missing[] = $label;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Verify the component's schema actually installed. The component has no
* installer script of its own, so this package postflight is the only place
* to catch a component whose extension row exists but whose CREATE TABLE
* statements failed (e.g. an installer-rejected migration). Reads the
* *installed* install SQL, extracts every declared table, and confirms each
* exists. Table names are derived dynamically — nothing is hard-coded.
*
* Fail-open: any IO/DB/parse error returns [] so a glitch can't fail a good
* install.
*
* @return string[] Missing table names (empty when all present)
*/
private function findMissingComponentTables(): array
{
try {
$sqlFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/sql/install.mysql.sql';
if (!is_file($sqlFile)) {
return [];
}
$sql = file_get_contents($sqlFile);
if ($sql === false || $sql === '') {
return [];
}
if (!preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(#__[A-Za-z0-9_]+)/i', $sql, $m)) {
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$prefix = $db->getPrefix();
/* Existing tables, compared case-insensitively. */
$existing = array_flip(array_map('strtolower', $db->getTableList()));
$missing = [];
foreach (array_unique($m[1]) as $declared) {
$real = str_replace('#__', $prefix, $declared);
if (!isset($existing[strtolower($real)])) {
$missing[] = $real;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
/**
* Remove the orphaned component registration created when the component
* <name> briefly used the "Type - Name" display convention. Joomla derives a