447f7b572e
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 10s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Generic: Project CI / Lint & Validate (pull_request) Successful in 52s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
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
Retention (RetentionManager and the plugin's hourly cleanup) only ever deleted local files; remote archives grew unbounded. Add an idempotent delete() to RemoteUploaderInterface + all four uploaders and have RetentionManager remove each pruned archive from the profile's enabled remotes (best-effort; failures logged). The shared restore.php is left in place since every backup overwrites it. (#229) Consolidate duplicated plumbing (#230): - New RemoteUploaderFactory replaces the createUploaderFromParams copy duplicated in BackupEngine and SteppedBackupEngine. - RetentionManager becomes the single retention authority (global-default fallback + pruneOrphans()); the system plugin delegates to it and its duplicate doCleanup()/deleteBackupRecord() logic is removed. - Backend controller, API controller and legacy cli/mokosuitebackup.php now run through the shared BackupRunner instead of BackupEngine directly. Refs #229 #230 Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
247 lines
8.3 KiB
PHP
247 lines
8.3 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
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
/**
|
|
* Enforces per-profile backup retention — the single retention authority.
|
|
*
|
|
* A profile may cap retained backups by age (retention_days) and/or by
|
|
* number of copies (retention_count). When a profile leaves a rule at 0 the
|
|
* caller-supplied global default is used instead. A backup is pruned when
|
|
* EITHER rule matches: it is older than the effective days OR it falls outside
|
|
* the newest effective count of copies.
|
|
*
|
|
* Pruning a record removes, in this order:
|
|
* 1. the DB row (#__mokosuitebackup_records),
|
|
* 2. the local archive + its .log file, and
|
|
* 3. the archive copy on every enabled remote destination for the profile.
|
|
*
|
|
* The standalone restore script (restore.php) is intentionally NOT deleted
|
|
* from remotes: it is a single, fixed-name file that every backup overwrites,
|
|
* so newer backups still depend on it — removing it while pruning an old
|
|
* archive would break restore for the copies that remain.
|
|
*
|
|
* Because records do not store which destinations they were uploaded to,
|
|
* remote deletion targets the profile's *currently enabled* remotes. Remote
|
|
* deletes are idempotent (an already-absent file counts as success) and
|
|
* best-effort: a failing remote is logged but never blocks local pruning.
|
|
*/
|
|
final class RetentionManager
|
|
{
|
|
/**
|
|
* Prune old backups for a profile according to its retention settings.
|
|
*
|
|
* Called after a backup completes and from the periodic admin-side cleanup.
|
|
* Only 'complete' and 'warning' records are considered — pending/running/
|
|
* failed records are never pruned here.
|
|
*
|
|
* @param object $db Database driver
|
|
* @param object $profile Profile row (needs id, retention_days, retention_count)
|
|
* @param int $globalDays Fallback max age (days) when the profile's is 0
|
|
* @param int $globalCount Fallback max copies when the profile's is 0
|
|
*
|
|
* @return int Number of backup records deleted
|
|
*/
|
|
public static function prune(object $db, object $profile, int $globalDays = 0, int $globalCount = 0): int
|
|
{
|
|
$days = (int) ($profile->retention_days ?? 0);
|
|
$count = (int) ($profile->retention_count ?? 0);
|
|
|
|
// A profile value of 0 means "use the global default".
|
|
$days = $days > 0 ? $days : $globalDays;
|
|
$count = $count > 0 ? $count : $globalCount;
|
|
|
|
// No retention configured — nothing to do.
|
|
if ($days <= 0 && $count <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Newest first, so the index is the copy's position from the top.
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['id', 'archivename', 'absolute_path', 'backupstart']))
|
|
->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'])) . ')')
|
|
->order($db->quoteName('backupstart') . ' DESC');
|
|
$db->setQuery($query);
|
|
$records = $db->loadObjectList() ?: [];
|
|
|
|
if (empty($records)) {
|
|
return 0;
|
|
}
|
|
|
|
$cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null;
|
|
$remotes = self::loadEnabledRemotes($db, (int) $profile->id);
|
|
$deleted = 0;
|
|
|
|
foreach ($records as $index => $record) {
|
|
$tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs;
|
|
$overCount = $count > 0 && $index >= $count;
|
|
|
|
// Delete-if-either: prune when age OR count rule is exceeded.
|
|
if (!$tooOld && !$overCount) {
|
|
continue;
|
|
}
|
|
|
|
if (self::deleteRecord($db, $record, $remotes)) {
|
|
$deleted++;
|
|
}
|
|
}
|
|
|
|
return $deleted;
|
|
}
|
|
|
|
/**
|
|
* Delete backup records whose profile no longer exists.
|
|
*
|
|
* Orphans have no owning profile and therefore no remote destinations to
|
|
* reconcile, so only the local archive/log and DB row are removed.
|
|
*
|
|
* @param object $db Database driver
|
|
*
|
|
* @return int Number of orphaned records deleted
|
|
*/
|
|
public static function pruneOrphans(object $db): int
|
|
{
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['r.id', 'r.archivename', 'r.absolute_path'], ['id', 'archivename', 'absolute_path']))
|
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
|
->join(
|
|
'LEFT',
|
|
$db->quoteName('#__mokosuitebackup_profiles', 'p')
|
|
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id')
|
|
)
|
|
->where($db->quoteName('p.id') . ' IS NULL')
|
|
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
|
|
$db->setQuery($query);
|
|
$orphans = $db->loadObjectList() ?: [];
|
|
|
|
$deleted = 0;
|
|
|
|
foreach ($orphans as $record) {
|
|
if (self::deleteRecord($db, $record, [])) {
|
|
$deleted++;
|
|
}
|
|
}
|
|
|
|
return $deleted;
|
|
}
|
|
|
|
/**
|
|
* Delete a single backup record, its on-disk archive + log file, and the
|
|
* archive copy on each enabled remote destination.
|
|
*
|
|
* The DB row is removed first; the local files are only unlinked if that
|
|
* succeeds, so a failed delete never orphans the record from its files.
|
|
* Remote deletion is best-effort and runs regardless.
|
|
*
|
|
* @param object $db Database driver
|
|
* @param object $record Record row (needs id, archivename, absolute_path)
|
|
* @param object[] $remotes Enabled remote rows (type, params) for the profile
|
|
*/
|
|
private static function deleteRecord(object $db, object $record, array $remotes): bool
|
|
{
|
|
$query = $db->getQuery(true)
|
|
->delete($db->quoteName('#__mokosuitebackup_records'))
|
|
->where($db->quoteName('id') . ' = ' . (int) $record->id);
|
|
$db->setQuery($query);
|
|
|
|
try {
|
|
$db->execute();
|
|
} catch (\Throwable $e) {
|
|
error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage());
|
|
|
|
return false;
|
|
}
|
|
|
|
$archivePath = (string) ($record->absolute_path ?? '');
|
|
|
|
if ($archivePath !== '' && is_file($archivePath)) {
|
|
@unlink($archivePath);
|
|
|
|
$logPath = BackupDirectory::logPathFromArchive($archivePath);
|
|
|
|
if (is_file($logPath)) {
|
|
@unlink($logPath);
|
|
}
|
|
}
|
|
|
|
self::deleteFromRemotes($record, $remotes);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove the record's archive from every enabled remote destination.
|
|
*
|
|
* Best-effort: each remote is tried independently and failures are logged,
|
|
* never thrown, so one unreachable destination can't stall retention.
|
|
*
|
|
* @param object $record Record row (needs archivename)
|
|
* @param object[] $remotes Enabled remote rows (type, params)
|
|
*/
|
|
private static function deleteFromRemotes(object $record, array $remotes): void
|
|
{
|
|
$archiveName = (string) ($record->archivename ?? '');
|
|
|
|
if ($archiveName === '' || empty($remotes)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($remotes as $remote) {
|
|
try {
|
|
$params = json_decode((string) ($remote->params ?? ''), true) ?: [];
|
|
$uploader = RemoteUploaderFactory::create((string) $remote->type, $params);
|
|
$result = $uploader->delete($archiveName);
|
|
|
|
if (empty($result['success'])) {
|
|
error_log(
|
|
'MokoSuiteBackup: retention could not delete ' . $archiveName
|
|
. ' from ' . $remote->type . ' remote: ' . ($result['message'] ?? 'unknown error')
|
|
);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log(
|
|
'MokoSuiteBackup: retention remote-delete error for ' . $archiveName
|
|
. ' (' . ($remote->type ?? '?') . '): ' . $e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the profile's enabled remote destinations (type + params only).
|
|
*
|
|
* Returns an empty array if the remotes table is missing (very old installs)
|
|
* or the profile has none, so callers can iterate unconditionally.
|
|
*/
|
|
private static function loadEnabledRemotes(object $db, int $profileId): array
|
|
{
|
|
try {
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName(['type', 'params']))
|
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
|
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
|
->where($db->quoteName('enabled') . ' = 1')
|
|
->order($db->quoteName('ordering') . ' ASC');
|
|
$db->setQuery($query);
|
|
|
|
return $db->loadObjectList() ?: [];
|
|
} catch (\Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|