repo = $repo ?? new WpBackupRepository(); $this->config = $config ?? new WpConfigStore(); } /** * Tag a backup with its GFS tiers. * * Returns BOTH the CSV tag and the computed expiry so the caller can forward them * to the backend. The expiry used to be written to the agent's own row and then * dropped on the floor: nothing carried it across, and `expires_at` was never * populated backend-side for any backup. * * @return array{tag: string, expires_at: ?string} */ public function tagBackup(int $backupId, \DateTimeInterface $date): array { // GFS tagging: each backup gets the highest applicable retention $tags = ['daily']; $maxRetention = 7; // days if ($date->format('w') === '0') { $tags[] = 'weekly'; $maxRetention = 28; } if ($date->format('j') === '1') { $tags[] = 'monthly'; $maxRetention = 365; } if ($date->format('j') === '1' && $date->format('n') === '1') { $tags[] = 'yearly'; $maxRetention = null; // never expire } $retentionTag = implode(',', $tags); $expiresAt = $maxRetention !== null ? $date->modify("+{$maxRetention} days")->format('Y-m-d H:i:s') : null; $this->repo->tag($backupId, $retentionTag, $expiresAt); Logger::info('backup', "Backup #{$backupId} tagged: {$retentionTag}, expires: " . ($expiresAt ?? 'never')); return ['tag' => $retentionTag, 'expires_at' => $expiresAt]; } /** * Grandfather-Father-Son retention. Keeps, by COUNT (not age): * 7 daily, 4 weekly, 12 monthly, 1 yearly — within a 24-month horizon. * A backup is kept if it is among the N most recent of ANY tier; everything * else (and anything older than 24 months) is deleted from cloud + DB. Manual * and safety backups are never auto-pruned. * * Kept as cleanExpired() for the existing callers (cron + BackupManager); the * old age-based expires_at cutoff is replaced by these per-tier count caps. */ public function cleanExpired(): array { return $this->enforceRetention(); } public function enforceRetention(): array { $repo = $this->repo; $rows = $repo->listRetainable(); if (empty($rows)) { return []; } // Map rows to the pure planner's input; keep a lookup for cloud deletion. // La lecture des rangées est celle du cœur : une date illisible est écartée, // et la base n'est lue que sur un différentiel. ['candidates' => $backups, 'rows' => $byId] = RetentionPlanner::candidatesFromRows($rows); if (empty($backups)) { return []; } // SR-01 : la base que le prochain différentiel citera ne part jamais, même // quand aucun différentiel conservé ne la cite encore. $pinned = []; $current = RetentionPlanner::currentBaseId($this->config->get(BackupRunner::BASE_INDEX_KEY)); if ($current > 0) { $pinned[] = $current; } // The GFS keep/prune decision is pure agent-core logic; persistence stays here. // UTC: stored backup timestamps are UTC, so the "now" they are ranked against // must be too — current_time('mysql') would compare against a local wall clock. // Une complète citée par un différentiel conservé est gardée par le planificateur. $plan = (new RetentionPlanner())->plan($backups, new \DateTimeImmutable('now', new \DateTimeZone('UTC')), $pinned); $deleted = []; $uploader = new CloudUploader(); foreach ($plan['prune'] as $id) { $r = $byId[$id]; try { // `delete()` rend false sans lever : sur un refus, la ligne qui NOMME // l'archive est gardée — l'effacer laisserait un objet payé que plus // rien ne retrouve. Elle sera reconsidérée au passage suivant // (même règle que `AgentCore\Backup\GfsRetention`). if (!empty($r->cloud_path) && !empty($r->cloud_provider) && !$uploader->delete($r->cloud_path, $r->cloud_provider)) { Logger::warning('backup', "GFS retention: cloud object not deleted for backup #{$id} ({$r->cloud_path}) — row kept, retry on next pass"); continue; } $repo->delete($id); $deleted[] = $id; Logger::info('backup', "GFS retention: pruned backup #{$id} ({$r->retention_tag})"); } catch (\Throwable $e) { Logger::error('backup', "Failed to prune backup #{$id}: " . $e->getMessage()); } } return $deleted; } public function getRetentionSummary(): array { $repo = $this->repo; $row = $repo->retentionAggregate(); $summary = [ 'daily' => ['count' => (int) ($row->daily_count ?? 0), 'size' => (int) ($row->daily_size ?? 0)], 'weekly' => ['count' => (int) ($row->weekly_count ?? 0), 'size' => (int) ($row->weekly_size ?? 0)], 'monthly' => ['count' => (int) ($row->monthly_count ?? 0), 'size' => (int) ($row->monthly_size ?? 0)], 'yearly' => ['count' => (int) ($row->yearly_count ?? 0), 'size' => (int) ($row->yearly_size ?? 0)], 'manual' => ['count' => (int) ($row->manual_count ?? 0), 'size' => (int) ($row->manual_size ?? 0)], ]; $nextExpiry = $repo->nextExpiry(); return [ 'by_type' => $summary, 'next_expiry' => $nextExpiry, 'total_backups' => array_sum(array_column($summary, 'count')), 'total_size' => array_sum(array_column($summary, 'size')), ]; } }