Compare commits
9 Commits
3ec23a3072
..
1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| ecae6e9cdd | |||
| d36c40e77b | |||
| 50c2636926 | |||
| d1e9a53ce5 | |||
| b625fe7666 | |||
| 74bfa7052f | |||
| bf48d339e4 | |||
| 029b3b9cc7 | |||
| dce93aa75e |
@@ -140,3 +140,5 @@ jobs:
|
|||||||
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite to-pgsql
|
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite to-pgsql
|
||||||
- name: Full restore with a damaged database dump (--salvage)
|
- name: Full restore with a damaged database dump (--salvage)
|
||||||
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage
|
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage
|
||||||
|
- name: Full restore with a damaged and a missing pack (--salvage)
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage-files
|
||||||
|
|||||||
+19
-1
@@ -11,7 +11,7 @@ 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
|
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.
|
shown under 1.0.0. The git tag of a release candidate goes after the date.
|
||||||
|
|
||||||
## [1.0.2] - unreleased
|
## [1.0.2] - 2026-11-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
||||||
@@ -34,9 +34,27 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
|
|||||||
- Full restores from S3 read the packs ahead, eight ranges at a time, as restores over SFTP do
|
- 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
|
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).
|
a 130 ms round trip, reading was four times faster in a test (18 instead of 4.5 MiB/s).
|
||||||
|
- Over SMB the index files, which a new server reads in `backup:target:add`, take fewer round
|
||||||
|
trips: from Canada to a Hetzner Storage Box (130 ms), 0.66 instead of 1.05 s per file, so about
|
||||||
|
5 instead of 8 hours for the 27,000 index files of a 1 TB repository.
|
||||||
|
- `occ backup:restore:full` that runs again after it stopped (`--force`) goes on where it was: data
|
||||||
|
files already in place with the size and modification time of the backup are left, and only
|
||||||
|
the rest is read from the location. Before, a stopped restore of 1 TB started over.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A full restore over SMB could hang at 100% CPU when the connection to the server dropped: a
|
||||||
|
failed read was counted as data. It is now read again over a new connection. Found in a
|
||||||
|
disaster recovery test of 1 TB from Canada to a Hetzner Storage Box, after 392 GiB.
|
||||||
|
- The read-ahead folder of a restore that was killed while it wrote is removed by the next run;
|
||||||
|
it kept one file of up to 8 MiB.
|
||||||
- `occ backup:target:add` only estimates the time left for reading the index once enough of it
|
- `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"
|
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.
|
for something done within a minute.
|
||||||
|
- A restore that stops because a file is gone from the location now points at `--salvage`, as it
|
||||||
|
already did for damaged data.
|
||||||
|
|
||||||
|
### Known issues
|
||||||
|
- Empty folders are not backed up or restored; to be fixed in 1.0.3 (#27).
|
||||||
|
|
||||||
## [1.0.1] - 2026-11-01
|
## [1.0.1] - 2026-11-01
|
||||||
|
|
||||||
|
|||||||
@@ -407,6 +407,29 @@ in README "Known limitations":
|
|||||||
`--skip-missing-tables`; daarna `--salvage` op een deel van de bestanden (moet hetzelfde
|
`--skip-missing-tables`; daarna `--salvage` op een deel van de bestanden (moet hetzelfde
|
||||||
resultaat geven, exitcode 0). Uitvoeren met Dillard, die VM 980 klaarzet; deze week, vóór
|
resultaat geven, exitcode 0). Uitvoeren met Dillard, die VM 980 klaarzet; deze week, vóór
|
||||||
de release. Screenshots akkoord (2026-11-04).
|
de release. Screenshots akkoord (2026-11-04).
|
||||||
|
**DR-test 1.0.2 over SMB (VM 980, snapshot a86271ed van nc01, 983 GiB, gestart 2026-11-04):**
|
||||||
|
index lezen 8 uur (~1 bestand/s; daarna versneld naar 0,66 s/bestand, gemeten vanaf VM 980);
|
||||||
|
restore zonder opties (geen `--install-apps`, geen `--skip-missing-tables`) liep ~12 MiB/s
|
||||||
|
(SFTP eerder 2,8-7,6; SMB vóór 1.0.2 2,6). Na 392 GiB viel de SMB-verbinding weg en bleef de
|
||||||
|
restore op 100% CPU hangen: de smbclient-extensie geeft bij een mislukte read een string met
|
||||||
|
lengte -1. Opgelost (controle + nieuwe verbinding), plus hervatten: `--force` laat bestanden met
|
||||||
|
dezelfde grootte en mtime staan. Hervat 2026-11-05 07:21 UTC na `maintenance:mode --off`
|
||||||
|
volgens de gids; binnen 10 min voorbij de 392 GiB. SMB op sub5 na de test weer uitzetten.
|
||||||
|
**Resultaat (klaar 2026-11-06 02:11 UTC, nazorg 02:27):** hervatte run 18 u 50 min, exit 0, 150.755
|
||||||
|
bestanden; 7-13 MiB/s (langzamer vanaf ~18 uur Duitse tijd, oorzaak niet vastgesteld). Vergeleken
|
||||||
|
met nc01: 150.467 van 150.758 bestanden gelijk in grootte en mtime, 397 op hash zonder verschil;
|
||||||
|
gebruikers, agenda's, contacten, shares en Deck gelijk; bestanden, agenda en contacten via WebDAV
|
||||||
|
getest als gebruiker. `backup:restore --salvage` op 16.904 bestanden: exit 0, geen verschil. Niet
|
||||||
|
vastgesteld: de 50.080 "updated" bij `files:scan`. Zonder `--install-apps` 51 van de 81 apps aan.
|
||||||
|
VM's 980-982 en de sim-backups op OVH en sub6 opgeruimd.
|
||||||
|
**Naar 1.0.3 (besluit 2026-11-06):** lege mappen komen niet terug (3.982 op nc01, 2.076 daarvan in
|
||||||
|
`files`, verklaart vrijwel zeker de 2.370 "removed" bij `files:scan`). De backup legt alleen
|
||||||
|
bestanden vast (`walk()`), dus oudere snapshots hebben ze niet. Voor een DR acceptabel, wel
|
||||||
|
fixen; issue #27. Daarna kleine volledige simulaties; een DR van 1 TB is hiervoor niet nodig.
|
||||||
|
**Salvage vóór de release getest (besluit 2026-11-06):** nieuwe modus `salvage-files` in
|
||||||
|
`full-restore-roundtrip.sh`, ook in CI: één pack beschadigd en één weg. Eerst stopt de restore,
|
||||||
|
daarna `--salvage --force` volgens de gids. Vond één fout: bij een ontbrekend bestand wees de
|
||||||
|
melding niet naar `--salvage` (opgelost).
|
||||||
Stand 2026-11-04 (`polish`, gepusht): cron-waarschuwing + tick vanaf de pagina +
|
Stand 2026-11-04 (`polish`, gepusht): cron-waarschuwing + tick vanaf de pagina +
|
||||||
voortgang van de db-fase; `--salvage` voor `backup:restore` en `backup:restore:full`; S3
|
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
|
vooruitlezen (8 ranges tegelijk, met `SaveAs` naar de spool); SMB in leesacties van 8 MiB
|
||||||
@@ -414,7 +437,10 @@ in README "Known limitations":
|
|||||||
met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`; nieuwe screenshots
|
met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`; nieuwe screenshots
|
||||||
(1–3 vervangen, 4-bin erbij; in `info.xml` bij de release naar tag v1.0.2 laten wijzen en
|
(1–3 vervangen, 4-bin erbij; in `info.xml` bij de release naar tag v1.0.2 laten wijzen en
|
||||||
4-bin toevoegen). Getest: unit (208), psalm, integratie NC35 sqlite/pgsql, S3/SFTP/FTP/SMB,
|
4-bin toevoegen). Getest: unit (208), psalm, integratie NC35 sqlite/pgsql, S3/SFTP/FTP/SMB,
|
||||||
volledige restore met `--salvage`. Nog te doen: DR-test, release.
|
volledige restore met `--salvage`. **1.0.2 uitgebracht 2026-11-06** (tag `v1.0.2`, PR #28).
|
||||||
|
- **Branches (besluit 2026-11-06):** werk voor een versie op een branch met die naam (`1.0.3`);
|
||||||
|
`polish` alleen voor kleine cosmetische zaken, per geval bekijken of en wanneer die gemerged
|
||||||
|
worden. SMB staat uit op sub5 en sub6; sub6 is het testaccount, eerst aanzetten voor een SMB-test.
|
||||||
- **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.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,
|
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
|
niet aangekondigd en los van de release. Voor 1.0.2 ook: nieuwe screenshots (het labelveld bij
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ installations and hosted Nextcloud. (Choosing SMB as a storage backend is the on
|
|||||||
see "Storage backend experiences" below — that requirement comes from files_external, not from
|
see "Storage backend experiences" below — that requirement comes from files_external, not from
|
||||||
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
||||||
|
|
||||||
**Status: 1.0.1, stable.** Disaster recovery has been tested on real installations (see "Tested
|
**Status: 1.0.2, stable.** Disaster recovery has been tested on real installations (see "Tested
|
||||||
disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and
|
disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and
|
||||||
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
||||||
`docs/DISASTER-RECOVERY.md`.
|
`docs/DISASTER-RECOVERY.md`.
|
||||||
@@ -168,6 +168,7 @@ Full restores onto a fresh server, following only [the guide](docs/DISASTER-RECO
|
|||||||
| Backup | Location | Database | Result |
|
| Backup | Location | Database | Result |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical |
|
| Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical |
|
||||||
|
| The same, 1.0.2 | Hetzner Storage Box (SMB), Germany → Canada | PostgreSQL → PostgreSQL | 150,755 files restored in about 29 h (7-13 MiB/s), going on after a lost connection; sample identical, empty folders not restored ([#27](https://github.com/dillardblom/ng_backup/issues/27)) |
|
||||||
| 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical |
|
| 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical |
|
||||||
| The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical |
|
| The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical |
|
||||||
| The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) |
|
| The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) |
|
||||||
@@ -264,16 +265,9 @@ What is planned, without dates. Ideas and votes are welcome in the
|
|||||||
[issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here,
|
[issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here,
|
||||||
a 👍 on its issue helps decide what comes first.
|
a 👍 on its issue helps decide what comes first.
|
||||||
|
|
||||||
**1.0.2**
|
**1.0.3**
|
||||||
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
- Empty folders in the backup and the restore
|
||||||
system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on
|
([#27](https://github.com/dillardblom/ng_backup/issues/27)).
|
||||||
while the NG Backup page is open, and the database phase of a backup shows its progress
|
|
||||||
([#13](https://github.com/dillardblom/ng_backup/issues/13)).
|
|
||||||
- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4.
|
|
||||||
- `--salvage` for a restore: restore what can be read, skip a file that cannot, and list those at
|
|
||||||
the end instead of stopping.
|
|
||||||
- The trash of restore points (most of them moved there by the retention policy) on a tab of its
|
|
||||||
own, "Bin", instead of below the list of restore points.
|
|
||||||
|
|
||||||
**1.1.0**
|
**1.1.0**
|
||||||
- Restore without the recovery kit: the backup key from the location itself, opened with one of
|
- Restore without the recovery kit: the backup key from the location itself, opened with one of
|
||||||
@@ -299,6 +293,9 @@ a 👍 on its issue helps decide what comes first.
|
|||||||
|
|
||||||
On the backlog; know them before you rely on NG Backup:
|
On the backlog; know them before you rely on NG Backup:
|
||||||
|
|
||||||
|
- **Empty folders do not come back.** A backup only records files, so a folder without files is
|
||||||
|
not restored. To be fixed in 1.0.3
|
||||||
|
([#27](https://github.com/dillardblom/ng_backup/issues/27)).
|
||||||
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
|
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
|
||||||
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
|
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
|
||||||
External storage app are not in the data directory and are **not included**, without a warning.
|
External storage app are not in the data directory and are **not included**, without a warning.
|
||||||
|
|||||||
+7
-6
@@ -40,12 +40,12 @@ NG Backup needs Nextcloud's background jobs on system cron (a real cron line, no
|
|||||||
Read "Known limitations" in the README before you rely on it, and test a restore. Feedback is very
|
Read "Known limitations" in the README before you rely on it, and test a restore. Feedback is very
|
||||||
welcome: use the "Share your experience" link on the admin page.
|
welcome: use the "Share your experience" link on the admin page.
|
||||||
|
|
||||||
If you like NG Backup, don't forget to give the app a star in the app store and the
|
**If you like NG Backup, don't forget to give the app a star in the app store and the
|
||||||
[repository](https://github.com/dillardblom/ng_backup) a star on GitHub!
|
[repository](https://github.com/dillardblom/ng_backup) a star on GitHub!**
|
||||||
|
|
||||||
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
|
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
|
||||||
you need help with restoring a backup after a crash.]]></description>
|
you need help with restoring a backup after a crash.]]></description>
|
||||||
<version>1.0.1</version>
|
<version>1.0.2</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
@@ -56,9 +56,10 @@ you need help with restoring a backup after a crash.]]></description>
|
|||||||
<website>https://github.com/dillardblom/ng_backup</website>
|
<website>https://github.com/dillardblom/ng_backup</website>
|
||||||
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
||||||
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.1/screenshots/1-backups.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.2/screenshots/1-backups.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.1/screenshots/2-restore.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.2/screenshots/2-restore.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.1/screenshots/3-settings.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.2/screenshots/3-settings.png</screenshot>
|
||||||
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.2/screenshots/4-bin.png</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<php min-version="8.1"/>
|
<php min-version="8.1"/>
|
||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ mode on yourself before, it stays on: switch it off with `occ maintenance:mode -
|
|||||||
If it stops halfway, maintenance mode stays on (the instance may be partly restored). Fix the cause,
|
If it stops halfway, maintenance mode stays on (the instance may be partly restored). Fix the cause,
|
||||||
then switch maintenance mode off and run the restore again with `--force`, in one line (the message
|
then switch maintenance mode off and run the restore again with `--force`, in one line (the message
|
||||||
prints it): `occ maintenance:mode --off && occ backup:restore:full <location> <snapshot> --force`.
|
prints it): `occ maintenance:mode --off && occ backup:restore:full <location> <snapshot> --force`.
|
||||||
Leave out `--install-apps` this time, so that the instance is open only for the seconds until the
|
It goes on where it stopped: data files that are already in place with the size and modification
|
||||||
|
time of the backup are left, so only the rest comes from the location again (since 1.0.2; before,
|
||||||
|
it started over). The database is restored again in full, which takes minutes. Leave out `--install-apps` this time, so that the instance is open only for the seconds until the
|
||||||
restore has switched maintenance mode on again; apps the first run could not install are listed at
|
restore has switched maintenance mode on again; apps the first run could not install are listed at
|
||||||
the end of the restore, install those afterwards.
|
the end of the restore, install those afterwards.
|
||||||
If occ then says that Nextcloud or one of the apps requires an upgrade, the database is already
|
If occ then says that Nextcloud or one of the apps requires an upgrade, the database is already
|
||||||
@@ -138,6 +140,9 @@ The command then ends with exit code 2 instead of 0. The same option exists for
|
|||||||
- From Nextcloud All-in-One to a regular server: also check the cache settings (`memcache.*`,
|
- From Nextcloud All-in-One to a regular server: also check the cache settings (`memcache.*`,
|
||||||
`redis`), which point at AIO's containers.
|
`redis`), which point at AIO's containers.
|
||||||
- Log in, open a few files, a calendar and a contact.
|
- Log in, open a few files, a calendar and a contact.
|
||||||
|
- Empty folders do not come back: a backup only records files (to be fixed in 1.0.3, see
|
||||||
|
[issue #27](https://github.com/dillardblom/ng_backup/issues/27)). `occ files:scan` then removes
|
||||||
|
them from the file cache, so it reports them as removed.
|
||||||
- Start background jobs again (cron), as in step 2 in reverse.
|
- Start background jobs again (cron), as in step 2 in reverse.
|
||||||
|
|
||||||
**10. Point your domain at the new server** and check that clients reconnect.
|
**10. Point your domain at the new server** and check that clients reconnect.
|
||||||
|
|||||||
+135
-26
@@ -133,7 +133,7 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
|
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
|
||||||
* after the other.
|
* after the other, over SMB in fewer round trips (smbWhole()).
|
||||||
*/
|
*/
|
||||||
public function getMany(array $paths): \Generator {
|
public function getMany(array $paths): \Generator {
|
||||||
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
|
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
|
||||||
@@ -149,7 +149,8 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
|
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
|
||||||
}
|
}
|
||||||
foreach ($chunk as $path) {
|
foreach ($chunk as $path) {
|
||||||
$data = $fast[$path] ?? null;
|
// over SMB in fewer round trips, one after the other (smbWhole())
|
||||||
|
$data = $fast[$path] ?? $this->smbWhole($this->abs($path));
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
// not read in the batch (or no batches here): the usual way, which also
|
// not read in the batch (or no batches here): the usual way, which also
|
||||||
// reports a missing file as such
|
// reports a missing file as such
|
||||||
@@ -411,7 +412,8 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// read the usual way below, which reports what is wrong
|
// read the usual way below, which reports what is wrong, over a new connection
|
||||||
|
$this->smbReconnect();
|
||||||
}
|
}
|
||||||
$fh = $this->get($path);
|
$fh = $this->get($path);
|
||||||
try {
|
try {
|
||||||
@@ -543,6 +545,126 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||||
*/
|
*/
|
||||||
private function smbChunks(string $abs, int $offset, int $length): ?\Generator {
|
private function smbChunks(string $abs, int $offset, int $length): ?\Generator {
|
||||||
|
$handle = $this->smbHandle($abs);
|
||||||
|
if ($handle === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$state, $url] = $handle;
|
||||||
|
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
|
||||||
|
return (static function () use ($state, $url, $offset, $length): \Generator {
|
||||||
|
$fh = $state->open($url, 'r');
|
||||||
|
try {
|
||||||
|
$state->lseek($fh, $offset, SEEK_SET, $url);
|
||||||
|
for ($got = 0; $got < $length;) {
|
||||||
|
$want = min(self::SMB_READ, $length - $got);
|
||||||
|
$chunk = self::smbRead($state, $fh, $want, $url);
|
||||||
|
if ($chunk === '') {
|
||||||
|
throw new BackendException('Short read in ' . $url);
|
||||||
|
}
|
||||||
|
$got += strlen($chunk);
|
||||||
|
yield $chunk;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$state->close($fh, $url);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole (small) file over SMB in four round trips: open, read, read up to the end, close.
|
||||||
|
* files_external's fopen() first checks that the file exists (a stat with its attributes) and
|
||||||
|
* icewind's stream reads 1 MiB at a time: about 7 round trips, 1 s per index file from Canada
|
||||||
|
* to the Storage Box (130 ms), so ~8 h for the 27,000 index files of a 1 TB repository. Null
|
||||||
|
* when the storage is not SMB through the extension or anything fails: the usual way then
|
||||||
|
* reads the file and reports a missing one as such.
|
||||||
|
*
|
||||||
|
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||||
|
*/
|
||||||
|
private function smbWhole(string $abs): ?string {
|
||||||
|
$handle = $this->smbHandle($abs);
|
||||||
|
if ($handle === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
[$state, $url] = $handle;
|
||||||
|
try {
|
||||||
|
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
|
||||||
|
$fh = $state->open($url, 'r');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = '';
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
$chunk = self::smbRead($state, $fh, self::SMB_READ, $url);
|
||||||
|
$data .= $chunk;
|
||||||
|
} while ($chunk !== '');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$data = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
/** @psalm-suppress MixedMethodCall */
|
||||||
|
$state->close($fh, $url);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
if ($data === null) {
|
||||||
|
$this->smbReconnect(); // a read failed: the usual way reads it over a new connection
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One read of at most $bytes through the native state, checked. When libsmbclient fails a read
|
||||||
|
* (seen when the connection to the server dropped during a long restore), the smbclient
|
||||||
|
* extension does not return false but a string whose length is -1, because it stores
|
||||||
|
* smbc_read()'s -1 in an unsigned length; icewind passes it on. Counting that as data made
|
||||||
|
* the read loop go backwards forever at 100% CPU (DR test 2026-11-05, after 392 GiB).
|
||||||
|
*
|
||||||
|
* @param resource $fh
|
||||||
|
* @psalm-suppress MixedMethodCall, MixedArgument the state is icewind's NativeState
|
||||||
|
*/
|
||||||
|
private static function smbRead(object $state, $fh, int $bytes, string $url): string {
|
||||||
|
$chunk = $state->read($fh, $bytes, $url);
|
||||||
|
$n = is_string($chunk) ? strlen($chunk) : -1;
|
||||||
|
if ($n < 0 || $n > $bytes) {
|
||||||
|
throw new BackendException('Read failed in ' . $url);
|
||||||
|
}
|
||||||
|
return $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a failed read: drop icewind's connection state, so the next read (the usual way, or
|
||||||
|
* the next range) connects again instead of failing on a connection that is gone.
|
||||||
|
*
|
||||||
|
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||||
|
*/
|
||||||
|
private function smbReconnect(): void {
|
||||||
|
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
(function (): void {
|
||||||
|
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||||
|
$share = $this->share;
|
||||||
|
if ($share instanceof \Icewind\SMB\Native\NativeShare) {
|
||||||
|
(function (): void {
|
||||||
|
/** @psalm-suppress UndefinedThisPropertyAssignment */
|
||||||
|
$this->state = null;
|
||||||
|
})->call($share);
|
||||||
|
}
|
||||||
|
})->call($this->storage);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* icewind's NativeState and the smb:// URL of $abs behind files_external's SMB storage, reached
|
||||||
|
* through bound closures; null when the storage is not SMB through the native extension or its
|
||||||
|
* internals are not as expected.
|
||||||
|
*
|
||||||
|
* @return array{0: object, 1: string}|null
|
||||||
|
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||||
|
*/
|
||||||
|
private function smbHandle(string $abs): ?array {
|
||||||
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB || !extension_loaded('smbclient')) {
|
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB || !extension_loaded('smbclient')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -562,27 +684,7 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if ($handle === null) {
|
return $handle;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
[$state, $url] = $handle;
|
|
||||||
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
|
|
||||||
return (static function () use ($state, $url, $offset, $length): \Generator {
|
|
||||||
$fh = $state->open($url, 'r');
|
|
||||||
try {
|
|
||||||
$state->lseek($fh, $offset, SEEK_SET, $url);
|
|
||||||
for ($got = 0; $got < $length;) {
|
|
||||||
$chunk = (string)$state->read($fh, min(self::SMB_READ, $length - $got), $url);
|
|
||||||
if ($chunk === '') {
|
|
||||||
throw new BackendException('Short read in ' . $url);
|
|
||||||
}
|
|
||||||
$got += strlen($chunk);
|
|
||||||
yield $chunk;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
$state->close($fh, $url);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -602,12 +704,15 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
try {
|
try {
|
||||||
foreach ($abs as $i => [$path, $offset, $length]) {
|
foreach ($abs as $i => [$path, $offset, $length]) {
|
||||||
$fh = null;
|
$fh = null;
|
||||||
|
$out = false;
|
||||||
try {
|
try {
|
||||||
$chunks = $this->smbChunks($path, $offset, $length);
|
$chunks = $this->smbChunks($path, $offset, $length);
|
||||||
$out = $chunks === null ? false : fopen($file, 'w+b');
|
$out = $chunks === null ? false : fopen($file, 'w+b');
|
||||||
if ($chunks !== null && $out !== false) {
|
if ($chunks !== null && $out !== false) {
|
||||||
foreach ($chunks as $chunk) {
|
foreach ($chunks as $chunk) {
|
||||||
fwrite($out, $chunk);
|
if (fwrite($out, $chunk) !== strlen($chunk)) {
|
||||||
|
throw new BackendException('Cannot write to the spool');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$fh = ftell($out) === $length && rewind($out) ? $out : null;
|
$fh = ftell($out) === $length && rewind($out) ? $out : null;
|
||||||
if ($fh === null) {
|
if ($fh === null) {
|
||||||
@@ -615,7 +720,11 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$fh = null; // read the usual way, which reports why
|
if (is_resource($out)) {
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
$fh = null; // read the usual way, which reports why, over a new connection
|
||||||
|
$this->smbReconnect();
|
||||||
} finally {
|
} finally {
|
||||||
@unlink($file); // an open stream keeps its data
|
@unlink($file); // an open stream keeps its data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ HELP);
|
|||||||
}, $skipped);
|
}, $skipped);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
if ($skipped === null && ($e instanceof \OCA\NgBackup\Repository\RepositoryException || $e instanceof \OCA\NgBackup\Crypto\CryptoException)) {
|
if ($skipped === null && self::backupLooksDamaged($e)) {
|
||||||
$output->writeln('The data of a file on the location is damaged or missing. Run the same command with --salvage to restore everything else and list what could not be restored.');
|
$output->writeln('The data of a file on the location is damaged or missing. Run the same command with --salvage to restore everything else and list what could not be restored.');
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
@@ -76,6 +76,13 @@ HELP);
|
|||||||
return self::reportSkipped($skipped, $output);
|
return self::reportSkipped($skipped, $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Damaged data, or a file that is gone from the location: what --salvage is for. */
|
||||||
|
public static function backupLooksDamaged(\Throwable $e): bool {
|
||||||
|
return $e instanceof \OCA\NgBackup\Repository\RepositoryException
|
||||||
|
|| $e instanceof \OCA\NgBackup\Crypto\CryptoException
|
||||||
|
|| ($e instanceof \OCA\NgBackup\Backend\BackendException && str_starts_with($e->getMessage(), 'Not found'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The files --salvage left out; exit code 2 when there are any.
|
* The files --salvage left out; exit code 2 when there are any.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ HELP);
|
|||||||
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
|
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
|
||||||
}
|
}
|
||||||
$output->writeln('If occ then says that Nextcloud needs an upgrade (the database is already the old server\'s), run occ upgrade first.');
|
$output->writeln('If occ then says that Nextcloud needs an upgrade (the database is already the old server\'s), run occ upgrade first.');
|
||||||
if (!$input->getOption('salvage') && ($e instanceof \OCA\NgBackup\Repository\RepositoryException || $e instanceof \OCA\NgBackup\Crypto\CryptoException)) {
|
if (!$input->getOption('salvage') && Restore::backupLooksDamaged($e)) {
|
||||||
$output->writeln('The backup itself looks damaged here. --salvage restores everything that can be read and lists the rest.');
|
$output->writeln('The backup itself looks damaged here. --salvage restores everything that can be read and lists the rest.');
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -224,14 +224,18 @@ final class Repository {
|
|||||||
* @param array<string, string>|null $skipped salvage: an array to collect the files that could
|
* @param array<string, string>|null $skipped salvage: an array to collect the files that could
|
||||||
* not be read or decrypted (path => reason), which are then left out instead of
|
* not be read or decrypted (path => reason), which are then left out instead of
|
||||||
* stopping the restore; null (default) stops at the first such file
|
* stopping the restore; null (default) stops at the first such file
|
||||||
|
* @param bool $keepUnchanged leave a file that is already in place with the size and
|
||||||
|
* modification time of the snapshot, as a backup takes such a file as unchanged:
|
||||||
|
* a restore that was stopped and runs again goes on where it was, without reading
|
||||||
|
* what it already restored from the location again
|
||||||
*/
|
*/
|
||||||
public function restore(string $snapshotId, string $target, string $prefix = '', bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null): int {
|
public function restore(string $snapshotId, string $target, string $prefix = '', bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null, bool $keepUnchanged = false): int {
|
||||||
if ($prefix !== '' && !self::isSafePath(rtrim($prefix, '/'))) {
|
if ($prefix !== '' && !self::isSafePath(rtrim($prefix, '/'))) {
|
||||||
throw new RepositoryException('Invalid path prefix');
|
throw new RepositoryException('Invalid path prefix');
|
||||||
}
|
}
|
||||||
$plan = $this->readAhead($snapshotId, $prefix, $target);
|
$plan = $this->readAhead($snapshotId, $prefix, $target, $stripPrefix, $keepUnchanged);
|
||||||
try {
|
try {
|
||||||
return $this->restoreEntries($snapshotId, $target, $prefix, $stripPrefix, $heartbeat, $plan, $skipped);
|
return $this->restoreEntries($snapshotId, $target, $prefix, $stripPrefix, $heartbeat, $plan, $skipped, $keepUnchanged);
|
||||||
} finally {
|
} finally {
|
||||||
$plan?->close();
|
$plan?->close();
|
||||||
if ($plan !== null) {
|
if ($plan !== null) {
|
||||||
@@ -245,13 +249,23 @@ final class Repository {
|
|||||||
* pass over the snapshot lists the ranges of packs it needs, in order. The spool is a
|
* pass over the snapshot lists the ranges of packs it needs, in order. The spool is a
|
||||||
* folder in the target, which has room for the restore anyway.
|
* folder in the target, which has room for the restore anyway.
|
||||||
*/
|
*/
|
||||||
private function readAhead(string $snapshotId, string $prefix, string $target): ?ReadAheadPlan {
|
private function readAhead(string $snapshotId, string $prefix, string $target, bool $stripPrefix, bool $keepUnchanged): ?ReadAheadPlan {
|
||||||
if (!$this->backend instanceof IReadsAhead || !$this->backend->canReadAhead()) {
|
if (!$this->backend instanceof IReadsAhead || !$this->backend->canReadAhead()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// what a restore that was stopped left behind: empty, its files were unlinked when opened
|
// what a restore that was stopped left behind: its files are unlinked once opened, but one
|
||||||
|
// that was being written when the process was killed stays (8 MiB, DR test 2026-11-05)
|
||||||
$stale = glob(rtrim($target, '/') . '/.ngb-restore-spool-*', GLOB_ONLYDIR);
|
$stale = glob(rtrim($target, '/') . '/.ngb-restore-spool-*', GLOB_ONLYDIR);
|
||||||
foreach ($stale === false ? [] : $stale as $dir) {
|
foreach ($stale === false ? [] : $stale as $dir) {
|
||||||
|
if (is_link($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$files = glob($dir . '/*');
|
||||||
|
foreach ($files === false ? [] : $files as $file) {
|
||||||
|
if (is_file($file) && !is_link($file)) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@rmdir($dir);
|
@rmdir($dir);
|
||||||
}
|
}
|
||||||
$plan = new ReadAheadPlan(rtrim($target, '/') . '/.ngb-restore-spool-' . bin2hex(random_bytes(4)));
|
$plan = new ReadAheadPlan(rtrim($target, '/') . '/.ngb-restore-spool-' . bin2hex(random_bytes(4)));
|
||||||
@@ -260,6 +274,9 @@ final class Repository {
|
|||||||
if (!self::underPrefix($entry['p'], $prefix)) {
|
if (!self::underPrefix($entry['p'], $prefix)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($keepUnchanged && self::inPlace(self::destination($target, $entry['p'], $prefix, $stripPrefix), $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
foreach ($entry['b'] as $id) {
|
foreach ($entry['b'] as $id) {
|
||||||
try {
|
try {
|
||||||
[$pack, $offset, $length] = $this->index->get($id);
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
@@ -289,18 +306,19 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, string>|null $skipped see restore() */
|
/** @param array<string, string>|null $skipped see restore() */
|
||||||
private function restoreEntries(string $snapshotId, string $target, string $prefix, bool $stripPrefix, ?callable $heartbeat, ?ReadAheadPlan $plan, ?array &$skipped): int {
|
private function restoreEntries(string $snapshotId, string $target, string $prefix, bool $stripPrefix, ?callable $heartbeat, ?ReadAheadPlan $plan, ?array &$skipped, bool $keepUnchanged): int {
|
||||||
$count = 0;
|
$count = 0;
|
||||||
$cleaned = [];
|
$cleaned = [];
|
||||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
if (!self::underPrefix($entry['p'], $prefix)) {
|
if (!self::underPrefix($entry['p'], $prefix)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$relPath = $entry['p'];
|
$dest = self::destination($target, $entry['p'], $prefix, $stripPrefix);
|
||||||
if ($stripPrefix && $prefix !== '') {
|
if ($keepUnchanged && self::inPlace($dest, $entry)) {
|
||||||
$relPath = $entry['p'] === rtrim($prefix, '/') ? basename($entry['p']) : substr($entry['p'], strlen($prefix));
|
// below the target as written by an earlier run of this restore (checked then)
|
||||||
|
$count++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
$dest = rtrim($target, '/') . '/' . $relPath;
|
|
||||||
if (!is_dir(dirname($dest))) {
|
if (!is_dir(dirname($dest))) {
|
||||||
mkdir(dirname($dest), 0700, true);
|
mkdir(dirname($dest), 0700, true);
|
||||||
}
|
}
|
||||||
@@ -358,6 +376,26 @@ final class Repository {
|
|||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where restore() writes the snapshot path $path. */
|
||||||
|
private static function destination(string $target, string $path, string $prefix, bool $stripPrefix): string {
|
||||||
|
$relPath = $path;
|
||||||
|
if ($stripPrefix && $prefix !== '') {
|
||||||
|
$relPath = $path === rtrim($prefix, '/') ? basename($path) : substr($path, strlen($prefix));
|
||||||
|
}
|
||||||
|
return rtrim($target, '/') . '/' . $relPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A regular file (no link) at $dest with the size and modification time of the snapshot
|
||||||
|
* entry: what a backup takes as unchanged, and what a finished restore of it leaves.
|
||||||
|
*
|
||||||
|
* @param array{s: int, m: int, ...} $entry
|
||||||
|
*/
|
||||||
|
private static function inPlace(string $dest, array $entry): bool {
|
||||||
|
clearstatcache(true, $dest);
|
||||||
|
return !is_link($dest) && is_file($dest) && filesize($dest) === $entry['s'] && filemtime($dest) === $entry['m'];
|
||||||
|
}
|
||||||
|
|
||||||
/** $prefix is "" (everything) or ends with "/"; the path without that "/" is a file to restore too. */
|
/** $prefix is "" (everything) or ends with "/"; the path without that "/" is a file to restore too. */
|
||||||
private static function underPrefix(string $path, string $prefix): bool {
|
private static function underPrefix(string $path, string $prefix): bool {
|
||||||
return $prefix === '' || str_starts_with($path, $prefix) || $path === rtrim($prefix, '/');
|
return $prefix === '' || str_starts_with($path, $prefix) || $path === rtrim($prefix, '/');
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ final class RestoreFullService {
|
|||||||
}
|
}
|
||||||
if ($dbError !== null) {
|
if ($dbError !== null) {
|
||||||
$salvageDir = $dataDir . '/ngb-salvage-' . substr($snapshotId, 0, 12);
|
$salvageDir = $dataDir . '/ngb-salvage-' . substr($snapshotId, 0, 12);
|
||||||
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $salvageDir . '/data', stripPrefix: true, heartbeat: $beat, skipped: $skipped);
|
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $salvageDir . '/data', stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
|
||||||
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $salvageDir . '/config', stripPrefix: true, heartbeat: $beat, skipped: $skipped);
|
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $salvageDir . '/config', stripPrefix: true, heartbeat: $beat, skipped: $skipped);
|
||||||
return ['dbReport' => ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => 0,
|
return ['dbReport' => ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => 0,
|
||||||
'dbConsistent' => true, 'missingApps' => $check['missingApps'], 'skipped' => $skipped ?? [], 'dbError' => $dbError,
|
'dbConsistent' => true, 'missingApps' => $check['missingApps'], 'skipped' => $skipped ?? [], 'dbError' => $dbError,
|
||||||
@@ -209,7 +209,7 @@ final class RestoreFullService {
|
|||||||
$backupConfig = [];
|
$backupConfig = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $dataDir, stripPrefix: true, heartbeat: $beat, skipped: $skipped);
|
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $dataDir, stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
|
||||||
|
|
||||||
// Last step: nothing after this may need $this->targets->repository($target) again.
|
// Last step: nothing after this may need $this->targets->repository($target) again.
|
||||||
if ($backupConfig !== []) {
|
if ($backupConfig !== []) {
|
||||||
|
|||||||
@@ -169,15 +169,18 @@ final class RestoreService {
|
|||||||
* @param bool $stripPrefix write into $directory itself instead of $directory/$prefix (see
|
* @param bool $stripPrefix write into $directory itself instead of $directory/$prefix (see
|
||||||
* Repository::restore()); used to restore "data/" or "config/" straight into the
|
* Repository::restore()); used to restore "data/" or "config/" straight into the
|
||||||
* real data/config directory for occ backup:restore:full.
|
* real data/config directory for occ backup:restore:full.
|
||||||
|
* @param bool $keepUnchanged leave files already in place with the snapshot's size and
|
||||||
|
* modification time (see Repository::restore()): a full restore that runs again
|
||||||
|
* goes on where it stopped
|
||||||
*/
|
*/
|
||||||
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory, bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null): int {
|
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory, bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null, bool $keepUnchanged = false): int {
|
||||||
if ($prefix !== '' && !Repository::isSafePath($prefix)) {
|
if ($prefix !== '' && !Repository::isSafePath($prefix)) {
|
||||||
throw new \InvalidArgumentException('Invalid path: ' . $prefix);
|
throw new \InvalidArgumentException('Invalid path: ' . $prefix);
|
||||||
}
|
}
|
||||||
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
||||||
throw new \RuntimeException("Cannot create $directory");
|
throw new \RuntimeException("Cannot create $directory");
|
||||||
}
|
}
|
||||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $prefix, $directory, $stripPrefix, $heartbeat, &$skipped): int {
|
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $prefix, $directory, $stripPrefix, $heartbeat, &$skipped, $keepUnchanged): int {
|
||||||
$own = LeaseService::throttled($refresh);
|
$own = LeaseService::throttled($refresh);
|
||||||
$beat = static function () use ($own, $heartbeat): void {
|
$beat = static function () use ($own, $heartbeat): void {
|
||||||
$own();
|
$own();
|
||||||
@@ -185,7 +188,7 @@ final class RestoreService {
|
|||||||
$heartbeat();
|
$heartbeat();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/', $stripPrefix, $beat, $skipped);
|
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/', $stripPrefix, $beat, $skipped, $keepUnchanged);
|
||||||
}, 3600);
|
}, 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,11 +75,14 @@ class ReadAheadTest extends TestCase {
|
|||||||
$this->assertSame(2, $backend->snapshotReads, 'the restore itself read the snapshot again');
|
$this->assertSame(2, $backend->snapshotReads, 'the restore itself read the snapshot again');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheEmptySpoolOfAStoppedRestoreIsRemoved(): void {
|
public function testTheSpoolOfAStoppedRestoreIsRemoved(): void {
|
||||||
$target = $this->tempDir();
|
$target = $this->tempDir();
|
||||||
mkdir("$target/.ngb-restore-spool-0badc0de", 0700); // a restore that was killed
|
mkdir("$target/.ngb-restore-spool-0badc0de", 0700); // a restore that was killed
|
||||||
|
mkdir("$target/.ngb-restore-spool-8badf00d", 0700); // ... while it wrote a range
|
||||||
|
file_put_contents("$target/.ngb-restore-spool-8badf00d/smb-01234567", str_repeat('x', 4096));
|
||||||
|
|
||||||
$this->assertRestored($this->restore(new ReadAheadBackend(new LocalBackend($this->root)), '', $target));
|
$this->assertRestored($this->restore(new ReadAheadBackend(new LocalBackend($this->root)), '', $target));
|
||||||
|
$this->assertSame([], glob("$target/.ngb-restore-spool-*"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testARangeThatCouldNotBeReadAheadIsReadTheUsualWay(): void {
|
public function testARangeThatCouldNotBeReadAheadIsReadTheUsualWay(): void {
|
||||||
|
|||||||
@@ -101,6 +101,34 @@ class RepositoryTest extends TestCase {
|
|||||||
$this->assertFileExists($stripped . '/note-1.txt');
|
$this->assertFileExists($stripped . '/note-1.txt');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testARestoreThatRunsAgainKeepsWhatIsInPlaceAndRestoresTheRest(): void {
|
||||||
|
$location = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($location);
|
||||||
|
$repo = Repository::init($backend, 'pw');
|
||||||
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$repo->restore($r['snapshot'], $target);
|
||||||
|
|
||||||
|
// as a stopped restore leaves it: one file not there yet, one with other content
|
||||||
|
unlink($target . '/docs/d1/note-1.txt');
|
||||||
|
file_put_contents($target . '/docs/d0/note-0.txt', 'something else');
|
||||||
|
$this->assertSame(34, $repo->restore($r['snapshot'], $target, keepUnchanged: true));
|
||||||
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
||||||
|
|
||||||
|
// all in place: nothing is read from the location, which could not give it anyway now
|
||||||
|
self::removeTree($location . '/packs');
|
||||||
|
$this->assertSame(34, Repository::open($backend, 'pw')->restore($r['snapshot'], $target, keepUnchanged: true));
|
||||||
|
$this->expectException(\Throwable::class);
|
||||||
|
Repository::open($backend, 'pw')->restore($r['snapshot'], $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function removeTree(string $dir): void {
|
||||||
|
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
|
||||||
|
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRestoreRemovesTemporariesOfAStoppedRestore(): void {
|
public function testRestoreRemovesTemporariesOfAStoppedRestore(): void {
|
||||||
$repo = Repository::init($this->backend, 'pw');
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
$r = $repo->backupDirectory($this->src);
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
# Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a
|
# Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a
|
||||||
# SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup
|
# SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup
|
||||||
# location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7.
|
# location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7.
|
||||||
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|salvage|to-pgsql|to-mysql|to-sqlite]
|
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|salvage|salvage-files|to-pgsql|to-mysql|to-sqlite]
|
||||||
# e.g. full-restore-roundtrip.sh 35-apache pgsql
|
# e.g. full-restore-roundtrip.sh 35-apache pgsql
|
||||||
|
# With "salvage-files" one pack is damaged and another is gone from the location. The restore must
|
||||||
|
# stop and point at --salvage; run again as the guide says, it restores the database and every file
|
||||||
|
# that can be read, and lists the rest (exit 2). NGB_FRT_DATA_MB sets the amount of data (default 200).
|
||||||
# With "sftp" the location is an SFTP server instead of a shared directory, and the origin has a
|
# With "sftp" the location is an SFTP server instead of a shared directory, and the origin has a
|
||||||
# file spread over several packs: the restore reads them ahead on connections of its own.
|
# file spread over several packs: the restore reads them ahead on connections of its own.
|
||||||
# With "to-<db>" the restore instance runs that database whatever the origin runs (a MySQL or
|
# With "to-<db>" the restore instance runs that database whatever the origin runs (a MySQL or
|
||||||
@@ -36,7 +39,7 @@ cleanup() {
|
|||||||
docker network rm "$NET" >/dev/null 2>&1 || true
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||||
docker run --rm -v "$SHARED:/target" alpine sh -c 'rm -rf /target/*' >/dev/null 2>&1 || true
|
docker run --rm -v "$SHARED:/target" alpine sh -c 'rm -rf /target/*' >/dev/null 2>&1 || true
|
||||||
rmdir "$SHARED" 2>/dev/null || true
|
rmdir "$SHARED" 2>/dev/null || true
|
||||||
rm -f /tmp/ngb-frt-kit.json /tmp/ngb-frt-pass
|
rm -f /tmp/ngb-frt-kit.json /tmp/ngb-frt-pass /tmp/ngb-frt-sums
|
||||||
[ -n "${LEGACY_DIR:-}" ] && rm -rf "$LEGACY_DIR"
|
[ -n "${LEGACY_DIR:-}" ] && rm -rf "$LEGACY_DIR"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,17 @@ if [ "$MODE" = sftp ]; then
|
|||||||
docker exec ngb-frt-origin sh -c "head -c 90000000 /dev/urandom > /var/www/html/data/frtest/files/Docs/large.bin && chown www-data:www-data /var/www/html/data/frtest/files/Docs/large.bin"
|
docker exec ngb-frt-origin sh -c "head -c 90000000 /dev/urandom > /var/www/html/data/frtest/files/Docs/large.bin && chown www-data:www-data /var/www/html/data/frtest/files/Docs/large.bin"
|
||||||
LARGE_SUM=$(docker exec ngb-frt-origin sha256sum /var/www/html/data/frtest/files/Docs/large.bin | cut -d' ' -f1)
|
LARGE_SUM=$(docker exec ngb-frt-origin sha256sum /var/www/html/data/frtest/files/Docs/large.bin | cut -d' ' -f1)
|
||||||
fi
|
fi
|
||||||
|
if [ "$MODE" = salvage-files ]; then
|
||||||
|
# Half in large files that fill packs of their own, half in files of 1 MiB; incompressible
|
||||||
|
DATA_MB=${NGB_FRT_DATA_MB:-200}
|
||||||
|
docker exec -e DATA_MB="$DATA_MB" ngb-frt-origin sh -c '
|
||||||
|
cd /var/www/html/data/frtest/files && mkdir -p Large Small/a Small/b
|
||||||
|
i=0; while [ $((i * 50)) -lt $((DATA_MB / 2)) ]; do head -c 52428800 /dev/urandom > Large/f$i.bin; i=$((i + 1)); done
|
||||||
|
i=0; while [ $i -lt $((DATA_MB / 2)) ]; do head -c 1048576 /dev/urandom > Small/$([ $((i % 2)) = 0 ] && echo a || echo b)/f$i.bin; i=$((i + 1)); done
|
||||||
|
chown -R www-data:www-data . && find . -type f -print0 | xargs -0 sha256sum > /tmp/frt-sums'
|
||||||
|
docker cp ngb-frt-origin:/tmp/frt-sums /tmp/ngb-frt-sums
|
||||||
|
ok "origin has $DATA_MB MiB of test data, $(wc -l < /tmp/ngb-frt-sums) files"
|
||||||
|
fi
|
||||||
run_on ngb-frt-origin files:scan frtest >/dev/null
|
run_on ngb-frt-origin files:scan frtest >/dev/null
|
||||||
|
|
||||||
# SQL on an instance's own database (pgsql/mysql only)
|
# SQL on an instance's own database (pgsql/mysql only)
|
||||||
@@ -143,6 +157,18 @@ if [ "$MODE" = salvage ]; then
|
|||||||
|| fail "damage the database dump"
|
|| fail "damage the database dump"
|
||||||
ok "database dump of the snapshot damaged on the location"
|
ok "database dump of the snapshot damaged on the location"
|
||||||
fi
|
fi
|
||||||
|
if [ "$MODE" = salvage-files ]; then
|
||||||
|
# The two largest packs hold user files for sure: bytes flipped in one, the other gone
|
||||||
|
docker exec -u www-data ngb-frt-origin php -r '
|
||||||
|
$packs = glob("/mnt/shared/repo/packs/*/*");
|
||||||
|
usort($packs, fn ($a, $b) => filesize($b) <=> filesize($a));
|
||||||
|
count($packs) >= 3 or exit(1);
|
||||||
|
$h = fopen($packs[0], "r+b");
|
||||||
|
for ($o = 4096; $o < filesize($packs[0]); $o += 1048576) { fseek($h, $o); $b = fread($h, 16); fseek($h, $o); fwrite($h, ~$b); }
|
||||||
|
fclose($h);
|
||||||
|
unlink($packs[1]);' || fail "damage the packs"
|
||||||
|
ok "one pack damaged and one pack gone on the location"
|
||||||
|
fi
|
||||||
|
|
||||||
docker cp ngb-frt-origin:/tmp/kit.json /tmp/ngb-frt-kit.json
|
docker cp ngb-frt-origin:/tmp/kit.json /tmp/ngb-frt-kit.json
|
||||||
docker exec ngb-frt-origin sh -c "cat /tmp/pass" > /tmp/ngb-frt-pass
|
docker exec ngb-frt-origin sh -c "cat /tmp/pass" > /tmp/ngb-frt-pass
|
||||||
@@ -196,6 +222,58 @@ if [ "$MODE" = salvage ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = salvage-files ]; then
|
||||||
|
set +e
|
||||||
|
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction 2>&1); CODE=$?
|
||||||
|
set -e
|
||||||
|
[ "$CODE" = 1 ] || { printf '%s\n' "$OUT" | tail -20; fail "a restore with damaged data did not stop (exit $CODE)"; }
|
||||||
|
printf '%s\n' "$OUT" | grep -- "--salvage" >/dev/null || { printf '%s\n' "$OUT" | tail -20; fail "the stopped restore does not point at --salvage"; }
|
||||||
|
run_on ngb-frt-restore status 2>&1 | grep "maintenance: true" >/dev/null || fail "maintenance mode is off after a restore that stopped halfway"
|
||||||
|
ok "damaged data: the restore stops, keeps maintenance mode on and points at --salvage"
|
||||||
|
|
||||||
|
# As the guide and the message say: maintenance mode off and again, now with --salvage
|
||||||
|
run_on ngb-frt-restore maintenance:mode --off >/dev/null || fail "maintenance mode off"
|
||||||
|
set +e
|
||||||
|
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction --salvage --force 2>&1); CODE=$?
|
||||||
|
set -e
|
||||||
|
[ "$CODE" = 2 ] || { printf '%s\n' "$OUT" | tail -30; fail "restore:full --salvage exited with $CODE, not 2"; }
|
||||||
|
printf '%s\n' "$OUT" | grep "could not be restored" >/dev/null || { printf '%s\n' "$OUT" | tail -30; fail "the damaged files are not listed"; }
|
||||||
|
printf '%s\n' "$OUT" | grep "ngb-salvage-" >/dev/null && { printf '%s\n' "$OUT" | tail -30; fail "the database was given up, only files are damaged"; }
|
||||||
|
docker cp /tmp/ngb-frt-sums ngb-frt-restore:/tmp/frt-sums
|
||||||
|
printf '%s\n' "$OUT" | docker exec -i ngb-frt-restore sh -c 'cat > /tmp/frt-out'
|
||||||
|
# Every file: listed and not there, or there and bit-identical
|
||||||
|
CHECK=$(docker exec ngb-frt-restore php -r '
|
||||||
|
$root = "/var/www/html/data/frtest/files/"; $listed = []; $reasons = [];
|
||||||
|
foreach (file("/tmp/frt-out", FILE_IGNORE_NEW_LINES) as $l) {
|
||||||
|
if (preg_match("#^ (?:data/)?frtest/files/(.+?): (.*)$#", $l, $m)) { $listed[$m[1]] = true; $reasons[] = $m[2]; }
|
||||||
|
}
|
||||||
|
$same = 0; $gone = 0; $bad = [];
|
||||||
|
foreach (file("/tmp/frt-sums", FILE_IGNORE_NEW_LINES) as $l) {
|
||||||
|
[$sum, $p] = preg_split("/\s+/", $l, 2); $p = substr($p, 2);
|
||||||
|
if (isset($listed[$p])) { file_exists($root . $p) ? $bad[] = "listed but there: $p" : $gone++; continue; }
|
||||||
|
if (!file_exists($root . $p)) { $bad[] = "not listed and not there: $p"; continue; }
|
||||||
|
hash_file("sha256", $root . $p) === $sum ? $same++ : $bad[] = "different: $p";
|
||||||
|
}
|
||||||
|
$temps = glob($root . "*/*.ngb-restore-*") ?: [];
|
||||||
|
foreach ($temps as $t) { $bad[] = "temporary left: $t"; }
|
||||||
|
echo "same=$same gone=$gone bad=", count($bad), "\n", implode("\n", array_slice($bad, 0, 10)), "\n";
|
||||||
|
echo "reasons: ", implode(" | ", array_unique(array_map(fn ($r) => substr(preg_replace("#[0-9a-f]{16,}#", "<id>", $r), 0, 70), $reasons))), "\n";')
|
||||||
|
printf '%s\n' "$CHECK"
|
||||||
|
printf '%s\n' "$CHECK" | grep -q " bad=0" || fail "files after the salvage are not as listed"
|
||||||
|
printf '%s\n' "$CHECK" | grep -qE "same=[1-9]" || fail "nothing was restored"
|
||||||
|
printf '%s\n' "$CHECK" | grep -qE "gone=[1-9]" || fail "no user file was hit by the damage"
|
||||||
|
ok "with --salvage: every file listed and absent, or there and identical"
|
||||||
|
run_on ngb-frt-restore status 2>&1 | grep "maintenance: false" >/dev/null || fail "maintenance mode is still on after the salvage"
|
||||||
|
run_on ngb-frt-restore user:list | grep frtest >/dev/null || fail "the backed-up user is missing: the database was not restored"
|
||||||
|
[ "$(docker exec ngb-frt-restore cat /var/www/html/data/frtest/files/Docs/note.txt 2>/dev/null)" = "hello disaster recovery" ] \
|
||||||
|
|| printf '%s\n' "$OUT" | grep "Docs/note.txt" >/dev/null || fail "note.txt is neither restored nor listed"
|
||||||
|
run_on ngb-frt-restore backup:status >/dev/null || fail "backup:status fails after the salvage"
|
||||||
|
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after the salvage"
|
||||||
|
ok "the database and config are the old server's, the instance works"
|
||||||
|
echo "FULL RESTORE ROUNDTRIP PASS ($DB, salvage-files)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
RESTORE_OPTS=(--no-interaction --install-apps)
|
RESTORE_OPTS=(--no-interaction --install-apps)
|
||||||
if [ "$LEGACY" = legacy ] && [ "$DB" != sqlite ]; then
|
if [ "$LEGACY" = legacy ] && [ "$DB" != sqlite ]; then
|
||||||
# Without --skip-missing-tables an older backup must be refused, naming the app's tables
|
# Without --skip-missing-tables an older backup must be refused, naming the app's tables
|
||||||
|
|||||||
Reference in New Issue
Block a user