Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php
T
jmiller 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
feat: prune remote backups on retention + consolidate backup helpers
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
2026-07-05 20:34:04 -05:00

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 [];
}
}
}