Phase 2: retention for per-user exports (occ backup:retention --user-last)
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
PruneService::userExportRetention()/setUserExportRetention(): a single, app-wide "keep the newest N" setting (default 3, same default as the snapshot policy's "last"), deliberately simpler than the full daily/weekly/monthly RetentionPolicy -- per-user exports are on-demand only (occ backup:backup:user, see the fold-out task about whether they ever join the automatic schedule), so there is no regular cadence yet to bucket by day/week/month. PruneService::applyUserExports(): groups a location's user exports by uid (read from each manifest's own 'time' field, the same field snapshot retention already reads), keeps the newest N per user, forgets the rest. No trash, unlike a snapshot: these are not yet the primary safety net the deletion delay protects. Runs under the same exclusive lock as snapshot retention. occ backup:retention gained --user-last=N; occ backup:prune now also applies it for every target it processes, reporting counts the same way it already does for snapshots and blob packs. Verified live against the dev rig (four real exports across four users, confirmed exactly the expected ones were kept/forgotten at two different --user-last values) and added coverage to tests/integration/user-roundtrip.sh (an account's manual export plus the automatic safety-export a replace-mode restore makes of it, pruned down to --user-last=2).
This commit is contained in:
@@ -111,6 +111,70 @@ final class PruneService {
|
||||
return self::lockKey($target) . '/catalog';
|
||||
}
|
||||
|
||||
/** How many of each user's own exports (occ backup:backup:user) to keep, newest first. */
|
||||
public function userExportRetention(): int {
|
||||
return $this->appConfig->getValueInt(Application::APP_ID, 'user_export_retention', 3);
|
||||
}
|
||||
|
||||
public function setUserExportRetention(int $keep): void {
|
||||
if ($keep < 1) {
|
||||
throw new \InvalidArgumentException('Keep at least the last export');
|
||||
}
|
||||
$old = $this->userExportRetention();
|
||||
$this->appConfig->setValueInt(Application::APP_ID, 'user_export_retention', $keep);
|
||||
if ($old !== $keep) {
|
||||
$this->alerts->securityEvent('user_export_retention_changed', ['keep' => (string)$keep]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a user's older exports once more than userExportRetention() exist for them,
|
||||
* newest first; no trash, unlike a full snapshot (occ backup:backup:user is on-demand, not
|
||||
* yet the primary safety net the deletion delay protects).
|
||||
*
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>}
|
||||
*/
|
||||
public function applyUserExports(Target $target, bool $dryRun = false): array {
|
||||
if ($target->getAppendOnly()) {
|
||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
||||
}
|
||||
$lease = $this->lockExclusive($target, 1800);
|
||||
try {
|
||||
$repo = $this->targets->repository($target);
|
||||
$keep = $this->userExportRetention();
|
||||
$byUser = [];
|
||||
foreach ($repo->listUserExports() as $path) {
|
||||
$uid = explode('/', $path)[1] ?? '';
|
||||
$manifest = json_decode($repo->getObject($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
$byUser[$uid][$path] = (int)strtotime((string)($manifest['time'] ?? '@0'));
|
||||
}
|
||||
$kept = [];
|
||||
$forget = [];
|
||||
foreach ($byUser as $uid => $times) {
|
||||
arsort($times);
|
||||
$i = 0;
|
||||
foreach (array_keys($times) as $path) {
|
||||
if ($i++ < $keep) {
|
||||
$kept[$uid][] = $path;
|
||||
} else {
|
||||
$forget[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$dryRun) {
|
||||
foreach ($forget as $path) {
|
||||
$repo->forgetUserExport($path);
|
||||
}
|
||||
if ($forget !== []) {
|
||||
$this->alerts->securityEvent('user_exports_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], false);
|
||||
}
|
||||
}
|
||||
return ['kept' => $kept, 'forgotten' => $forget];
|
||||
} finally {
|
||||
$this->leases->release($lease);
|
||||
}
|
||||
}
|
||||
|
||||
public function policy(): RetentionPolicy {
|
||||
return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user