Without system cron: warning and a backup that moves on while the page is open (#13); progress of the database phase; Bin tab
- The admin page and backup:status warn when background jobs do not run on a
system cron, or the cron stopped, with a link to how to set one up.
- Without a working system cron, the open admin page moves a running backup
forward through /api/tick, a short step per poll.
- The database phase shows the tables dumped ("db: 47/183 tables"); a backup
that has not moved for 15 minutes says it is waiting for background jobs.
- The trash of restore points is on a tab of its own, Bin.
- New screenshots; changelog and plan for 1.0.2.
@@ -11,6 +11,33 @@ Each heading starts with that `<version>`, once: the app store shows a release t
|
||||
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
|
||||
shown under 1.0.0. The git tag of a release candidate goes after the date.
|
||||
|
||||
## [1.0.2] - unreleased
|
||||
|
||||
### Added
|
||||
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
||||
system cron, or the cron stopped, with a link to how to set one up. Backups, also the scheduled
|
||||
ones, need it (#13).
|
||||
- Without a working system cron, a backup started with "Back up now" moves on while the NG Backup
|
||||
page stays open (#13).
|
||||
- `--salvage` for `occ backup:restore` and `occ backup:restore:full`: restore whatever can be
|
||||
read when a backup is damaged. Files that cannot be read or decrypted are left out and listed,
|
||||
and the command exits with code 2. When the database cannot be restored, `backup:restore:full`
|
||||
leaves the fresh database as it is and puts the data directory and config of the backup in
|
||||
`ngb-salvage-<snapshot>` in the data directory, to put back by hand.
|
||||
- The trash of restore points has a tab of its own, "Bin", instead of being shown below the
|
||||
backups.
|
||||
|
||||
### Changed
|
||||
- A backup in its database phase shows how far it is, for example "running (db: 47/183 tables)",
|
||||
and one that has not moved for 15 minutes says "waiting for background jobs" with when it last
|
||||
took a step. Before, it only said "running (db)", which looked the same as being stuck (#13).
|
||||
- Full restores from S3 read the packs ahead, eight ranges at a time, as restores over SFTP do
|
||||
since 1.0.0-rc.4. Over SMB the packs are read in 8 MiB reads instead of 1 MiB: over a line with
|
||||
a 130 ms round trip, reading was four times faster in a test (18 instead of 4.5 MiB/s).
|
||||
- `occ backup:target:add` only estimates the time left for reading the index once enough of it
|
||||
is in. The first file also carries the time to connect, which made it say "about 642 min left"
|
||||
for something done within a minute.
|
||||
|
||||
## [1.0.1] - 2026-11-01
|
||||
|
||||
Fixes for what was reported after 1.0.0 (#22, #24) and small improvements. Nothing changes in
|
||||
|
||||
@@ -392,6 +392,20 @@ in README "Known limitations":
|
||||
gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een
|
||||
productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0.
|
||||
Problemen of meldingen van gebruikers: rc5.
|
||||
- **1.0.2** (plan akkoord 2026-11-04): punten 1, 2 en 5 hieronder, de Bin, nieuwe screenshots en
|
||||
het polish-punt over de schatting van `target:add`. Besluiten 2026-11-04:
|
||||
- `--salvage` haalt ook bij een kapotte database terug wat kan: bestanden zijn per gebruiker
|
||||
gescheiden en het waardevolst. Kan de database niet terug (dump onleesbaar, of past niet op
|
||||
deze installatie), dan blijft de verse database staan en gaan data en config van de backup
|
||||
naar `<datadir>/ngb-salvage-<snapshot>/`; vanaf daar is het handwerk. Exitcode 2.
|
||||
- De DR-test met een backup mét tabelstructuur, zonder `--skip-missing-tables` (punt 3), is
|
||||
nog niet gedaan en wordt ingepland vóór de release van 1.0.2.
|
||||
Stand 2026-11-04 (lokaal op `polish`, niet gepusht): cron-waarschuwing + tick vanaf de pagina +
|
||||
voortgang van de db-fase; `--salvage` voor `backup:restore` en `backup:restore:full`; S3
|
||||
vooruitlezen (8 ranges tegelijk, met `SaveAs` naar de spool); SMB in leesacties van 8 MiB
|
||||
(gemeten met netem, 130 ms RTT: 4,5 → 18 MiB/s per leesactie; een volledige restore over SMB
|
||||
met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`. Nog te doen:
|
||||
screenshots, DR-test, release.
|
||||
- **1.0.1** (uitgebracht 2026-11-01): alleen de kleine punten (besluit 2026-10-30): 4, 6–13. Punten 1, 2 en 5 gaan naar
|
||||
1.0.2 (README-roadmap aangepast); punt 3 is een test, ingepland voor de week van 2 november,
|
||||
niet aangekondigd en los van de release. Voor 1.0.2 ook: nieuwe screenshots (het labelveld bij
|
||||
|
||||
@@ -42,7 +42,8 @@ Need help restoring a backup after a crash? Ensembia offers paid restore assista
|
||||
background job, so a backup only moves forward when that job runs. Choosing "Cron" under
|
||||
**Administration settings → Basic settings → Background jobs** is not enough on its own: it
|
||||
tells Nextcloud that the system will call it, so you also need a cron line that does. Without
|
||||
it a backup stays at "running (db)", and so does one started with "Back up now".
|
||||
it scheduled backups do not run; the admin page and `occ backup:status` warn about it, and a
|
||||
backup started with "Back up now" only moves on while the NG Backup page stays open.
|
||||
`occ backup:run` in a terminal works without cron (it runs in the foreground).
|
||||
- Regular server: `crontab -u www-data -e` and add
|
||||
`*/5 * * * * php -f /var/www/nextcloud/cron.php` (your Nextcloud path).
|
||||
|
||||
@@ -24,6 +24,7 @@ return [
|
||||
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\CronHealth;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
@@ -29,6 +30,7 @@ final class Status extends Command {
|
||||
private RunMapper $runs,
|
||||
private VerifyService $verify,
|
||||
private Schedule $schedule,
|
||||
private CronHealth $cron,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -40,6 +42,10 @@ final class Status extends Command {
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$cronProblem = CronHealth::describe($this->cron->check());
|
||||
if ($cronProblem !== null) {
|
||||
$output->writeln('<error>' . $cronProblem . '</error>');
|
||||
}
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
||||
} else {
|
||||
@@ -57,7 +63,7 @@ final class Status extends Command {
|
||||
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
|
||||
foreach ($shown as $r) {
|
||||
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''),
|
||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() === 'running' ? self::runningStatus($r) : $r->getStatus(),
|
||||
Format::time($r->getStartedAt(), $zone), $r->getFinishedAt() ? Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
|
||||
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||
@@ -88,8 +94,24 @@ final class Status extends Command {
|
||||
return sprintf('%.1f TiB', $b);
|
||||
}
|
||||
|
||||
/** " 47/183 tables" in the database phase, " 68%" or " ~68%" in the files phase; empty without a figure */
|
||||
private static function percent(\OCA\NgBackup\Db\Run $r): string {
|
||||
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
|
||||
return $p === null ? '' : sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||
if ($p === null) {
|
||||
return '';
|
||||
}
|
||||
if (isset($p['tables'], $p['totalTables'])) {
|
||||
return sprintf(': %d/%d tables', $p['tables'], $p['totalTables']);
|
||||
}
|
||||
return sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||
}
|
||||
|
||||
/** "running (db: 47/183 tables)", or "waiting for background jobs (last step 12 min ago)" */
|
||||
private static function runningStatus(\OCA\NgBackup\Db\Run $r): string {
|
||||
$waiting = $r->getKind() === \OCA\NgBackup\Service\BackupService::KIND_FULL ? CronHealth::waitingFor($r) : null;
|
||||
if ($waiting !== null) {
|
||||
return 'waiting for background jobs (' . $r->getPhase() . self::percent($r) . ', last step ' . CronHealth::ago($waiting) . ')';
|
||||
}
|
||||
return 'running (' . $r->getPhase() . self::percent($r) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ final class AdminApiController extends Controller {
|
||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
||||
private \OCA\NgBackup\Service\PruneService $prune,
|
||||
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||
private \OCA\NgBackup\Service\CronHealth $cron,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
@@ -75,9 +76,36 @@ final class AdminApiController extends Controller {
|
||||
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
|
||||
'schedule' => $this->schedule->get()['time'],
|
||||
'scheduleTimezone' => $this->schedule->get()['timezone'],
|
||||
'cron' => $this->cron->check(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Seconds of backup work one tick() does: well within a normal web request. */
|
||||
public const TICK_SECONDS = 10.0;
|
||||
|
||||
/**
|
||||
* Move running backups forward from the open admin page, when background jobs do not run on a
|
||||
* working system cron (#13). The page calls this while a backup runs, so "Back up now" works
|
||||
* without cron as long as the page stays open; scheduled backups still need cron. With a
|
||||
* working system cron this does nothing: the background job does the work.
|
||||
*/
|
||||
public function tick(): JSONResponse {
|
||||
if ($this->cron->check()['ok']) {
|
||||
return new JSONResponse(['stepped' => 0]);
|
||||
}
|
||||
$deadline = microtime(true) + self::TICK_SECONDS;
|
||||
$stepped = 0;
|
||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
// step() returns at once when the background job (or another tab) holds the run.
|
||||
$this->backups->step($run, $deadline);
|
||||
$stepped++;
|
||||
}
|
||||
return new JSONResponse(['stepped' => $stepped]);
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
|
||||
try {
|
||||
@@ -313,6 +341,7 @@ final class AdminApiController extends Controller {
|
||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
||||
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r),
|
||||
'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +176,12 @@ final class BackupRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* progress() of a run, while it is in its files phase.
|
||||
* progress() of a run in its files phase; in its database phase, the tables dumped so far.
|
||||
*
|
||||
* @return array{fraction: float, approximate: bool}|null
|
||||
* @return array{fraction: float, approximate: bool, tables?: int, totalTables?: int}|null
|
||||
*/
|
||||
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
|
||||
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') {
|
||||
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || !in_array($run->getPhase(), ['db', 'files'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -189,6 +189,14 @@ final class BackupRun {
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
if ($run->getPhase() === 'db') {
|
||||
// Tables dumped so far, as of the last step (none known before the first step ends)
|
||||
$tables = count($state['dump']['tables'] ?? []);
|
||||
return $tables > 0
|
||||
? ['fraction' => min(1.0, ($state['dump']['i'] ?? 0) / $tables), 'approximate' => false,
|
||||
'tables' => (int)($state['dump']['i'] ?? 0), 'totalTables' => $tables]
|
||||
: null;
|
||||
}
|
||||
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCP\Exceptions\AppConfigTypeConflictException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
/**
|
||||
* Whether Nextcloud's background jobs run on a system cron, which backups need: a backup moves
|
||||
* forward only when a background job run takes its next step (#13). Without system cron, AJAX
|
||||
* cron runs a job only now and then while someone has Nextcloud open, and webcron only as often as
|
||||
* the outside service calls it.
|
||||
*/
|
||||
final class CronHealth {
|
||||
/** A system cron that has not run for this long has stopped (it runs every 5 minutes). */
|
||||
public const STOPPED_AFTER = 3600;
|
||||
|
||||
/**
|
||||
* A running backup that took no step for this long is waiting for background jobs. A step with
|
||||
* system cron takes at most a few minutes (BackupJob) and the job runs every minute.
|
||||
*/
|
||||
public const WAITING_AFTER = 900;
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private IURLGenerator $urls,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* mode: ajax, webcron or cron; lastRun: the last background job run (0: never); ok: a system
|
||||
* cron that ran recently; stopped: system cron is set but has not run for STOPPED_AFTER.
|
||||
*
|
||||
* @return array{mode: string, lastRun: int, ok: bool, stopped: bool, docs: string}
|
||||
*/
|
||||
public function check(?int $now = null): array {
|
||||
$now ??= time();
|
||||
$mode = $this->coreString('backgroundjobs_mode', 'ajax');
|
||||
$lastRun = (int)$this->coreString('lastcron', '0');
|
||||
$stopped = $mode === 'cron' && $now - $lastRun > self::STOPPED_AFTER;
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'lastRun' => $lastRun,
|
||||
'ok' => $mode === 'cron' && !$stopped,
|
||||
'stopped' => $stopped,
|
||||
'docs' => $this->urls->linkToDocs('admin-background-jobs'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds since a running backup took its last step, when that is long enough to say it waits
|
||||
* for background jobs; null while it moves (or when it is not running).
|
||||
*/
|
||||
public static function waitingFor(Run $run, ?int $now = null): ?int {
|
||||
$idle = ($now ?? time()) - $run->getUpdatedAt();
|
||||
return $run->getStatus() === Run::RUNNING && $idle >= self::WAITING_AFTER ? $idle : null;
|
||||
}
|
||||
|
||||
/** One line for occ and the log; null when system cron runs. */
|
||||
public static function describe(array $health, ?int $now = null): ?string {
|
||||
if ($health['ok']) {
|
||||
return null;
|
||||
}
|
||||
$last = $health['lastRun'] > 0 ? 'last ran ' . self::ago(($now ?? time()) - $health['lastRun']) : 'never ran';
|
||||
$what = $health['stopped']
|
||||
? 'Background jobs are set to system cron, but cron has not run for over an hour (' . $last . ').'
|
||||
: 'Background jobs do not run on a system cron: they are set to "' . ($health['mode'] === 'webcron' ? 'Webcron' : 'AJAX') . '" and ' . $last . '.';
|
||||
return $what . ' Backups, also the scheduled ones, need a system cron that runs every 5 minutes: ' . $health['docs'];
|
||||
}
|
||||
|
||||
/** "12 min ago" style, for durations from a minute to days */
|
||||
public static function ago(int $seconds): string {
|
||||
if ($seconds < 3600) {
|
||||
return max(1, intdiv($seconds, 60)) . ' min ago';
|
||||
}
|
||||
if ($seconds < 172800) {
|
||||
return intdiv($seconds, 3600) . ' h ago';
|
||||
}
|
||||
return intdiv($seconds, 86400) . ' days ago';
|
||||
}
|
||||
|
||||
/** Core stores these with different value types across versions: read whatever is there. */
|
||||
private function coreString(string $key, string $default): string {
|
||||
try {
|
||||
return $this->appConfig->getValueString('core', $key, $default);
|
||||
} catch (AppConfigTypeConflictException) {
|
||||
return (string)$this->appConfig->getValueInt('core', $key, (int)$default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 189 KiB |
@@ -12,11 +12,22 @@
|
||||
</p>
|
||||
<NcLoadingIcon v-if="!status" :size="32" />
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
<NcNoteCard v-if="status && !status.cron.ok" type="warning">
|
||||
<p>
|
||||
{{ status.cron.stopped
|
||||
? t('ng_backup', 'Background jobs are set to system cron, but cron has not run since {time}.', { time: lastCron })
|
||||
: t('ng_backup', 'Background jobs do not run on a system cron (they are set to "{mode}").', { mode: status.cron.mode === 'webcron' ? 'Webcron' : 'AJAX' }) }}
|
||||
{{ t('ng_backup', 'Backups need a system cron that runs every 5 minutes, also the scheduled ones.') }}
|
||||
<a :href="status.cron.docs" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'How to set up cron ↗') }}</a>
|
||||
</p>
|
||||
<p>{{ t('ng_backup', 'Until then, a backup started with "Back up now" only moves on while this page is open.') }}</p>
|
||||
</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
|
||||
<template v-if="status">
|
||||
<div class="ngb-tabs">
|
||||
<NcButton :variant="tab === 'backup' ? 'primary' : 'secondary'" @click="tab = 'backup'">{{ t('ng_backup', 'Backup and restore') }}</NcButton>
|
||||
<NcButton v-if="status.key.everConfirmed && status.targets.length" :variant="tab === 'bin' ? 'primary' : 'secondary'" @click="tab = 'bin'">{{ t('ng_backup', 'Bin') }}</NcButton>
|
||||
<NcButton :variant="tab === 'settings' ? 'primary' : 'secondary'" @click="tab = 'settings'">{{ t('ng_backup', 'Settings') }}</NcButton>
|
||||
<NcButton :variant="tab === 'docs' ? 'primary' : 'secondary'" @click="tab = 'docs'">{{ t('ng_backup', 'Docs') }}</NcButton>
|
||||
</div>
|
||||
@@ -29,6 +40,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</NcSettingsSection>
|
||||
<Bin v-else-if="tab === 'bin' && status.key.everConfirmed && status.targets.length" :status="status" @changed="refresh" />
|
||||
<template v-else-if="tab === 'settings'">
|
||||
<KeySetup :status="status" @changed="refresh" />
|
||||
<Locations v-if="status.key.everConfirmed" :status="status" @changed="refresh" />
|
||||
@@ -46,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
@@ -54,6 +66,7 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import api from './api.js'
|
||||
import Backups from './components/Backups.vue'
|
||||
import Bin from './components/Bin.vue'
|
||||
import KeySetup from './components/KeySetup.vue'
|
||||
import Locations from './components/Locations.vue'
|
||||
import RestoreBrowser from './components/RestoreBrowser.vue'
|
||||
@@ -105,13 +118,27 @@ async function refresh() {
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
// Poll while something runs, so progress shows up without reloading.
|
||||
// Poll while something runs, so progress shows up without reloading. Without a working
|
||||
// system cron, each poll also lets a running backup take a short step (see tick()).
|
||||
clearTimeout(timer)
|
||||
if (status.value?.runs.some(r => r.status === 'running')) {
|
||||
timer = setTimeout(refresh, 4000)
|
||||
timer = setTimeout(tickAndRefresh, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
async function tickAndRefresh() {
|
||||
if (status.value && !status.value.cron.ok && status.value.runs.some(r => r.status === 'running' && r.kind === 'full')) {
|
||||
try {
|
||||
await api.tick()
|
||||
} catch (e) {
|
||||
// The next poll tries again; the error of a failed step shows on its run.
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const lastCron = computed(() => status.value?.cron.lastRun ? new Date(status.value.cron.lastRun * 1000).toLocaleString() : '')
|
||||
|
||||
onMounted(refresh)
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
</script>
|
||||
|
||||
@@ -33,6 +33,7 @@ export default {
|
||||
testTarget: (id) => call('post', `/targets/${id}/test`),
|
||||
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
||||
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
||||
tick: () => call('post', '/tick'),
|
||||
setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }),
|
||||
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
||||
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
||||
|
||||
@@ -18,34 +18,6 @@
|
||||
</div>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
|
||||
<template v-for="target in status.targets" :key="'trash' + target.id">
|
||||
<div v-if="trash[target.id]?.length" class="ngb-trash">
|
||||
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
|
||||
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
||||
<ul>
|
||||
<li v-for="item in trash[target.id]" :key="item.id">
|
||||
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
|
||||
<button v-if="item.by === RETENTION"
|
||||
class="ngb-info"
|
||||
:aria-expanded="retentionInfo ? 'true' : 'false'"
|
||||
:aria-label="t('ng_backup', 'What does this mean?')"
|
||||
:title="t('ng_backup', 'What does this mean?')"
|
||||
@click="retentionInfo = !retentionInfo">
|
||||
ⓘ
|
||||
</button>
|
||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
<NcNoteCard v-if="retentionInfo && trash[target.id].some(item => item.by === RETENTION)" type="info">
|
||||
{{ n('ng_backup',
|
||||
'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n day before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
|
||||
'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this trash, where they stay for %n days before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
|
||||
status.key.deleteDelayDays) }}
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="status.runs.length" class="ngb-table-wrap">
|
||||
<table class="ngb-table">
|
||||
<thead>
|
||||
@@ -66,7 +38,7 @@
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + percent(run) + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||
@@ -95,9 +67,7 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { formatDateTime, formatDate, formatSize, toZone } from '../format.js'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { reactive } from 'vue'
|
||||
import { formatDateTime, formatSize, toZone } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['changed', 'show-all-runs'])
|
||||
@@ -110,35 +80,29 @@ const error = ref('')
|
||||
watch(() => [props.status.schedule, props.status.scheduleTimezone], ([s]) => { schedule.value = shown(s) })
|
||||
|
||||
const label = ref('')
|
||||
// "by" of a restore point the weekly cleanup removed (PruneService)
|
||||
const RETENTION = 'retention policy'
|
||||
const retentionInfo = ref(false)
|
||||
const trash = reactive({})
|
||||
async function loadTrash() {
|
||||
for (const target of props.status.targets) {
|
||||
try {
|
||||
trash[target.id] = await api.trash(target.id)
|
||||
} catch (e) {
|
||||
trash[target.id] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
|
||||
|
||||
async function untrash(target, item) {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.untrash(target.id, item.id)
|
||||
await loadTrash()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e?.message || ''
|
||||
// ": 47/183 tables" in the database phase; " 68%", or " ~68%" when the total is estimated from
|
||||
// Nextcloud's file cache (first backup)
|
||||
const percent = (run) => {
|
||||
if (!run.progress) {
|
||||
return ''
|
||||
}
|
||||
if (run.progress.totalTables) {
|
||||
return ': ' + t('ng_backup', '{done}/{total} tables', { done: run.progress.tables, total: run.progress.totalTables })
|
||||
}
|
||||
return ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%'
|
||||
}
|
||||
// "running (db: 47/183 tables)", or "waiting for background jobs (…, last step 12 min ago)"
|
||||
const runStatus = (run) => {
|
||||
if (run.status !== 'running') {
|
||||
return run.status
|
||||
}
|
||||
const detail = run.phase + percent(run)
|
||||
if (run.waitingFor) {
|
||||
return t('ng_backup', 'waiting for background jobs ({detail}, last step {minutes} min ago)', { detail, minutes: Math.floor(run.waitingFor / 60) })
|
||||
}
|
||||
return run.status + ' (' + detail + ')'
|
||||
}
|
||||
|
||||
// " 68%", or " ~68%" when the total is estimated from Nextcloud's file cache (first backup)
|
||||
const percent = (run) => run.progress ? ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%' : ''
|
||||
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
||||
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||
|
||||
@@ -168,13 +132,10 @@ async function saveSchedule() {
|
||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-time { max-width: 260px; }
|
||||
.ngb-label { max-width: 360px; }
|
||||
.ngb-info { background: none; border: none; padding: 0 4px; min-height: auto; cursor: pointer; color: var(--color-primary-element); }
|
||||
.ngb-table-wrap { overflow-x: auto; }
|
||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.ngb-failed { color: var(--color-error-text); }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-trash { margin: 12px 0; }
|
||||
.ngb-trash h3 { font-weight: bold; }
|
||||
.ngb-done { color: var(--color-success-text); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Bin')"
|
||||
:description="t('ng_backup', 'Restore points that were removed, most of them by the retention policy. They can be brought back until they are deleted permanently.')">
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
<NcLoadingIcon v-if="loading" :size="32" />
|
||||
<NcNoteCard v-else-if="empty" type="info">{{ t('ng_backup', 'The bin is empty.') }}</NcNoteCard>
|
||||
|
||||
<template v-for="target in status.targets" :key="'trash' + target.id">
|
||||
<div v-if="trash[target.id]?.length" class="ngb-trash">
|
||||
<h3>{{ status.targets.length > 1 ? target.name : t('ng_backup', 'Removed restore points') }}</h3>
|
||||
<ul>
|
||||
<li v-for="item in trash[target.id]" :key="item.id">
|
||||
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
|
||||
<button v-if="item.by === RETENTION"
|
||||
class="ngb-info"
|
||||
:aria-expanded="retentionInfo ? 'true' : 'false'"
|
||||
:aria-label="t('ng_backup', 'What does this mean?')"
|
||||
:title="t('ng_backup', 'What does this mean?')"
|
||||
@click="retentionInfo = !retentionInfo">
|
||||
ⓘ
|
||||
</button>
|
||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
<NcNoteCard v-if="retentionInfo && trash[target.id].some(item => item.by === RETENTION)" type="info">
|
||||
{{ n('ng_backup',
|
||||
'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this bin, where they stay for %n day before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
|
||||
'The retention policy keeps the newest restore points, and for older days, weeks and months only the last restore point of each. It moves the others to this bin, where they stay for %n days before they are deleted permanently. Until then you can bring one back with "Restore". Nothing was lost by accident.',
|
||||
status.key.deleteDelayDays) }}
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</template>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import api from '../api.js'
|
||||
import { formatDate, formatDateTime } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
// "by" of a restore point the weekly cleanup removed (PruneService)
|
||||
const RETENTION = 'retention policy'
|
||||
const retentionInfo = ref(false)
|
||||
const trash = reactive({})
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const empty = computed(() => props.status.targets.every(target => !trash[target.id]?.length))
|
||||
|
||||
async function loadTrash() {
|
||||
for (const target of props.status.targets) {
|
||||
try {
|
||||
trash[target.id] = await api.trash(target.id)
|
||||
} catch (e) {
|
||||
trash[target.id] = []
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
|
||||
|
||||
async function untrash(target, item) {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.untrash(target.id, item.id)
|
||||
await loadTrash()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e?.message || ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ngb-info { background: none; border: none; padding: 0 4px; min-height: auto; cursor: pointer; color: var(--color-primary-element); }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-trash { margin: 12px 0; }
|
||||
.ngb-trash h3 { font-weight: bold; }
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
||||
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
||||
<NcTextField v-model="delay" type="number" min="1" max="365"
|
||||
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
|
||||
:label="t('ng_backup', 'Days a removed backup stays in the Bin before it is really deleted')"
|
||||
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 16 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
|
||||
{{ t('ng_backup', 'Create backup key') }}
|
||||
@@ -22,7 +22,7 @@
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }} ·
|
||||
{{ t('ng_backup', 'Removed backups are kept in the trash for {days} days', { days: status.key.deleteDelayDays }) }}
|
||||
{{ t('ng_backup', 'Removed backups are kept in the Bin for {days} days', { days: status.key.deleteDelayDays }) }}
|
||||
</p>
|
||||
|
||||
<!-- Passphrase slots -->
|
||||
|
||||
@@ -24,4 +24,25 @@ class BackupProgressTest extends TestCase {
|
||||
$this->assertNull(BackupRun::progress(['phase' => 'files', 'estimate' => ['bytes' => 0, 'approximate' => true], 'stats' => ['bytes' => 1]]));
|
||||
$this->assertNull(BackupRun::progress(['phase' => 'commit', 'estimate' => ['bytes' => 10, 'approximate' => false], 'stats' => ['bytes' => 1]]));
|
||||
}
|
||||
|
||||
private static function runIn(string $phase, array $state, string $status = \OCA\NgBackup\Db\Run::RUNNING): \OCA\NgBackup\Db\Run {
|
||||
$run = new \OCA\NgBackup\Db\Run();
|
||||
$run->setStatus($status);
|
||||
$run->setPhase($phase);
|
||||
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function testDatabasePhaseCountsTheTablesDumped(): void {
|
||||
$run = self::runIn('db', ['dump' => ['tables' => array_fill(0, 183, 'oc_t'), 'i' => 47]]);
|
||||
$this->assertSame(['fraction' => 47 / 183, 'approximate' => false, 'tables' => 47, 'totalTables' => 183], BackupRun::progressOf($run));
|
||||
}
|
||||
|
||||
public function testDatabasePhaseHasNoFigureBeforeItsFirstStep(): void {
|
||||
$this->assertNull(BackupRun::progressOf(self::runIn('db', ['files' => []])));
|
||||
}
|
||||
|
||||
public function testNoProgressForAFinishedRun(): void {
|
||||
$this->assertNull(BackupRun::progressOf(self::runIn('db', ['dump' => ['tables' => ['oc_a'], 'i' => 0]], \OCA\NgBackup\Db\Run::DONE)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCA\NgBackup\Service\CronHealth;
|
||||
use OCP\Exceptions\AppConfigTypeConflictException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IURLGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CronHealthTest extends TestCase {
|
||||
private const NOW = 1_800_000_000;
|
||||
private const DOCS = 'https://docs.example.com/background-jobs';
|
||||
|
||||
private function health(string $mode, ?int $lastCron, bool $lastCronIsInt = false): CronHealth {
|
||||
$config = $this->createMock(IAppConfig::class);
|
||||
$config->method('getValueString')->willReturnCallback(function (string $app, string $key, string $default) use ($mode, $lastCron, $lastCronIsInt): string {
|
||||
if ($key === 'backgroundjobs_mode') {
|
||||
return $mode;
|
||||
}
|
||||
if ($lastCronIsInt) {
|
||||
throw new AppConfigTypeConflictException('stored as int');
|
||||
}
|
||||
return $lastCron === null ? $default : (string)$lastCron;
|
||||
});
|
||||
$config->method('getValueInt')->willReturn($lastCron ?? 0);
|
||||
$urls = $this->createMock(IURLGenerator::class);
|
||||
$urls->method('linkToDocs')->with('admin-background-jobs')->willReturn(self::DOCS);
|
||||
return new CronHealth($config, $urls);
|
||||
}
|
||||
|
||||
public function testSystemCronThatRanRecentlyIsFine(): void {
|
||||
$check = $this->health('cron', self::NOW - 120)->check(self::NOW);
|
||||
$this->assertTrue($check['ok']);
|
||||
$this->assertFalse($check['stopped']);
|
||||
$this->assertNull(CronHealth::describe($check, self::NOW));
|
||||
}
|
||||
|
||||
public function testAjaxAndWebcronAreNotEnough(): void {
|
||||
foreach (['ajax' => 'AJAX', 'webcron' => 'Webcron'] as $mode => $name) {
|
||||
$check = $this->health($mode, self::NOW - 60)->check(self::NOW);
|
||||
$this->assertFalse($check['ok'], $mode);
|
||||
$this->assertFalse($check['stopped'], $mode);
|
||||
$this->assertStringContainsString("they are set to \"$name\" and last ran 1 min ago.", (string)CronHealth::describe($check, self::NOW));
|
||||
$this->assertStringContainsString(self::DOCS, (string)CronHealth::describe($check, self::NOW));
|
||||
}
|
||||
}
|
||||
|
||||
public function testSystemCronThatStoppedRunning(): void {
|
||||
$check = $this->health('cron', self::NOW - 2 * 3600)->check(self::NOW);
|
||||
$this->assertFalse($check['ok']);
|
||||
$this->assertTrue($check['stopped']);
|
||||
$this->assertStringContainsString('has not run for over an hour (last ran 2 h ago)', (string)CronHealth::describe($check, self::NOW));
|
||||
}
|
||||
|
||||
public function testDefaultsToAjaxThatNeverRan(): void {
|
||||
$check = $this->health('ajax', null)->check(self::NOW);
|
||||
$this->assertSame(0, $check['lastRun']);
|
||||
$this->assertStringContainsString('and never ran.', (string)CronHealth::describe($check, self::NOW));
|
||||
}
|
||||
|
||||
public function testReadsALastRunStoredAsAnInteger(): void {
|
||||
$check = $this->health('cron', self::NOW - 60, true)->check(self::NOW);
|
||||
$this->assertSame(self::NOW - 60, $check['lastRun']);
|
||||
$this->assertTrue($check['ok']);
|
||||
}
|
||||
|
||||
public function testARunningBackupWaitsForBackgroundJobsWhenItDidNotMoveForAWhile(): void {
|
||||
$run = new Run();
|
||||
$run->setStatus(Run::RUNNING);
|
||||
$run->setUpdatedAt(self::NOW - 12 * 60);
|
||||
$this->assertNull(CronHealth::waitingFor($run, self::NOW));
|
||||
$run->setUpdatedAt(self::NOW - CronHealth::WAITING_AFTER);
|
||||
$this->assertSame(CronHealth::WAITING_AFTER, CronHealth::waitingFor($run, self::NOW));
|
||||
$run->setStatus(Run::DONE);
|
||||
$this->assertNull(CronHealth::waitingFor($run, self::NOW));
|
||||
}
|
||||
|
||||
public function testAgo(): void {
|
||||
$this->assertSame('1 min ago', CronHealth::ago(10));
|
||||
$this->assertSame('12 min ago', CronHealth::ago(12 * 60));
|
||||
$this->assertSame('3 h ago', CronHealth::ago(3 * 3600 + 5));
|
||||
$this->assertSame('4 days ago', CronHealth::ago(4 * 86400));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ ok "location added"
|
||||
|
||||
# Test data: a user with files.
|
||||
$X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null"
|
||||
$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin"
|
||||
# large.bin (40 MB) fills more than one pack: a restore to a directory reads ahead over several ranges
|
||||
$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin; head -c 40000000 /dev/urandom > $DATA/$U/files/large.bin"
|
||||
occ files:scan "$U" >/dev/null
|
||||
SUM_BEFORE=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
||||
|
||||
@@ -56,10 +57,47 @@ occ backup:restore ci "$SNAP" "data/$U/files/Docs/f2.bin" -d /tmp/ngb-rt-one | g
|
||||
$X sh -c "cmp -s $ONE/f2.bin $DATA/$U/files/Docs/f2.bin" || fail "the file restored to a directory differs"
|
||||
$X sh -c "ls -A $ONE | grep -q ngb-restore" && fail "the temporary of a stopped restore was left"
|
||||
ok "restore of one file to a directory; the temporary of a stopped restore removed"
|
||||
|
||||
# All of the user's files to a directory: over S3 and SFTP the packs are read ahead
|
||||
occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-dir >/dev/null || fail "restore of a folder to a directory"
|
||||
SUM_DIR=$($X sh -c "cd /tmp/ngb-rt-dir/data/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
||||
[ "$SUM_BEFORE" = "$SUM_DIR" ] || fail "the files restored to a directory differ"
|
||||
$X sh -c "ls -A /tmp/ngb-rt-dir | grep -q ngb-restore-spool" && fail "the read-ahead spool was left in the directory"
|
||||
$X sh -c "rm -rf /tmp/ngb-rt-dir"
|
||||
ok "restore of all the user's files to a directory: identical, no spool left"
|
||||
# not grep -q: it stops reading at the first match, and occ, still writing, then fails (pipefail)
|
||||
occ backup:status | grep "started (" >/dev/null || fail "backup:status does not name the time zone"
|
||||
ok "backup:status names the time zone of its times"
|
||||
|
||||
# Without a system cron (#13): backup:status warns, and the open admin page moves a backup started
|
||||
# with "Back up now" forward through /api/tick. Nothing runs cron in this container, so only the
|
||||
# ticks below can finish this backup.
|
||||
occ background:ajax >/dev/null
|
||||
occ backup:status | grep "do not run on a system cron" >/dev/null || fail "backup:status gives no warning without system cron"
|
||||
ADM=ngbadmin-$RANDOM; ADM_PW="Tick-admin-pw-$RANDOM-2026"
|
||||
$X sh -c "OC_PASS='$ADM_PW' php occ user:add --password-from-env --group=admin $ADM >/dev/null"
|
||||
api() { $X curl -sf -u "$ADM:$ADM_PW" -H 'OCS-APIRequest: true' -H 'Content-Type: application/json' "$@"; }
|
||||
API=http://localhost/index.php/apps/ng_backup/api
|
||||
STATUS=$(api "$API/status") || fail "status API"
|
||||
echo "$STATUS" | php -r '$s = json_decode(stream_get_contents(STDIN), true); exit(!$s["cron"]["ok"] && $s["cron"]["mode"] === "ajax" && $s["cron"]["docs"] !== "" ? 0 : 1);' || fail "status API does not report the missing cron"
|
||||
TID=$(echo "$STATUS" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["targets"][0]["id"];')
|
||||
RUN=$(api -X POST -d "{\"targetId\": $TID, \"label\": \"page\"}" "$API/backups" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["id"];') || fail "start a backup through the API"
|
||||
for i in $(seq 1 60); do
|
||||
api -X POST "$API/tick" >/dev/null || fail "tick"
|
||||
RS=$(api "$API/status" | php -r '$s = json_decode(stream_get_contents(STDIN), true); foreach ($s["runs"] as $r) { if ($r["id"] == '"$RUN"') { echo $r["status"]; } }')
|
||||
[ "$RS" = running ] || break
|
||||
done
|
||||
[ "$RS" = done ] || fail "the backup started through the API ended as '$RS' after $i ticks"
|
||||
occ backup:list ci | grep " page " >/dev/null || fail "the snapshot of the ticked backup is not listed"
|
||||
ok "without system cron: warning in backup:status and the API, backup finished by $i ticks of the open page"
|
||||
occ background:cron >/dev/null
|
||||
$X php -f /var/www/html/cron.php >/dev/null 2>&1 || true
|
||||
occ backup:status | grep "system cron" >/dev/null && fail "backup:status still warns after cron ran"
|
||||
sleep 8 # APCu app config cache of the web server
|
||||
api "$API/status" | php -r 'exit(json_decode(stream_get_contents(STDIN), true)["cron"]["ok"] ? 0 : 1);' || fail "the status API still reports a missing cron after cron ran"
|
||||
ok "with a system cron that ran: no warning in backup:status or the API"
|
||||
occ user:delete "$ADM" >/dev/null
|
||||
|
||||
# Checksum audit: shallow (index presence) and deep (download, decrypt, verify every blob).
|
||||
occ backup:verify ci "$SNAP" | grep "^OK$" >/dev/null || fail "shallow verify"
|
||||
occ backup:verify ci "$SNAP" --deep | grep "^OK$" >/dev/null || fail "deep verify"
|
||||
@@ -71,13 +109,39 @@ ok "database dump and restore"
|
||||
|
||||
# Retention, trash and catalog.
|
||||
occ backup:retention --last=1 --daily=0 --weekly=0 --monthly=0 >/dev/null
|
||||
occ backup:prune ci | grep "forgot 1" >/dev/null || fail "prune"
|
||||
occ backup:prune ci | grep "forgot 2" >/dev/null || fail "prune" # first and second; "page" (above) is the last
|
||||
occ backup:trash ci | grep " first " >/dev/null || fail "trash"
|
||||
TID=$(occ backup:trash ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')
|
||||
occ backup:trash:restore ci "$TID" >/dev/null || fail "trash restore"
|
||||
[ "$(occ backup:list ci | grep -c ' ci ')" = "2" ] || fail "snapshot not back from the trash"
|
||||
ok "retention, trash and restore from trash"
|
||||
|
||||
# Damaged data on the location (last: the location is not used after this). A byte flipped every
|
||||
# MiB of every pack hits big.bin (6 MB) for sure; a small file mostly survives. Without --salvage
|
||||
# the restore stops and points at --salvage; with it, the rest is restored and the damage listed.
|
||||
if [[ "$TARGET_ARGS" == local* ]]; then
|
||||
# "second" went to the trash above: the backup of the page is still there
|
||||
SNAP=$(occ backup:list ci | awk -F'|' '!f && / page /{gsub(/ /,"",$3); print $3; f=1}')
|
||||
[ -n "$SNAP" ] || fail "the snapshot of the ticked backup is not listed"
|
||||
$X php -r 'foreach (glob("/tmp/ngb-rt-target/repo/packs/*/*") as $p) { $h = fopen($p, "r+b"); for ($o = 4096; $o < filesize($p); $o += 1048576) { fseek($h, $o); $b = fread($h, 1); fseek($h, $o); fwrite($h, ~$b); } fclose($h); }'
|
||||
set +e
|
||||
OUT=$(occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-salvage 2>&1); CODE=$?
|
||||
set -e
|
||||
[ "$CODE" = 1 ] || { echo "$OUT"; fail "a restore of damaged data did not stop (exit $CODE)"; }
|
||||
echo "$OUT" | grep -- "--salvage" >/dev/null || { echo "$OUT"; fail "the stopped restore does not point at --salvage"; }
|
||||
set +e
|
||||
OUT=$(occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-salvage --salvage 2>&1); CODE=$?
|
||||
set -e
|
||||
[ "$CODE" = 2 ] || { echo "$OUT"; fail "restore --salvage of damaged data exited with $CODE, not 2"; }
|
||||
echo "$OUT" | grep "data/$U/files/big.bin: " >/dev/null || { echo "$OUT"; fail "the damaged big.bin is not listed"; }
|
||||
N=$(echo "$OUT" | sed -n 's/^Restored \([0-9]*\) files.*/\1/p')
|
||||
[ "${N:-0}" -ge 1 ] || { echo "$OUT"; fail "restore --salvage restored nothing"; }
|
||||
$X sh -c "test ! -e /tmp/ngb-rt-salvage/data/$U/files/big.bin" || fail "the damaged big.bin was written anyway"
|
||||
$X sh -c "find /tmp/ngb-rt-salvage -name '*.ngb-restore-*' | grep -q ." && fail "a temporary of a damaged file was left"
|
||||
ok "damaged data: the restore stops and points at --salvage; with --salvage $N files restored and the damaged ones listed (exit 2)"
|
||||
$X sh -c "rm -rf /tmp/ngb-rt-salvage"
|
||||
fi
|
||||
|
||||
# No errors caused by loading files_external while it is disabled (its tables may not exist).
|
||||
if $X sh -c "grep -q 'external_mounts' $DATA/nextcloud.log 2>/dev/null"; then fail "files_external table errors in the log"; fi
|
||||
ok "no files_external errors in the log"
|
||||
|
||||