diff --git a/PLAN.md b/PLAN.md index 53ff3a6..3709d3d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -346,7 +346,10 @@ minimale volledige restore (`occ backup:restore:full` op een verse installatie) SMB vereist de `smbclient` PECL-extensie (niet standaard in de Nextcloud-image, in CI nu on-the-fly gebouwd). NFS is geen eigen backend: lokaal mounten + `local`-backend erop richten. 3. Verificatie/checksum-audit-commando — **klaar** (2026-10-05) -4. Retentie/prune voor user-exports +4. Retentie/prune voor user-exports — **klaar** (2026-10-06): `occ backup:retention --user-last=N` + (standaard 3, per installatie, net als de bestaande snapshot-policy), toegepast door + `occ backup:prune` (geen prullenbak voor user-exports, in tegenstelling tot snapshots — on-demand, + niet het primaire vangnet). 5. Automatische user-export inpluggen in het reguliere schema (of expliciet besluit dat het on-demand blijft) 9. SMB/NFS intern testen op de eigen OMV-instantie — nog open (intern, niet in CI) diff --git a/lib/Command/Prune.php b/lib/Command/Prune.php index aaafc3e..6b5bf3b 100644 --- a/lib/Command/Prune.php +++ b/lib/Command/Prune.php @@ -37,6 +37,20 @@ final class Prune extends Command { $output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : '')); $rc = 0; foreach ($targets as $t) { + // User exports first: Repository::prune()'s blob garbage collection (run as part of + // apply() below) scans the users/ manifests still on the location at that point, so + // forgetting one here lets its now-unused blobs be freed in this same pass. + try { + $ru = $this->prune->applyUserExports($t, $dry); + } catch (\Throwable $e) { + $output->writeln('' . $t->getName() . ' (user exports): ' . $e->getMessage() . ''); + $rc = 1; + continue; + } + if ($ru['forgotten'] !== []) { + $output->writeln(sprintf('%s: %s %d user export(s) for %d user(s)', + $t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), count($ru['kept']))); + } try { $r = $this->prune->apply($t, $dry); } catch (\Throwable $e) { diff --git a/lib/Command/Retention.php b/lib/Command/Retention.php index 9e205e5..68c60c6 100644 --- a/lib/Command/Retention.php +++ b/lib/Command/Retention.php @@ -28,7 +28,8 @@ final class Retention extends Command { ->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup') ->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks') ->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months') - ->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12"); + ->addOption('user-last', null, InputOption::VALUE_REQUIRED, 'Keep the newest N of each user\'s own exports (occ backup:backup:user)') + ->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12 --user-last=3"); } protected function execute(InputInterface $input, OutputInterface $output): int { @@ -48,7 +49,16 @@ final class Retention extends Command { return 1; } } + if ($input->getOption('user-last') !== null) { + try { + $this->prune->setUserExportRetention((int)$input->getOption('user-last')); + } catch (\InvalidArgumentException $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + } $output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly'])); + $output->writeln(sprintf('Keep the newest %d of each user\'s own exports.', $this->prune->userExportRetention())); return 0; } } diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 2c78399..284de2c 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -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>, forgotten: list} + */ + 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) ?: []); } diff --git a/tests/integration/user-roundtrip.sh b/tests/integration/user-roundtrip.sh index 69d3197..e6efe5a 100755 --- a/tests/integration/user-roundtrip.sh +++ b/tests/integration/user-roundtrip.sh @@ -54,6 +54,18 @@ SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xa [ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore" ok "replace restores the original content under the same uid, byte-identical" +# Retention: $U now has the manual export above plus the automatic safety-export the replace +# restore made of it just before deleting it (4 total after these two more), then --user-last=2 +# must forget exactly the 2 oldest. +sleep 1.1 # exportId is time-based to the second; force each one to be distinct +occ backup:backup:user ci "$U" >/dev/null || fail "third backup:backup:user" +sleep 1.1 +occ backup:backup:user ci "$U" >/dev/null || fail "fourth backup:backup:user" +occ backup:retention --user-last=2 >/dev/null || fail "retention --user-last" +PRUNE_OUT=$(occ backup:prune ci) +echo "$PRUNE_OUT" | grep -q "forgot 2 user export(s) for 1 user(s)" || fail "prune did not forget the expected two user exports" +ok "user-export retention forgets exactly the exports over the keep count" + occ user:delete "$U" >/dev/null $X sh -c "rm -rf /tmp/ngb-urt-target /tmp/ngb-urt-pass" echo "USER ROUNDTRIP PASS"