M5: end-to-end test matrix and CI
- tests/integration/roundtrip.sh: key, kit code and confirmation, location, full and unchanged incremental backup, restore new-folder and replace (files identical, extra file in the trash), database dump and restore, retention, trash and restore from trash, no files_external errors in the log - tests/integration/matrix.sh: fresh Nextcloud per version and database (SQLite, MariaDB, PostgreSQL), removes only its own containers and their volumes; locally green on NC32 and NC35 with all three databases - GitHub Actions: unit tests on PHP 8.2-8.4, round trip on NC32-35 x SQLite/MariaDB/PostgreSQL Fixes found by the matrix: - files_external is no longer booted when it is disabled: only its classes are loaded and a private BackendService gets its backends from its Application class (booting registered its mount provider, which failed on installations where its tables were never created) - SQLite: the database file is excluded from the file backup (it is covered by the logical dump and copying the live file is inconsistent) - occ backup:key:confirm accepts --code and --confirm for scripts
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.2', '8.3', '8.4']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
extensions: sodium, zip
|
||||||
|
coverage: none
|
||||||
|
- run: composer install --no-interaction --no-progress
|
||||||
|
- run: vendor/bin/phpunit -c tests/phpunit.xml
|
||||||
|
|
||||||
|
integration:
|
||||||
|
# Full round trip (key, kit, location, backup, incremental, restore modes, database dump and
|
||||||
|
# restore, retention, trash) on a fresh Nextcloud per version and database.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
nextcloud: ['32', '33', '34', '35']
|
||||||
|
database: ['sqlite', 'mariadb', 'pgsql']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }}
|
||||||
|
run: tests/integration/matrix.sh "${{ matrix.nextcloud }}" "${{ matrix.database }}"
|
||||||
@@ -28,7 +28,9 @@ class KeyConfirm extends Command {
|
|||||||
protected function configure(): void {
|
protected function configure(): void {
|
||||||
$this->setName('backup:key:confirm')
|
$this->setName('backup:key:confirm')
|
||||||
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
|
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
|
||||||
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ');
|
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ')
|
||||||
|
->addOption('code', null, InputOption::VALUE_REQUIRED, 'Confirmation code from the kit file (for scripts; asked otherwise)')
|
||||||
|
->addOption('confirm', null, InputOption::VALUE_REQUIRED, 'The confirmation phrase "' . self::CONFIRMATION . '" (for scripts; asked otherwise)');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
@@ -40,12 +42,12 @@ class KeyConfirm extends Command {
|
|||||||
$output->writeln('');
|
$output->writeln('');
|
||||||
$output->writeln(wordwrap(self::STATEMENT, 78));
|
$output->writeln(wordwrap(self::STATEMENT, 78));
|
||||||
$output->writeln('');
|
$output->writeln('');
|
||||||
$code = (string)$this->getHelper('question')->ask($input, $output, new Question('Confirmation code from the recovery kit file ("confirmation_code"): '));
|
$code = $input->getOption('code') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Confirmation code from the recovery kit file ("confirmation_code"): '));
|
||||||
if (!$this->keys->checkKitCode($code)) {
|
if (!$this->keys->checkKitCode($code)) {
|
||||||
$output->writeln('<error>That code does not match the current recovery kit (download it with occ backup:key:kit first).</error>');
|
$output->writeln('<error>That code does not match the current recovery kit (download it with occ backup:key:kit first).</error>');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
$answer = $input->getOption('confirm') ?? (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
||||||
if (trim($answer) !== self::CONFIRMATION) {
|
if (trim($answer) !== self::CONFIRMATION) {
|
||||||
$output->writeln('<error>Not confirmed.</error>');
|
$output->writeln('<error>Not confirmed.</error>');
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ class BackupService {
|
|||||||
'data/*/cache',
|
'data/*/cache',
|
||||||
'data/*/uploads',
|
'data/*/uploads',
|
||||||
];
|
];
|
||||||
|
// SQLite: the database file is in the data directory; it is backed up by the logical dump,
|
||||||
|
// and copying the live file would give an inconsistent copy.
|
||||||
|
if ($this->config->getSystemValueString('dbtype') === 'sqlite3') {
|
||||||
|
$dbName = $this->config->getSystemValueString('dbname', 'owncloud');
|
||||||
|
$exclude[] = "data/$dbName.db";
|
||||||
|
$exclude[] = "data/$dbName.db-journal";
|
||||||
|
$exclude[] = "data/$dbName.db-wal";
|
||||||
|
$exclude[] = "data/$dbName.db-shm";
|
||||||
|
}
|
||||||
$logFile = $this->config->getSystemValueString('logfile', '');
|
$logFile = $this->config->getSystemValueString('logfile', '');
|
||||||
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
|
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
|
||||||
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
|
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
|
||||||
|
|||||||
@@ -121,11 +121,33 @@ class TargetService {
|
|||||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ?ExternalStorageFactory $factory = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* files_external's connection code, without changing the app's state:
|
||||||
|
* - enabled: it is booted by Nextcloud; use its own BackendService;
|
||||||
|
* - disabled: do NOT boot it (booting registers its mount provider and listeners, which use
|
||||||
|
* its database tables; on installations where it was never enabled those do not exist).
|
||||||
|
* Only its classes are loaded, and a private BackendService gets its backends and auth
|
||||||
|
* mechanisms from its Application class.
|
||||||
|
*/
|
||||||
private function factory(): ExternalStorageFactory {
|
private function factory(): ExternalStorageFactory {
|
||||||
// Load files_external's classes without enabling the app (its state stays as the admin set it).
|
if ($this->factory !== null) {
|
||||||
if (!class_exists(\OCA\Files_External\Service\BackendService::class)) {
|
return $this->factory;
|
||||||
$this->appManager->loadApp('files_external');
|
|
||||||
}
|
}
|
||||||
return new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
if ($this->appManager->isEnabledForAnyone('files_external')) {
|
||||||
|
$this->appManager->loadApp('files_external');
|
||||||
|
return $this->factory = new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
||||||
|
}
|
||||||
|
if (!class_exists(\OCA\Files_External\AppInfo\Application::class)) {
|
||||||
|
$path = $this->appManager->getAppPath('files_external');
|
||||||
|
require_once $path . '/composer/autoload.php';
|
||||||
|
}
|
||||||
|
$service = new \OCA\Files_External\Service\BackendService(
|
||||||
|
Server::get(\OCP\IAppConfig::class), Server::get(\Psr\Log\LoggerInterface::class));
|
||||||
|
$app = new \OCA\Files_External\AppInfo\Application();
|
||||||
|
$service->registerBackendProvider($app);
|
||||||
|
$service->registerAuthMechanismProvider($app);
|
||||||
|
return $this->factory = new ExternalStorageFactory($service);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ $seqStates = static function () use ($db, $prefix): array {
|
|||||||
return $out;
|
return $out;
|
||||||
};
|
};
|
||||||
// Let one sequence run ahead of the data (deleted rows, rollbacks): MAX+1 would be wrong.
|
// Let one sequence run ahead of the data (deleted rows, rollbacks): MAX+1 would be wrong.
|
||||||
$fcSeq = $db->fetchOne("SELECT pg_get_serial_sequence(?, 'fileid')", [$prefix . 'filecache']);
|
if ($db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
for ($i = 0; $i < 50; $i++) {
|
$fcSeq = $db->fetchOne("SELECT pg_get_serial_sequence(?, 'fileid')", [$prefix . 'filecache']);
|
||||||
$db->fetchOne('SELECT nextval(CAST(? AS regclass))', [$fcSeq]);
|
for ($i = 0; $i < 50; $i++) {
|
||||||
|
$db->fetchOne('SELECT nextval(CAST(? AS regclass))', [$fcSeq]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$seqBefore = $seqStates();
|
$seqBefore = $seqStates();
|
||||||
$before = [];
|
$before = [];
|
||||||
@@ -122,7 +124,8 @@ if ($mismatch) {
|
|||||||
// Sequences must be restored exactly (PostgreSQL), including one that ran ahead of the data.
|
// Sequences must be restored exactly (PostgreSQL), including one that ran ahead of the data.
|
||||||
$seqAfter = $seqStates();
|
$seqAfter = $seqStates();
|
||||||
$diff = array_filter(array_keys($seqBefore), fn ($k) => ($seqAfter[$k] ?? null) !== $seqBefore[$k]);
|
$diff = array_filter(array_keys($seqBefore), fn ($k) => ($seqAfter[$k] ?? null) !== $seqBefore[$k]);
|
||||||
$check(sprintf('all %d sequences restored exactly (filecache seq 50 ahead of data)', count($seqBefore)), $diff === [] && count($seqBefore) > 0);
|
$isPg = $db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||||
|
$check(sprintf('all %d sequences restored exactly%s', count($seqBefore), $isPg ? ' (filecache seq 50 ahead of data)' : ' (n/a on this database)'), $diff === [] && (!$isPg || count($seqBefore) > 0));
|
||||||
if ($diff) {
|
if ($diff) {
|
||||||
foreach (array_slice($diff, 0, 5) as $k) {
|
foreach (array_slice($diff, 0, 5) as $k) {
|
||||||
echo "$k before=" . json_encode($seqBefore[$k]) . ' after=' . json_encode($seqAfter[$k] ?? null) . "\n";
|
echo "$k before=" . json_encode($seqBefore[$k]) . ' after=' . json_encode($seqAfter[$k] ?? null) . "\n";
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Local test matrix for NG Backup: fresh Nextcloud per (version, database), run the round trip, tear down.
|
||||||
|
# Usage: tests/integration/matrix.sh "32 35" "sqlite mariadb pgsql"
|
||||||
|
VERSIONS=${1:-"32 35"}; DBS=${2:-"sqlite mariadb pgsql"}
|
||||||
|
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
RESULTS=()
|
||||||
|
for v in $VERSIONS; do for db in $DBS; do
|
||||||
|
n=ngbm-$v-$db; net=$n-net
|
||||||
|
docker network create $net >/dev/null
|
||||||
|
env=(-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin-matrix-pw -e NEXTCLOUD_TRUSTED_DOMAINS=localhost)
|
||||||
|
case $db in
|
||||||
|
sqlite) env+=(-e SQLITE_DATABASE=nextcloud) ;;
|
||||||
|
mariadb) docker run -d --name $n-db --network $net -e MARIADB_ROOT_PASSWORD=r -e MARIADB_DATABASE=nextcloud -e MARIADB_USER=nc -e MARIADB_PASSWORD=nc mariadb:11 --transaction-isolation=READ-COMMITTED --binlog-format=ROW >/dev/null
|
||||||
|
env+=(-e MYSQL_HOST=$n-db -e MYSQL_DATABASE=nextcloud -e MYSQL_USER=nc -e MYSQL_PASSWORD=nc) ;;
|
||||||
|
pgsql) docker run -d --name $n-db --network $net -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nc -e POSTGRES_PASSWORD=nc postgres:16-alpine >/dev/null
|
||||||
|
env+=(-e POSTGRES_HOST=$n-db -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nc -e POSTGRES_PASSWORD=nc) ;;
|
||||||
|
esac
|
||||||
|
[ "$db" != sqlite ] && sleep 8
|
||||||
|
docker run -d --name $n --network $net "${env[@]}" -v "$APP:/var/www/html/custom_apps/ng_backup:ro" nextcloud:$v-apache >/dev/null
|
||||||
|
for i in $(seq 1 90); do docker exec -u www-data $n php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done
|
||||||
|
docker exec $n chown www-data:www-data /var/www/html/custom_apps
|
||||||
|
echo "=== NC $v / $db: $(docker exec -u www-data $n php occ -V 2>/dev/null)"
|
||||||
|
if bash ${NGB_TRACE:+-x} "$APP/tests/integration/roundtrip.sh" "docker exec -u www-data $n" /var/www/html/data /var/www/html/custom_apps; then
|
||||||
|
RESULTS+=("NC $v / $db: PASS")
|
||||||
|
else
|
||||||
|
RESULTS+=("NC $v / $db: FAIL"); docker exec $n tail -c 2000 /var/www/html/data/nextcloud.log | tr ',' '\n' | grep -E '"message"' | tail -3
|
||||||
|
fi
|
||||||
|
# -v removes only these containers' own anonymous volumes (never a global prune)
|
||||||
|
docker rm -fv $n $n-db >/dev/null 2>&1; docker network rm $net >/dev/null
|
||||||
|
done; done
|
||||||
|
printf '%s\n' "${RESULTS[@]}"
|
||||||
|
# Non-zero exit when any combination failed (CI).
|
||||||
|
printf '%s\n' "${RESULTS[@]}" | grep -q FAIL && exit 1
|
||||||
|
exit 0
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# End-to-end round trip of NG Backup inside a Nextcloud installation (CI and local matrix).
|
||||||
|
# Usage: roundtrip.sh "<command prefix to run occ/php as the web user in the instance>" <datadir> <apps-path>
|
||||||
|
# e.g. roundtrip.sh "docker exec -u www-data ngb-nc35-pgsql" /var/www/html/data /var/www/html/custom_apps
|
||||||
|
set -euo pipefail
|
||||||
|
X="$1"; DATA="$2"; APPS="$3"
|
||||||
|
occ() { $X php occ "$@"; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
ok() { echo "ok $*"; }
|
||||||
|
U=ngbtest-$RANDOM
|
||||||
|
|
||||||
|
occ app:enable ng_backup >/dev/null
|
||||||
|
sleep 8 # APCu app config cache
|
||||||
|
$X sh -c "printf 'roundtrip passphrase 2026\n' > /tmp/ngb-rt-pass && mkdir -p /tmp/ngb-rt-target"
|
||||||
|
occ backup:key:init --passphrase-file=/tmp/ngb-rt-pass --label=CI --delete-delay-days=7 >/dev/null || fail "key init"
|
||||||
|
CODE=$(occ backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
|
||||||
|
occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
|
||||||
|
ok "key, kit code and confirmation"
|
||||||
|
|
||||||
|
occ backup:target:add ci local -a null::null -o datadir=/tmp/ngb-rt-target --path=repo >/dev/null || fail "target add"
|
||||||
|
ok "local 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"
|
||||||
|
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")
|
||||||
|
|
||||||
|
occ backup:run ci -l first >/dev/null || fail "first backup"
|
||||||
|
SECOND=$(occ backup:run ci -l second)
|
||||||
|
echo "$SECOND" | tail -2 | head -1
|
||||||
|
echo "$SECOND" | grep -qE "uploaded 0\.[0-9] MiB" || fail "unchanged second backup uploaded more than 1 MiB"
|
||||||
|
ok "full backup and unchanged incremental backup"
|
||||||
|
SNAP=$(occ backup:list ci | awk -F'|' '!f && / second /{gsub(/ /,"",$3); print $3; f=1}')
|
||||||
|
[ -n "$SNAP" ] || fail "snapshot not listed"
|
||||||
|
|
||||||
|
# Damage, then restore in each mode.
|
||||||
|
$X sh -c "cd $DATA/$U/files && rm Docs/Sub/note.txt && echo broken > Docs/f1.bin && echo extra > Docs/extra.txt"
|
||||||
|
occ files:scan "$U" >/dev/null
|
||||||
|
occ backup:restore ci "$SNAP" "data/$U/files/Docs" -m new-folder >/dev/null || fail "restore new-folder"
|
||||||
|
$X sh -c "ls $DATA/$U/files | grep -q '^Restored '" || fail "no Restored folder"
|
||||||
|
occ backup:restore ci "$SNAP" "data/$U/files/Docs" -m replace >/dev/null || fail "restore replace"
|
||||||
|
$X sh -c "rm -rf '$DATA/$U/files/Restored '*" ; occ files:scan "$U" >/dev/null
|
||||||
|
SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
||||||
|
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after restore"
|
||||||
|
$X sh -c "ls $DATA/$U/files_trashbin/files/ | grep -q '^extra.txt'" || fail "extra file not in trash"
|
||||||
|
ok "restore new-folder and replace: files identical, extra file in the trash"
|
||||||
|
|
||||||
|
# Database dump/restore round trip (all tables, checksums, sequences on PostgreSQL).
|
||||||
|
$X php -d memory_limit=512M "$APPS/ng_backup/spikes/03-db-dump.php" /tmp/ngb-rt-db | grep "ALL PASS" >/dev/null || fail "database round trip"
|
||||||
|
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: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"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
occ user:delete "$U" >/dev/null
|
||||||
|
$X sh -c "rm -rf /tmp/ngb-rt-target /tmp/ngb-rt-pass /tmp/ngb-rt-db"
|
||||||
|
echo "ROUNDTRIP PASS"
|
||||||
Reference in New Issue
Block a user