Compare commits

..

7 Commits

Author SHA1 Message Date
gitea-actions[bot] ac3727f22f chore: promote changelog [Unreleased] → [01.27.00]
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
2026-06-21 22:54:50 +00:00
gitea-actions[bot] 43a4e552ce chore(release): build 01.27.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-21 22:54:47 +00:00
jmiller a532e639ea Merge pull request 'feat: Pre-flight checks before backup starts (#67)' (#70) from feature/67-preflight-checks into main 2026-06-21 22:54:34 +00:00
gitea-actions[bot] f099ad8fe9 chore(version): auto-bump patch 01.26.02-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m35s
2026-06-21 22:54:29 +00:00
Jonathan Miller dbed0d0da7 fix: address PR review — remove dead code, consistent warnings key
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 35s
- Remove dead checkRequiredExtensions() method (superseded by PreflightCheck)
- Add 'warnings' key to ALL return paths in BackupEngine::run() and
  SteppedBackupEngine::init() to prevent undefined key access on PHP 8.x
- Include preflight warnings in success, failure, and early-exit returns
2026-06-21 17:54:11 -05:00
gitea-actions[bot] 617c103055 chore(version): auto-bump patch 01.26.01-dev [skip ci] 2026-06-21 22:47:28 +00:00
Jonathan Miller edb202071c feat: add pre-flight checks before backup starts (#67)
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Validate backup prerequisites before creating any record, catching
common issues early with clear messages instead of failing mid-backup.

Pre-flight checks:
- Required PHP extensions (zip, pdo, pdo_mysql, mbstring, curl)
- Backup directory exists and is writable
- Sufficient disk space (last backup size + 20% buffer, skipped if
  no previous backup exists)
- No other backup already running for this profile
- Excluded tables exist in database (warns on missing)
- Remote storage credentials minimally configured (FTP/S3/GDrive)

Errors block the backup; warnings are logged and displayed but allow
the backup to proceed. Integrated into both BackupEngine::run() and
SteppedBackupEngine::init() before any record is inserted.

UI: AJAX init response includes warnings array, displayed in the
stepped backup progress modal.

Closes #67
2026-06-21 17:47:13 -05:00
19 changed files with 367 additions and 58 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.26.00
# VERSION: 01.27.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+4 -4
View File
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.26.00] --- 2026-06-21
## [01.26.00] --- 2026-06-21
@@ -8,7 +12,3 @@
## [01.25.00] --- 2026-06-20
## [01.25.00] --- 2026-06-20
## [01.24.00] --- 2026-06-20
## [01.24.00] --- 2026-06-19
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.26.00 -->
<!-- VERSION: 01.27.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
+1 -1
View File
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -49,6 +49,13 @@ class BackupsController extends AdminController
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'backend');
// Surface preflight warnings as Joomla messages
if (!empty($result['warnings'])) {
foreach ($result['warnings'] as $warning) {
$this->app->enqueueMessage($warning, 'warning');
}
}
if ($result['success']) {
$this->setMessage($result['message']);
} else {
@@ -32,16 +32,21 @@ class BackupEngine
*/
public function run(int $profileId, string $description, string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'success' => false,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
// Override PHP limits for long-running backup operations
$this->overridePhpLimits();
// Verify required extensions
$extCheck = $this->checkRequiredExtensions();
if ($extCheck !== true) {
return ['success' => false, 'message' => $extCheck];
}
$db = Factory::getDbo();
// Load profile
@@ -53,7 +58,12 @@ class BackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Log any preflight warnings
foreach ($preflightResult['warnings'] as $warning) {
$this->log('PREFLIGHT WARNING: ' . $warning);
}
// Read settings directly from profile columns
@@ -68,7 +78,7 @@ class BackupEngine
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (!BackupDirectory::ensureReady($this->backupDir)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
}
// Create backup record
@@ -300,6 +310,7 @@ class BackupEngine
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
'record_id' => $recordId,
'warnings' => $preflightResult['warnings'],
];
} catch (\Throwable $e) {
$this->log('FATAL: ' . $e->getMessage());
@@ -328,7 +339,7 @@ class BackupEngine
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
}
}
@@ -383,35 +394,6 @@ class BackupEngine
};
}
/**
* Verify required PHP extensions are loaded.
*
* @return true|string True if all ok, or error message string
*/
private function checkRequiredExtensions(): true|string
{
$required = [
'zip' => 'ext-zip (required for archive creation)',
'pdo' => 'ext-pdo (required for database operations)',
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
];
$missing = [];
foreach ($required as $ext => $label) {
if (!extension_loaded($ext)) {
$missing[] = $label;
}
}
if (!empty($missing)) {
return 'Missing PHP extensions: ' . implode(', ', $missing);
}
return true;
}
/**
* Create the appropriate archiver based on the archive format.
*/
@@ -0,0 +1,288 @@
<?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
{
$db = Factory::getDbo();
// Load profile
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
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)) {
// Can't fully validate paths with unresolved placeholders
return;
}
if (!is_dir($resolvedDir)) {
// Try to create it
if (!@mkdir($resolvedDir, 0755, true)) {
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir;
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') . ' = ' . $db->quote('complete'))
->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)';
}
}
/**
* Check if another backup is already running for this profile.
*/
private function checkRunningBackup(object $profile, object $db): void
{
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
$db->setQuery($query);
$running = (int) $db->loadResult();
if ($running > 0) {
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
. ' — wait for it to finish or delete the stale record';
}
}
/**
* 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 '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,
];
}
}
@@ -32,6 +32,18 @@ class SteppedBackupEngine
*/
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'error' => true,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
$db = Factory::getDbo();
// Load profile
@@ -43,7 +55,7 @@ class SteppedBackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Create session
@@ -130,6 +142,11 @@ class SteppedBackupEngine
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description);
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
// Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning);
}
$session->statusMessage = 'Initialized — starting backup...';
$session->save();
@@ -138,6 +155,7 @@ class SteppedBackupEngine
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'warnings' => $preflightResult['warnings'],
];
}
@@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction'));
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
setTimeout(hideModal, 5000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
setTimeout(hideModal, 5000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.26.00</version>
<version>01.27.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>