From 99f1655d04caf21fd03582fb681b7bed0ed20b8d Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Sun, 8 Nov 2026 16:57:55 +0100 Subject: [PATCH] Review fixes: a missed passphrase change follows at the next backup, a key from before 1.1.0 counts as used, schedules edited in their own time zone - A location that a passphrase change could not reach gets it at its next backup - A key from before 1.1.0 is never replaced as an unused one - An unreadable settings file on a location is told apart from a missing one - A schedule is shown and saved in the zone it was saved in - Saving an unchanged schedule keeps its moment; --timezone needs a schedule - Schedule coverage uses the time the backup began reading its data - Tests: catch-up scenario, upgrade test restores the new file, autumn clock change --- CHANGELOG.md | 11 +++++-- lib/BackgroundJob/BackupJob.php | 13 +++++++- lib/Command/TargetAdd.php | 9 ++++-- lib/Command/TargetSchedule.php | 4 +++ .../Version000009Date20261108000000.php | 31 +++++++++++++++++++ lib/Repository/Repository.php | 8 ++--- lib/Service/BackupService.php | 1 + lib/Service/KeyService.php | 19 ++++++++++++ lib/Service/KeySlotService.php | 4 ++- lib/Service/TargetService.php | 23 ++++++++++++-- src/components/LocationScheduleRow.vue | 13 ++++---- src/format.js | 27 ---------------- tests/Unit/Repository/KeyFromLocationTest.php | 6 ++-- tests/Unit/Service/LocationScheduleTest.php | 5 ++- tests/integration/recovery-scenarios.sh | 29 +++++++++++++++++ tests/integration/upgrade-from-release.sh | 14 ++++++--- 16 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 lib/Migration/Version000009Date20261108000000.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e134a..21825e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,8 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. An older version of NG Backup can still read the location, but drops them when it writes to it. - A schedule per location: off, once a day, or every N hours from a given time, on the page under Backups or with `occ backup:target:schedule`. After the update every location keeps the time that - held for all of them; a new location starts with the schedule of the first one. + held for all of them; a new location starts with the schedule of the first one. A schedule is + shown and changed in the time zone it was saved in, which the page names next to the time. - All folders get their original modification time back on a restore, not only the empty ones, from the first backup made with this version. An older version of NG Backup can still read new restore points. A restore through Nextcloud into a folder that is already there leaves its time alone. @@ -33,7 +34,10 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. `occ backup:target:add` finds the backups on the location and takes their key from there with one of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The deletion delay is now also stored on the location, sealed with the key, from its first backup - with this version; a location backed up only by an older version gives the default of 7 days. + with this version; a location backed up only by an older version gives the default of 7 days, + and so does a settings file that cannot be read (the command then says so). +- A passphrase change that cannot reach a location (offline at the time) now reaches it at the + location's next backup, so a new server can open it with the new passphrase. - `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery kit writes it again (for locations backed up by this version or newer). Before, the message said this was possible, but it was not. @@ -41,7 +45,8 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. by mistake can still make way for the key of the backups, as long as it was never used (no location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or `occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that - protected a location is never replaced. All administrators are notified of a replacement. + protected a location is never replaced, and neither is a key from before this version. All + administrators are notified of a replacement. ### Changed - The list of backups stays within the page: a long label, note or error wraps onto more lines diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php index 1e57bfc..7a3823a 100644 --- a/lib/BackgroundJob/BackupJob.php +++ b/lib/BackgroundJob/BackupJob.php @@ -116,7 +116,7 @@ final class BackupJob extends TimedJob { } $attempts = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $moment); if (!\OCA\NgBackup\Service\LocationSchedule::isDue($moment, (int)$t->getScheduleSince(), - $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)?->getStartedAt(), + $this->dataStarted($this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)), count($attempts), ($attempts[0] ?? null)?->getFinishedAt(), $now)) { continue; } @@ -127,4 +127,15 @@ final class BackupJob extends TimedJob { } } } + + /** When a backup began reading its data: a queued one can start well after it was asked for. */ + private function dataStarted(?\OCA\NgBackup\Db\Run $run): ?int { + if ($run === null) { + return null; + } + $state = json_decode($run->getState(), true); + $started = is_array($state) ? ($state['files']['started'] ?? null) : null; + $at = is_string($started) ? strtotime($started) : false; + return $at !== false ? $at : $run->getStartedAt(); + } } diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php index b168973..e8b447f 100644 --- a/lib/Command/TargetAdd.php +++ b/lib/Command/TargetAdd.php @@ -161,9 +161,12 @@ HELP); $r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted')); if ($passphrase !== null) { $output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint ' . $this->keys->fingerprint() . '.'); - $output->writeln(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null - ? 'Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days (from the location).' - : 'Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days (the default: the location has none stored, its backups were made before NG Backup 1.1.0).'); + $days = 'Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days'; + $output->writeln(match (true) { + ($r['deleteDelayDays'] ?? null) !== null => $days . ' (from the location).', + $r['settingsUnreadable'] ?? false => '' . $days . ' (the default: the settings file on the location could not be read, it may be damaged). If this server used a longer delay, removed restore points leave the Bin sooner than before.', + default => $days . ' (the default: the location has none stored, its backups were made before NG Backup 1.1.0).', + }); $output->writeln('Next: occ backup:list and occ backup:restore:full. Before backups run again, download a new recovery kit and confirm it (occ backup:key:kit, occ backup:key:confirm).'); } return 0; diff --git a/lib/Command/TargetSchedule.php b/lib/Command/TargetSchedule.php index 39233c2..f5bebfb 100644 --- a/lib/Command/TargetSchedule.php +++ b/lib/Command/TargetSchedule.php @@ -34,6 +34,10 @@ final class TargetSchedule extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { $t = $this->targets->get($input->getArgument('target')); $schedule = $input->getArgument('schedule'); + if ($schedule === null && $input->getOption('timezone') !== null) { + $output->writeln('--timezone goes with a schedule, e.g. backup:target:schedule ' . $t->getName() . ' 02:30 --timezone=Europe/Amsterdam'); + return 1; + } $zone = $input->getOption('timezone') ?? $this->schedule->zoneOr($t->getScheduleZone()); if ($schedule !== null) { try { diff --git a/lib/Migration/Version000009Date20261108000000.php b/lib/Migration/Version000009Date20261108000000.php new file mode 100644 index 0000000..bcc6ac4 --- /dev/null +++ b/lib/Migration/Version000009Date20261108000000.php @@ -0,0 +1,31 @@ +keys->isInitialized()) { + $this->keys->markUsed(); + } + } +} diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index f485746..262a825 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -239,8 +239,8 @@ final class Repository { /** * Settings of the installation that never change (the deletion delay), sealed with the * master key so that, unlike the config and the recovery kit, nobody can alter them unseen. - * Empty when the location has none yet (made before 1.1.0, until its next backup) or they - * cannot be read. + * Empty when the location has none yet (made before 1.1.0, until its next backup); only + * ['unreadable' => true] when they are there but cannot be read. * * @return array */ @@ -253,9 +253,9 @@ final class Repository { $raw = (string)stream_get_contents($fh); fclose($fh); $settings = json_decode((new StreamCipher($keys))->decryptString($raw, 'settings'), true, 16, JSON_THROW_ON_ERROR); - return is_array($settings) ? $settings : []; + return is_array($settings) ? $settings : ['unreadable' => true]; } catch (BackendException|\OCA\NgBackup\Crypto\CryptoException|\JsonException) { - return []; + return ['unreadable' => true]; } } diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index fc71455..817ff05 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -227,6 +227,7 @@ final class BackupService { $state['files']['started'] = gmdate('c'); // Locations from before 1.1.0 get their sealed settings at their next backup. $this->targets->recordSettings($repo); + $this->targets->catchUpWrappedKey($target, $repo); } $dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_')); $dbStats = $state['dbStats'] ?? []; diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php index efb658a..4145978 100644 --- a/lib/Service/KeyService.php +++ b/lib/Service/KeyService.php @@ -32,6 +32,7 @@ final class KeyService { private const KEY_KIT_CODE = 'recovery_kit_code'; /** Set once a location was created or adopted with this key: from then on it is never replaced. */ private const KEY_USED = 'master_key_used'; + private const KEY_WRAPPED_PENDING = 'master_key_wrapped_pending'; public const DEFAULT_DELETE_DELAY = 7; /** * The location holds the passphrase-wrapped key too, so whoever can read it can try @@ -127,6 +128,24 @@ final class KeyService { } } + /** + * Locations that missed the last passphrase change (offline at the time): they still hold + * the previous slots until the change reaches them, which is tried again at each backup. + */ + public function wrappedKeyPending(int $targetId): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_WRAPPED_PENDING . '_' . $targetId); + } + + /** One flag per location, so that two processes never overwrite each other's. */ + public function setWrappedKeyPending(int $targetId, bool $pending): void { + $key = self::KEY_WRAPPED_PENDING . '_' . $targetId; + if ($pending) { + $this->appConfig->setValueBool(Application::APP_ID, $key, true); + } elseif ($this->wrappedKeyPending($targetId)) { + $this->appConfig->deleteKey(Application::APP_ID, $key); + } + } + /** * A key made by mistake on a server that is being restored: remove it, so the key of the * backups can take its place. Only after the new key was opened with its passphrase, and diff --git a/lib/Service/KeySlotService.php b/lib/Service/KeySlotService.php index cf6ed86..2eb2894 100644 --- a/lib/Service/KeySlotService.php +++ b/lib/Service/KeySlotService.php @@ -43,8 +43,10 @@ final class KeySlotService { foreach ($this->targets->list() as $t) { try { $this->targets->repository($t)->updateWrappedKey($wrapped); + $this->keys->setWrappedKeyPending($t->getId(), false); } catch (\Throwable $e) { $failed[] = $t->getName(); + $this->keys->setWrappedKeyPending($t->getId(), true); } } $label = ''; @@ -55,7 +57,7 @@ final class KeySlotService { } $this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]); if ($failed !== []) { - throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed)); + throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases; the change is tried again at their next backup): ' . implode(', ', $failed)); } } } diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index b466b56..f8a7967 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -172,7 +172,7 @@ final class TargetService { * * @param array $options * @param ?callable(int, int): void $progress - * @return array{target: Target, created: bool, deleteDelayDays: ?int} the deletion delay the location had stored, if any + * @return array{target: Target, created: bool, deleteDelayDays: ?int, settingsUnreadable: bool} the deletion delay the location had stored, if any, and whether its settings could not be read * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase */ public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath, @@ -194,13 +194,26 @@ final class TargetService { if (!Repository::madeWithKey($storageBackend, $keys)) { throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import , then this command again.'); } - $delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null; + $settings = Repository::settingsAt($storageBackend, $keys); + $delay = $settings['delete_delay_days'] ?? null; $delay = is_int($delay) ? $delay : null; $this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey); if ($old !== null) { $this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]); } - return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay]; + return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay, 'settingsUnreadable' => ($settings['unreadable'] ?? false) === true]; + } + + /** A passphrase change that missed this location (offline at the time) reaches it now. A failure is tried again at the next backup. */ + public function catchUpWrappedKey(Target $target, Repository $repo): void { + if (!$this->keys->wrappedKeyPending($target->getId())) { + return; + } + try { + $repo->updateWrappedKey($this->keys->wrappedKey()); + $this->keys->setWrappedKeyPending($target->getId(), false); + } catch (\Throwable) { + } } /** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */ @@ -402,6 +415,10 @@ final class TargetService { } $new = LocationSchedule::normalize($schedule); $turnedOff = $new === '' && (string)$target->getSchedule() !== ''; + if ($new === (string)$target->getSchedule() && $zone === $target->getScheduleZone()) { + // Saved again unchanged: a moment that already passed still gets its backup + return $target; + } $target->setSchedule($new); $target->setScheduleZone($zone); $target->setScheduleSince(time()); diff --git a/src/components/LocationScheduleRow.vue b/src/components/LocationScheduleRow.vue index 3c47a80..a0b8df8 100644 --- a/src/components/LocationScheduleRow.vue +++ b/src/components/LocationScheduleRow.vue @@ -6,7 +6,7 @@
{{ target.name }} - + {{ t('ng_backup', 'Save') }}
@@ -19,7 +19,6 @@ import NcButton from '@nextcloud/vue/components/NcButton' import NcSelect from '@nextcloud/vue/components/NcSelect' import NcTextField from '@nextcloud/vue/components/NcTextField' import api from '../api.js' -import { toZone } from '../format.js' const props = defineProps({ target: { type: Object, required: true } }) const emit = defineEmits(['changed', 'error']) @@ -29,9 +28,11 @@ const kinds = [ { id: 'daily', label: t('ng_backup', 'Once a day') }, { id: 'hours', label: t('ng_backup', 'Every few hours') }, ] -// The time is saved in the administrator's own zone (the browser's); a schedule saved in another -// zone is shown in this one, so it reads as the moment it runs here. +// A schedule is shown and edited in the zone it was saved in, named next to the time: "every 6 +// hours from 02:00" runs until midnight of that zone, so moving it to another zone would change +// how often it runs. A location without a schedule gets the administrator's own zone (the browser's). const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' +const zone = computed(() => props.target.schedule !== '' && props.target.scheduleZone ? props.target.scheduleZone : browserZone) const kind = ref(kinds[0]) const time = ref('02:00') const hours = ref('6') @@ -39,7 +40,7 @@ const busy = ref(false) const current = computed(() => { const [at, every] = props.target.schedule.split('/') - return at === '' ? '' : (props.target.scheduleZone !== browserZone ? toZone(at, props.target.scheduleZone, browserZone) : at) + (every ? '/' + every : '') + return at === '' ? '' : at + (every ? '/' + every : '') }) const wanted = computed(() => { if (kind.value.id === 'off') { @@ -59,7 +60,7 @@ async function save() { busy.value = true emit('error', '') try { - await api.setTargetSchedule(props.target.id, wanted.value, browserZone) + await api.setTargetSchedule(props.target.id, wanted.value, zone.value) emit('changed') } catch (e) { emit('error', e.message) diff --git a/src/format.js b/src/format.js index 5774270..add6404 100644 --- a/src/format.js +++ b/src/format.js @@ -26,30 +26,3 @@ export function formatSize(bytes) { } return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}` } - -/** - * A time of day "HH:MM" in zone `from`, as "HH:MM" in zone `to`, at today's offsets. - * - * @param {string} hhmm the time - * @param {string} from IANA zone it is in - * @param {string} to IANA zone to show it in - * @param {Date} now the day to convert on (today) - * @return {string} - */ -export function toZone(hhmm, from, to, now = new Date()) { - const [h, m] = hhmm.split(':').map(Number) - const parts = (zone, date) => Object.fromEntries(new Intl.DateTimeFormat('en-GB', { - timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', - }).formatToParts(date).map((p) => [p.type, p.value])) - const offsetAt = (at) => { - const seen = parts(from, new Date(at)) - return Date.UTC(+seen.year, +seen.month - 1, +seen.day, +seen.hour, +seen.minute) - at - } - const today = parts(from, now) - const wall = Date.UTC(+today.year, +today.month - 1, +today.day, h, m) - // the offset at the wall time itself: on a day the clocks change it differs from the one at - // midnight, so it is looked up a second time at the moment found first - const at = wall - offsetAt(wall - offsetAt(wall)) - const there = parts(to, new Date(at)) - return `${there.hour}:${there.minute}` -} diff --git a/tests/Unit/Repository/KeyFromLocationTest.php b/tests/Unit/Repository/KeyFromLocationTest.php index 033560c..14cbcfc 100644 --- a/tests/Unit/Repository/KeyFromLocationTest.php +++ b/tests/Unit/Repository/KeyFromLocationTest.php @@ -51,7 +51,9 @@ class KeyFromLocationTest extends TestCase { $this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']); Repository::openWithKey($this->backend, $this->keys)->recordSettings(['delete_delay_days' => 1]); $this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']); - $this->assertSame([], Repository::settingsAt($this->backend, KeyRing::generate())); + $this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, KeyRing::generate()), 'another key: there, but not readable'); + unlink($this->dir . '/settings'); + $this->assertSame([], Repository::settingsAt($this->backend, $this->keys), 'none at all: a location from before 1.1.0'); } public function testAlteredSettingsAreNotBelieved(): void { @@ -59,7 +61,7 @@ class KeyFromLocationTest extends TestCase { $raw = file_get_contents($file); $raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01"; file_put_contents($file, $raw); - $this->assertSame([], Repository::settingsAt($this->backend, $this->keys)); + $this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, $this->keys)); } public function testTheKitWritesALostConfigAgainAndTheBackupsOpen(): void { diff --git a/tests/Unit/Service/LocationScheduleTest.php b/tests/Unit/Service/LocationScheduleTest.php index 7a994a0..851caca 100644 --- a/tests/Unit/Service/LocationScheduleTest.php +++ b/tests/Unit/Service/LocationScheduleTest.php @@ -48,7 +48,10 @@ class LocationScheduleTest extends TestCase { public function testAClockChangeInTheNightStillGivesOneMoment(): void { $this->assertSame(self::at('2026-03-29 01:30 UTC'), S::lastMoment('02:30', self::BERLIN, self::at('2026-03-29 12:00 UTC')), '02:30 does not exist that night'); - $this->assertNotNull(S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC'))); + $autumn = S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC')); + $this->assertNotNull($autumn); + $local = (new \DateTimeImmutable('@' . $autumn))->setTimezone(new \DateTimeZone(self::BERLIN)); + $this->assertSame('2026-10-25 02:30', $local->format('Y-m-d H:i'), '02:30 happens twice that night: one of them, on that day'); } public function testChoosingATimeDoesNotStartABackup(): void { diff --git a/tests/integration/recovery-scenarios.sh b/tests/integration/recovery-scenarios.sh index 1083b7f..f6f2eae 100755 --- a/tests/integration/recovery-scenarios.sh +++ b/tests/integration/recovery-scenarios.sh @@ -19,6 +19,8 @@ # unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase # of the old server; a wrong one keeps the unused key # used-key a key that protected a location is never replaced, also after the location was removed +# slot-catch-up a passphrase added while a location was unreachable reaches it at its next +# backup; a new server then gets the key from it with that passphrase set -euo pipefail IMAGE="nextcloud:${1:-35-apache}" APP=$(cd "$(dirname "$0")/../.." && pwd) @@ -269,4 +271,31 @@ expect "$OUT" "never replaced" "used-key key:import" [ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed" ok "used-key: never replaced, by kit or passphrase, also after its location was removed" +# slot-catch-up: a passphrase change that missed a location reaches it at the next backup +start ngb-rs-new +docker exec ngb-rs-new sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2 && rm -rf /mnt/shared/catchup && mkdir -p /mnt/shared/catchup && chown www-data /mnt/shared/catchup" +run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass --label=Safe >/dev/null || fail "slot-catch-up: key init" +CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];') +run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm" +# shellcheck disable=SC2046 +run_on ngb-rs-new backup:target:add offsite $(location catchup) >/dev/null || fail "slot-catch-up: target add" +run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: first backup" +BEFORE=$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1) +docker exec ngb-rs-new chmod 000 /mnt/shared/catchup/repo +OUT=$(must_fail ngb-rs-new "slot-catch-up: slot add with the location unreachable" backup:key:slot:add --label=CTO --passphrase-file=/tmp/pass2) +expect "$OUT" "tried again at their next backup" "slot-catch-up: says the change follows" +docker exec ngb-rs-new chmod 755 /mnt/shared/catchup/repo +[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" = "$BEFORE" ] || fail "slot-catch-up: the location changed while unreachable" +CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];') +run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm the new kit" +run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: backup after the location came back" +[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" != "$BEFORE" ] || fail "slot-catch-up: the backup did not bring the new passphrase to the location" +CATCHFP=$(key_of ngb-rs-new) +start ngb-rs-other +docker exec ngb-rs-other sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2" +# shellcheck disable=SC2046 +OUT=$(run_on ngb-rs-other backup:target:add offsite $(location catchup) --passphrase-file=/tmp/pass2 2>&1) || { printf '%s\n' "$OUT"; fail "slot-catch-up: a new server with the new passphrase"; } +expect "$OUT" "fingerprint $CATCHFP" "slot-catch-up: the new server" +ok "slot-catch-up: a passphrase that missed a location reaches it at the next backup and opens it on a new server" + echo "RECOVERY SCENARIOS PASS" diff --git a/tests/integration/upgrade-from-release.sh b/tests/integration/upgrade-from-release.sh index a441001..089aba0 100644 --- a/tests/integration/upgrade-from-release.sh +++ b/tests/integration/upgrade-from-release.sh @@ -77,8 +77,8 @@ VER=$(occ app:list --output=json | php -r '$a = json_decode(stream_get_contents( [ -z "$NEWVER" ] || [ "$VER" = "$NEWVER" ] || fail "after the upgrade the app has version '$VER'" ok "occ upgrade to $VER" -LIST=$(occ backup:target:list) -printf '%s\n' "$LIST" | grep offsite | grep -q "03:15" || { printf '%s\n' "$LIST"; fail "the location did not keep 03:15 as its schedule"; } +SCHED=$(occ backup:target:schedule offsite) +printf '%s\n' "$SCHED" | grep -q "^offsite: 03:15 " || { printf '%s\n' "$SCHED"; fail "the location did not keep 03:15 as its schedule"; } occ backup:list offsite | grep -q "$OLDSNAP" || fail "the old restore point is not listed" occ backup:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify" occ backup:status >/dev/null || fail "backup:status" @@ -91,9 +91,15 @@ ok "after the upgrade: schedule kept, old restore point listed, verified, a file docker exec -u www-data $N sh -c 'head -c 2000000 /dev/urandom > data/admin/files/Docs/after.bin' occ files:scan admin >/dev/null occ backup:run offsite >/dev/null || fail "backup after the upgrade" -[ "$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | sort -u | wc -l)" -ge 2 ] || fail "no new restore point after the upgrade" +NEWSNAP=$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | grep -v "$OLDSNAP" | head -1) +[ -n "$NEWSNAP" ] || fail "no new restore point after the upgrade" +SUM2=$(docker exec $N sha256sum /var/www/html/data/admin/files/Docs/after.bin | cut -d' ' -f1) +docker exec $N sh -c 'rm -rf /tmp/rst && mkdir /tmp/rst && chown www-data /tmp/rst' +occ backup:restore offsite "$NEWSNAP" data/admin/files/Docs/after.bin --to-directory=/tmp/rst >/dev/null || fail "restore from the new restore point" +GOT=$(docker exec $N sh -c 'find /tmp/rst -name after.bin -exec sha256sum {} \;' | cut -d' ' -f1) +[ "$GOT" = "$SUM2" ] || fail "the file restored from the new restore point differs" docker exec $N test -f /mnt/shared/loc/repo/settings || fail "the location got no settings file" OUT=$(occ backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) && fail "a used key from before the upgrade was offered for replacement" printf '%s\n' "$OUT" | grep -q "never replaced" || { printf '%s\n' "$OUT"; fail "the refusal does not say the key is never replaced"; } -ok "a new backup runs, the location got its settings, the key counts as used" +ok "a new backup runs and restores, the location got its settings, the key counts as used" echo "UPGRADE $OLDVER -> ${VER:-working tree} PASS"