Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php
T
jmiller aefa46e0c4
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
fix: use warning status when backup succeeds but remote upload fails
Previously a successful backup with a failed remote upload was marked
as "complete", hiding the upload failure. Now these records get a
"warning" status with a yellow badge so operators can see at a glance
which backups didn't reach their remote destination.

Warning-status records are treated as usable backups throughout:
- Downloadable, browsable, and restorable (the archive is intact)
- Counted in dashboard stats, storage totals, and success streaks
- Included in purge operations and differential base lookups
- Shown with yellow "warning" badge in list, detail, and cpanel module
- Filterable via the status dropdown on Backup Records

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 14:14:06 -05:00

357 lines
10 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Pre-flight validation for backup operations.
*
* Runs before any backup record is created, catching problems early
* with clear messages instead of failing mid-backup. Returns a result
* with errors (blockers) and warnings (informational).
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class PreflightCheck
{
/** @var string[] Fatal issues that prevent backup from starting */
private array $errors = [];
/** @var string[] Non-fatal issues the user should know about */
private array $warnings = [];
/**
* Run all pre-flight checks for a backup profile.
*
* @param int $profileId Profile to validate
*
* @return array{pass: bool, errors: string[], warnings: string[]}
*/
public function run(int $profileId): array
{
try {
$db = Factory::getDbo();
} catch (\Exception $e) {
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
return $this->result();
}
// Load profile
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
} catch (\Exception $e) {
$this->errors[] = 'Cannot load profile: ' . $e->getMessage();
return $this->result();
}
if (!$profile) {
$this->errors[] = 'Profile not found: #' . $profileId;
return $this->result();
}
if (!$profile->published) {
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
return $this->result();
}
$this->checkPhpExtensions($profile);
$this->checkBackupDirectory($profile);
$this->checkDiskSpace($profile, $db);
$this->checkRunningBackup($profile, $db);
$this->checkExcludedTables($profile, $db);
$this->checkRemoteCredentials($profile);
return $this->result();
}
/**
* Check that required PHP extensions are loaded.
*/
private function checkPhpExtensions(object $profile): void
{
$required = ['pdo', 'pdo_mysql', 'mbstring'];
// ZIP is required unless using tar.gz
$format = $profile->archive_format ?? 'zip';
if ($format === 'zip') {
$required[] = 'zip';
}
foreach ($required as $ext) {
if (!extension_loaded($ext)) {
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
}
}
// curl is only needed for remote upload and ntfy notifications
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|| !empty($profile->ntfy_topic);
if ($needsCurl && !extension_loaded('curl')) {
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
}
}
/**
* Check that the backup directory exists and is writable.
*/
private function checkBackupDirectory(object $profile): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
// Resolve placeholders using a temporary resolver
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
$this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir
. ' — directory cannot be validated until backup runs';
return;
}
if (!is_dir($resolvedDir)) {
// Try to create it
if (!@mkdir($resolvedDir, 0755, true)) {
$lastError = error_get_last();
$reason = $lastError['message'] ?? 'unknown reason';
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
. ' (' . $reason . ')';
return;
}
}
if (!is_writable($resolvedDir)) {
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
}
}
/**
* Check available disk space against the last backup size + 20% buffer.
* Skipped if no previous backup exists for this profile.
*/
private function checkDiskSpace(object $profile, object $db): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
return;
}
// Find last successful backup size for this profile
$query = $db->getQuery(true)
->select($db->quoteName('total_size'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->where($db->quoteName('total_size') . ' > 0')
->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query, 0, 1);
$lastSize = (int) $db->loadResult();
if ($lastSize === 0) {
// No previous backup — skip disk space check
return;
}
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
$freeBytes = @disk_free_space($resolvedDir);
if ($freeBytes === false) {
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
return;
}
if ($freeBytes < $requiredBytes) {
$freeMB = number_format($freeBytes / 1048576, 1);
$neededMB = number_format($requiredBytes / 1048576, 1);
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
. ' (based on last backup + 20% buffer)';
}
}
private const STALE_TIMEOUT_MINUTES = 30;
/**
* Check if another backup is already running for this profile.
*
* Backups running longer than STALE_TIMEOUT_MINUTES are automatically
* marked as failed so they don't permanently block future runs.
*/
private function checkRunningBackup(object $profile, object $db): void
{
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'backupstart', 'absolute_path']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
$db->setQuery($query);
$rows = $db->loadObjectList();
if (empty($rows)) {
return;
}
$cutoff = time() - (self::STALE_TIMEOUT_MINUTES * 60);
$stillAlive = 0;
foreach ($rows as $row) {
$started = strtotime($row->backupstart);
if ($started !== false && $started < $cutoff) {
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_records'))
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . (int) $row->id);
$db->setQuery($update);
$db->execute();
if (!empty($row->absolute_path) && is_file($row->absolute_path)) {
@unlink($row->absolute_path);
}
$this->warnings[] = 'Auto-cancelled stalled backup #' . $row->id
. ' (started ' . $row->backupstart . ', exceeded '
. self::STALE_TIMEOUT_MINUTES . ' min timeout)';
} else {
$stillAlive++;
}
}
if ($stillAlive > 0) {
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
. ' — wait for it to finish or use Cancel Stalled from the Backup Records toolbar';
}
}
/**
* Check that excluded tables actually exist in the database.
* Missing tables are warnings, not errors — the profile may have
* been copied from another site or a table may have been removed.
*/
private function checkExcludedTables(object $profile, object $db): void
{
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
if (empty($excludeRaw)) {
return;
}
$prefix = $db->getPrefix();
$allTables = array_flip($db->getTableList());
foreach ($excludeRaw as $entry) {
// Strip :data-only / :structure-only suffixes
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
// Resolve #__ prefix to real prefix
$realName = str_replace('#__', $prefix, $tableName);
if (!isset($allTables[$realName])) {
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
. ' — it will be silently skipped during backup';
}
}
}
/**
* Check that remote storage credentials are minimally configured.
* Does not test the actual connection (too slow for preflight).
*/
private function checkRemoteCredentials(object $profile): void
{
$remote = $profile->remote_storage ?? 'none';
if ($remote === 'none') {
return;
}
switch ($remote) {
case 'ftp':
if (empty($profile->ftp_host)) {
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
}
if (empty($profile->ftp_username)) {
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
}
break;
case 's3':
if (empty($profile->s3_bucket)) {
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
}
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
}
break;
case 'sftp':
if (empty($profile->sftp_host)) {
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
}
if (empty($profile->sftp_username)) {
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
}
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
}
break;
case 'google_drive':
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
}
if (empty($profile->gdrive_refresh_token)) {
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
}
break;
}
}
/**
* Build the result array.
*/
private function result(): array
{
return [
'pass' => empty($this->errors),
'errors' => $this->errors,
'warnings' => $this->warnings,
];
}
}