Compare commits
136 Commits
v0.9.2-beta.3
..
rc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a0f882a38 | |||
| 632d2a2838 | |||
| 9e9234e2a8 | |||
| 3ac3298313 | |||
| 384b2d2792 | |||
| 8032411789 | |||
| b4f20cb070 | |||
| 343389b98b | |||
| 133f5f9d34 | |||
| cf4bd9db7f | |||
| 67cc350b8c | |||
| fb5b234e8a | |||
| fd12c586b6 | |||
| 6ffe979ea7 | |||
| a03b7354b3 | |||
| be1c628f07 | |||
| a8355df12e | |||
| f0537f0f2c | |||
| fd0f786f5f | |||
| 2eabdf44d5 | |||
| 61e2a28b67 | |||
| 7ed53ad0eb | |||
| bdcb4f0cae | |||
| 2616b6e543 | |||
| 9bc4e4a4ab | |||
| 827e46381e | |||
| 8eb6c4c033 | |||
| d623416306 | |||
| cd251cffb3 | |||
| 0b4872599d | |||
| 95184dadc7 | |||
| 9b6bd7d2ab | |||
| 2fcd38f478 | |||
| b7d09c9784 | |||
| 177361acae | |||
| 24686897c3 | |||
| 85ea0acc50 | |||
| ac57e31c93 | |||
| 315e83b847 | |||
| e461c48056 | |||
| d504067cd8 | |||
| c37b26ea38 | |||
| b728db36c7 | |||
| 6ed0a555cf | |||
| 221ed9dc17 | |||
| 285153a968 | |||
| 6c8a78f6bb | |||
| 0accd5a3f2 | |||
| 583592f69b | |||
| a384ab41bb | |||
| 9b85221e50 | |||
| c95f552cc6 | |||
| 6121f684e9 | |||
| 43a71684f9 | |||
| 88b719879d | |||
| 1d7f7e6423 | |||
| ccbbf01c54 | |||
| b789d17636 | |||
| 8e5c8ccda4 | |||
| e0b79939f9 | |||
| 39b4fe359b | |||
| 40e147f3a0 | |||
| 7f6e5c15f8 | |||
| 11ac30635a | |||
| 9e1378c6ab | |||
| 04e91359a0 | |||
| 46eaceb928 | |||
| 9eeaefd938 | |||
| 4f6a2e5b70 | |||
| 7b735ea941 | |||
| d8ef50732f | |||
| 5af712acc3 | |||
| 01e16199f7 | |||
| 0924da48ac | |||
| 88477a1a9f | |||
| f633de59b4 | |||
| 523620acde | |||
| bbddc83f80 | |||
| 456dffc880 | |||
| 4d423825a0 | |||
| 29a56e432b | |||
| 7f29d009f3 | |||
| 511c0fc285 | |||
| 76e8bb0245 | |||
| f32568f83c | |||
| ff8619ae6b | |||
| 629b8dd315 | |||
| c7cbc0b058 | |||
| 942e8ec003 | |||
| 144097d159 | |||
| 5b7dd08e71 | |||
| 30f93fbb8a | |||
| ca003c88ba | |||
| 9a3bb99d12 | |||
| c38cc47534 | |||
| dfd3f38d8c | |||
| 92e693cfe6 | |||
| bec070b1b5 | |||
| 89a126999f | |||
| 1b986c9d31 | |||
| 025bbfbb62 | |||
| 8ed7455619 | |||
| 30a250bc11 | |||
| 9c06ab2841 | |||
| d659322e60 | |||
| 7726c179f0 | |||
| df732f185a | |||
| b3c8068f8d | |||
| 0a9bd963fb | |||
| 379b81db02 | |||
| 828f3f142a | |||
| c146d700e8 | |||
| 8e273ac259 | |||
| 6385925845 | |||
| 894258f836 | |||
| 54750e9742 | |||
| fe92ef70bf | |||
| b3e3054737 | |||
| c775e27dd2 | |||
| d8e772137f | |||
| bcfec17cfe | |||
| b309081d1a | |||
| 5f8d5faee7 | |||
| 65f14b4380 | |||
| 4fa7cbf6ef | |||
| f4abfe0a8d | |||
| fe1a289c2d | |||
| ab3265e42c | |||
| 466cff8857 | |||
| 6bba0b59fc | |||
| a01aa16ccd | |||
| cc20b9174f | |||
| 239aa1b923 | |||
| 62766e1e71 | |||
| 9775cdc475 | |||
| a8da8cb54a |
@@ -0,0 +1 @@
|
|||||||
|
custom: ["https://www.ensembia.com"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Experience report
|
||||||
|
description: Tell us how NG Backup works for you, also when everything went fine.
|
||||||
|
title: "Experience: "
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for trying NG Backup! Reports of what works are just as useful as bug reports:
|
||||||
|
they tell us which setups are tested. Please leave out host names, user names and keys.
|
||||||
|
- type: dropdown
|
||||||
|
id: location
|
||||||
|
attributes:
|
||||||
|
label: Backup location
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- S3 / S3-compatible
|
||||||
|
- SFTP
|
||||||
|
- WebDAV
|
||||||
|
- SMB/CIFS
|
||||||
|
- FTP
|
||||||
|
- Swift
|
||||||
|
- Local path (disk, NFS mount, ...)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: provider
|
||||||
|
attributes:
|
||||||
|
label: Storage provider (optional)
|
||||||
|
placeholder: e.g. Hetzner Storage Box, OVH, Backblaze B2, own NAS
|
||||||
|
- type: input
|
||||||
|
id: setup
|
||||||
|
attributes:
|
||||||
|
label: Nextcloud version and setup
|
||||||
|
placeholder: e.g. 32.0.1, AIO / Docker / bare metal, PostgreSQL / MariaDB
|
||||||
|
- type: input
|
||||||
|
id: size
|
||||||
|
attributes:
|
||||||
|
label: Rough size of the backup
|
||||||
|
placeholder: e.g. 50 GB, 200,000 files
|
||||||
|
- type: checkboxes
|
||||||
|
id: tried
|
||||||
|
attributes:
|
||||||
|
label: What did you try?
|
||||||
|
options:
|
||||||
|
- label: Full backup
|
||||||
|
- label: Incremental backup
|
||||||
|
- label: Verify
|
||||||
|
- label: Restore of files or folders
|
||||||
|
- label: Full restore on another server
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: How did it go?
|
||||||
|
description: What worked, what was unclear, slow or broken.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -104,3 +104,23 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Full restore round trip
|
- name: Full restore round trip
|
||||||
run: tests/integration/full-restore-roundtrip.sh 35-apache
|
run: tests/integration/full-restore-roundtrip.sh 35-apache
|
||||||
|
- name: Full restore round trip on PostgreSQL, with Mail (foreign keys) and a disabled app
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql
|
||||||
|
- name: Full restore round trip on MariaDB, with Mail (foreign keys) and a disabled app
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql
|
||||||
|
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on PostgreSQL
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql legacy
|
||||||
|
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on MariaDB
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql legacy
|
||||||
|
- name: Full restore over SFTP on PostgreSQL, reading packs ahead on several connections
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql sftp
|
||||||
|
- name: Full restore of a MariaDB backup onto PostgreSQL (ID counters after the data)
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-pgsql
|
||||||
|
- name: Full restore of a PostgreSQL backup onto MariaDB
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-mysql
|
||||||
|
- name: Full restore of a PostgreSQL backup onto SQLite
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-sqlite
|
||||||
|
- name: Full restore of a MariaDB backup onto SQLite
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-sqlite
|
||||||
|
- name: Full restore of a SQLite backup onto PostgreSQL
|
||||||
|
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite to-pgsql
|
||||||
|
|||||||
+296
@@ -7,6 +7,297 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
|
|||||||
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
||||||
instead carried by the app description, the git tag, the release, and this file.
|
instead carried by the app description, the git tag, the release, and this file.
|
||||||
|
|
||||||
|
## [1.0.0-rc.6] - 2026-10-24
|
||||||
|
|
||||||
|
Sixth release candidate for 1.0.0; the app version is 0.9.12. Fixes from two disaster recovery
|
||||||
|
simulations and a user report. If no problems are reported for about a week, 1.0.0 will be this
|
||||||
|
same code.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The daily backup time was taken as UTC (Nextcloud runs PHP in UTC): 03:00 ran at 05:00 in
|
||||||
|
Berlin (#19). The time is now saved with the administrator's time zone and shown with it. A time
|
||||||
|
saved before is converted once to the zone of the server or an administrator, so it shows as it
|
||||||
|
effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer) and nothing moves.
|
||||||
|
- A full restore over SMB stopped after 17 hours with "Decryption failed": a read over a long
|
||||||
|
line handed out wrong bytes (the backup itself was fine, the same files restored identically
|
||||||
|
afterwards). A blob that does not decrypt as first read, of a file or of the database, is now
|
||||||
|
read once more on a request of its own, and only a second failure stops the restore.
|
||||||
|
- A full restore onto MySQL/MariaDB stopped with "Incorrect string value" when it created a table
|
||||||
|
(of an app that is not installed here) whose rows hold emoji or other 4-byte characters: the
|
||||||
|
table got 3-byte utf8. Tables the restore creates now get this installation's character set and
|
||||||
|
collation (utf8mb4). Found in a disaster recovery simulation onto MariaDB.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The admin page uses more of the screen (sections up to 1400 pixels instead of 900), so the
|
||||||
|
list of backup runs fits without scrolling sideways.
|
||||||
|
- README: a roadmap, the tested disaster recoveries, measurements for OVH S3, the Storage Box over
|
||||||
|
SFTP and SMB, how to build the SMB extension when PECL is down, and what works without shell
|
||||||
|
access. The guide lists shell access among what you need.
|
||||||
|
- The full-restore integration test checks a 4-byte character in a table the restore creates, and
|
||||||
|
retries app installs from the app store.
|
||||||
|
|
||||||
|
## [1.0.0-rc.5] - 2026-10-22
|
||||||
|
|
||||||
|
Fifth release candidate for 1.0.0; the app version is 0.9.11. A full restore of 1 TB over SFTP
|
||||||
|
stopped after 13 hours with "Blob not in index"; the backup itself was fine.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README and app store description: NG Backup needs background jobs on system cron, with how to
|
||||||
|
set that up (issues #13, #17).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A full restore no longer stops with "Blob not in index" after a pack could not be read for a
|
||||||
|
moment. Reading the index again then got an empty listing (from a connection the location had
|
||||||
|
dropped) and emptied the index. Reading the index again now only adds, an empty listing of a
|
||||||
|
location that has an index is an error instead of an empty index, and a pack that could not be
|
||||||
|
read is read once more before the restore gives up.
|
||||||
|
- After a full restore of a MySQL/MariaDB or SQLite backup onto PostgreSQL, new files, users or
|
||||||
|
activities could not be added ("duplicate key", "File entry could not be inserted"): such
|
||||||
|
backups record no ID counters, and they stayed at 1. Every counter of a restored table now goes
|
||||||
|
after its data. Found in a disaster recovery simulation of 138 GB from MariaDB to PostgreSQL,
|
||||||
|
where all 15,772 files and every count were identical.
|
||||||
|
- A full restore now runs in maintenance mode: a background job, a page visit or a user writing
|
||||||
|
into the instance meanwhile could collide with restored rows ("duplicate key") or be lost. It
|
||||||
|
is switched off when the restore completes; after an error it stays on, and the message gives
|
||||||
|
the one line to switch it off and run the restore again. Maintenance mode that was on before
|
||||||
|
the restore stays on.
|
||||||
|
- Adding a location whose repository config could not be read, while backup data is there,
|
||||||
|
no longer creates a new repository over it: it stops with an explanation. A snapshot that
|
||||||
|
cannot be taken over when a location is added is now logged instead of left out silently.
|
||||||
|
- An SFTP connection that a Hetzner Storage Box dropped without phpseclib noticing is now
|
||||||
|
recognised and replaced: the check asks the server and counts its answer.
|
||||||
|
- The extra connection a full restore reads ahead on is replaced when it breaks (up to 20 times),
|
||||||
|
instead of reading the rest of the restore the slow way.
|
||||||
|
|
||||||
|
## [1.0.0-rc.4] - 2026-10-19
|
||||||
|
|
||||||
|
Fourth release candidate for 1.0.0; the app version is 0.9.10. 1.0.0-rc.3 (0.9.9) was not
|
||||||
|
released: its fixes are in here.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A full restore over SFTP reads the packs ahead on a connection of its own, 32 requests in
|
||||||
|
flight, into temporary files in the restore folder. From a Hetzner Storage Box to a server in
|
||||||
|
Canada (122 ms) packs now come in at about 14 MiB/s instead of 2.8. More connections can be set
|
||||||
|
with `restore_sftp_connections` (README). A part that cannot be read this way is read as
|
||||||
|
before. Restores from other locations work as before.
|
||||||
|
- App store description: the line about Ensembia.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- After a full restore stopped halfway, running it again with `--force` was refused when the
|
||||||
|
database was already restored: Nextcloud then first wants `occ upgrade`. The guide and the
|
||||||
|
error message now say so.
|
||||||
|
- A full restore of a backup made before 1.0.0-rc.2 with `--skip-missing-tables` failed on
|
||||||
|
PostgreSQL with "relation ... does not exist": the left-out tables were still looked up to set
|
||||||
|
their ID counters. It now skips them. A new test restores a backup made by 1.0.0-rc.1 on
|
||||||
|
PostgreSQL and MariaDB.
|
||||||
|
- The app store showed old screenshots: it keeps a copy per URL, and the URLs always pointed at
|
||||||
|
`main`. They now point at the release's tag.
|
||||||
|
|
||||||
|
## [1.0.0-rc.2] - 2026-10-18
|
||||||
|
|
||||||
|
Second release candidate for 1.0.0; the app version is 0.9.8. Fixes from a full disaster recovery
|
||||||
|
test of a production instance (about 1 TB, Nextcloud All-in-One onto a plain Nextcloud with
|
||||||
|
PostgreSQL).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `docs/DISASTER-RECOVERY.md`: step-by-step guide for when your server is gone.
|
||||||
|
- `occ backup:restore:full --install-apps` installs (from the app store) or enables the apps the
|
||||||
|
old server had enabled, before the restore starts.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Backups record the structure of every database table. A full restore creates tables that do not
|
||||||
|
exist on the new instance, for example those left behind by an app that was removed long ago or
|
||||||
|
is switched off, instead of stopping at the first one.
|
||||||
|
- A full restore no longer refuses when apps are missing: it restores their data and lists the
|
||||||
|
apps to install afterwards. `nextcloud-aio` is never required, so a backup of Nextcloud
|
||||||
|
All-in-One can be restored onto a plain Nextcloud.
|
||||||
|
- A full restore onto an installation with a different table prefix (`dbtableprefix`) is refused
|
||||||
|
before it starts, with what to change, instead of restoring into tables nobody uses.
|
||||||
|
- All problems are listed at once, with the app each missing table probably belongs to. For
|
||||||
|
backups made before this version, `--skip-missing-tables` leaves out tables whose structure is
|
||||||
|
unknown.
|
||||||
|
- A fuller app store description, ready for 1.0.0, and a note on paid restore help by Ensembia.
|
||||||
|
- README: switching the server to TCP BBR made backups over a long distance about 3 times faster.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A full restore failed on foreign keys (for example those of the Mail app), because tables are
|
||||||
|
restored in name order. Foreign keys are now checked after all rows are loaded, before the
|
||||||
|
restore commits.
|
||||||
|
- `occ backup:target:add` without a key pointed to `backup:key:init`, which makes a new key that
|
||||||
|
cannot read existing backups. It now points to `backup:key:import` for a restore, and says that
|
||||||
|
connecting to a location with backups can take a while. While it reads the index of a location
|
||||||
|
with backups, it shows how far it is and about how long it still takes.
|
||||||
|
- A restore stopped with "Not found: packs/..." when the location was cleaned up while it ran,
|
||||||
|
for example by the weekly cleanup of the original server during a recovery test on a second
|
||||||
|
server: blobs that were moved into new packs were still looked for in the old ones. A restore
|
||||||
|
now reloads the index and reads them from where they are now.
|
||||||
|
- Removing a location left its cached index in the database (tens of thousands of rows for a large
|
||||||
|
backup). It is removed with the location now, and once for locations that are already gone.
|
||||||
|
|
||||||
|
## [1.0.0-rc.1] - 2026-10-17
|
||||||
|
|
||||||
|
Release candidate for 1.0.0; the app version is 0.9.7. Only bug fixes until 1.0.0. Tested on a
|
||||||
|
production instance (about 1 TB, 150,000 files): full and incremental backups and a verify on
|
||||||
|
SFTP and SMB (Hetzner Storage Box) and S3 (OVH), a deep verify on SFTP, and partial restores from
|
||||||
|
SMB and S3 checked against checksums.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The admin page asks for feedback, with a link to a short "Experience report" form on GitHub:
|
||||||
|
which location, setup and size you used, and how it went.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- New passphrases must be at least 16 characters (was 12). The location stores the key
|
||||||
|
encrypted with each passphrase, so the passphrase is what really protects the backups.
|
||||||
|
Existing passphrases keep working. The README and the how-to now say so, and why the
|
||||||
|
recovery kit must still be kept off the server.
|
||||||
|
- Dates and times in the web interface follow the locale in the user's Nextcloud settings instead
|
||||||
|
of the browser's language, so a 24-hour locale shows 16:00 rather than 4:00 PM.
|
||||||
|
- The list of backup runs (web interface and `occ backup:status`) shows the runs of the last 7
|
||||||
|
days, those still running, and at least the 5 newest; a failed run stays visible for that week.
|
||||||
|
`occ backup:status --all` and "Show older runs" in the web interface list all of them.
|
||||||
|
- `occ backup:status` and `occ backup:verify` show durations of a day or more with the days
|
||||||
|
("1d 12:10:13" instead of "12:10:13").
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- With system cron, a running backup moved forward only every other cron run: Nextcloud
|
||||||
|
skipped the background job when cron came a few seconds before its 5-minute interval was
|
||||||
|
over, so a backup was idle 6 of every 10 minutes. The job now runs at every cron run.
|
||||||
|
- Photos, video and other compressed data are no longer deflated only to throw the result away:
|
||||||
|
a quick test on three small slices of each block skips it. Compressing was the slowest part of
|
||||||
|
a backup (about 24 MiB/s).
|
||||||
|
- Checking a backup on an S3 location (`occ backup:verify`) took hours on a large repository:
|
||||||
|
the index files were downloaded one at a time, at about two a second. They are now downloaded
|
||||||
|
16 at a time: on a production instance (27,585 index files, 1 TB) the check went from about
|
||||||
|
4 hours to 3.5 minutes.
|
||||||
|
- Writing the snapshot at the end of a long backup to S3 took a quarter of an hour without any
|
||||||
|
progress shown: it read back the file list of every step one at a time. These reads are now
|
||||||
|
done 16 at a time on S3 (96 reads: 82 seconds before, 2 now), blobs that follow each other in
|
||||||
|
a pack are read at once on every location, the snapshot is written in a step of its own, and
|
||||||
|
the run shows "commit" while it does so.
|
||||||
|
|
||||||
|
## [0.9.6-beta.7] - 2026-10-14
|
||||||
|
|
||||||
|
Bugfix release for SMB locations, with `occ backup:cancel` to stop a backup that does not get
|
||||||
|
anywhere. Tested on a production instance: a first full backup of 151,021 files (807 GiB) to OVH
|
||||||
|
S3 completed, and a backup to a Hetzner Storage Box over SMB that had slowed to 12 GiB in 35
|
||||||
|
hours ran at about 5.6 MiB/s after the update, while the S3 backup was still reading.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The list of backup runs (web interface and `occ backup:status`) shows the total size of each
|
||||||
|
backup next to what it uploaded: an incremental backup of 1 TB that uploads 800 MB now shows
|
||||||
|
both. `occ backup:status` shows sizes in GiB where that reads better than MiB.
|
||||||
|
- `occ backup:cancel <run>` stops a running backup; `occ backup:status` shows the run numbers.
|
||||||
|
No new step of the run starts, the step in progress finishes (usually within a minute), then
|
||||||
|
the process working on the run (`occ backup:run` or cron) stops. What it already stored is
|
||||||
|
removed by the next cleanup. A backup started by an earlier version is stopped as well: after
|
||||||
|
two minutes it is marked cancelled while it works, and its process stops at the end of the
|
||||||
|
step.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Backups to SMB locations, and to other slow locations with many packs, slowed to a crawl: each
|
||||||
|
30-second step opened the location again, and reading the list of index files took longer
|
||||||
|
than the step itself. Each step then stored a single 4 MiB pack, which made the list longer
|
||||||
|
still. A first full backup of 0.8 TB to a Hetzner Storage Box over SMB managed 12 GiB in 35
|
||||||
|
hours. `occ backup:run` and system cron now open the location once per run, opening no longer
|
||||||
|
counts towards the step's time, and SMB folders are listed in one request instead of one per
|
||||||
|
file. A backup to an SMB location that is not getting anywhere keeps running in its old
|
||||||
|
process after the update: update first, then stop it with `occ backup:cancel <run>` and start
|
||||||
|
it again. The new backup reuses the packs the old one stored.
|
||||||
|
|
||||||
|
## [0.9.5-beta.6] - 2026-10-12
|
||||||
|
|
||||||
|
Bugfix release for SFTP locations and large repositories. Tested on a production instance with a
|
||||||
|
1 TB repository on a Hetzner Storage Box over SFTP: a check in 5.5 minutes, a deep check of all
|
||||||
|
316,529 blobs (815 GiB) in 11 hours, and an incremental backup in under 8 minutes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Progress of a running backup in the web interface and `occ backup:status`, e.g.
|
||||||
|
"running (files) 68%". The total is the size of the previous snapshot; for a first backup it
|
||||||
|
is estimated from Nextcloud's file cache (users' files, versions and trash) and shown as
|
||||||
|
"~68%".
|
||||||
|
- Edit a backup location (web interface and `occ backup:target:edit`): change the password, host,
|
||||||
|
folder, connection type or name without losing its history. Before this the only option was a
|
||||||
|
new location, which starts an empty repository and, with a retention lock at the provider,
|
||||||
|
keeps paying for the old one. The new settings are only saved when they lead to the same
|
||||||
|
repository; stored passwords are never sent to the browser, and an empty password field keeps
|
||||||
|
the stored one. Administrators are notified of the change.
|
||||||
|
- `occ backup:verify` reports how far it is every 30 seconds, and `occ backup:status` shows the
|
||||||
|
progress of a running check and how the last check of each location ended. On a large
|
||||||
|
repository a check takes hours, a deep one days.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Checking and opening a large repository over SFTP is much faster: the index files (one per
|
||||||
|
pack, 27,547 for 1 TB) are downloaded 32 at a time instead of one after the other, 114 instead
|
||||||
|
of 6 files per second to a Hetzner Storage Box (about 4 minutes instead of 77 for 1 TB). A check
|
||||||
|
of a repository with more than 256 packs also lists the pack folders (256 requests) instead of
|
||||||
|
asking for every pack.
|
||||||
|
- App store listing: the author's e-mail address is shown.
|
||||||
|
- App store listing: the homepage link points to the GitHub repository instead of back to the
|
||||||
|
app store page itself.
|
||||||
|
- The notification for a location without a successful backup read "No successful backup to …
|
||||||
|
since never" when there had never been one; it now reads "No successful backup to … yet".
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 0.9.4 could not open a repository at the root of an SFTP location (empty path): its faster
|
||||||
|
SFTP reads sent their first request before phpseclib had opened the SFTP channel, which only
|
||||||
|
happens on the first SFTP call after login. Every backup then failed with "No repository at
|
||||||
|
this location". The channel is now opened first.
|
||||||
|
- A check (`occ backup:verify`) did not renew its hold on the location while it downloaded the
|
||||||
|
index; on a large repository that takes longer than the hold lasts, so a weekly cleanup could
|
||||||
|
have started during the check.
|
||||||
|
- A long first backup could end as failed although its snapshot was complete: when its final
|
||||||
|
step took longer than the step's lease (150 s with `occ backup:run`), the background job took
|
||||||
|
the run over, finished it a second time and failed on the snapshot it had already recorded,
|
||||||
|
overwriting the result. The lease now lasts at least 15 minutes, a step writes the run only if
|
||||||
|
no other process wrote it since the step loaded it (in one database statement), and recording
|
||||||
|
a snapshot that is already recorded is no longer an error.
|
||||||
|
- `occ backup:verify` on a large repository failed with "SFTP connection failed while opening
|
||||||
|
snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that
|
||||||
|
carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went
|
||||||
|
quiet after downloading the index: it refreshed the local index cache, first in 29 minutes (a
|
||||||
|
pass over all blobs for every pack: 27,547 packs, 400,000 blobs for 1 TB), then still in more
|
||||||
|
than 6 minutes (a database transaction per pack on PostgreSQL). Nextcloud's SFTP storage keeps
|
||||||
|
using a dropped connection, on which every file looks missing. Now:
|
||||||
|
- an SFTP connection that was not used for a minute is checked first, and one that turns out
|
||||||
|
to be dead after a failed request is replaced, once per request; uploads, moves and deletes
|
||||||
|
are only checked first, never repeated;
|
||||||
|
- the index cache is written in one transaction (27,547 packs in 11 seconds on a test server,
|
||||||
|
against about a minute row by row);
|
||||||
|
- after a check the cache holds the index files as they are: before, a blob that was in two
|
||||||
|
packs was only cached for one of them.
|
||||||
|
|
||||||
|
## [0.9.4-beta.5] - 2026-10-09
|
||||||
|
|
||||||
|
Bugfix release: faster restores.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Restores and `occ backup:verify --deep` fetched every blob with its own request, so on a
|
||||||
|
remote location they ran far below the speed of the connection. The blobs of a pack are now
|
||||||
|
read from one open stream, in the order they were written. Restoring 86 MB in 202 files from a
|
||||||
|
Hetzner Storage Box over SMB went from 0.78 MB/s to 4.2 MB/s, the speed of reading the packs
|
||||||
|
as plain files over that connection.
|
||||||
|
- Reading from an SFTP location waited a full round trip for every 256 KiB, whatever the line
|
||||||
|
speed. Reads now keep up to 8 requests in flight. Restoring 1.97 GB in 500 files from a Hetzner
|
||||||
|
Storage Box (47 ms away) took 694 s before the fix above, 499 s with it and 142 s with both
|
||||||
|
(2.8 → 3.9 → 13.9 MB/s); a full 51 GB restore ran at 13.0 MB/s. When a server error
|
||||||
|
interrupts such a read, it goes on from the same position with Nextcloud's own SFTP stream;
|
||||||
|
after three failing files in a row, or on a Nextcloud whose SFTP library lacks what this needs,
|
||||||
|
only Nextcloud's own stream is used.
|
||||||
|
- README: a WebDAV location on a slow upload needs a higher `davstorage.request_timeout`.
|
||||||
|
|
||||||
|
## [0.9.3-beta.4] - 2026-10-08
|
||||||
|
|
||||||
|
Bugfix release.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A backup failed with "cannot execute INSERT in a read-only transaction" on PostgreSQL as soon
|
||||||
|
as the database dump filled a whole pack (32 MiB): the pack's index cache row was written
|
||||||
|
inside the read-only dump transaction. On other databases that row was rolled back silently.
|
||||||
|
- Backups to a remote location slowed down as the repository grew: every backup step listed the
|
||||||
|
repository's index with a round trip per file. Over SFTP to a Hetzner Storage Box a 60 GiB
|
||||||
|
repository already lost a large part of each step to it; backing up continuously
|
||||||
|
(`occ backup:run`) went from 45 KiB/s to 5.8 MB/s with the fix.
|
||||||
|
|
||||||
## [0.9.2-beta.3] - 2026-10-08
|
## [0.9.2-beta.3] - 2026-10-08
|
||||||
|
|
||||||
Bugfix release.
|
Bugfix release.
|
||||||
@@ -122,6 +413,11 @@ and not implemented yet.
|
|||||||
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
||||||
so far (e.g. Hetzner Storage Box sub-accounts).
|
so far (e.g. Hetzner Storage Box sub-accounts).
|
||||||
|
|
||||||
|
[1.0.0-rc.1]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.1
|
||||||
|
[0.9.6-beta.7]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.6-beta.7
|
||||||
|
[0.9.5-beta.6]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.5-beta.6
|
||||||
|
[0.9.4-beta.5]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.4-beta.5
|
||||||
|
[0.9.3-beta.4]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.3-beta.4
|
||||||
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
|
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
|
||||||
[0.9.1-beta.2]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.1-beta.2
|
[0.9.1-beta.2]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.1-beta.2
|
||||||
[0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1
|
[0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1
|
||||||
|
|||||||
@@ -377,6 +377,163 @@ in README "Known limitations":
|
|||||||
11. `backup:restore:full` in onderhoudsmodus en hervatbaar maken; nu: opnieuw draaien met `--force`.
|
11. `backup:restore:full` in onderhoudsmodus en hervatbaar maken; nu: opnieuw draaien met `--force`.
|
||||||
12. Config/rename op FTP/SMB-servers die geen rename over een bestaand bestand toestaan.
|
12. Config/rename op FTP/SMB-servers die geen rename over een bestaand bestand toestaan.
|
||||||
|
|
||||||
|
**Plan richting 1.0.0 en 1.1.0 (besluit 2026-10-15/16):**
|
||||||
|
- **1.0.0-rc.1:** wat nu op `polish` staat (snelle S3-verify en snapshot-commit, kortere runlijst,
|
||||||
|
duur met dagen, job bij elke cron-run, deflate-test, feedbackformulier, datums in de locale van
|
||||||
|
de gebruiker, wachtwoordzin min. 16 tekens). Geen nieuwe functies meer.
|
||||||
|
- **Vóór 1.0.0 (verplicht):** volledige restore met kit op een tweede server (PVE02), alleen lezend
|
||||||
|
of op een kopie van de locatie; plus een user-restore. Zonder geslaagde test geen 1.0.0.
|
||||||
|
- **1.0.0 (besluit 2026-10-18):** alleen betrouwbaarheid. rc2 uitgebracht; slaagt de grote
|
||||||
|
DR-test op VM 980 (snapshot van 12-10, met `--skip-missing-tables`), dan 1.0.0. Zo niet: fix,
|
||||||
|
rc3, en rc3 pas uitbrengen na een geslaagde volledige DR-test.
|
||||||
|
- **rc4 (besluit 2026-10-19):** rc3 is niet uitgebracht. De DR-test met rc3 liep ~2,8 MiB/s
|
||||||
|
(~3,5 dag); parallel lezen over SFTP haalde vanaf VM 980 ~7,6 MiB/s. Daarom rc4 = rc3 +
|
||||||
|
vooruitlezen over SFTP (4 eigen verbindingen). Volgorde: volledige DR-test op VM 980 volgens de
|
||||||
|
gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een
|
||||||
|
productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0.
|
||||||
|
Problemen of meldingen van gebruikers: rc5.
|
||||||
|
- **1.0.1:**
|
||||||
|
1. (Naar rc4: herstelsnelheid over SFTP.) S3 leest al 16 tegelijk bij index en tree, maar een
|
||||||
|
restore leest de packs nog één voor één: ook vooruitlezen via de S3-client.
|
||||||
|
2. Achtergrondjobs zonder systeem-cron (issue #13; besluit 2026-10-19). De PR die dit
|
||||||
|
oplost zegt "Fixes #13" (bevestigd: #13 was de cron; besluit 2026-10-21):
|
||||||
|
a. **Waarschuwen** in de beheerpagina en `backup:status`: modus uit `core backgroundjobs_mode`
|
||||||
|
(`ajax`/`webcron`/`cron`) en de laatste run uit `core lastcron`. Niet op `cron`, of de
|
||||||
|
laatste run te lang geleden: melding met de cron-regel erbij ("Backups only move while
|
||||||
|
someone has Nextcloud open"), en "waiting for background jobs" als een lopende backup lang
|
||||||
|
geen stap zette.
|
||||||
|
b. **De pagina laat de backup lopen** zolang ze openstaat: in `ajax`/`webcron` voert de
|
||||||
|
statusaanroep die de pagina toch al herhaalt ook een korte, begrensde stap uit (zoals de
|
||||||
|
korte stappen die AJAX-cron nu al krijgt). "Back up now" werkt dan ook zonder cron zolang
|
||||||
|
het tabblad openstaat; de melding zegt dat geplande backups echte cron nodig hebben.
|
||||||
|
De melding zegt (zonder werkende systeem-cron): backups hebben systeem-cron nodig, ook de
|
||||||
|
gewone nachtelijke; zo stel je cron in (link naar de Nextcloud-documentatie over
|
||||||
|
achtergrondjobs voor de versie van de server, nu
|
||||||
|
https://docs.nextcloud.com/server/34/admin_manual/configuration_server/background_jobs_configuration.html,
|
||||||
|
versienummer uit de server, niet vast); zolang er geen cron is, loopt een backup die je
|
||||||
|
met de knop start alleen door terwijl de NG Backup-pagina openstaat.
|
||||||
|
c. **Voortgang in de databasefase** (issue #13): nu staat er alleen "running (db)", wat er
|
||||||
|
hetzelfde uitziet als vastzitten. Toon "running (db: 47/183 tables)" zoals de bestandsfase
|
||||||
|
een percentage toont, en "waiting for background jobs (last ran 12 min ago)" als een run
|
||||||
|
een tijd geen stap zette, ook bij een cron die is blijven staan.
|
||||||
|
Niet doen: de cron-modus vanuit de app omzetten (zonder echte cron-regel stopt alles, en
|
||||||
|
`cron.php` zet de modus zelf op `cron`), en geen `exec php occ backup:run &` vanuit de knop
|
||||||
|
(vaak uitgeschakeld, niet op hosted, buiten Nextcloud om lastig te volgen en te stoppen).
|
||||||
|
3. Volledige DR-test met een backup die al de tabelstructuur bevat (gemaakt met rc2 of later),
|
||||||
|
zonder `--skip-missing-tables`: dat pad is op 1 TB nog niet getest.
|
||||||
|
4. **Label bij "Back up now"** (besluit 2026-10-19): een optioneel tekstveld naast de knop; de
|
||||||
|
tekst gaat als label van de snapshot mee (nu kan dat alleen met `occ backup:run -l`; de knop
|
||||||
|
stuurt een leeg label, geplande runs krijgen "scheduled"). Raakt alleen de UI en een
|
||||||
|
bestaande parameter.
|
||||||
|
5. **`--salvage`** voor `backup:restore` en `backup:restore:full` (besluit 2026-10-22): terughalen
|
||||||
|
wat kan, een bestand dat niet te lezen of te ontsleutelen is overslaan en aan het eind
|
||||||
|
opsommen, in plaats van stoppen.
|
||||||
|
6. **Tijdelijke bestanden van een afgebroken restore opruimen** (gevonden in de SMB-DR van
|
||||||
|
rc6, 2026-10-24): de rc5-poging die stopte liet `VID_0009.mp4.ngb-restore-<id>` staan; de
|
||||||
|
geslaagde restore daarna haalde het niet weg, en `files:scan` maakte het zichtbaar in Files.
|
||||||
|
Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op.
|
||||||
|
7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden
|
||||||
|
terug (er komt een `/` achter); nu werkt alleen een map.
|
||||||
|
- **DR-tests over databasegrenzen** (besluit 2026-10-20; productie nc01 draait PostgreSQL): na
|
||||||
|
de MariaDB→PostgreSQL-fout (tellers, rc5) elke overstap in de integratietest (`to-pgsql`,
|
||||||
|
`to-mysql`, `to-sqlite`, ook in CI). Volle DR-tests met echte data: vóór 1.0.0 de S3-simulatie
|
||||||
|
(MariaDB-bron, 138 GB) nogmaals naar een MariaDB-doel en de SMB-simulatie naar een SQLite-doel;
|
||||||
|
**na 1.0.0** de 1 TB van nc01 (PostgreSQL) naar een MariaDB-doel.
|
||||||
|
- **Polish:** `target:add` toonde bij het eerste indexbestand al "about 642 min left" (S3,
|
||||||
|
4.568 bestanden, daarna binnen een minuut klaar): een schatting pas na ~5% of na meer dan
|
||||||
|
een paar bestanden.
|
||||||
|
- **1.1.0:**
|
||||||
|
1. `backup:key:import` zonder kit: de sleutel-slots van de locatie (`config`) lezen, met de kit
|
||||||
|
als reserve als `config` ontbreekt of niet opent. Vergt een andere volgorde van
|
||||||
|
`target:add` en sleutelimport (het gevoeligste pad), daarom niet in rc1. Daarna de
|
||||||
|
DR-test op PVE02 herhalen zonder kit.
|
||||||
|
2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten):
|
||||||
|
Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch.
|
||||||
|
Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal
|
||||||
|
~6 uur achter elkaar, zichtbaar in UI en log. Op nc01 met de hand getest tijdens SMB-run 10.
|
||||||
|
3. Snelheid: lezen en uploaden laten overlappen, meerdere bestanden tegelijk lezen, eventueel
|
||||||
|
een eigen worker (`occ background-job:worker`). Gemeten op nc01: 55% wachten op de schijf,
|
||||||
|
33% op de SMB-upload, ~7% CPU. Vergt extra PHP-processen; botst mogelijk met uitgangspunt 1.
|
||||||
|
4. **Schema per locatie** (besluit 2026-10-19): in de instellingen van een locatie "uit",
|
||||||
|
"dagelijks om HH:MM" of "elke N uur (vanaf HH:MM)". Nu is er één `schedule_time` voor alle
|
||||||
|
locaties, één keer per dag. Bij de update wordt de huidige tijd het schema van elke
|
||||||
|
bestaande locatie (voor bestaande gebruikers verandert er niets).
|
||||||
|
5. **Locaties na elkaar laten starten** in plaats van tegelijk: twee runs die dezelfde schijven
|
||||||
|
lezen zijn samen niet sneller (gezien op nc01); wachtende runs zichtbaar als "queued".
|
||||||
|
7. **SMB sneller** (gemeten 2026-10-21): lokaal netwerk zonder vertraging (Samba-container op de
|
||||||
|
laptop, 1,8 GB) back-up 24,9 MB/s, restore 27,6 MiB/s; vanaf Canada naar de Storage Box (130 ms)
|
||||||
|
2,6 MiB/s, omdat SMB per blok op het antwoord wacht. Grotere schrijfblokken, en vooruitlezen
|
||||||
|
bij een restore zoals SFTP (IReadsAhead) of meerdere packs tegelijk. Een NAS met 1 Gbit haalt
|
||||||
|
~110 MB/s: daar is winst.
|
||||||
|
Ook de index: `target:add` over SMB vanaf Canada haalt ~1 indexbestand per seconde (4.927
|
||||||
|
bestanden ~85 min), want SMB krijgt geen `getMany()` met verzoeken tegelijk zoals S3 en SFTP.
|
||||||
|
6. **Notitie achteraf bij een restorepunt**, te wijzigen in de lijst. Het label in de snapshot
|
||||||
|
is versleuteld en ligt vast; de notitie apart op de locatie bewaren (bijv. in de catalogus),
|
||||||
|
zodat een nieuwe server na een DR hem ook ziet.
|
||||||
|
- **Bevindingen DR-test op PVE02 (2026-10-17, snapshot van nc01, 1 TB, AIO → gewone NC 34 met
|
||||||
|
PostgreSQL 18)** — voor **1.0.0-rc.2**:
|
||||||
|
1. Tabellen van apps die bij de backup **niet aanstonden** (restanten: Deck, ONLYOFFICE,
|
||||||
|
AppAPI, een archief-app) blokkeren de restore met één tabelnaam per poging. Standaard
|
||||||
|
overslaan met één waarschuwing per app; optie om ze toch terug te zetten.
|
||||||
|
2. Alle ontbrekende apps en tabellen **vooraf in één lijst**, per app benoemd (nu één per poging,
|
||||||
|
ook voor uitgeschakelde standaard-apps zoals `admin_audit`).
|
||||||
|
3. `--skip-missing-apps`; `nextcloud-aio` standaard overslaan (bestaat buiten AIO niet), zodat
|
||||||
|
AIO → gewone Nextcloud werkt.
|
||||||
|
4. `--install-apps`: ontbrekende apps uit de app store installeren.
|
||||||
|
5. Apps buiten de app store (eigen builds, `side_menu`, `nextcloud-aio`): documenteren; later
|
||||||
|
eventueel `custom_apps` meenemen in de backup.
|
||||||
|
6. Tabellen van een app die wél aanstond maar die hun tabel later zelf maakt (Memories:
|
||||||
|
`memories_planet_geometry`) netjes afhandelen.
|
||||||
|
7. `backup:target:add` zonder sleutel verwijst naar `key:init`; als er al een repository staat,
|
||||||
|
moet het naar `key:import <kit>` wijzen.
|
||||||
|
8. `backup:target:add` toont geen voortgang bij het ophalen van de index (27.582 bestanden).
|
||||||
|
9. `backup:target:remove` laat de index-cache van die locatie in de database staan; opruimen,
|
||||||
|
ook bestaande wezen.
|
||||||
|
10. Snapshot-meta uitbreiden: ook geïnstalleerde maar uitgeschakelde apps, met per app of hij
|
||||||
|
uit de app store komt; `occ backup:apps <locatie> <snapshot>` toont de lijst leesbaar
|
||||||
|
(geen platte tekst op de locatie: dat lekt welke apps/versies er draaien).
|
||||||
|
11. **Tabelstructuur meedumpen** (kolommen, typen, indexen, primary key), zodat de restore een
|
||||||
|
ontbrekende tabel zelf aanmaakt met de gegevens erin. Lost restanten van oude apps op zonder
|
||||||
|
ze over te slaan; een later opnieuw geïnstalleerde app vindt zijn oude gegevens terug.
|
||||||
|
12. **Foreign keys**: de restore zet tabellen op alfabet terug; `oc_mail_accounts` verwijst naar
|
||||||
|
`oc_mail_mailboxes` en PostgreSQL weigert (fk_61a7e87f59a77cdc). Iedereen met Mail loopt hier
|
||||||
|
vast. Oplossing: in de juiste volgorde terugzetten (afhankelijkheden eerst), of de controle
|
||||||
|
tijdelijk uitzetten en na afloop terugzetten en controleren. In de test: 15 FK's (alle van
|
||||||
|
Mail) tijdelijk verwijderd en na de restore teruggezet: alle 15 geldig op de teruggezette
|
||||||
|
data, dus "uitzetten, terugzetten, achteraf controleren" werkt.
|
||||||
|
13. **SFTP-restore over een lange lijn is traag**: 0,75 MiB/s vanaf Hetzner naar Canada (RTT 122 ms),
|
||||||
|
terwijl OpenSSH `sftp` 16,6 MiB/s haalt. De SFTP-stream van files_external (phpseclib) wacht na
|
||||||
|
elk klein blok op antwoord. Oplossing: pack-ranges bij SFTP met phpseclib's eigen `get()`
|
||||||
|
(veel verzoeken tegelijk) lezen in plaats van via de stream, zoals bij S3. 1 TB duurt nu ~2 weken.
|
||||||
|
Na meten: de grootste oorzaak was het netwerk (cubic, zie 14): met BBR gaat de restore van 0,75
|
||||||
|
naar 2,3 MiB/s, een hele pack lezen 4-5 MiB/s. Meer verzoeken in één stream (DEPTH 16/32) gaf geen
|
||||||
|
duidelijke winst. Verder komen vraagt meerdere verbindingen tegelijk: naar **1.1.0**.
|
||||||
|
14. **Uploaden over een lange lijn**: backup naar OVH S3 Warschau vanaf Canada (RTT 114 ms) haalt
|
||||||
|
~1 MiB/s met één pack tegelijk; `aws s3 cp` (multipart, parallel) 6 MiB/s. Meerdere packs tegelijk
|
||||||
|
uploaden of S3 multipart. Minder dringend dan 13 (restore).
|
||||||
|
Oorzaak netwerk (met Sysop): pakketverlies trans-Atlantisch + cubic in de VM; met BBR gaat één
|
||||||
|
PUT van 2,4 naar 7,5 MiB/s, ng_backup van ~1 naar ~1,6 MiB/s (rest = serieel lezen/uploaden).
|
||||||
|
Tip over BBR in de README gezet. Let op: PVE02 staat in Canada; een restore in de EU gaat sneller.
|
||||||
|
15. DR-document: in het Docker-voorbeeld "je containernaam" (`docker ps`) i.p.v. `nextcloud`.
|
||||||
|
16. Grote restore (VM 980, snapshot aa7cce8d van de Storage Box) stopte na 22 uur met
|
||||||
|
`Not found: packs/26/2642ef39…`. Oorzaak: de wekelijkse opruiming van nc01 (zo 18-10) heeft
|
||||||
|
dat pakket tijdens de restore omgepakt (blob staat nu in `389567b7…`); de herstelserver had de
|
||||||
|
index van vóór de opruiming. Productie heel: `backup:verify` op nc01 OK (150.639 bestanden,
|
||||||
|
316.830 blobs). Opgelost in rc2: bij een mislukte pakketlezing de index herladen en de blob
|
||||||
|
lezen waar hij nu staat (test `testRestoreFollowsPacksRepackedByAnotherServer`).
|
||||||
|
**Kleine DR-test met rc2 (2026-10-18), volautomatisch geslaagd:** bron VM 981 (NC 34, PostgreSQL,
|
||||||
|
51 GB, Mail met gesynct account, Deck uitgeschakeld, Calendar/Contacts, admin_audit) → OVH S3 →
|
||||||
|
verse VM 982 met alleen de DR-gids en `--install-apps`: apps vanzelf geïnstalleerd, 10 Deck-tabellen
|
||||||
|
aangemaakt, 15 FK's in orde, 23.519/23.519 checksums gelijk, agenda/contact/mail/bord terug.
|
||||||
|
Restore 1 u 47 vanuit Warschau naar Canada (met BBR). Lokaal slaagt de test ook op SQLite en MariaDB.
|
||||||
|
**Daarna verplicht:** een tweede volledige restore op een verse VM, alleen met de HOWTO en de
|
||||||
|
nieuwe opties, zonder handwerk. Pas als die in één keer slaagt: 1.0.0.
|
||||||
|
|
||||||
|
- **Later (geen versie):** optie om de sleutel niet op de locatie op te slaan (kit is dan de enige
|
||||||
|
kopie; uitdrukkelijke waarschuwing). Primaire object storage en External-storage-koppelingen
|
||||||
|
meenemen via de opslaglaag (§3.4; nu niet ondersteund, zie README "Known limitations").
|
||||||
|
- **Open:** of de bevestigingscode van de kit dubbelop is (nog niet over besloten).
|
||||||
|
|
||||||
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
|
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
|
||||||
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
||||||
terugzetten vanuit de Files-app).
|
terugzetten vanuit de Files-app).
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ 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: beta (0.9.2).** Tested thoroughly, usable alongside your current backup, but not yet a
|
**Status: release candidate for 1.0.0 (1.0.0-rc.1, app version 0.9.7).** Tested thoroughly, usable
|
||||||
drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
|
alongside your current backup, but not yet a drop-in replacement for it. 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.
|
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
||||||
|
`docs/DISASTER-RECOVERY.md`.
|
||||||
|
|
||||||
License: AGPL-3.0-or-later.
|
License: AGPL-3.0-or-later. Developed and sponsored by [Ensembia](https://www.ensembia.com).
|
||||||
|
Need help restoring a backup after a crash? Ensembia offers paid restore assistance:
|
||||||
|
[www.ensembia.com](https://www.ensembia.com).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -23,9 +26,33 @@ License: AGPL-3.0-or-later.
|
|||||||
leaves the server, and streamed straight to the location: no local copy, no double disk space.
|
leaves the server, and streamed straight to the location: no local copy, no double disk space.
|
||||||
Unchanged files are not read again; only new data is uploaded.
|
Unchanged files are not read again; only new data is uploaded.
|
||||||
- The database is dumped through Nextcloud's own connection, inside one consistent transaction.
|
- The database is dumped through Nextcloud's own connection, inside one consistent transaction.
|
||||||
|
- A backup runs in short steps from Nextcloud's background jobs, and every step saves where it
|
||||||
|
got to. A restart of the server or the containers does not lose the run: it continues at the
|
||||||
|
next background job run. Tested on a production Nextcloud AIO instance: a 1 TB full backup over
|
||||||
|
SMB carried on through the nightly AIO update, which restarts every container.
|
||||||
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
|
- Restores go through Nextcloud, so overwritten files keep their old content as a version and
|
||||||
removed files go to the trash.
|
removed files go to the trash.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Background jobs on system cron.** NG Backup runs backups in steps from Nextcloud's
|
||||||
|
background job, so a backup only moves forward when that job runs. Choosing "Cron" under
|
||||||
|
**Administration settings → Basic settings → Background jobs** is not enough on its own: it
|
||||||
|
tells Nextcloud that the system will call it, so you also need a cron line that does. Without
|
||||||
|
it a backup stays at "running (db)", and so does one started with "Back up now".
|
||||||
|
`occ backup:run` in a terminal works without cron (it runs in the foreground).
|
||||||
|
- Regular server: `crontab -u www-data -e` and add
|
||||||
|
`*/5 * * * * php -f /var/www/nextcloud/cron.php` (your Nextcloud path).
|
||||||
|
- Docker (the official `nextcloud` image): on the host, `crontab -e` as root and add
|
||||||
|
`*/5 * * * * docker exec -u www-data <container> php -f /var/www/html/cron.php`, or add a
|
||||||
|
service with the same image and `entrypoint: /cron.sh` to your `docker-compose.yml`.
|
||||||
|
- Nextcloud All-in-One already runs cron.
|
||||||
|
|
||||||
|
After a few minutes, the Background jobs section shows that the last job ran just now. See the
|
||||||
|
[Nextcloud documentation on background jobs](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html).
|
||||||
|
- A storage location: S3 or compatible, SFTP, WebDAV, SMB (needs the PHP `smbclient` extension,
|
||||||
|
see below), FTP or a local path.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -47,9 +74,19 @@ All backups of an installation are encrypted with one master key.
|
|||||||
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
|
- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups
|
||||||
and restores work without anyone typing a passphrase. Any administrator can restore from the
|
and restores work without anyone typing a passphrase. Any administrator can restore from the
|
||||||
web interface. Consequence: whoever fully controls the server can read the backups; whoever
|
web interface. Consequence: whoever fully controls the server can read the backups; whoever
|
||||||
only gets hold of the backup location cannot.
|
only gets hold of the backup location cannot, as long as the passphrases are strong (see below).
|
||||||
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
|
- The **location holds the key too**, by design (like restic and Borg in repokey mode): its
|
||||||
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
|
`config` file contains the master key, encrypted separately with each passphrase (Argon2id),
|
||||||
|
the same as in the recovery kit. Without a passphrase it is of no use, but whoever can read the
|
||||||
|
location can try passphrases offline. **Your passphrase is therefore the actual key**: it must
|
||||||
|
be at least 16 characters; five or more random words are a good choice.
|
||||||
|
- The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and
|
||||||
|
off the location: an attacker who gets write access to the location can damage or delete its
|
||||||
|
`config` file on purpose, and without the kit that makes every backup there unreadable.
|
||||||
|
- For a **restore on a new server** (disaster recovery) you currently need the **recovery kit**
|
||||||
|
and **one passphrase**.
|
||||||
|
- Planned: an option to not store the key on the location at all. Then the kit is the only copy,
|
||||||
|
and losing it means losing every backup; that option will come with an explicit warning.
|
||||||
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
|
- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not
|
||||||
lock the company out. A suggested setup:
|
lock the company out. A suggested setup:
|
||||||
|
|
||||||
@@ -109,10 +146,31 @@ What you should do on the location (strongly recommended):
|
|||||||
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
|
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
|
||||||
("Restored <date>"), merged into the original place, or replacing it.
|
("Restored <date>"), merged into the original place, or replacing it.
|
||||||
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
|
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
|
||||||
- **Whole server** (disaster recovery onto a fresh installation): `occ backup:restore:full`, see the how-to.
|
- **Whole server** (disaster recovery onto a fresh installation): `occ backup:restore:full`, see
|
||||||
|
[docs/DISASTER-RECOVERY.md](docs/DISASTER-RECOVERY.md). It runs in maintenance mode, so that
|
||||||
|
nobody and no background job writes into the instance while it is being replaced.
|
||||||
- **One user** (account, settings, files, calendars, contacts via user_migration): `occ backup:user:backup` and
|
- **One user** (account, settings, files, calendars, contacts via user_migration): `occ backup:user:backup` and
|
||||||
`occ backup:user:restore`, replacing the account or restoring it as `<uid>-bak`. See the how-to.
|
`occ backup:user:restore`, replacing the account or restoring it as `<uid>-bak`. See the how-to.
|
||||||
|
|
||||||
|
**Without shell access** (a hosted Nextcloud where you cannot run `occ`): files and folders can be
|
||||||
|
restored in the web interface. Restoring a whole user or the whole server needs `occ`, so on such
|
||||||
|
hosting it is something your provider (or whoever has shell access) runs. A restore mode that
|
||||||
|
works without maintenance mode, and so from the web interface, is on the roadmap.
|
||||||
|
|
||||||
|
## Tested disaster recoveries
|
||||||
|
|
||||||
|
Full restores onto a fresh server, following only [the guide](docs/DISASTER-RECOVERY.md):
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 | 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) |
|
||||||
|
| 51 GB test instance | OVH S3 | PostgreSQL → PostgreSQL | 23,519/23,519 checksums identical |
|
||||||
|
|
||||||
|
The integration tests restore across every pair of PostgreSQL, MariaDB and SQLite.
|
||||||
|
|
||||||
## Storage backend experiences
|
## Storage backend experiences
|
||||||
|
|
||||||
Notes from actually running a backend, beyond what's in files_external's own documentation.
|
Notes from actually running a backend, beyond what's in files_external's own documentation.
|
||||||
@@ -136,9 +194,10 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
|||||||
Conflict`. SMB: share = the sub-account name, port 445 must be reachable from your server (it was
|
Conflict`. SMB: share = the sub-account name, port 445 must be reachable from your server (it was
|
||||||
from a home connection here, but some providers block it), and the `smbclient` PECL extension is
|
from a home connection here, but some providers block it), and the `smbclient` PECL extension is
|
||||||
required (see the SMB entry below). Both have a noticeable fixed cost per run: an incremental run
|
required (see the SMB entry below). Both have a noticeable fixed cost per run: an incremental run
|
||||||
that uploads nothing took about 20 s over WebDAV and 50 s over SMB. A deep verify is slow over
|
that uploads nothing took about 20 s over WebDAV and 50 s over SMB. Over WebDAV, Nextcloud gives
|
||||||
remote storage, since each blob is fetched with its own request (19 minutes for 66 MiB over
|
every request at most 30 s by default, also an upload of a whole 32 MiB pack: on an upload
|
||||||
WebDAV); run it occasionally, not after every backup.
|
slower than about 9 Mbit/s a backup fails with `cURL error 28: Operation timed out`. Raise the
|
||||||
|
limit with `occ config:system:set davstorage.request_timeout --value=600 --type=integer`.
|
||||||
|
|
||||||
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
|
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
|
||||||
absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style
|
absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style
|
||||||
@@ -157,16 +216,84 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
|||||||
the backend being listed is not proof it will work here. Once the extension is present it works
|
the backend being listed is not proof it will work here. Once the extension is present it works
|
||||||
the same as any other backend; no ng_backup-specific config beyond host/share/user/password.
|
the same as any other backend; no ng_backup-specific config beyond host/share/user/password.
|
||||||
|
|
||||||
|
- **Backups over a long distance (high latency):** on Linux, the default TCP congestion control
|
||||||
|
(cubic) slows a single connection down a lot on a long line with some packet loss. From Canada to
|
||||||
|
an S3 bucket in Warsaw (114 ms), one upload went from 2.4 to 7.5 MiB/s after switching the
|
||||||
|
server (or the VM/container host where TCP ends) to BBR:
|
||||||
|
`sysctl -w net.core.default_qdisc=fq net.ipv4.tcp_congestion_control=bbr`
|
||||||
|
(`modprobe tcp_bbr` first; make it permanent in `/etc/sysctl.d/`).
|
||||||
|
A full restore over SFTP reads the packs ahead on a second connection, with many requests in
|
||||||
|
flight. A server that handles parallel reads well (not a Hetzner Storage Box, which gets slower)
|
||||||
|
can be read on more connections: `occ config:app:set ng_backup restore_sftp_connections
|
||||||
|
--value=4` (at most 8; the server must allow that many plus one at once for the user).
|
||||||
|
|
||||||
|
- **OVH Object Storage (S3, Warsaw, 2026-10):** works with the `amazons3` backend (path style,
|
||||||
|
region `waw`). A full backup of 1 TB from Germany ran at about 7 MiB/s, from Canada about
|
||||||
|
6 MiB/s with BBR; a check of a 1 TB backup took 3.5 minutes (index files are fetched 16 at a
|
||||||
|
time); a restore of 138 GB from Warsaw to Canada took about 6 hours. Turn on versioning and
|
||||||
|
Object Lock on the bucket (see above); OVH charges no egress.
|
||||||
|
|
||||||
|
- **Hetzner Storage Box over SFTP, full restore:** about 13-14 MiB/s from Germany to Canada
|
||||||
|
(122 ms) with the read-ahead of 1.0.0-rc.4; without it about 2.8 MiB/s. A Storage Box gets
|
||||||
|
slower with several connections reading at once (4-6 MiB/s for 2-4 connections, it reads from
|
||||||
|
disks), so one extra connection is the default. A Storage Box drops an SFTP connection that
|
||||||
|
stayed quiet for a while without telling the client; NG Backup checks and replaces it.
|
||||||
|
|
||||||
|
- **SMB speed:** on a local network (Samba, no delay) a backup ran at about 25 MB/s and a
|
||||||
|
restore at about 28 MiB/s. Over a long line SMB waits for an answer to each request: from
|
||||||
|
Canada to a Storage Box in Germany (130 ms) about 2.6 MiB/s for a backup and 1.6 MiB/s for a
|
||||||
|
restore, and connecting a location with an existing backup reads its index at about one file a
|
||||||
|
second. SMB is meant for a local network; use SFTP or S3 for a location far away. When
|
||||||
|
`pecl install smbclient` fails (pecl.php.net was unreachable for hours on 2026-10-21), the
|
||||||
|
extension builds from its source: `libsmbclient-php` on GitHub, `phpize && ./configure && make
|
||||||
|
&& make install`, then `docker-php-ext-enable smbclient`.
|
||||||
|
|
||||||
- **NFS is not a files_external backend at all.** Mount the NFS export on the server yourself
|
- **NFS is not a files_external backend at all.** Mount the NFS export on the server yourself
|
||||||
(e.g. via `/etc/fstab` or a systemd `.mount` unit) and point ng_backup's `local` backend at the
|
(e.g. via `/etc/fstab` or a systemd `.mount` unit) and point ng_backup's `local` backend at the
|
||||||
mount point; ng_backup never mounts or unmounts anything on its own, so an NFS mount dropping
|
mount point; ng_backup never mounts or unmounts anything on its own, so an NFS mount dropping
|
||||||
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
||||||
can't be driven from PHP directly.
|
can't be driven from PHP directly.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
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,
|
||||||
|
a 👍 on its issue helps decide what comes first.
|
||||||
|
|
||||||
|
**1.0.1**
|
||||||
|
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
||||||
|
system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on
|
||||||
|
while the NG Backup page is open ([#13](https://github.com/dillardblom/ng_backup/issues/13)).
|
||||||
|
- An optional label for a backup started with "Back up now".
|
||||||
|
- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4.
|
||||||
|
|
||||||
|
**1.1.0**
|
||||||
|
- Restore without the recovery kit: the backup key from the location itself, opened with one of
|
||||||
|
your passphrases.
|
||||||
|
- A schedule per location, also "every N hours".
|
||||||
|
- Locations backed up one after the other instead of at the same time.
|
||||||
|
- A note on a restore point that you can change afterwards.
|
||||||
|
- An option to pause heavy background jobs (such as Memories, Recognize or the Preview
|
||||||
|
Generator) while a backup runs.
|
||||||
|
- Faster backups: reading and uploading at the same time.
|
||||||
|
|
||||||
|
**Later**
|
||||||
|
- A full restore onto the existing installation, without first installing a fresh Nextcloud, in a
|
||||||
|
restore mode of its own instead of maintenance mode, so that it can also run from the web
|
||||||
|
interface (for hosted Nextcloud without shell access).
|
||||||
|
- Users restoring their own files from the Files app.
|
||||||
|
- Primary object storage and External Storage mounts (see Known limitations).
|
||||||
|
- An option not to keep the backup key on the location (the recovery kit is then the only copy).
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
|
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
|
||||||
|
|
||||||
|
- **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
|
||||||
|
External storage app are not in the data directory and are **not included**, without a warning.
|
||||||
|
Back them up separately. On the roadmap: reading files through Nextcloud's storage layer, which
|
||||||
|
covers both, with external mounts optional per mount (`PLAN.md` section 3.4).
|
||||||
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
|
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
|
||||||
snapshot in the web interface and per-user export manifests are held in memory. Expect high
|
snapshot in the web interface and per-user export manifests are held in memory. Expect high
|
||||||
memory use there; reports from such instances are very welcome.
|
memory use there; reports from such instances are very welcome.
|
||||||
@@ -182,8 +309,11 @@ On the backlog; none of these block the beta or 1.0.0, but know them before you
|
|||||||
- Location passwords passed with `-o password=...` are visible in the process list and shell
|
- Location passwords passed with `-o password=...` are visible in the process list and shell
|
||||||
history while the command runs.
|
history while the command runs.
|
||||||
- Temporary files of a restore into a user's files end up in that user's trash.
|
- Temporary files of a restore into a user's files end up in that user's trash.
|
||||||
- Restoring the database does not order tables by foreign keys; apps with cascading foreign keys
|
- **Restoring a whole user or the whole server needs `occ`** (shell access); see "Without shell
|
||||||
could lose rows (Nextcloud core does not use them).
|
access" above.
|
||||||
|
- **`--install-apps` depends on the app store.** Nextcloud downloads the whole app list (about
|
||||||
|
38 MB) and gives up after 120 seconds; on a slow day apps are not installed. The restore then
|
||||||
|
lists them: install them afterwards with `occ app:install`, their data is restored already.
|
||||||
- A pack whose index upload failed stays on the location until removed by hand.
|
- A pack whose index upload failed stays on the location until removed by hand.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -192,8 +322,9 @@ On the backlog; none of these block the beta or 1.0.0, but know them before you
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
|
| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation |
|
||||||
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
|
| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
|
||||||
| `backup:target:backends/add/list/remove/append-only` | backup locations |
|
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
|
||||||
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
|
| `backup:run`, `backup:status`, `backup:list` | back up, status (runs of the last 7 days; `--all` for all), restore points |
|
||||||
|
| `backup:cancel <run>` | stop a running backup |
|
||||||
| `backup:browse`, `backup:restore` | browse and restore |
|
| `backup:browse`, `backup:restore` | browse and restore |
|
||||||
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
||||||
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
||||||
|
|||||||
+47
-12
@@ -3,26 +3,59 @@
|
|||||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||||
<id>ng_backup</id>
|
<id>ng_backup</id>
|
||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud — files, database and config — with restore from the web interface</summary>
|
||||||
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
|
<description><![CDATA[Back up your whole Nextcloud — data directory, database and configuration — encrypted and
|
||||||
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
|
incremental, to storage you choose. Restore single files, folders, a user, or the whole server from
|
||||||
single users (via user_migration) and of the whole server onto a fresh installation. Tested
|
the web interface or `occ`. No shell access or external tools needed, so it also runs on Nextcloud
|
||||||
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
|
All-in-One and hosted Nextcloud.
|
||||||
See the README for known limitations and the recovery kit before relying on it.]]></description>
|
|
||||||
<version>0.9.2</version>
|
**Where to**
|
||||||
|
- S3 and S3-compatible storage, SFTP, WebDAV, SMB, FTP, Swift or a local path (NFS mount), through
|
||||||
|
the connection code of the External storage app. A location is never mounted and never shows up
|
||||||
|
in Files.
|
||||||
|
|
||||||
|
**How**
|
||||||
|
- Deduplicated, compressed and encrypted (XChaCha20-Poly1305) on the server before upload, streamed
|
||||||
|
straight to the location: no local copy, no double disk space. Unchanged files are not read again.
|
||||||
|
- Database dump through Nextcloud's own connection, in one consistent transaction (PostgreSQL,
|
||||||
|
MySQL/MariaDB, SQLite).
|
||||||
|
- Runs in short steps from the background jobs, so a server or container restart does not lose a
|
||||||
|
running backup.
|
||||||
|
- Daily schedule, retention policy, and `occ backup:verify` (optionally `--deep`) to check that a
|
||||||
|
backup can be read.
|
||||||
|
|
||||||
|
**Safe**
|
||||||
|
- Up to three passphrases plus a recovery kit; the passphrase is the actual key.
|
||||||
|
- Deletion delay and append-only locations against ransomware or a compromised server; S3 object
|
||||||
|
lock supported.
|
||||||
|
- Admin notifications on failures, stale backups and changes to keys and locations.
|
||||||
|
|
||||||
|
**Restore**
|
||||||
|
- Files and folders into a new folder, merged, or replacing the original — through Nextcloud, so
|
||||||
|
overwritten files keep a version and removed ones go to the trash.
|
||||||
|
- One user (account, files, calendars, contacts) via user_migration.
|
||||||
|
- The whole server onto a fresh installation (disaster recovery).
|
||||||
|
|
||||||
|
NG Backup needs Nextcloud's background jobs on system cron (a real cron line, not only the setting).
|
||||||
|
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.
|
||||||
|
|
||||||
|
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>
|
||||||
|
<version>0.9.12</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
<documentation>
|
<documentation>
|
||||||
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
|
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
|
||||||
</documentation>
|
</documentation>
|
||||||
<category>tools</category>
|
<category>tools</category>
|
||||||
<website>https://apps.nextcloud.com/apps/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/main/screenshots/1-backups.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/1-backups.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/2-restore.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/2-restore.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/3-settings.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/3-settings.png</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<php min-version="8.1"/>
|
<php min-version="8.1"/>
|
||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
@@ -37,9 +70,11 @@ See the README for known limitations and the recovery kit before relying on it.]
|
|||||||
<command>OCA\NgBackup\Command\KeyConfirm</command>
|
<command>OCA\NgBackup\Command\KeyConfirm</command>
|
||||||
<command>OCA\NgBackup\Command\TargetBackends</command>
|
<command>OCA\NgBackup\Command\TargetBackends</command>
|
||||||
<command>OCA\NgBackup\Command\TargetAdd</command>
|
<command>OCA\NgBackup\Command\TargetAdd</command>
|
||||||
|
<command>OCA\NgBackup\Command\TargetEdit</command>
|
||||||
<command>OCA\NgBackup\Command\TargetList</command>
|
<command>OCA\NgBackup\Command\TargetList</command>
|
||||||
<command>OCA\NgBackup\Command\TargetRemove</command>
|
<command>OCA\NgBackup\Command\TargetRemove</command>
|
||||||
<command>OCA\NgBackup\Command\RunBackup</command>
|
<command>OCA\NgBackup\Command\RunBackup</command>
|
||||||
|
<command>OCA\NgBackup\Command\CancelRun</command>
|
||||||
<command>OCA\NgBackup\Command\Status</command>
|
<command>OCA\NgBackup\Command\Status</command>
|
||||||
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
||||||
<command>OCA\NgBackup\Command\Browse</command>
|
<command>OCA\NgBackup\Command\Browse</command>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ return [
|
|||||||
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
|
['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'],
|
||||||
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
||||||
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
|
['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'],
|
||||||
|
['name' => 'AdminApi#targetSettings', 'url' => '/api/targets/{id}/settings', 'verb' => 'GET'],
|
||||||
|
['name' => 'AdminApi#updateTarget', 'url' => '/api/targets/{id}', 'verb' => 'PUT'],
|
||||||
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
|
['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'],
|
||||||
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
['name' => 'AdminApi#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
||||||
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Disaster recovery: your server is gone
|
||||||
|
|
||||||
|
Your Nextcloud server is lost. You have a new, empty server. These are the steps to get your
|
||||||
|
Nextcloud back from an NG Backup location. Do them in this order.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- [ ] The **recovery kit** file (`ng_backup-kit.json`) and **one passphrase** of it.
|
||||||
|
- [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder.
|
||||||
|
- [ ] A new server with the **same Nextcloud major version** as the old one (for example 34),
|
||||||
|
a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for
|
||||||
|
the whole backup plus a margin.
|
||||||
|
- [ ] **Shell access** to the new server, to run `occ` (on a hosted Nextcloud without it, your
|
||||||
|
provider runs these steps).
|
||||||
|
- [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by
|
||||||
|
hand). Keep their packages somewhere safe beforehand.
|
||||||
|
|
||||||
|
No kit? If the location still holds its `config` file, the key is there too, but this version
|
||||||
|
can only import it from a kit. Keep the kit off the server, next to the passphrase.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
**1. Install a fresh Nextcloud** of the same major version. Create an admin account. Do not add
|
||||||
|
users or files.
|
||||||
|
|
||||||
|
**2. Do not let background jobs run yet.** A new Nextcloud runs them on page visits (AJAX).
|
||||||
|
Switch that off:
|
||||||
|
|
||||||
|
occ background:cron
|
||||||
|
|
||||||
|
As long as no cron is set up, nothing runs. Check that none is:
|
||||||
|
|
||||||
|
- Regular server: `sudo crontab -u www-data -l`. If it shows a line with `cron.php`, remove it
|
||||||
|
for now with `sudo crontab -u www-data -e` (put it back in step 9).
|
||||||
|
- Docker Compose with a cron service: `docker compose stop cron`.
|
||||||
|
- Nextcloud All-in-One: cron runs inside the Nextcloud container; leave it. It does no harm here:
|
||||||
|
the restore keeps NG Backup's own settings of the new server, so it does not start backups.
|
||||||
|
|
||||||
|
This keeps the new server quiet (no mail syncs, no notifications, no backups) until it is ready.
|
||||||
|
|
||||||
|
**3. Install NG Backup** (the same version or newer) and enable it.
|
||||||
|
|
||||||
|
**4. Import the backup key.** Copy the kit to the new server so the web server user can read it.
|
||||||
|
|
||||||
|
From your own computer:
|
||||||
|
|
||||||
|
scp ng_backup-kit.json root@newserver:/tmp/
|
||||||
|
|
||||||
|
Then on the server, one of:
|
||||||
|
|
||||||
|
chown www-data /tmp/ng_backup-kit.json # regular server
|
||||||
|
docker cp /tmp/ng_backup-kit.json <container>:/tmp/ && \
|
||||||
|
docker exec <container> chown www-data /tmp/ng_backup-kit.json # Docker
|
||||||
|
|
||||||
|
`<container>` is the name of your Nextcloud container: `docker ps` shows it (for example
|
||||||
|
`nextcloud-aio-nextcloud`, or `myproject-nextcloud-1` with Docker Compose).
|
||||||
|
|
||||||
|
Import it; you type the passphrase yourself:
|
||||||
|
|
||||||
|
occ backup:key:import /tmp/ng_backup-kit.json
|
||||||
|
|
||||||
|
It shows the key fingerprint: it must match the one of your old server (on the recovery kit).
|
||||||
|
|
||||||
|
**5. Add the backup location** with the same details as before. On a large backup this first reads
|
||||||
|
the index and shows how far it is, for example
|
||||||
|
`Reading the index: 6,913/26,791 files (25%), about 12 min left`.
|
||||||
|
|
||||||
|
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... -o root=... --path=...
|
||||||
|
|
||||||
|
If it says "No backup key yet", the key is not imported. Run step 4:
|
||||||
|
|
||||||
|
occ backup:key:import /tmp/ng_backup-kit.json
|
||||||
|
|
||||||
|
Do **not** run `backup:key:init`, even though the message suggests it: that makes a brand new key,
|
||||||
|
and your backups were encrypted with the old one. NG Backup then refuses the location ("created
|
||||||
|
with a different key").
|
||||||
|
|
||||||
|
**6. List the restore points** and pick one, normally the newest:
|
||||||
|
|
||||||
|
occ backup:list offsite
|
||||||
|
|
||||||
|
**7. Start the restore, and let it install the apps.**
|
||||||
|
|
||||||
|
occ backup:restore:full offsite <snapshot> --install-apps
|
||||||
|
|
||||||
|
`--install-apps` first installs every app the old server had enabled from the app store (or
|
||||||
|
switches it on, if it is part of Nextcloud). Tables of apps that are missing or were removed long
|
||||||
|
ago are created from the backup, so they do not stop the restore.
|
||||||
|
|
||||||
|
At the end it lists the apps it could not install, usually your own builds that are not in the
|
||||||
|
app store. Copy those into `custom_apps` and enable them (`occ app:enable <app>`); their data is
|
||||||
|
already restored.
|
||||||
|
|
||||||
|
Backups made before 1.0.0-rc.2 do not record the structure of their tables. If the restore lists
|
||||||
|
tables that do not exist here, install the apps they belong to and run it again, or leave them
|
||||||
|
out with `--skip-missing-tables`.
|
||||||
|
|
||||||
|
**8. Let the restore run.** It restores the database, then the data directory, and takes hours for
|
||||||
|
a large backup. Nextcloud is in maintenance mode meanwhile, so nobody and no background job
|
||||||
|
writes into it; the restore switches it off when it completes. (If you had switched maintenance
|
||||||
|
mode on yourself before, it stays on: switch it off with `occ maintenance:mode --off` when ready.)
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
If occ then says that Nextcloud or one of the apps requires an upgrade, the database is already
|
||||||
|
the old server's: run `occ upgrade` first, then the same command with `--force`.
|
||||||
|
|
||||||
|
**9. After the restore.** These worked in our tests:
|
||||||
|
|
||||||
|
occ upgrade
|
||||||
|
occ maintenance:repair
|
||||||
|
occ files:scan --all
|
||||||
|
|
||||||
|
- Check `config/config.php`: `trusted_domains`, `overwrite.cli.url` and `overwritehost` come from
|
||||||
|
the old server; change them if the new server has another name. The database settings and the
|
||||||
|
data directory are kept from the new installation.
|
||||||
|
- From Nextcloud All-in-One to a regular server: also check the cache settings (`memcache.*`,
|
||||||
|
`redis`), which point at AIO's containers.
|
||||||
|
- Log in, open a few files, a calendar and a contact.
|
||||||
|
- Start background jobs again (cron), as in step 2 in reverse.
|
||||||
|
|
||||||
|
**10. Point your domain at the new server** and check that clients reconnect.
|
||||||
|
|
||||||
|
**11. Make a backup of the new server** to the same location (`occ backup:run offsite`). The old
|
||||||
|
server must stay off: two servers must never write to the same location.
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
- Never let the old server write to the location again if it comes back. Switch its backups off
|
||||||
|
first.
|
||||||
|
- Never give your passphrase to anyone. Nobody helping you needs it.
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
Stuck? [Ensembia](https://www.ensembia.com), the developer of NG Backup, offers paid help with
|
||||||
|
restoring a backup after a crash.
|
||||||
+27
-2
@@ -12,13 +12,16 @@ occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
|
|||||||
occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I confirm"
|
occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I confirm"
|
||||||
```
|
```
|
||||||
|
|
||||||
- The passphrase protects the backup. Keep it somewhere other than this server.
|
- The passphrase protects the backup: the location stores the key encrypted with it, so whoever
|
||||||
|
can read the location can try to guess it. Use at least 16 characters (five or more random
|
||||||
|
words work well) and keep it somewhere other than this server.
|
||||||
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
||||||
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
|
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
|
||||||
who kept an old kit and that passphrase (see "Known limitations" in the README).
|
who kept an old kit and that passphrase (see "Known limitations" in the README).
|
||||||
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
|
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
|
||||||
user writes it there; then move it off the server as root, next to the passphrase, and delete the
|
user writes it there; then move it off the server as root, next to the passphrase, and delete the
|
||||||
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
|
copy in `/var/tmp`. The location holds the same encrypted key, but keep the kit anyway: if
|
||||||
|
someone damages or deletes the key on the location, the kit is the only way back in.
|
||||||
|
|
||||||
## 2. Add a backup location
|
## 2. Add a backup location
|
||||||
|
|
||||||
@@ -50,6 +53,22 @@ occ backup:target:list
|
|||||||
occ backup:status
|
occ backup:status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A backup that hangs or takes far too long can be stopped. `backup:status` shows the run number;
|
||||||
|
the command waits until the step in progress is finished, then the process working on the run
|
||||||
|
stops, and the next cleanup removes what it stored:
|
||||||
|
|
||||||
|
```
|
||||||
|
occ backup:cancel 7
|
||||||
|
```
|
||||||
|
|
||||||
|
Changed password, new host name, or moved the repository? Edit the location instead of adding a
|
||||||
|
new one, so its history stays (in the web interface: **Edit** next to the location). The new
|
||||||
|
settings must lead to the same repository; options you do not pass keep their value:
|
||||||
|
|
||||||
|
```
|
||||||
|
occ backup:target:edit nas -o password=<new pw>
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Run the first backup
|
## 3. Run the first backup
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -117,6 +136,8 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
|||||||
|
|
||||||
## 7. Disaster recovery: restore the whole server
|
## 7. Disaster recovery: restore the whole server
|
||||||
|
|
||||||
|
Server lost? Follow the step-by-step [DISASTER-RECOVERY.md](DISASTER-RECOVERY.md).
|
||||||
|
|
||||||
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
||||||
the same location available. For a test, use a **copy** of the location: two servers writing to one
|
the same location available. For a test, use a **copy** of the location: two servers writing to one
|
||||||
location break each other's catalog.
|
location break each other's catalog.
|
||||||
@@ -134,6 +155,10 @@ starts; `--force` skips both (also needed to rerun it after it stopped halfway).
|
|||||||
Afterwards, check that users, files and the database are complete, and run a backup of the restored
|
Afterwards, check that users, files and the database are complete, and run a backup of the restored
|
||||||
server. The restored installation keeps its own app configuration and secret.
|
server. The restored installation keeps its own app configuration and secret.
|
||||||
|
|
||||||
|
Stuck? [Ensembia](https://www.ensembia.com), the developer of NG Backup, offers paid help with
|
||||||
|
restoring a backup after a crash. You keep your passphrase to yourself: it is never needed by
|
||||||
|
anyone helping you.
|
||||||
|
|
||||||
## 8. Retention
|
## 8. Retention
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/** IBackend::getMany() and getRanges() for a backend where reading one after the other costs nothing extra. */
|
||||||
|
trait GetsOneByOne {
|
||||||
|
/**
|
||||||
|
* @param list<string> $paths
|
||||||
|
* @return \Generator<string, string>
|
||||||
|
*/
|
||||||
|
public function getMany(array $paths): \Generator {
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$fh = $this->get($path);
|
||||||
|
try {
|
||||||
|
$data = stream_get_contents($fh);
|
||||||
|
} finally {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
if ($data === false) {
|
||||||
|
throw new BackendException('Read failed for ' . $path);
|
||||||
|
}
|
||||||
|
yield $path => $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{0:string, 1:int, 2:int}> $ranges
|
||||||
|
* @return \Generator<int, string>
|
||||||
|
*/
|
||||||
|
public function getRanges(array $ranges): \Generator {
|
||||||
|
foreach ($ranges as $i => [$path, $offset, $length]) {
|
||||||
|
yield $i => $this->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,30 @@ interface IBackend {
|
|||||||
/** @return resource readable stream of the whole object */
|
/** @return resource readable stream of the whole object */
|
||||||
public function get(string $path);
|
public function get(string $path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Many small objects whole, in the order given (the repository's index files). A backend
|
||||||
|
* where every request costs a round trip can fetch them several at a time.
|
||||||
|
*
|
||||||
|
* @param list<string> $paths
|
||||||
|
* @return \Generator<string, string> contents by path
|
||||||
|
* @throws BackendException for an object that cannot be read
|
||||||
|
*/
|
||||||
|
public function getMany(array $paths): \Generator;
|
||||||
|
|
||||||
/** @return string bytes [$offset, $offset + $length) of the object (used to fetch single blobs from a pack) */
|
/** @return string bytes [$offset, $offset + $length) of the object (used to fetch single blobs from a pack) */
|
||||||
public function getRange(string $path, int $offset, int $length): string;
|
public function getRange(string $path, int $offset, int $length): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Many byte ranges, in the order given (blobs spread over many packs, such as the tree
|
||||||
|
* segments of a backup). A backend where every request costs a round trip can fetch them
|
||||||
|
* several at a time.
|
||||||
|
*
|
||||||
|
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
|
||||||
|
* @return \Generator<int, string> contents by position in $ranges
|
||||||
|
* @throws BackendException for a range that cannot be read
|
||||||
|
*/
|
||||||
|
public function getRanges(array $ranges): \Generator;
|
||||||
|
|
||||||
public function exists(string $path): bool;
|
public function exists(string $path): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,5 +58,15 @@ interface IBackend {
|
|||||||
/** @return list<string> object paths below $prefix (recursive) */
|
/** @return list<string> object paths below $prefix (recursive) */
|
||||||
public function list(string $prefix): array;
|
public function list(string $prefix): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object paths directly in $dir, for a folder that only holds objects (index/, snapshots/,
|
||||||
|
* catalog/, trash/info/). One listing, without checking the type of every entry: on a remote
|
||||||
|
* storage list() costs a round trip per entry, which for index/ (one file per pack) grows
|
||||||
|
* with the size of the repository.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function listFiles(string $dir): array;
|
||||||
|
|
||||||
public function delete(string $path): void;
|
public function delete(string $path): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A backend that can read many byte ranges ahead of use, several at a time over connections of
|
||||||
|
* its own (a full restore, where one connection at a time is bound by the round trip).
|
||||||
|
*/
|
||||||
|
interface IReadsAhead {
|
||||||
|
/** Whether readAhead() is worth planning for: false when it would return null anyway. */
|
||||||
|
public function canReadAhead(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ranges in the order given, each as a local stream at offset 0 holding its bytes, read a
|
||||||
|
* few ranges ahead of the one the caller is at. A stream is closed when the generator is
|
||||||
|
* resumed. Null for a range that could not be read this way (a missing object, a server or
|
||||||
|
* connection error): the caller reads it the usual way, which reports why.
|
||||||
|
*
|
||||||
|
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
|
||||||
|
* @param string $spool an existing local directory for the files that hold the ranges
|
||||||
|
* @return \Generator<int, resource|null>|null by position in $ranges; null when this backend
|
||||||
|
* cannot read ahead now
|
||||||
|
*/
|
||||||
|
public function readAhead(array $ranges, string $spool): ?\Generator;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ declare(strict_types=1);
|
|||||||
namespace OCA\NgBackup\Backend;
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
final class LocalBackend implements IBackend {
|
final class LocalBackend implements IBackend {
|
||||||
|
use GetsOneByOne;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private string $root,
|
private string $root,
|
||||||
) {
|
) {
|
||||||
@@ -107,6 +109,25 @@ final class LocalBackend implements IBackend {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
$base = $this->abs($dir);
|
||||||
|
if (!is_dir($base)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$result = [];
|
||||||
|
$names = scandir($base);
|
||||||
|
if ($names === false) {
|
||||||
|
throw new BackendException('Cannot list ' . $dir);
|
||||||
|
}
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if ($name !== '.' && $name !== '..' && !StorageBackend::isTemporary($name)) {
|
||||||
|
$result[] = trim($dir, '/') . '/' . $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(string $path): void {
|
public function delete(string $path): void {
|
||||||
@unlink($this->abs($path));
|
@unlink($this->abs($path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads byte ranges of files on an SFTP location ahead of use, over several connections at once,
|
||||||
|
* into local files (StorageBackend::readAhead(), for a full restore).
|
||||||
|
*
|
||||||
|
* A restore that reads its packs as it goes is bound by the round trip: from a Hetzner Storage
|
||||||
|
* Box to Canada (RTT 122 ms) it reached about 2.8 MiB/s, with the PHP process mostly waiting.
|
||||||
|
* Here every connection reads its own range with DEPTH requests in flight, ahead of the restore. PHP runs one thing at a time: while it waits for an answer on one connection, the
|
||||||
|
* answers of the others arrive in the kernel's buffers, so the connections still transfer at the
|
||||||
|
* same time.
|
||||||
|
*
|
||||||
|
* Uses the same internal phpseclib 2 methods as SftpReadStream (see SftpReadStream::connection()).
|
||||||
|
* The connections are this class's own, never the storage's: no other request goes over them.
|
||||||
|
* A range that fails (missing file, server error, a connection that breaks) comes out as null,
|
||||||
|
* for the caller to read the usual way; a broken connection is not used again.
|
||||||
|
*/
|
||||||
|
final class SftpRangeFetcher {
|
||||||
|
/**
|
||||||
|
* Connections a full restore uses, unless the app setting restore_sftp_connections says
|
||||||
|
* otherwise. One: a Hetzner Storage Box gave about 14 MiB/s on one connection with DEPTH
|
||||||
|
* requests in flight, and 4-6 MiB/s on 2 or 4 (it reads from disks, and parallel readers make
|
||||||
|
* them seek). On a line with packet loss to a server that handles parallel reads well, 4
|
||||||
|
* connections did better (21 against 14 MiB/s).
|
||||||
|
*/
|
||||||
|
public const DEFAULT_CONNECTIONS = 1;
|
||||||
|
public const MAX_CONNECTIONS = 8;
|
||||||
|
/** READ requests in flight per connection. */
|
||||||
|
public const DEPTH = 32;
|
||||||
|
/** Ranges read ahead of the one the caller is at, in flight or waiting in the spool. */
|
||||||
|
public const AHEAD = 8;
|
||||||
|
/**
|
||||||
|
* Seconds ready() waits for any connection to answer before it waits for each in turn: short,
|
||||||
|
* in case a connection holds an answer where ready() does not look.
|
||||||
|
*/
|
||||||
|
private const SELECT_TIMEOUT = 1;
|
||||||
|
/** See SftpReadStream::CHUNK and MIN_CHUNK. */
|
||||||
|
private const CHUNK = 261120;
|
||||||
|
private const MIN_CHUNK = 32768;
|
||||||
|
|
||||||
|
private const FXP_OPEN = 3;
|
||||||
|
private const FXP_CLOSE = 4;
|
||||||
|
private const FXP_READ = 5;
|
||||||
|
private const FXP_STATUS = 101;
|
||||||
|
private const FXP_HANDLE = 102;
|
||||||
|
private const FXP_DATA = 103;
|
||||||
|
private const FXF_READ = 1;
|
||||||
|
private const FX_OK = 0;
|
||||||
|
|
||||||
|
/** Connections that broke and are replaced, at most, in one restore. */
|
||||||
|
public const MAX_RECONNECTS = 20;
|
||||||
|
|
||||||
|
private const OPEN = 0;
|
||||||
|
private const READ = 1;
|
||||||
|
private const CLOSE = 2;
|
||||||
|
|
||||||
|
/** @var array<int, int> READ size per connection, lowered when a server answers less than asked */
|
||||||
|
private array $chunks = [];
|
||||||
|
/** @var array<int, SftpRangeJob> the range each busy connection reads */
|
||||||
|
private array $jobs = [];
|
||||||
|
/** @var array<int, resource|null> ranges read (or failed) and not yet given to the caller */
|
||||||
|
private array $done = [];
|
||||||
|
private int $requestId = 0x4e520000;
|
||||||
|
/** Connections replaced so far (see MAX_RECONNECTS). */
|
||||||
|
public int $reconnects = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, object> $connections phpseclib 2 connections with their SFTP channel open
|
||||||
|
* @param list<array{0:string, 1:int, 2:int}> $ranges paths below $root
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private array $connections,
|
||||||
|
private string $root,
|
||||||
|
private array $ranges,
|
||||||
|
private string $spool,
|
||||||
|
private ?\Closure $connect = null,
|
||||||
|
) {
|
||||||
|
foreach (array_keys($connections) as $c) {
|
||||||
|
$this->chunks[$c] = self::CHUNK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See IReadsAhead::readAhead(). The connections are ended when the generator ends.
|
||||||
|
*
|
||||||
|
* @param list<object> $connections
|
||||||
|
* @param list<array{0:string, 1:int, 2:int}> $ranges
|
||||||
|
* @param (\Closure():?object)|null $connect a new connection in place of one that broke
|
||||||
|
* (null when it cannot be made)
|
||||||
|
* @return \Generator<int, resource|null>
|
||||||
|
*/
|
||||||
|
public static function fetch(array $connections, string $root, array $ranges, string $spool, ?\Closure $connect = null): \Generator {
|
||||||
|
$fetcher = new self($connections, rtrim($root, '/'), $ranges, $spool, $connect);
|
||||||
|
try {
|
||||||
|
yield from $fetcher->run();
|
||||||
|
} finally {
|
||||||
|
$fetcher->end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return \Generator<int, resource|null> */
|
||||||
|
private function run(): \Generator {
|
||||||
|
$count = count($this->ranges);
|
||||||
|
$next = 0;
|
||||||
|
for ($want = 0; $want < $count; $want++) {
|
||||||
|
while (!array_key_exists($want, $this->done)) {
|
||||||
|
foreach (array_keys($this->connections) as $c) {
|
||||||
|
if (!isset($this->jobs[$c]) && $next < $count && $next < $want + self::AHEAD) {
|
||||||
|
$this->start($c, $next++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->jobs === [] && !array_key_exists($want, $this->done)) {
|
||||||
|
// no connection left: the rest the usual way
|
||||||
|
$this->done[$want] = null;
|
||||||
|
$next = max($next, $want + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($this->ready() as $c) {
|
||||||
|
$this->step($c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fh = $this->done[$want];
|
||||||
|
unset($this->done[$want]);
|
||||||
|
try {
|
||||||
|
yield $want => $fh;
|
||||||
|
} finally {
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The busy connections that have an answer waiting, so that a connection that waits for a
|
||||||
|
* lost packet to be sent again does not hold up the others; all of them when the connections
|
||||||
|
* do not tell (then they are taken in turn).
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function ready(): array {
|
||||||
|
$sockets = [];
|
||||||
|
$ready = [];
|
||||||
|
foreach ($this->jobs as $c => $job) {
|
||||||
|
$sftp = $this->connections[$c];
|
||||||
|
$vars = get_object_vars($sftp); // only what is public: phpseclib 2's are
|
||||||
|
// Answers phpseclib has read already: the socket does not show them. Its request
|
||||||
|
// buffer holds whole answers read while it waited for another; its packet buffer and
|
||||||
|
// channel buffers hold what came in with the last packet it read (small answers, such
|
||||||
|
// as a CLOSE and the next OPEN, often arrive together).
|
||||||
|
$buffered = $vars['requestBuffer'] ?? null;
|
||||||
|
$channels = $vars['channel_buffers'] ?? null;
|
||||||
|
if ((is_array($buffered) && isset($job->queue[0]) && array_key_exists($job->queue[0][0], $buffered))
|
||||||
|
|| (is_string($vars['packet_buffer'] ?? null) && self::wholePacket($vars['packet_buffer']))
|
||||||
|
|| (is_array($channels) && array_filter($channels) !== [])) {
|
||||||
|
$ready[] = $c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$socket = $vars['fsock'] ?? null;
|
||||||
|
if (!is_resource($socket)) {
|
||||||
|
return array_keys($this->jobs);
|
||||||
|
}
|
||||||
|
$sockets[$c] = $socket;
|
||||||
|
}
|
||||||
|
if ($ready !== [] || $sockets === []) {
|
||||||
|
return $ready;
|
||||||
|
}
|
||||||
|
$read = $sockets;
|
||||||
|
$write = null;
|
||||||
|
$except = null;
|
||||||
|
// Data in PHP's own buffer of a socket counts as readable. Nothing within the time:
|
||||||
|
// each in turn, where phpseclib's own timeout applies.
|
||||||
|
$n = @stream_select($read, $write, $except, self::SELECT_TIMEOUT);
|
||||||
|
return $n === false || $n === 0 ? array_keys($this->jobs) : array_keys($read);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether $buffer starts with a whole SFTP packet (length, then that many bytes). */
|
||||||
|
private static function wholePacket(string $buffer): bool {
|
||||||
|
$length = strlen($buffer) >= 4 ? unpack('N', $buffer) : false;
|
||||||
|
return is_array($length) && strlen($buffer) >= 4 + $length[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function start(int $c, int $i): void {
|
||||||
|
[$path, $offset, $length] = $this->ranges[$i];
|
||||||
|
$fh = $this->spoolFile();
|
||||||
|
if ($fh === null) {
|
||||||
|
$this->done[$i] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($length <= 0) {
|
||||||
|
$this->done[$i] = $fh;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->jobs[$c] = new SftpRangeJob($i, $fh, $offset, $offset + $length);
|
||||||
|
try {
|
||||||
|
$remote = $this->connections[$c]->_realpath($this->root . '/' . ltrim($path, '/'));
|
||||||
|
if (!is_string($remote)) {
|
||||||
|
$this->finish($c);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->send($c, self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), self::OPEN);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->drop($c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take the oldest answer on connection $c, then keep its requests in flight. */
|
||||||
|
private function step(int $c): void {
|
||||||
|
$job = $this->jobs[$c];
|
||||||
|
[$id, $kind, $offset, $length] = array_shift($job->queue) ?? throw new \LogicException('no request in flight');
|
||||||
|
$sftp = $this->connections[$c];
|
||||||
|
try {
|
||||||
|
$response = $sftp->_get_sftp_packet($id);
|
||||||
|
if (!is_string($response)) {
|
||||||
|
throw new BackendException('SFTP connection lost');
|
||||||
|
}
|
||||||
|
$type = $sftp->packet_type;
|
||||||
|
if ($kind === self::OPEN) {
|
||||||
|
if ($type === self::FXP_HANDLE) {
|
||||||
|
$job->handle = substr($response, 4);
|
||||||
|
} else {
|
||||||
|
$job->failed = true; // missing, or no permission
|
||||||
|
}
|
||||||
|
} elseif ($kind === self::READ) {
|
||||||
|
if (!$job->failed) {
|
||||||
|
$this->received($c, $type, $response, $offset, $length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||||
|
$closed = is_array($status) && $status[1] === self::FX_OK;
|
||||||
|
$this->finish($c);
|
||||||
|
if (!$closed) {
|
||||||
|
// handles might pile up on the server: not used again
|
||||||
|
$this->drop($c);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->refill($c);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->drop($c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function received(int $c, int $type, string $response, int $offset, int $length): void {
|
||||||
|
$job = $this->jobs[$c];
|
||||||
|
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||||
|
$n = is_array($declared) ? $declared[1] : 0;
|
||||||
|
if ($type !== self::FXP_DATA || $n <= 0 || $n !== strlen($response) - 4 || $n > $length) {
|
||||||
|
// end of file before the end of the range, a server error or a malformed answer
|
||||||
|
$job->failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fseek($job->fh, $offset - $job->start) !== 0 || fwrite($job->fh, substr($response, 4)) !== $n) {
|
||||||
|
$job->failed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$job->got += $n;
|
||||||
|
if ($n < $length) {
|
||||||
|
// a server that caps a READ: ask for the rest, and no more than it gives from now on
|
||||||
|
$job->redo[] = [$offset + $n, $length - $n];
|
||||||
|
$this->chunks[$c] = max(self::MIN_CHUNK, min($this->chunks[$c], $n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send READs until DEPTH are in flight; when nothing is left to ask for, close the file. */
|
||||||
|
private function refill(int $c): void {
|
||||||
|
$job = $this->jobs[$c];
|
||||||
|
if ($job->handle !== '' && !$job->failed) {
|
||||||
|
while (count($job->queue) < self::DEPTH) {
|
||||||
|
if ($job->redo !== []) {
|
||||||
|
[$offset, $length] = array_shift($job->redo);
|
||||||
|
} elseif ($job->next < $job->end) {
|
||||||
|
$offset = $job->next;
|
||||||
|
$length = min($this->chunks[$c], $job->end - $offset);
|
||||||
|
$job->next += $length;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$packet = pack('Na*N3', strlen($job->handle), $job->handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
|
||||||
|
$this->send($c, self::FXP_READ, $packet, self::READ, $offset, $length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($job->queue !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($job->handle === '') {
|
||||||
|
$this->finish($c); // not opened: nothing to close
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->send($c, self::FXP_CLOSE, pack('Na*', strlen($job->handle), $job->handle), self::CLOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function send(int $c, int $type, string $packet, int $kind, int $offset = 0, int $length = 0): void {
|
||||||
|
$this->requestId = $this->requestId >= 0x7fffffff ? 0x4e520000 : $this->requestId + 1;
|
||||||
|
if (!$this->connections[$c]->_send_sftp_packet($type, $packet, $this->requestId)) {
|
||||||
|
throw new BackendException('SFTP connection failed while sending a request');
|
||||||
|
}
|
||||||
|
$this->jobs[$c]->queue[] = [$this->requestId, $kind, $offset, $length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The range of connection $c is done: its stream for the caller, or null when it failed. */
|
||||||
|
private function finish(int $c): void {
|
||||||
|
$job = $this->jobs[$c];
|
||||||
|
unset($this->jobs[$c]);
|
||||||
|
if ($job->failed || $job->got !== $job->end - $job->start || !rewind($job->fh)) {
|
||||||
|
self::closeFile($job->fh);
|
||||||
|
$this->done[$job->i] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->done[$job->i] = $job->fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connection in an unknown state: its range fails, and it is not used again. */
|
||||||
|
private function drop(int $c): void {
|
||||||
|
if (isset($this->jobs[$c])) {
|
||||||
|
$job = $this->jobs[$c];
|
||||||
|
unset($this->jobs[$c]);
|
||||||
|
self::closeFile($job->fh);
|
||||||
|
$this->done[$job->i] = null;
|
||||||
|
}
|
||||||
|
self::disconnect($this->connections[$c]);
|
||||||
|
unset($this->connections[$c]);
|
||||||
|
// A long restore outlives connections (a Storage Box drops one now and then): without a
|
||||||
|
// new one, the rest would be read the slow way.
|
||||||
|
if ($this->connect === null || $this->reconnects >= self::MAX_RECONNECTS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->reconnects++;
|
||||||
|
try {
|
||||||
|
$new = ($this->connect)();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$new = null;
|
||||||
|
}
|
||||||
|
if (function_exists('OCP\Log\logger')) {
|
||||||
|
\OCP\Log\logger('ng_backup')->info($new === null
|
||||||
|
? 'Reading ahead: an SFTP connection broke and no new one could be made'
|
||||||
|
: 'Reading ahead: an SFTP connection broke; reading on with a new one');
|
||||||
|
}
|
||||||
|
if ($new !== null) {
|
||||||
|
$key = max(array_merge(array_keys($this->chunks), [-1])) + 1;
|
||||||
|
$this->connections[$key] = $new;
|
||||||
|
$this->chunks[$key] = self::CHUNK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return resource|null a new file in the spool, already unlinked: nothing is left behind if the process dies */
|
||||||
|
private function spoolFile() {
|
||||||
|
$path = $this->spool . '/' . bin2hex(random_bytes(8));
|
||||||
|
$fh = @fopen($path, 'x+b');
|
||||||
|
if ($fh === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
@unlink($path);
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function end(): void {
|
||||||
|
foreach ($this->jobs as $job) {
|
||||||
|
self::closeFile($job->fh);
|
||||||
|
}
|
||||||
|
foreach ($this->done as $fh) {
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->jobs = [];
|
||||||
|
$this->done = [];
|
||||||
|
foreach ($this->connections as $sftp) {
|
||||||
|
self::disconnect($sftp);
|
||||||
|
}
|
||||||
|
$this->connections = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function closeFile(mixed $fh): void {
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function disconnect(object $sftp): void {
|
||||||
|
try {
|
||||||
|
if (is_callable([$sftp, 'disconnect'])) {
|
||||||
|
$sftp->disconnect();
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One range that SftpRangeFetcher reads on one connection.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SftpRangeJob {
|
||||||
|
public string $handle = '';
|
||||||
|
/** Offset of the next READ to send. */
|
||||||
|
public int $next;
|
||||||
|
/** @var list<array{int, int}> offset and length of parts to ask for again (after a short answer) */
|
||||||
|
public array $redo = [];
|
||||||
|
/** @var list<array{int, int, int, int}> request id, kind, offset and length, in the order sent */
|
||||||
|
public array $queue = [];
|
||||||
|
/** Bytes received. */
|
||||||
|
public int $got = 0;
|
||||||
|
public bool $failed = false;
|
||||||
|
|
||||||
|
/** @var resource the spool file that holds the range */
|
||||||
|
public $fh;
|
||||||
|
|
||||||
|
/** @param resource $fh */
|
||||||
|
public function __construct(
|
||||||
|
public int $i,
|
||||||
|
$fh,
|
||||||
|
public int $start,
|
||||||
|
public int $end,
|
||||||
|
) {
|
||||||
|
$this->fh = $fh;
|
||||||
|
$this->next = $start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read stream for files_external's SFTP storage that keeps several READ requests in flight.
|
||||||
|
*
|
||||||
|
* files_external's own read stream asks for the next 256 KiB only after the previous one has
|
||||||
|
* arrived, so every request costs a full round trip: about 5 MB/s at 47 ms to a Hetzner Storage
|
||||||
|
* Box, whatever the line speed. With 8 requests in flight the same connection reached 15 MB/s.
|
||||||
|
* phpseclib's get() pipelines too, but only into memory or a local file, and only from offset 0.
|
||||||
|
*
|
||||||
|
* Talks to the storage's phpseclib connection through the same internal methods files_external's
|
||||||
|
* read stream uses (_send_sftp_packet, _get_sftp_packet, _realpath); open() returns null when the
|
||||||
|
* connection does not offer them (phpseclib 3 does not), and StorageBackend then falls back to the
|
||||||
|
* storage's own stream. A restore must rather be slow than fail: when a read fails halfway, the
|
||||||
|
* stream goes on from the same position with the storage's own stream. After MAX_FAILURES streams
|
||||||
|
* in a row failed, open() returns null for the rest of the process; one that works resets the count.
|
||||||
|
* Like that stream, it owns the connection while open: no other request may go over the same
|
||||||
|
* connection until it is closed (PackReaderGuard takes care of that for the repository).
|
||||||
|
*/
|
||||||
|
final class SftpReadStream {
|
||||||
|
private const PROTOCOL = 'ngbsftpread';
|
||||||
|
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
|
||||||
|
public const DEPTH = 8;
|
||||||
|
/** Files read at once by readMany(): each holds a handle on the server while its batch runs. */
|
||||||
|
public const BATCH = 32;
|
||||||
|
/** Larger files are left to the usual read by readMany(), which holds every file in memory. */
|
||||||
|
private const MANY_MAX_BYTES = 16 * 1048576;
|
||||||
|
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
|
||||||
|
private const CHUNK = 261120;
|
||||||
|
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
|
||||||
|
private const MIN_CHUNK = 32768;
|
||||||
|
|
||||||
|
private const FXP_OPEN = 3;
|
||||||
|
private const FXP_CLOSE = 4;
|
||||||
|
private const FXP_READ = 5;
|
||||||
|
private const FXP_STATUS = 101;
|
||||||
|
private const FXP_HANDLE = 102;
|
||||||
|
private const FXP_DATA = 103;
|
||||||
|
private const FXF_READ = 1;
|
||||||
|
private const FX_OK = 0;
|
||||||
|
private const FX_EOF = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ size per connection, lowered when a server answers less than asked and then goes on, so
|
||||||
|
* later streams over that connection ask for no more than it gives.
|
||||||
|
*
|
||||||
|
* @var \WeakMap<object, int>|null
|
||||||
|
*/
|
||||||
|
private static ?\WeakMap $chunks = null;
|
||||||
|
private static int $requestId = 0x4e470000;
|
||||||
|
/**
|
||||||
|
* Connections whose server did not confirm closing a batch's files: readMany() is not used on
|
||||||
|
* them again, so handles cannot pile up.
|
||||||
|
*
|
||||||
|
* @var \WeakMap<object, true>|null
|
||||||
|
*/
|
||||||
|
private static ?\WeakMap $noBatches = null;
|
||||||
|
/** Streams in a row that failed; at MAX_FAILURES only the storage's own stream is used. */
|
||||||
|
private static int $failures = 0;
|
||||||
|
public const MAX_FAILURES = 3;
|
||||||
|
/** Set by stream_open() when the connection failed rather than the file. */
|
||||||
|
private static bool $openBroke = false;
|
||||||
|
/** Why the last stream_open() failed on the connection. */
|
||||||
|
private static string $openCause = '';
|
||||||
|
|
||||||
|
/** @var resource|null set by PHP for stream wrappers */
|
||||||
|
public $context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phpseclib connection. PHP creates stream wrappers without constructor arguments; it is
|
||||||
|
* set in stream_open(), and no other method is called when that fails.
|
||||||
|
*
|
||||||
|
* @psalm-suppress PropertyNotSetInConstructor
|
||||||
|
*/
|
||||||
|
private object $sftp;
|
||||||
|
/** The storage and the path on it, to fall back to the storage's own stream. */
|
||||||
|
private ?object $storage = null;
|
||||||
|
private string $file = '';
|
||||||
|
/** @var resource|null the storage's own stream, once this one has failed */
|
||||||
|
private $fallback = null;
|
||||||
|
private string $handle = '';
|
||||||
|
/** Position of the reader: the first byte of $buffer. */
|
||||||
|
private int $pos = 0;
|
||||||
|
/** Offset of the next READ to send. */
|
||||||
|
private int $next = 0;
|
||||||
|
private string $buffer = '';
|
||||||
|
/** @var list<array{int, int, int}> request id, offset, length, in the order sent */
|
||||||
|
private array $inFlight = [];
|
||||||
|
private int $depth = 1;
|
||||||
|
private bool $eof = false;
|
||||||
|
/** This stream gave up on its own requests. */
|
||||||
|
private bool $failed = false;
|
||||||
|
/** The connection is in an unknown state: nothing more is sent over it or read from it. */
|
||||||
|
private bool $broken = false;
|
||||||
|
/** Length of the last short answer, until the next answer tells whether it was the end of the file. */
|
||||||
|
private ?int $shortAnswer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param object $storage files_external's SFTP storage (getConnection(), getRoot())
|
||||||
|
* @return resource|false|null the stream, false when the file cannot be opened, null when the
|
||||||
|
* connection does not offer what this stream needs
|
||||||
|
* @throws BackendException when the connection failed after the OPEN request went out: the
|
||||||
|
* storage's own stream must not be tried on it, as it would take a
|
||||||
|
* late answer to that request for its own
|
||||||
|
*/
|
||||||
|
public static function open(object $storage, string $path) {
|
||||||
|
if (self::$failures >= self::MAX_FAILURES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$sftp = self::connection($storage);
|
||||||
|
if ($sftp === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||||
|
stream_wrapper_register(self::PROTOCOL, self::class);
|
||||||
|
}
|
||||||
|
$root = rtrim((string)$storage->getRoot(), '/');
|
||||||
|
$context = stream_context_create([self::PROTOCOL => [
|
||||||
|
'session' => $sftp,
|
||||||
|
'path' => $root . '/' . ltrim($path, '/'),
|
||||||
|
'storage' => $storage,
|
||||||
|
'file' => $path,
|
||||||
|
]]);
|
||||||
|
self::$openBroke = false;
|
||||||
|
$fh = @fopen(self::PROTOCOL . '://file', 'r', false, $context);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// nothing was sent yet
|
||||||
|
self::$failures++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($fh === false && self::openBroke()) {
|
||||||
|
throw new BackendException('SFTP connection failed while opening ' . $path . ': ' . self::$openCause);
|
||||||
|
}
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage's phpseclib connection, with its SFTP channel open, when it offers what these
|
||||||
|
* streams (and SftpRangeFetcher) need; null otherwise.
|
||||||
|
*/
|
||||||
|
public static function connection(object $storage): ?object {
|
||||||
|
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$sftp = $storage->getConnection();
|
||||||
|
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||||
|
// methods of these names neither public nor at all.
|
||||||
|
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||||
|
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|
||||||
|
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
|
||||||
|
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
|
||||||
|
if ($sftp->pwd() === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $sftp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read many small files whole, BATCH at a time: the OPEN, READ and CLOSE requests of a batch
|
||||||
|
* all go out before their answers are read, so a batch costs a few round trips instead of
|
||||||
|
* four per file. Meant for files of up to a few hundred KiB (the repository's index files):
|
||||||
|
* every file is held in memory.
|
||||||
|
*
|
||||||
|
* @param list<string> $paths below the storage's root
|
||||||
|
* @return array<string, string|null>|null contents by path; null for a file this could not
|
||||||
|
* read (missing, or a server error), so the caller
|
||||||
|
* reads it the usual way; null for all when the
|
||||||
|
* connection does not offer what this needs
|
||||||
|
* @throws BackendException when the connection failed while requests were out: the storage's
|
||||||
|
* own stream must not be tried on it
|
||||||
|
*/
|
||||||
|
public static function readMany(object $storage, array $paths): ?array {
|
||||||
|
if (self::$failures >= self::MAX_FAILURES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$sftp = self::connection($storage);
|
||||||
|
$root = $sftp === null ? '' : rtrim((string)$storage->getRoot(), '/');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
self::$failures++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($sftp === null || self::$noBatches?->offsetExists($sftp) === true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$result = array_fill_keys($paths, null);
|
||||||
|
foreach (array_chunk($paths, self::BATCH) as $batch) {
|
||||||
|
try {
|
||||||
|
[$read, $closed] = self::readBatch($sftp, $root, $batch);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$failures++;
|
||||||
|
// Answers to requests already sent may still arrive, and files_external would take
|
||||||
|
// them for its own: end the connection rather than leave it out of step.
|
||||||
|
self::disconnect($sftp);
|
||||||
|
throw $e instanceof BackendException ? $e : new BackendException('SFTP connection failed while reading ' . count($batch) . ' files: ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
foreach ($read as $path => $contents) {
|
||||||
|
$result[$path] = $contents;
|
||||||
|
}
|
||||||
|
if (!$closed) {
|
||||||
|
// The server did not confirm closing these files: no more batches on this
|
||||||
|
// connection, so handles cannot pile up. The rest is read the usual way.
|
||||||
|
self::noMoreBatches($sftp);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::$failures = 0;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function noMoreBatches(object $sftp): void {
|
||||||
|
self::$noBatches ??= new \WeakMap();
|
||||||
|
self::$noBatches[$sftp] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function disconnect(object $sftp): void {
|
||||||
|
try {
|
||||||
|
if (is_callable([$sftp, 'disconnect'])) {
|
||||||
|
$sftp->disconnect();
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests of one kind for a whole batch, then their answers in order. @return list<array{int, string}> */
|
||||||
|
private static function exchange(object $sftp, int $type, array $packets): array {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($packets as $packet) {
|
||||||
|
$id = self::nextId();
|
||||||
|
if (!$sftp->_send_sftp_packet($type, $packet, $id)) {
|
||||||
|
throw new BackendException('SFTP connection failed while sending a request');
|
||||||
|
}
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
$answers = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$response = $sftp->_get_sftp_packet($id);
|
||||||
|
if (!is_string($response)) {
|
||||||
|
throw new BackendException('SFTP connection failed while waiting for an answer');
|
||||||
|
}
|
||||||
|
$answers[] = [$sftp->packet_type, $response];
|
||||||
|
}
|
||||||
|
return $answers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $batch
|
||||||
|
* @return array{0: array<string, string>, 1: bool} the files read whole, and whether the
|
||||||
|
* server confirmed closing every handle
|
||||||
|
*/
|
||||||
|
private static function readBatch(object $sftp, string $root, array $batch): array {
|
||||||
|
$opens = [];
|
||||||
|
$openPaths = [];
|
||||||
|
foreach ($batch as $path) {
|
||||||
|
$remote = $sftp->_realpath($root . '/' . ltrim($path, '/'));
|
||||||
|
if (is_string($remote)) {
|
||||||
|
$opens[] = pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0);
|
||||||
|
$openPaths[] = $path;
|
||||||
|
}
|
||||||
|
// a path that cannot be resolved is left to the caller's usual read
|
||||||
|
}
|
||||||
|
$handles = []; // path => handle
|
||||||
|
foreach (self::exchange($sftp, self::FXP_OPEN, $opens) as $i => [$type, $response]) {
|
||||||
|
if ($type === self::FXP_HANDLE) {
|
||||||
|
$handles[$openPaths[$i]] = substr($response, 4);
|
||||||
|
}
|
||||||
|
// any other answer (missing, no permission): the caller reads it the usual way
|
||||||
|
}
|
||||||
|
$data = array_fill_keys(array_keys($handles), '');
|
||||||
|
$reading = $handles;
|
||||||
|
while ($reading !== []) {
|
||||||
|
$length = self::chunk($sftp);
|
||||||
|
$reads = [];
|
||||||
|
foreach ($reading as $path => $handle) {
|
||||||
|
$offset = strlen($data[$path]);
|
||||||
|
$reads[] = pack('Na*N3', strlen($handle), $handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
|
||||||
|
}
|
||||||
|
$paths = array_keys($reading);
|
||||||
|
foreach (self::exchange($sftp, self::FXP_READ, $reads) as $i => [$type, $response]) {
|
||||||
|
$path = $paths[$i];
|
||||||
|
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||||
|
if ($type === self::FXP_DATA && is_array($declared) && $declared[1] > 0 && $declared[1] === strlen($response) - 4
|
||||||
|
&& $declared[1] <= $length && strlen($data[$path]) + $declared[1] <= self::MANY_MAX_BYTES) {
|
||||||
|
$data[$path] .= substr($response, 4); // more may follow: read on from there
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unset($reading[$path]);
|
||||||
|
$status = $type === self::FXP_STATUS && is_array($declared) ? $declared[1] : null;
|
||||||
|
if ($status !== self::FX_EOF) {
|
||||||
|
// a server error, a malformed answer or a file too large for this: the caller
|
||||||
|
// reads it the usual way
|
||||||
|
unset($data[$path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$closes = [];
|
||||||
|
foreach ($handles as $handle) {
|
||||||
|
$closes[] = pack('Na*', strlen($handle), $handle);
|
||||||
|
}
|
||||||
|
$closed = true;
|
||||||
|
foreach (self::exchange($sftp, self::FXP_CLOSE, $closes) as [$type, $response]) {
|
||||||
|
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||||
|
if (!is_array($status) || $status[1] !== self::FX_OK) {
|
||||||
|
$closed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [$data, $closed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: forget the learned READ sizes and earlier failures. */
|
||||||
|
public static function reset(): void {
|
||||||
|
self::$chunks = null;
|
||||||
|
self::$noBatches = null;
|
||||||
|
self::$failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_open(string $url, string $mode, int $options, ?string &$openedPath): bool {
|
||||||
|
$options = stream_context_get_options($this->context ?? stream_context_get_default())[self::PROTOCOL] ?? [];
|
||||||
|
if (!isset($options['session'], $options['path']) || !is_object($options['session'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->sftp = $options['session'];
|
||||||
|
$storage = $options['storage'] ?? null;
|
||||||
|
$this->storage = is_object($storage) ? $storage : null;
|
||||||
|
$this->file = (string)($options['file'] ?? '');
|
||||||
|
try {
|
||||||
|
$remote = $this->sftp->_realpath((string)$options['path']);
|
||||||
|
if (!is_string($remote)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$id = self::nextId();
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) {
|
||||||
|
return $this->connectionFailed('the request could not be sent');
|
||||||
|
}
|
||||||
|
$response = $this->sftp->_get_sftp_packet($id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
if (!is_string($response)) {
|
||||||
|
return $this->connectionFailed('no answer');
|
||||||
|
}
|
||||||
|
if ($this->sftp->packet_type !== self::FXP_HANDLE) {
|
||||||
|
return false; // an answer, but no handle: the file cannot be opened
|
||||||
|
}
|
||||||
|
$this->handle = substr($response, 4);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not a missing file but a connection that does not behave as expected. */
|
||||||
|
private function connectionFailed(string $cause): bool {
|
||||||
|
self::$failures++;
|
||||||
|
self::$openBroke = true;
|
||||||
|
self::$openCause = $cause . self::connectionState($this->sftp);
|
||||||
|
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
|
||||||
|
self::disconnect($this->sftp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What phpseclib knows about the connection, for an error message. */
|
||||||
|
private static function connectionState(object $sftp): string {
|
||||||
|
$state = [];
|
||||||
|
try {
|
||||||
|
if (is_callable([$sftp, 'isConnected'])) {
|
||||||
|
$state[] = 'connected: ' . ($sftp->isConnected() ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
if (is_callable([$sftp, 'getLastError'])) {
|
||||||
|
$last = (string)$sftp->getLastError();
|
||||||
|
if ($last !== '') {
|
||||||
|
$state[] = 'ssh: ' . $last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_callable([$sftp, 'getLastSFTPError'])) {
|
||||||
|
$last = (string)$sftp->getLastSFTPError();
|
||||||
|
if ($last !== '') {
|
||||||
|
$state[] = 'sftp: ' . $last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
return $state === [] ? '' : ' (' . implode('; ', $state) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
|
||||||
|
private static function openBroke(): bool {
|
||||||
|
return self::$openBroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_read(int $count): string|false {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return fread($this->fallback, $count);
|
||||||
|
}
|
||||||
|
while (strlen($this->buffer) < $count && !$this->eof) {
|
||||||
|
if (!$this->receive()) {
|
||||||
|
return $this->useFallback() ? $this->stream_read($count) : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data = substr($this->buffer, 0, $count);
|
||||||
|
$this->buffer = substr($this->buffer, strlen($data));
|
||||||
|
$this->pos += strlen($data);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_eof(): bool {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return feof($this->fallback);
|
||||||
|
}
|
||||||
|
return $this->eof && $this->buffer === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_tell(): int {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
$pos = ftell($this->fallback);
|
||||||
|
return $pos === false ? $this->pos : $pos;
|
||||||
|
}
|
||||||
|
return $this->pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return fseek($this->fallback, $offset, $whence) === 0;
|
||||||
|
}
|
||||||
|
if ($whence === SEEK_CUR) {
|
||||||
|
$offset += $this->pos;
|
||||||
|
} elseif ($whence !== SEEK_SET) {
|
||||||
|
return false; // the size is never asked for, to save a round trip
|
||||||
|
}
|
||||||
|
if ($offset < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->failed) {
|
||||||
|
$this->pos = $offset;
|
||||||
|
return $this->useFallback();
|
||||||
|
}
|
||||||
|
if ($offset >= $this->pos && $offset - $this->pos <= strlen($this->buffer)) {
|
||||||
|
$this->buffer = substr($this->buffer, $offset - $this->pos);
|
||||||
|
} else {
|
||||||
|
$this->drain();
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->next = $offset;
|
||||||
|
$this->eof = false;
|
||||||
|
$this->depth = 1;
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
}
|
||||||
|
$this->pos = $offset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int|string, int> */
|
||||||
|
public function stream_stat(): array {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_close(): void {
|
||||||
|
if (!$this->failed) {
|
||||||
|
self::$failures = 0;
|
||||||
|
}
|
||||||
|
$fallback = $this->fallback;
|
||||||
|
$this->fallback = null;
|
||||||
|
if ($fallback !== null) {
|
||||||
|
fclose($fallback);
|
||||||
|
}
|
||||||
|
$this->closeHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go on with the storage's own stream from the current position, after a server error on this
|
||||||
|
* one. The data still buffered is dropped and read again. False after a connection error, or
|
||||||
|
* when that stream cannot be opened or positioned: then the read fails as it would have
|
||||||
|
* without this class. Reopening by path is safe for a repository: packs and index files are
|
||||||
|
* never rewritten, and every object is authenticated when it is decrypted.
|
||||||
|
*/
|
||||||
|
private function useFallback(): bool {
|
||||||
|
$this->closeHandle();
|
||||||
|
$this->buffer = '';
|
||||||
|
// files_external's stream takes whatever answer comes next on the connection: after a
|
||||||
|
// connection error, answers to this stream's requests may still be on their way.
|
||||||
|
if ($this->broken || $this->storage === null || !is_callable([$this->storage, 'fopen'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$fh = $this->storage->fopen($this->file, 'r');
|
||||||
|
if (!is_resource($fh)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->pos > 0 && fseek($fh, $this->pos) !== 0) {
|
||||||
|
fclose($fh);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->fallback = $fh;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function closeHandle(): void {
|
||||||
|
$this->drain();
|
||||||
|
if ($this->handle !== '' && !$this->broken) {
|
||||||
|
try {
|
||||||
|
$id = self::nextId();
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_CLOSE, pack('Na*', strlen($this->handle), $this->handle), $id)
|
||||||
|
|| !is_string($this->sftp->_get_sftp_packet($id))) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
if ($this->broken) {
|
||||||
|
self::disconnect($this->sftp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->handle = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the pipeline full, then take the oldest answer. False on a connection or server error. */
|
||||||
|
private function receive(): bool {
|
||||||
|
if ($this->failed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return $this->receiveOne();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function receiveOne(): bool {
|
||||||
|
while (count($this->inFlight) < $this->depth) {
|
||||||
|
$id = self::nextId();
|
||||||
|
$length = self::chunk($this->sftp);
|
||||||
|
$packet = pack('Na*N3', strlen($this->handle), $this->handle, intdiv($this->next, 4294967296), $this->next % 4294967296, $length);
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_READ, $packet, $id)) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
$this->inFlight[] = [$id, $this->next, $length];
|
||||||
|
$this->next += $length;
|
||||||
|
}
|
||||||
|
[$id, $offset, $length] = array_shift($this->inFlight);
|
||||||
|
$response = $this->sftp->_get_sftp_packet($id);
|
||||||
|
if (!is_string($response)) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
if ($this->sftp->packet_type === self::FXP_DATA) {
|
||||||
|
$data = substr($response, 4);
|
||||||
|
if ($data === '') {
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
$this->buffer .= $data;
|
||||||
|
$this->depth = min(self::DEPTH, $this->depth * 2);
|
||||||
|
if ($this->shortAnswer !== null) {
|
||||||
|
// More data after a short answer: that one was the server's cap, not the end.
|
||||||
|
// Never below what every server must support: a one-off small answer is no cap.
|
||||||
|
self::$chunks ??= new \WeakMap();
|
||||||
|
self::$chunks[$this->sftp] = max(self::MIN_CHUNK, min(self::chunk($this->sftp), $this->shortAnswer));
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
}
|
||||||
|
if (strlen($data) < $length) {
|
||||||
|
// Fewer bytes than asked: the end of the file, or a server that caps a READ. The
|
||||||
|
// requests behind this one assumed a full answer, so drop them and ask again from here.
|
||||||
|
$this->drain();
|
||||||
|
$this->next = $offset + strlen($data);
|
||||||
|
$this->shortAnswer = strlen($data);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$status = $this->sftp->packet_type === self::FXP_STATUS ? unpack('N', $response) : false;
|
||||||
|
if (is_array($status) && ($status[1] ?? null) === self::FX_EOF) {
|
||||||
|
$this->eof = true;
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
$this->drain();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(): bool {
|
||||||
|
$this->drain();
|
||||||
|
if (!$this->failed) {
|
||||||
|
self::$failures++;
|
||||||
|
}
|
||||||
|
$this->failed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and drop the answers still on their way, so the connection is free again. */
|
||||||
|
private function drain(): void {
|
||||||
|
$inFlight = $this->inFlight;
|
||||||
|
$this->inFlight = [];
|
||||||
|
try {
|
||||||
|
foreach ($this->broken ? [] : $inFlight as [$id]) {
|
||||||
|
if (!is_string($this->sftp->_get_sftp_packet($id))) {
|
||||||
|
$this->broken = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
if ($this->broken) {
|
||||||
|
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
|
||||||
|
self::disconnect($this->sftp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function chunk(object $sftp): int {
|
||||||
|
$learned = self::$chunks?->offsetExists($sftp) === true ? self::$chunks->offsetGet($sftp) : null;
|
||||||
|
return is_int($learned) ? $learned : self::CHUNK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nextId(): int {
|
||||||
|
self::$requestId = self::$requestId >= 0x7fffffff ? 0x4e470000 : self::$requestId + 1;
|
||||||
|
return self::$requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,18 +14,43 @@ use OCP\Files\Storage\IWriteStreamStorage;
|
|||||||
* IBackend on top of a Nextcloud storage object (from files_external or core), below a base
|
* IBackend on top of a Nextcloud storage object (from files_external or core), below a base
|
||||||
* directory on that storage.
|
* directory on that storage.
|
||||||
*/
|
*/
|
||||||
final class StorageBackend implements IBackend {
|
final class StorageBackend implements IBackend, IReadsAhead {
|
||||||
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
|
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
|
||||||
private bool $atomicPut;
|
private bool $atomicPut;
|
||||||
|
/** SFTP: read through SftpReadStream, which keeps several requests in flight. */
|
||||||
|
private bool $sftpReads;
|
||||||
|
/** S3: many small reads go straight to the storage's S3 client, several at a time. */
|
||||||
|
private bool $s3Reads;
|
||||||
|
/** S3 requests in flight at once for getMany() and getRanges(). */
|
||||||
|
public const S3_CONCURRENCY = 16;
|
||||||
|
/** Most ranges, and bytes, getRanges() holds in memory at once. */
|
||||||
|
private const RANGES_LOT = 64;
|
||||||
|
private const RANGES_LOT_BYTES = 64 * 1048576;
|
||||||
|
/** Seconds without a request after which an SFTP connection is checked before it is used. */
|
||||||
|
public const IDLE_CHECK = 60;
|
||||||
|
private float $lastUsed;
|
||||||
|
/** Connections readAhead() opens (see SftpRangeFetcher::DEFAULT_CONNECTIONS). */
|
||||||
|
public int $readAheadConnections = SftpRangeFetcher::DEFAULT_CONNECTIONS;
|
||||||
|
/** Times the storage was built anew after its connection died (for tests and diagnostics). */
|
||||||
|
public int $reconnects = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (\Closure():IStorage)|null $reconnect builds the same storage anew, with a new
|
||||||
|
* connection
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IStorage $storage,
|
private IStorage $storage,
|
||||||
private string $base = '',
|
private string $base = '',
|
||||||
|
bool $createBase = true,
|
||||||
|
private ?\Closure $reconnect = null,
|
||||||
) {
|
) {
|
||||||
|
$this->lastUsed = microtime(true);
|
||||||
$this->base = trim($base, '/');
|
$this->base = trim($base, '/');
|
||||||
self::validate($this->base, true);
|
self::validate($this->base, true);
|
||||||
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
||||||
if ($this->base !== '') {
|
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
|
||||||
|
$this->s3Reads = $storage instanceof \OCA\Files_External\Lib\Storage\AmazonS3;
|
||||||
|
if ($createBase && $this->base !== '') {
|
||||||
$this->mkdirs($this->base);
|
$this->mkdirs($this->base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +61,8 @@ final class StorageBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function put(string $path, $stream): void {
|
public function put(string $path, $stream): void {
|
||||||
|
// not repeated on a new connection: the stream is (partly) read
|
||||||
|
$this->idleCheck();
|
||||||
$expected = self::remaining($stream);
|
$expected = self::remaining($stream);
|
||||||
$target = $this->abs($path);
|
$target = $this->abs($path);
|
||||||
$this->mkdirs(dirname($target));
|
$this->mkdirs(dirname($target));
|
||||||
@@ -79,6 +106,19 @@ final class StorageBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $path) {
|
public function get(string $path) {
|
||||||
|
return $this->call(fn () => $this->open($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return resource */
|
||||||
|
private function open(string $path) {
|
||||||
|
if ($this->sftpReads) {
|
||||||
|
$fh = SftpReadStream::open($this->storage, $this->abs($path));
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
// null: not supported or failing for now; false: not opened, which need not mean
|
||||||
|
// missing. Either way the storage's own stream decides.
|
||||||
|
}
|
||||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
throw new BackendException('Not found: ' . $path);
|
throw new BackendException('Not found: ' . $path);
|
||||||
@@ -86,7 +126,186 @@ final class StorageBackend implements IBackend {
|
|||||||
return $fh;
|
return $fh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
|
||||||
|
* after the other.
|
||||||
|
*/
|
||||||
|
public function getMany(array $paths): \Generator {
|
||||||
|
$size = $this->s3Reads ? 16 * self::S3_CONCURRENCY : 8 * SftpReadStream::BATCH;
|
||||||
|
foreach (array_chunk($paths, $size) as $chunk) {
|
||||||
|
$fast = null;
|
||||||
|
if ($this->s3Reads) {
|
||||||
|
$this->idleCheck();
|
||||||
|
$fast = $this->s3Fetch(array_map(fn (string $p): array => [$p, null], $chunk));
|
||||||
|
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (int $i): ?string => $fast[$i] ?? null, array_keys($chunk)));
|
||||||
|
} elseif ($this->sftpReads) {
|
||||||
|
$abs = array_map(fn (string $p): string => $this->abs($p), $chunk);
|
||||||
|
$fast = $this->call(fn () => SftpReadStream::readMany($this->storage, $abs));
|
||||||
|
$fast = $fast === null ? null : array_combine($chunk, array_map(fn (string $a): ?string => $fast[$a] ?? null, $abs));
|
||||||
|
}
|
||||||
|
foreach ($chunk as $path) {
|
||||||
|
$data = $fast[$path] ?? null;
|
||||||
|
if ($data === null) {
|
||||||
|
// not read in the batch (or no batches here): the usual way, which also
|
||||||
|
// reports a missing file as such
|
||||||
|
$fh = $this->get($path);
|
||||||
|
try {
|
||||||
|
$data = stream_get_contents($fh);
|
||||||
|
} finally {
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
if ($data === false) {
|
||||||
|
throw new BackendException('Read failed for ' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield $path => $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Over S3 several at a time (s3Fetch()); elsewhere one after the other. */
|
||||||
|
public function getRanges(array $ranges): \Generator {
|
||||||
|
$lot = [];
|
||||||
|
$bytes = 0;
|
||||||
|
foreach ($ranges as $i => $range) {
|
||||||
|
if ($lot !== [] && (count($lot) >= self::RANGES_LOT || $bytes + $range[2] > self::RANGES_LOT_BYTES)) {
|
||||||
|
yield from $this->rangesLot($lot);
|
||||||
|
$lot = [];
|
||||||
|
$bytes = 0;
|
||||||
|
}
|
||||||
|
$lot[$i] = $range;
|
||||||
|
$bytes += $range[2];
|
||||||
|
}
|
||||||
|
if ($lot !== []) {
|
||||||
|
yield from $this->rangesLot($lot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{0:string, 1:int, 2:int}> $lot
|
||||||
|
* @return \Generator<int, string>
|
||||||
|
*/
|
||||||
|
private function rangesLot(array $lot): \Generator {
|
||||||
|
$fast = null;
|
||||||
|
if ($this->s3Reads) {
|
||||||
|
$this->idleCheck();
|
||||||
|
$fast = $this->s3Fetch(array_map(fn (array $r): array => [$r[0], $r[1] . '-' . ($r[1] + $r[2] - 1)], $lot));
|
||||||
|
}
|
||||||
|
foreach ($lot as $i => [$path, $offset, $length]) {
|
||||||
|
$data = $fast[$i] ?? null;
|
||||||
|
// not read in the batch, or not all of it: the usual way, which also reports why
|
||||||
|
yield $i => ($data !== null && strlen($data) === $length) ? $data : $this->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read objects (or byte ranges of them, "first-last") straight through the S3 client of
|
||||||
|
* files_external's S3 storage, S3_CONCURRENCY at a time. Its own reads open a new HTTPS
|
||||||
|
* connection per object, after a HEAD request: one after the other that is about two reads a
|
||||||
|
* second, which made checking the index of a large repository take hours. What fails here
|
||||||
|
* is left out of the result, for the caller to read the usual way (which reports the error).
|
||||||
|
*
|
||||||
|
* @param array<int, array{0:string, 1:?string}> $requests [path, range or null] by key
|
||||||
|
* @return array<int, string>|null contents by key; null when the storage is not as expected
|
||||||
|
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
|
||||||
|
*/
|
||||||
|
private function s3Fetch(array $requests): ?array {
|
||||||
|
try {
|
||||||
|
/** @psalm-suppress UndefinedMethod, UndefinedThisPropertyFetch */
|
||||||
|
[$client, $bucket, $sse] = (function (): array {
|
||||||
|
$sse = method_exists($this, 'getServerSideEncryptionParameters') ? $this->getServerSideEncryptionParameters()
|
||||||
|
: (method_exists($this, 'getSSECParameters') ? $this->getSSECParameters() : []);
|
||||||
|
return [$this->getConnection(), $this->getBucket(), $sse];
|
||||||
|
})->call($this->storage);
|
||||||
|
if (!$client instanceof \Aws\S3\S3Client || !is_string($bucket)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$results = [];
|
||||||
|
$keys = array_keys($requests); // the pool numbers results 0, 1, ...: mapped back below
|
||||||
|
$commands = (function () use ($requests, $client, $bucket, $sse): \Generator {
|
||||||
|
foreach (array_values($requests) as $n => [$path, $range]) {
|
||||||
|
$args = ['Bucket' => $bucket, 'Key' => trim($this->abs($path), '/')] + $sse;
|
||||||
|
if ($range !== null) {
|
||||||
|
$args['Range'] = 'bytes=' . $range;
|
||||||
|
}
|
||||||
|
yield $n => $client->getCommand('GetObject', $args);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
(new \Aws\CommandPool($client, $commands, [
|
||||||
|
'concurrency' => self::S3_CONCURRENCY,
|
||||||
|
'fulfilled' => function (mixed $result, int $n) use (&$results, $keys): void {
|
||||||
|
$results[$keys[$n]] = (string)$result['Body'];
|
||||||
|
},
|
||||||
|
'rejected' => static function (): void {
|
||||||
|
},
|
||||||
|
]))->promise()->wait();
|
||||||
|
return $results;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over SFTP: SftpRangeFetcher, on readAheadConnections new connections of its own (built with the
|
||||||
|
* reconnect function), so the storage's connection stays free for other requests. Elsewhere
|
||||||
|
* not (yet): null.
|
||||||
|
*/
|
||||||
|
public function canReadAhead(): bool {
|
||||||
|
return $this->sftpReads && $this->reconnect !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readAhead(array $ranges, string $spool): ?\Generator {
|
||||||
|
$reconnect = $this->reconnect;
|
||||||
|
if (!$this->sftpReads || $reconnect === null || $ranges === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// before any connection is opened: an invalid path throws
|
||||||
|
$abs = array_map(fn (array $r): array => [$this->abs($r[0]), $r[1], $r[2]], $ranges);
|
||||||
|
$storages = [];
|
||||||
|
$connections = [];
|
||||||
|
$root = '';
|
||||||
|
$wanted = max(1, min(SftpRangeFetcher::MAX_CONNECTIONS, $this->readAheadConnections));
|
||||||
|
for ($n = 0; $n < $wanted; $n++) {
|
||||||
|
try {
|
||||||
|
$storage = $reconnect();
|
||||||
|
$sftp = SftpReadStream::connection($storage);
|
||||||
|
if ($sftp === null || !is_callable([$storage, 'getRoot'])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$root = (string)$storage->getRoot();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
break; // the server may allow fewer connections: go on with those that work
|
||||||
|
}
|
||||||
|
$storages[] = $storage;
|
||||||
|
$connections[] = $sftp;
|
||||||
|
}
|
||||||
|
if ($connections === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// in place of a connection that broke: a new storage, kept like the others
|
||||||
|
$connect = static function () use ($reconnect, &$storages): ?object {
|
||||||
|
$storage = $reconnect();
|
||||||
|
$sftp = SftpReadStream::connection($storage);
|
||||||
|
if ($sftp !== null) {
|
||||||
|
$storages[] = $storage;
|
||||||
|
}
|
||||||
|
return $sftp;
|
||||||
|
};
|
||||||
|
return (static function () use (&$storages, $connections, $root, $abs, $spool, $connect): \Generator {
|
||||||
|
// the storages hold their connections: kept until the fetcher is done
|
||||||
|
try {
|
||||||
|
yield from SftpRangeFetcher::fetch($connections, $root, $abs, $spool, $connect);
|
||||||
|
} finally {
|
||||||
|
unset($storages);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
public function getRange(string $path, int $offset, int $length): string {
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
return $this->call(fn () => $this->readRange($path, $offset, $length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readRange(string $path, int $offset, int $length): string {
|
||||||
$fh = $this->get($path);
|
$fh = $this->get($path);
|
||||||
try {
|
try {
|
||||||
if (fseek($fh, $offset) !== 0) {
|
if (fseek($fh, $offset) !== 0) {
|
||||||
@@ -109,23 +328,40 @@ final class StorageBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the storage accepts the connection settings (files_external's own check). */
|
||||||
|
public function canConnect(): bool {
|
||||||
|
try {
|
||||||
|
return !method_exists($this->storage, 'test') || $this->storage->test();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function exists(string $path): bool {
|
public function exists(string $path): bool {
|
||||||
return $this->storage->file_exists($this->abs($path));
|
$abs = $this->abs($path);
|
||||||
|
return $this->call(fn () => $this->storage->file_exists($abs), fn (bool $found) => !$found);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function list(string $prefix): array {
|
public function list(string $prefix): array {
|
||||||
$result = [];
|
$abs = $this->abs($prefix);
|
||||||
$this->listInto($this->abs($prefix), $result);
|
$result = $this->call(function () use ($abs): array {
|
||||||
|
$result = [];
|
||||||
|
$this->listInto($abs, $result);
|
||||||
|
return $result;
|
||||||
|
}, fn (array $found) => $found === []);
|
||||||
sort($result);
|
sort($result);
|
||||||
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||||
return array_map(fn ($p) => substr($p, $cut), $result);
|
return array_map(fn ($p) => substr($p, $cut), $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $path): void {
|
public function delete(string $path): void {
|
||||||
|
$this->idleCheck();
|
||||||
$this->storage->unlink($this->abs($path));
|
$this->storage->unlink($this->abs($path));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function move(string $from, string $to): void {
|
public function move(string $from, string $to): void {
|
||||||
|
// not repeated on a new connection: the server may have done it before the connection died
|
||||||
|
$this->idleCheck();
|
||||||
$target = $this->abs($to);
|
$target = $this->abs($to);
|
||||||
$this->mkdirs(dirname($target));
|
$this->mkdirs(dirname($target));
|
||||||
if (!$this->storage->rename($this->abs($from), $target)) {
|
if (!$this->storage->rename($this->abs($from), $target)) {
|
||||||
@@ -133,6 +369,174 @@ final class StorageBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
$abs = $this->abs($dir);
|
||||||
|
return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function filesIn(string $dir, string $abs): array {
|
||||||
|
if (!$this->storage->is_dir($abs)) {
|
||||||
|
return []; // not created yet
|
||||||
|
}
|
||||||
|
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||||
|
$names = $this->smbNames($abs);
|
||||||
|
if ($names !== null) {
|
||||||
|
$result = [];
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (!self::isTemporary($name)) {
|
||||||
|
$result[] = substr($abs . '/' . $name, $cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
$dh = $this->storage->opendir($abs);
|
||||||
|
if ($dh === false) {
|
||||||
|
// An existing folder read as empty would look like a new repository.
|
||||||
|
throw new BackendException('Cannot list ' . $dir);
|
||||||
|
}
|
||||||
|
$result = [];
|
||||||
|
while (($name = readdir($dh)) !== false) {
|
||||||
|
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
|
||||||
|
$result[] = substr($abs . '/' . $name, $cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closedir($dh);
|
||||||
|
sort($result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The names in an SMB folder, read in one listing; null for other storages, with access
|
||||||
|
* control lists on, or when the storage's internals are not as expected. The SMB storage's
|
||||||
|
* opendir() asks the server for the attributes of each entry to leave out hidden ones: with
|
||||||
|
* thousands of index files that took minutes. Hidden entries are still left out: names
|
||||||
|
* starting with a dot, and, after asking, any name a repository does not use (letters,
|
||||||
|
* digits, - and _), such as desktop.ini or Thumbs.db. The repository's own files have such
|
||||||
|
* names: they are not checked one by one, which was the cost.
|
||||||
|
*
|
||||||
|
* @return list<string>|null
|
||||||
|
*/
|
||||||
|
private function smbNames(string $abs): ?array {
|
||||||
|
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
/** @var list<string>|null */
|
||||||
|
return (function (string $path): ?array {
|
||||||
|
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||||
|
if ($this->checkAcl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
/** @psalm-suppress UndefinedThisPropertyFetch, UndefinedMethod */
|
||||||
|
foreach ($this->share->dir(ltrim($this->buildPath($path), '/')) as $info) {
|
||||||
|
$name = (string)$info->getName();
|
||||||
|
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||||
|
if (!$this->showHidden && (str_starts_with($name, '.')
|
||||||
|
|| (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1 && $info->isHidden()))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
return $names;
|
||||||
|
})->call($this->storage, $abs);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null; // another SMB storage version, or an error: the usual way reports it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run $op; when it throws, or $failed says its result may come from a dead connection, and
|
||||||
|
* the SFTP connection turns out to be dead, run it once more on a new connection.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param \Closure():T $op
|
||||||
|
* @param (\Closure(T):bool)|null $failed
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
private function call(\Closure $op, ?\Closure $failed = null): mixed {
|
||||||
|
$this->idleCheck();
|
||||||
|
try {
|
||||||
|
$result = $op();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if (!$this->reopenIfDead()) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return $op();
|
||||||
|
}
|
||||||
|
if ($failed !== null && $failed($result) && $this->reopenIfDead()) {
|
||||||
|
return $op();
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
|
||||||
|
* quiet for a few minutes, while phpseclib still takes it for open; files_external never
|
||||||
|
* builds a new one, and every request on it fails as if the file were missing. So after
|
||||||
|
* such a pause the connection is checked (one round trip) before it is used again.
|
||||||
|
*/
|
||||||
|
private function idleCheck(): void {
|
||||||
|
$now = microtime(true);
|
||||||
|
if ($this->reconnect !== null && $this->sftpReads && $now - $this->lastUsed >= self::IDLE_CHECK
|
||||||
|
&& $this->alive(true) === false) {
|
||||||
|
$this->reopen();
|
||||||
|
}
|
||||||
|
$this->lastUsed = microtime(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a failed request: ask the server (one round trip), as phpseclib may not know it is gone. */
|
||||||
|
private function reopenIfDead(): bool {
|
||||||
|
if ($this->reconnect === null || !$this->sftpReads || $this->alive(true) !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->reopen();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the storage's SFTP connection is alive; null when it cannot tell.
|
||||||
|
* $ask: send a request instead of trusting what phpseclib saw last.
|
||||||
|
*/
|
||||||
|
private function alive(bool $ask): ?bool {
|
||||||
|
try {
|
||||||
|
if (!is_callable([$this->storage, 'getConnection'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$sftp = $this->storage->getConnection();
|
||||||
|
if (!is_object($sftp) || !is_callable([$sftp, 'isConnected'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!$sftp->isConnected()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!$ask || !is_callable([$sftp, 'stat'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (is_callable([$sftp, 'clearStatCache'])) {
|
||||||
|
$sftp->clearStatCache(); // or stat() answers from memory
|
||||||
|
}
|
||||||
|
// A Storage Box drops a quiet connection without phpseclib noticing: it still says
|
||||||
|
// connected, and every request fails. So the answer counts, not what it says after.
|
||||||
|
return $sftp->stat('.') !== false && (bool)$sftp->isConnected();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reopen(): void {
|
||||||
|
/** @var \Closure():IStorage $reconnect */
|
||||||
|
$reconnect = $this->reconnect;
|
||||||
|
$this->storage = $reconnect();
|
||||||
|
$this->reconnects++;
|
||||||
|
$this->lastUsed = microtime(true);
|
||||||
|
if (function_exists('OCP\Log\logger')) {
|
||||||
|
\OCP\Log\logger('ng_backup')->info('The SFTP connection to the backup location was lost; reconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function listInto(string $dir, array &$result): void {
|
private function listInto(string $dir, array &$result): void {
|
||||||
if (!$this->storage->is_dir($dir)) {
|
if (!$this->storage->is_dir($dir)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ final class BackupJob extends TimedJob {
|
|||||||
private RunMapper $runs,
|
private RunMapper $runs,
|
||||||
private IAppConfig $appConfig,
|
private IAppConfig $appConfig,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
|
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||||
) {
|
) {
|
||||||
parent::__construct($time);
|
parent::__construct($time);
|
||||||
$this->setInterval(300);
|
// Well below the usual 5-minute cron interval: the last run is stamped a moment after cron
|
||||||
|
// starts, so with 300 the next cron run came a few seconds too early and the job ran only
|
||||||
|
// every other time, idle for 6 of every 10 minutes during a backup. When nothing runs, the
|
||||||
|
// job only does a few cheap database checks.
|
||||||
|
$this->setInterval(60);
|
||||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,14 +102,10 @@ final class BackupJob extends TimedJob {
|
|||||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
|
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
/** Daily schedule: "HH:MM" in the schedule's time zone (see Schedule); empty = no schedule. */
|
||||||
private function startScheduled(): void {
|
private function startScheduled(): void {
|
||||||
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
|
$todayAt = $this->schedule->todayAt();
|
||||||
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
if ($todayAt === null || time() < $todayAt) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
$todayAt = (int)strtotime(date('Y-m-d') . ' ' . $at);
|
|
||||||
if (time() < $todayAt) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Command;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Service\BackupService;
|
||||||
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class CancelRun extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private RunMapper $runs,
|
||||||
|
private BackupService $backups,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:cancel')
|
||||||
|
->setDescription('Stop a running backup')
|
||||||
|
->addArgument('run', InputArgument::REQUIRED, 'Run number, as shown by backup:status')
|
||||||
|
->setHelp("No new step of the run starts; the step in progress finishes first (usually within a
|
||||||
|
"
|
||||||
|
. "minute), so a backup that is just being completed is not cut off. The process working on the
|
||||||
|
"
|
||||||
|
. "run (occ backup:run, or cron) then stops. A backup started by an earlier version is marked
|
||||||
|
"
|
||||||
|
. "cancelled after two minutes and stops at the end of its step. What it already stored is
|
||||||
|
"
|
||||||
|
. "removed by the next cleanup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$id = (string)$input->getArgument('run');
|
||||||
|
if (!ctype_digit($id)) {
|
||||||
|
$output->writeln('<error>Give the run number, as shown by backup:status.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$run = $this->runs->find((int)$id);
|
||||||
|
} catch (DoesNotExistException) {
|
||||||
|
$output->writeln("<error>There is no run $id.</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($run->getKind() !== BackupService::KIND_FULL) {
|
||||||
|
$output->writeln("<error>Run $id is not a backup; only backups can be cancelled.</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($run->getStatus() === Run::RUNNING) {
|
||||||
|
$output->writeln("Waiting for the current step of run $id to finish...");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$run = $this->backups->cancel($run);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($run->getStatus() !== Run::CANCELLED) {
|
||||||
|
$output->writeln("Run $id is not running (status: {$run->getStatus()}); nothing to cancel.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$output->writeln("Run $id is cancelled.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ HELP);
|
|||||||
$pass = rtrim((string)@file_get_contents($file), "\r\n");
|
$pass = rtrim((string)@file_get_contents($file), "\r\n");
|
||||||
} else {
|
} else {
|
||||||
$helper = $this->getHelper('question');
|
$helper = $this->getHelper('question');
|
||||||
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
|
$q = (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false);
|
||||||
$pass = (string)$helper->ask($input, $output, $q);
|
$pass = (string)$helper->ask($input, $output, $q);
|
||||||
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
||||||
if ($pass !== $again) {
|
if ($pass !== $again) {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ HELP;
|
|||||||
return rtrim((string)@file_get_contents($file), "\r\n");
|
return rtrim((string)@file_get_contents($file), "\r\n");
|
||||||
}
|
}
|
||||||
$h = $this->getHelper('question');
|
$h = $this->getHelper('question');
|
||||||
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false));
|
$p = (string)$h->ask($input, $output, (new Question('Passphrase (min. ' . \OCA\NgBackup\Service\KeyService::MIN_PASSPHRASE . ' characters): '))->setHidden(true)->setHiddenFallback(false));
|
||||||
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
|
if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) {
|
||||||
throw new \InvalidArgumentException('The passphrases do not match');
|
throw new \InvalidArgumentException('The passphrases do not match');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ final class RestoreFull extends Command {
|
|||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private RestoreFullService $restoreFull,
|
private RestoreFullService $restoreFull,
|
||||||
private IUserManager $userManager,
|
private IUserManager $userManager,
|
||||||
|
private \OCP\App\IAppManager $appManager,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -33,14 +34,19 @@ final class RestoreFull extends Command {
|
|||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||||
->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking')
|
->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking')
|
||||||
|
->addOption('install-apps', null, InputOption::VALUE_NONE, 'First install (from the app store) or enable the apps that were enabled at backup time and are missing here')
|
||||||
|
->addOption('skip-missing-tables', null, InputOption::VALUE_NONE, 'Older backups only: leave out tables that do not exist here and whose structure the backup does not record')
|
||||||
->setHelp(<<<'HELP'
|
->setHelp(<<<'HELP'
|
||||||
For a FRESH Nextcloud installation only, same major version as the backup, right after
|
For a FRESH Nextcloud installation only, same major version as the backup, right after
|
||||||
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
|
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
|
||||||
uses this instance.
|
uses this instance.
|
||||||
|
|
||||||
Every app that was enabled when the snapshot was taken must already be installed here (its
|
Apps: --install-apps first installs (from the app store) or enables every app that was enabled
|
||||||
database tables must exist before the restore can write into them); the command lists what is
|
when the snapshot was taken. Apps that are not in the app store (own builds) must be copied into
|
||||||
missing and refuses to continue rather than guess.
|
custom_apps by hand; the command lists what is still missing. Tables of apps that are missing or
|
||||||
|
were removed long ago are created from the structure recorded in the backup (backups made with
|
||||||
|
1.0.0-rc.2 or later), so they do not block the restore. Older backups do not record it: the
|
||||||
|
command then lists those tables per app, and --skip-missing-tables leaves them out.
|
||||||
|
|
||||||
Steps: the database is replaced (consistent, in one transaction); the data directory is restored
|
Steps: the database is replaced (consistent, in one transaction); the data directory is restored
|
||||||
directly in place; config.php is merged in -- everything from the backup except the database
|
directly in place; config.php is merged in -- everything from the backup except the database
|
||||||
@@ -75,10 +81,28 @@ HELP);
|
|||||||
try {
|
try {
|
||||||
$target = $this->targets->get($input->getArgument('target'));
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
$snapshot = (string)$input->getArgument('snapshot');
|
$snapshot = (string)$input->getArgument('snapshot');
|
||||||
$result = $this->restoreFull->restoreFull($target, $snapshot);
|
$skipMissingTables = (bool)$input->getOption('skip-missing-tables');
|
||||||
|
if ($input->getOption('install-apps')) {
|
||||||
|
$check = $this->restoreFull->checkCompatibility($target, $snapshot, $skipMissingTables);
|
||||||
|
foreach ($check['missingApps'] as $app) {
|
||||||
|
$this->installApp($app, $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output->writeln('Nextcloud is in maintenance mode while the restore runs; it is switched off when the restore completes.');
|
||||||
|
$result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
$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.');
|
if (\OCP\Server::get(\OCP\IConfig::class)->getSystemValueBool('maintenance', false)) {
|
||||||
|
$output->writeln('The restore had started: the instance may be partly restored, and maintenance mode stays on so that nobody uses it like that.');
|
||||||
|
$output->writeln('Fix the cause, then switch maintenance mode off and run the same command again with --force, in one line:');
|
||||||
|
$again = 'occ backup:restore:full ' . escapeshellarg((string)$input->getArgument('target')) . ' ' . escapeshellarg((string)$input->getArgument('snapshot'))
|
||||||
|
. ($input->getOption('skip-missing-tables') ? ' --skip-missing-tables' : '') . ' --force --no-interaction';
|
||||||
|
$output->writeln(' occ maintenance:mode --off && ' . $again);
|
||||||
|
$output->writeln('(Without --install-apps, so the instance is open only for the seconds until the restore starts again. Apps the first run could not install are listed at the end: install those afterwards.)');
|
||||||
|
} else {
|
||||||
|
$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.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$output->writeln(sprintf(
|
$output->writeln(sprintf(
|
||||||
@@ -88,10 +112,49 @@ HELP);
|
|||||||
$result['configKeys'],
|
$result['configKeys'],
|
||||||
$result['dataFiles'],
|
$result['dataFiles'],
|
||||||
));
|
));
|
||||||
|
if ($result['dbReport']['created'] !== []) {
|
||||||
|
$output->writeln(sprintf('Created <info>%d</info> tables that did not exist here (apps that are missing or were removed): %s',
|
||||||
|
count($result['dbReport']['created']), implode(', ', $result['dbReport']['created'])));
|
||||||
|
}
|
||||||
|
foreach ($result['dbReport']['foreignKeyErrors'] ?? [] as $error) {
|
||||||
|
$output->writeln('<comment>Could not add a foreign key to a created table (the data is restored): ' . $error . '</comment>');
|
||||||
|
}
|
||||||
|
if ($result['dbReport']['missing'] !== []) {
|
||||||
|
$output->writeln('<comment>Left out (structure unknown): ' . implode(', ', $result['dbReport']['missing']) . '</comment>');
|
||||||
|
}
|
||||||
|
if ($result['missingApps'] !== []) {
|
||||||
|
$output->writeln('<comment>These apps were enabled on the old server but are not installed here. Their data is restored; install them so they work again:</comment>');
|
||||||
|
foreach ($result['missingApps'] as $app) {
|
||||||
|
$output->writeln(" occ app:install $app (not in the app store? copy it into custom_apps and run occ app:enable $app)");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!$result['dbConsistent']) {
|
if (!$result['dbConsistent']) {
|
||||||
$output->writeln('<comment>Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.</comment>');
|
$output->writeln('<comment>Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.</comment>');
|
||||||
}
|
}
|
||||||
|
if ($result['maintenanceLeftOn']) {
|
||||||
|
$output->writeln('<comment>Maintenance mode was on before the restore and stays on: switch it off with occ maintenance:mode --off when the instance is ready.</comment>');
|
||||||
|
}
|
||||||
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enable an app that is here but switched off, or install it from the app store. */
|
||||||
|
private function installApp(string $app, OutputInterface $output): void {
|
||||||
|
try {
|
||||||
|
$this->appManager->getAppPath($app);
|
||||||
|
$command = 'app:enable'; // here, but switched off
|
||||||
|
} catch (\OCP\App\AppPathNotFoundException) {
|
||||||
|
$command = 'app:install';
|
||||||
|
}
|
||||||
|
$output->writeln("Running occ $command $app ...");
|
||||||
|
try {
|
||||||
|
$code = $this->getApplication()?->find($command)->run(new \Symfony\Component\Console\Input\ArrayInput(['app-id' => $command === 'app:enable' ? [$app] : $app]), $output);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$code = 1;
|
||||||
|
$output->writeln('<comment>' . $e->getMessage() . '</comment>');
|
||||||
|
}
|
||||||
|
if ($code !== 0) {
|
||||||
|
$output->writeln("<comment>Could not $command $app (not in the app store?). Copy it into custom_apps and enable it, or restore without it.</comment>");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-6
@@ -10,22 +10,29 @@ namespace OCA\NgBackup\Command;
|
|||||||
use OCA\NgBackup\Db\RunMapper;
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
use OCA\NgBackup\Service\KeyService;
|
use OCA\NgBackup\Service\KeyService;
|
||||||
use OCA\NgBackup\Service\TargetService;
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCA\NgBackup\Service\VerifyService;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Helper\Table;
|
use Symfony\Component\Console\Helper\Table;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
final class Status extends Command {
|
final class Status extends Command {
|
||||||
|
/** Runs of the last days that are listed by default (failed ones stay visible that long). */
|
||||||
|
public const RECENT_DAYS = 7;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private KeyService $keys,
|
private KeyService $keys,
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private RunMapper $runs,
|
private RunMapper $runs,
|
||||||
|
private VerifyService $verify,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void {
|
protected function configure(): void {
|
||||||
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
|
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs')
|
||||||
|
->addOption('all', null, InputOption::VALUE_NONE, 'List all runs, not only those of the last ' . self::RECENT_DAYS . ' days');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
@@ -42,15 +49,43 @@ final class Status extends Command {
|
|||||||
}
|
}
|
||||||
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($this->runs->findRecent(10) as $r) {
|
$all = (bool)$input->getOption('all');
|
||||||
|
$shown = $this->runs->findShown($all ? null : time() - self::RECENT_DAYS * 86400);
|
||||||
|
foreach ($shown as $r) {
|
||||||
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
||||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
|
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . self::percent($r) . ')' : ''),
|
||||||
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
|
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? \OCA\NgBackup\Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||||
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
|
||||||
|
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||||
}
|
}
|
||||||
if ($rows !== []) {
|
if ($rows !== []) {
|
||||||
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
|
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'size', 'uploaded', 'error'])->setRows($rows)->render();
|
||||||
|
}
|
||||||
|
$older = $all ? 0 : $this->runs->countAll() - count($shown);
|
||||||
|
if ($older > 0) {
|
||||||
|
$output->writeln(sprintf('%d older run%s not shown (--all)', $older, $older === 1 ? '' : 's'));
|
||||||
|
}
|
||||||
|
foreach ($this->verify->progress() as $targetId => $state) {
|
||||||
|
$output->writeln(sprintf('Verify of %s on %s: %s', mb_strimwidth((string)($state['what'] ?? ''), 0, 40, '…'),
|
||||||
|
$names[$targetId] ?? '#' . $targetId, VerifyService::describe($state)));
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** " 68%", or " ~68%" when estimated from the file cache; empty without an estimate */
|
||||||
|
private static function size(int $bytes): string {
|
||||||
|
$b = (float)$bytes;
|
||||||
|
foreach (['B', 'KiB', 'MiB', 'GiB'] as $u) {
|
||||||
|
if ($b < 1024) {
|
||||||
|
return $u === 'B' ? "$bytes B" : sprintf('%.1f %s', $b, $u);
|
||||||
|
}
|
||||||
|
$b /= 1024.0;
|
||||||
|
}
|
||||||
|
return sprintf('%.1f TiB', $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function percent(\OCA\NgBackup\Db\Run $r): string {
|
||||||
|
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
|
||||||
|
return $p === null ? '' : sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ HELP);
|
|||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
if (!$this->keys->isInitialized()) {
|
if (!$this->keys->isInitialized()) {
|
||||||
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
$output->writeln('<error>No backup key yet.</error>');
|
||||||
|
$output->writeln('Restoring from a location that already has backups (disaster recovery)? Import the key from the recovery kit first:');
|
||||||
|
$output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json');
|
||||||
|
$output->writeln('Only for a new location without backups: create a new key with occ backup:key:init. A new key cannot read existing backups.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$options = [];
|
$options = [];
|
||||||
@@ -60,9 +63,16 @@ HELP);
|
|||||||
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
// Adopting an existing repository reads its whole index first, which takes a while on a
|
||||||
|
// large backup or a slow line; show how far it is instead of looking stuck.
|
||||||
|
$output->writeln('Connecting to the location; with existing backups this reads their index first ...');
|
||||||
try {
|
try {
|
||||||
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
|
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options,
|
||||||
|
(string)$input->getOption('path'), self::progress($output));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if ($output->isDecorated()) {
|
||||||
|
$output->write("\r\033[K"); // clear an unfinished progress line
|
||||||
|
}
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -70,4 +80,34 @@ HELP);
|
|||||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of reading the index: on a terminal one line that is updated in place, otherwise
|
||||||
|
* (a log, a pipe) a line every 30 seconds. The time left is estimated from the rate so far.
|
||||||
|
*
|
||||||
|
* @return callable(int, int): void
|
||||||
|
*/
|
||||||
|
private static function progress(OutputInterface $output): callable {
|
||||||
|
$start = microtime(true);
|
||||||
|
$shown = 0.0;
|
||||||
|
$inPlace = $output->isDecorated();
|
||||||
|
return static function (int $done, int $total) use ($output, $start, &$shown, $inPlace): void {
|
||||||
|
$now = microtime(true);
|
||||||
|
if ($done < $total && $now - $shown < ($inPlace ? 1 : 30)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$shown = $now;
|
||||||
|
$line = sprintf('Reading the index: %s/%s files (%d%%)', number_format($done), number_format($total), intdiv(100 * $done, max(1, $total)));
|
||||||
|
$elapsed = $now - $start;
|
||||||
|
if ($done < $total && $done > 0 && $elapsed >= 5) {
|
||||||
|
$left = (int)ceil($elapsed / (float)$done * (float)($total - $done) / 60.0);
|
||||||
|
$line .= $left <= 1 ? ', less than a minute left' : sprintf(', about %d min left', $left);
|
||||||
|
}
|
||||||
|
if ($inPlace) {
|
||||||
|
$output->write("\r\033[K" . $line . ($done >= $total ? "\n" : ''));
|
||||||
|
} else {
|
||||||
|
$output->writeln($line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Command;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Service\AlertService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
final class TargetEdit extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private AlertService $alerts,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:target:edit')
|
||||||
|
->setDescription('Change the connection settings or the name of a backup location, keeping its backups')
|
||||||
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
|
->addOption('name', null, InputOption::VALUE_REQUIRED, 'New name')
|
||||||
|
->addOption('backend', 'b', InputOption::VALUE_REQUIRED, 'New files_external backend id (see backup:target:backends)')
|
||||||
|
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'New auth mechanism id')
|
||||||
|
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Option key=value to change (repeatable); other options keep their value')
|
||||||
|
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'New folder on the location')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
The new settings must lead to the same repository as before, so the history of the location is
|
||||||
|
kept. Use it after a password change, a new host name, or after moving the repository.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
occ backup:target:edit nas -o password=new-secret
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
try {
|
||||||
|
$t = $this->targets->get($input->getArgument('target'));
|
||||||
|
$options = $this->targets->options($t);
|
||||||
|
if ($input->getOption('backend') !== null || $input->getOption('auth') !== null) {
|
||||||
|
$options = []; // a different connection type has its own options
|
||||||
|
}
|
||||||
|
foreach ($input->getOption('option') as $pair) {
|
||||||
|
if (!str_contains($pair, '=')) {
|
||||||
|
$output->writeln("<error>Option '$pair' must be key=value</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$k = (string)strstr($pair, '=', true);
|
||||||
|
$v = substr($pair, strlen($k) + 1);
|
||||||
|
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
|
||||||
|
}
|
||||||
|
$before = $t->getName();
|
||||||
|
$t = $this->targets->update($t, $input->getOption('name') ?? $t->getName(), $input->getOption('backend') ?? $t->getBackend(),
|
||||||
|
$input->getOption('auth') ?? $t->getAuth(), $options, $input->getOption('path') ?? $t->getBasePath());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->alerts->securityEvent('target_changed', ['target' => $before]);
|
||||||
|
$output->writeln(sprintf('Location <info>%s</info> updated; repository %s found with the new settings.', $t->getName(), (string)$t->getRepositoryId()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ blob is also downloaded and decrypted, which fails loudly on exactly that even i
|
|||||||
lists it -- the same verification an actual restore would do, run here deliberately instead of
|
lists it -- the same verification an actual restore would do, run here deliberately instead of
|
||||||
waiting to find out during one.
|
waiting to find out during one.
|
||||||
|
|
||||||
|
While it runs it prints how far it is every 30 seconds; occ backup:status shows the same, and
|
||||||
|
how the last check of each location ended.
|
||||||
|
|
||||||
Run this periodically (cron, picking a sample of recent snapshots) rather than only right before
|
Run this periodically (cron, picking a sample of recent snapshots) rather than only right before
|
||||||
you'd need to rely on a restore.
|
you'd need to rely on a restore.
|
||||||
|
|
||||||
@@ -51,9 +54,12 @@ HELP);
|
|||||||
$target = $this->targets->get($input->getArgument('target'));
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
$ref = (string)$input->getArgument('snapshot');
|
$ref = (string)$input->getArgument('snapshot');
|
||||||
$deep = (bool)$input->getOption('deep');
|
$deep = (bool)$input->getOption('deep');
|
||||||
|
$onProgress = static function (array $state) use ($output): void {
|
||||||
|
$output->writeln(date('H:i:s') . ' ' . VerifyService::describe($state));
|
||||||
|
};
|
||||||
$result = str_starts_with($ref, 'users/')
|
$result = str_starts_with($ref, 'users/')
|
||||||
? $this->verify->verifyUserExport($target, $ref, $deep)
|
? $this->verify->verifyUserExport($target, $ref, $deep, $onProgress)
|
||||||
: $this->verify->verify($target, $ref, $deep);
|
: $this->verify->verify($target, $ref, $deep, $onProgress);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -47,18 +47,21 @@ final class AdminApiController extends Controller {
|
|||||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
private \OCA\NgBackup\Service\KeySlotService $slots,
|
||||||
private \OCA\NgBackup\Service\PruneService $prune,
|
private \OCA\NgBackup\Service\PruneService $prune,
|
||||||
|
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||||
) {
|
) {
|
||||||
parent::__construct(Application::APP_ID, $request);
|
parent::__construct(Application::APP_ID, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function status(): JSONResponse {
|
/** @param bool $allRuns all runs instead of those of the last days (Status::RECENT_DAYS) */
|
||||||
|
public function status(bool $allRuns = false): JSONResponse {
|
||||||
$targets = [];
|
$targets = [];
|
||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||||
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
|
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
|
||||||
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
|
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t)];
|
||||||
}
|
}
|
||||||
$runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15));
|
$shown = $this->runs->findShown($allRuns ? null : time() - \OCA\NgBackup\Command\Status::RECENT_DAYS * 86400);
|
||||||
|
$runs = array_map(fn (Run $r) => $this->runJson($r), $shown);
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
||||||
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
|
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
|
||||||
@@ -68,7 +71,10 @@ final class AdminApiController extends Controller {
|
|||||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||||
'targets' => $targets,
|
'targets' => $targets,
|
||||||
'runs' => $runs,
|
'runs' => $runs,
|
||||||
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
|
||||||
|
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
|
||||||
|
'schedule' => $this->schedule->get()['time'],
|
||||||
|
'scheduleTimezone' => $this->schedule->get()['timezone'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +187,28 @@ final class AdminApiController extends Controller {
|
|||||||
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
return new JSONResponse(['id' => $r['target']->getId(), 'created' => $r['created']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The stored settings for the edit form; password values are left out (null). */
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
|
public function targetSettings(int $id): JSONResponse {
|
||||||
|
$t = $this->targets->get((string)$id);
|
||||||
|
return new JSONResponse(['name' => $t->getName(), 'backend' => $t->getBackend(), 'auth' => $t->getAuth(),
|
||||||
|
'options' => $this->targets->editableOptions($t), 'path' => $t->getBasePath()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options with a null value keep their stored value. */
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
|
public function updateTarget(int $id, string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup'): JSONResponse {
|
||||||
|
try {
|
||||||
|
$t = $this->targets->get((string)$id);
|
||||||
|
$before = $t->getName();
|
||||||
|
$t = $this->targets->update($t, $name, $backend, $auth, $options, $path);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$this->alerts->securityEvent('target_changed', ['target' => $before]);
|
||||||
|
return new JSONResponse(['id' => $t->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Size limit in GiB; null or 0 = unlimited. */
|
/** Size limit in GiB; null or 0 = unlimited. */
|
||||||
#[PasswordConfirmationRequired]
|
#[PasswordConfirmationRequired]
|
||||||
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
|
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
|
||||||
@@ -214,12 +242,14 @@ final class AdminApiController extends Controller {
|
|||||||
return new JSONResponse($this->runJson($run));
|
return new JSONResponse($this->runJson($run));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSchedule(string $time): JSONResponse {
|
/** @param string $timezone the administrator's zone (the browser's); empty: keep the current one */
|
||||||
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
public function setSchedule(string $time, string $timezone = ''): JSONResponse {
|
||||||
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
|
try {
|
||||||
|
$this->schedule->set($time, $timezone !== '' ? $timezone : $this->schedule->get()['timezone']);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
|
return new JSONResponse(['schedule' => $time, 'scheduleTimezone' => $this->schedule->get()['timezone']]);
|
||||||
return new JSONResponse(['schedule' => $time]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function snapshots(int $targetId): JSONResponse {
|
public function snapshots(int $targetId): JSONResponse {
|
||||||
@@ -283,6 +313,6 @@ final class AdminApiController extends Controller {
|
|||||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
||||||
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
||||||
'stats' => json_decode($r->getStats() ?? 'null', true)];
|
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+323
-47
@@ -23,8 +23,12 @@ use OCA\NgBackup\Repository\Repository;
|
|||||||
* sets, so a single big SELECT would not stream).
|
* sets, so a single big SELECT would not stream).
|
||||||
* - Each table becomes its own blob stream of JSON lines: a header line, then one JSON array per
|
* - Each table becomes its own blob stream of JSON lines: a header line, then one JSON array per
|
||||||
* row. A table that did not change produces the same blobs and costs nothing in the next run.
|
* row. A table that did not change produces the same blobs and costs nothing in the next run.
|
||||||
* - The schema itself is not dumped: it comes from Nextcloud's migrations on the instance that is
|
* - The structure of each table (columns, types, primary key, indexes) is recorded in the manifest,
|
||||||
* restored into (same Nextcloud and app versions). The dump records those versions.
|
* so a restore can create a table that does not exist on the new instance, for example one left
|
||||||
|
* behind by an app that was removed long ago. Tables that exist keep the structure the new
|
||||||
|
* instance's migrations gave them.
|
||||||
|
* - Foreign keys are not checked while rows are loaded (tables are restored in name order, not in
|
||||||
|
* dependency order), and are checked again before the restore commits.
|
||||||
*
|
*
|
||||||
* Phase 0 uses the Doctrine connection behind IDBConnection (private API) for schema
|
* Phase 0 uses the Doctrine connection behind IDBConnection (private API) for schema
|
||||||
* introspection; to be replaced by information_schema queries or a public API.
|
* introspection; to be replaced by information_schema queries or a public API.
|
||||||
@@ -72,50 +76,57 @@ final class DbDumper {
|
|||||||
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
||||||
$state['steps']++;
|
$state['steps']++;
|
||||||
$sm = $this->db->createSchemaManager();
|
$sm = $this->db->createSchemaManager();
|
||||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
// A pack that fills up during the dump is uploaded right away; its index cache row must
|
||||||
$this->db->beginTransaction();
|
// wait until the read-only transaction is over.
|
||||||
|
$repo->holdIndexCache();
|
||||||
try {
|
try {
|
||||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
$this->db->beginTransaction();
|
||||||
}
|
try {
|
||||||
while ($state['i'] < count($state['tables'])) {
|
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
$table = $state['tables'][$state['i']];
|
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||||
$schema = $sm->introspectTable($table);
|
|
||||||
$writer = $repo->blobWriter($stats);
|
|
||||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
|
||||||
if ($state['after'] === null && $state['segments'] === []) {
|
|
||||||
$writer->write($this->header($schema) . "\n");
|
|
||||||
}
|
}
|
||||||
$complete = true;
|
while ($state['i'] < count($state['tables'])) {
|
||||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
$table = $state['tables'][$state['i']];
|
||||||
$writer->write($line . "\n");
|
$schema = $sm->introspectTable($table);
|
||||||
hash_update($hash, $line . "\n");
|
$writer = $repo->blobWriter($stats);
|
||||||
$state['rows']++;
|
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||||
$state['after'] = $key;
|
if ($state['after'] === null && $state['segments'] === []) {
|
||||||
if (microtime(true) >= $deadline) {
|
$writer->write($this->header($schema) . "\n");
|
||||||
$complete = false;
|
}
|
||||||
|
$complete = true;
|
||||||
|
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
||||||
|
$writer->write($line . "\n");
|
||||||
|
hash_update($hash, $line . "\n");
|
||||||
|
$state['rows']++;
|
||||||
|
$state['after'] = $key;
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
$complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$state['segments'][] = $writer->finish();
|
||||||
|
$state['bytes'] += $writer->bytes();
|
||||||
|
if (!$complete) {
|
||||||
|
$state['hash'] = base64_encode(serialize($hash));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||||
|
'bytes' => $state['bytes'], 'sha256' => hash_final($hash), 'schema' => self::structure($schema)];
|
||||||
|
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
||||||
|
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$state['segments'][] = $writer->finish();
|
if ($state['i'] >= count($state['tables'])) {
|
||||||
$state['bytes'] += $writer->bytes();
|
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||||
if (!$complete) {
|
$state['done'] = true;
|
||||||
$state['hash'] = base64_encode(serialize($hash));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
} finally {
|
||||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
$this->db->rollBack();
|
||||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
|
||||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($state['i'] >= count($state['tables'])) {
|
|
||||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
|
||||||
$state['done'] = true;
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
$this->db->rollBack();
|
$repo->releaseIndexCache();
|
||||||
}
|
}
|
||||||
$repo->flushPacks();
|
$repo->flushPacks();
|
||||||
return $state;
|
return $state;
|
||||||
@@ -130,18 +141,38 @@ final class DbDumper {
|
|||||||
* Replace the contents of every table in the dump. Tables that exist but are not in the dump
|
* Replace the contents of every table in the dump. Tables that exist but are not in the dump
|
||||||
* are left alone (e.g. a newer app); the caller decides whether that is acceptable.
|
* are left alone (e.g. a newer app); the caller decides whether that is acceptable.
|
||||||
*
|
*
|
||||||
* @param array{tables: array<string, array{blobs: list<string>, rows: int}>} $manifest
|
* @param array{tables: array<string, array{blobs: list<string>, rows: int, sha256: string, schema?: array}>, sequences?: array} $manifest
|
||||||
*/
|
*/
|
||||||
public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null): int {
|
public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null, bool $skipMissing = false): int {
|
||||||
$sm = $this->db->createSchemaManager();
|
$report = ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []];
|
||||||
$existing = array_flip($sm->listTableNames());
|
$source = self::dumpPrefix($manifest);
|
||||||
|
if ($source !== null && $source !== $this->prefix) {
|
||||||
|
// Every table would look missing and be created under the old prefix, unused.
|
||||||
|
throw new \RuntimeException("The backup's tables use the prefix '$source', this installation '{$this->prefix}'. Install Nextcloud again with 'dbtableprefix' => '$source' in config/config.php before occ maintenance:install, and restore again.");
|
||||||
|
}
|
||||||
|
$missing = $this->missingTables($manifest);
|
||||||
|
$unknown = array_values(array_filter($missing, fn (string $t) => !isset($manifest['tables'][$t]['schema'])));
|
||||||
|
if ($unknown !== [] && !$skipMissing) {
|
||||||
|
throw new \RuntimeException(self::describeMissing($unknown, $this->prefix));
|
||||||
|
}
|
||||||
|
// Tables the backup knows the structure of are created first (DDL is not transactional
|
||||||
|
// everywhere); tables it does not know are left out, as asked.
|
||||||
|
foreach ($missing as $table) {
|
||||||
|
if (isset($manifest['tables'][$table]['schema'])) {
|
||||||
|
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
|
||||||
|
$report['created'][] = $table;
|
||||||
|
} else {
|
||||||
|
$report['missing'][] = $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
$total = 0;
|
$total = 0;
|
||||||
$report = ['skipped' => [], 'restored' => []];
|
$foreignKeys = [];
|
||||||
$this->db->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
|
$foreignKeys = $this->suspendForeignKeys();
|
||||||
foreach ($manifest['tables'] as $table => $info) {
|
foreach ($manifest['tables'] as $table => $info) {
|
||||||
if (!isset($existing[$table])) {
|
if (in_array($table, $report['missing'], true)) {
|
||||||
throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)");
|
continue;
|
||||||
}
|
}
|
||||||
// Unchanged since the backup: leave it alone (much faster than delete + insert).
|
// Unchanged since the backup: leave it alone (much faster than delete + insert).
|
||||||
$live = $this->checksum($table);
|
$live = $this->checksum($table);
|
||||||
@@ -185,15 +216,229 @@ final class DbDumper {
|
|||||||
}
|
}
|
||||||
$report['restored'][] = $table;
|
$report['restored'][] = $table;
|
||||||
}
|
}
|
||||||
$this->restoreSequences($manifest['sequences'] ?? []);
|
// unchanged tables too: a restore that stopped earlier may have left their counters at 1
|
||||||
|
$this->restoreSequences($manifest['sequences'] ?? [], $report['missing'], array_merge($report['restored'], $report['skipped']));
|
||||||
|
$this->resumeForeignKeys($foreignKeys);
|
||||||
$this->db->commit();
|
$this->db->commit();
|
||||||
|
// Foreign keys of the tables created above, now their rows are in. Not inside the
|
||||||
|
// transaction: MySQL commits on DDL. A failure is reported, not fatal: the data is
|
||||||
|
// restored either way.
|
||||||
|
foreach ($report['created'] as $table) {
|
||||||
|
$structure = $manifest['tables'][$table]['schema'] ?? [];
|
||||||
|
foreach ($structure['foreignKeys'] ?? [] as $fk) {
|
||||||
|
try {
|
||||||
|
$this->db->createSchemaManager()->createForeignKey(
|
||||||
|
new \Doctrine\DBAL\Schema\ForeignKeyConstraint($fk['columns'], $fk['table'], $fk['foreign'], $fk['name'], $fk['options'] ?? []),
|
||||||
|
$table);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$report['foreignKeyErrors'][] = $table . '.' . $fk['name'] . ': ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
|
// PostgreSQL rolls the dropped foreign keys back with the transaction; MySQL's session
|
||||||
|
// switch must be turned on again by hand.
|
||||||
|
if (in_array('SET FOREIGN_KEY_CHECKS = 1', $foreignKeys, true)) {
|
||||||
|
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
return $total;
|
return $total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table prefix of the instance the dump was made on (from its appconfig table), or null
|
||||||
|
* when the dump has none.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public static function dumpPrefix(array $manifest): ?string {
|
||||||
|
/** @var list<string> $tables table names */
|
||||||
|
$tables = array_keys($manifest['tables'] ?? []);
|
||||||
|
foreach ($tables as $name) {
|
||||||
|
if (str_ends_with($name, 'appconfig') && !str_ends_with($name, '_ex_appconfig')) {
|
||||||
|
return substr($name, 0, -strlen('appconfig'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables in the dump that do not exist on this instance.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function missingTables(array $manifest): array {
|
||||||
|
$existing = array_flip($this->db->createSchemaManager()->listTableNames());
|
||||||
|
return array_values(array_filter(array_keys($manifest['tables']), fn (string $t) => !isset($existing[$t])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message for all missing tables of an older backup, grouped by the app they probably belong to. */
|
||||||
|
public static function describeMissing(array $tables, string $prefix): string {
|
||||||
|
$groups = [];
|
||||||
|
foreach ($tables as $t) {
|
||||||
|
$name = substr($t, strlen($prefix));
|
||||||
|
$groups[explode('_', $name, 2)[0]][] = $t;
|
||||||
|
}
|
||||||
|
$lines = [];
|
||||||
|
foreach ($groups as $app => $list) {
|
||||||
|
$lines[] = '- ' . $app . ': ' . implode(', ', $list);
|
||||||
|
}
|
||||||
|
return "These tables from the backup do not exist here, and this backup does not record their structure (made before 1.0.0-rc.2):\n"
|
||||||
|
. implode("\n", $lines)
|
||||||
|
. "\nInstall the apps they belong to (often apps that were removed or switched off long ago) and run the restore again, or leave these tables out with --skip-missing-tables.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portable description of a table: enough to create it again, also on another database type.
|
||||||
|
*
|
||||||
|
* @return array{columns: list<array<string, mixed>>, pk: list<string>, indexes: list<array{name: string, columns: list<string>, unique: bool}>}
|
||||||
|
*/
|
||||||
|
private static function structure(\Doctrine\DBAL\Schema\Table $table): array {
|
||||||
|
$columns = [];
|
||||||
|
foreach ($table->getColumns() as $c) {
|
||||||
|
$columns[] = ['name' => $c->getName(), 'type' => \Doctrine\DBAL\Types\Type::lookupName($c->getType()),
|
||||||
|
'length' => $c->getLength(), 'precision' => $c->getPrecision(), 'scale' => $c->getScale(),
|
||||||
|
'unsigned' => $c->getUnsigned(), 'fixed' => $c->getFixed(), 'notnull' => $c->getNotnull(),
|
||||||
|
'default' => $c->getDefault(), 'autoincrement' => $c->getAutoincrement()];
|
||||||
|
}
|
||||||
|
$indexes = [];
|
||||||
|
foreach ($table->getIndexes() as $i) {
|
||||||
|
if (!$i->isPrimary()) {
|
||||||
|
// options holds the prefix lengths MySQL needs for indexes on long text columns
|
||||||
|
$indexes[] = ['name' => $i->getName(), 'columns' => array_values($i->getColumns()), 'unique' => $i->isUnique(),
|
||||||
|
'flags' => array_values($i->getFlags()), 'options' => $i->getOptions()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$foreignKeys = [];
|
||||||
|
foreach ($table->getForeignKeys() as $fk) {
|
||||||
|
$foreignKeys[] = ['name' => $fk->getName(), 'columns' => array_values($fk->getLocalColumns()),
|
||||||
|
'table' => $fk->getForeignTableName(), 'foreign' => array_values($fk->getForeignColumns()), 'options' => $fk->getOptions()];
|
||||||
|
}
|
||||||
|
return ['columns' => $columns, 'pk' => array_values($table->getPrimaryKey()?->getColumns() ?? []), 'indexes' => $indexes,
|
||||||
|
'foreignKeys' => $foreignKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table options for a table the restore creates: those of this installation's own tables.
|
||||||
|
* On MySQL/MariaDB that is the character set and collation, as Nextcloud's tables have them
|
||||||
|
* (utf8mb4, so emoji and other 4-byte characters fit); DBAL alone would use 3-byte utf8.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @psalm-suppress UndefinedClass the DBAL classes come with Nextcloud, not with this app
|
||||||
|
*/
|
||||||
|
private function tableOptions(): array {
|
||||||
|
$options = $this->db->createSchemaManager()->createSchemaConfig()->getDefaultTableOptions();
|
||||||
|
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||||
|
$collation = $this->db->fetchOne('SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?',
|
||||||
|
[$this->prefix . 'appconfig']);
|
||||||
|
if (is_string($collation) && $collation !== '') {
|
||||||
|
$options['collation'] = $collation;
|
||||||
|
$options['charset'] = explode('_', $collation, 2)[0];
|
||||||
|
unset($options['collate']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $options table options (see tableOptions()) */
|
||||||
|
private static function tableFromStructure(string $name, array $structure, array $options = []): \Doctrine\DBAL\Schema\Table {
|
||||||
|
$table = new \Doctrine\DBAL\Schema\Table($name, [], [], [], [], $options);
|
||||||
|
foreach ($structure['columns'] as $c) {
|
||||||
|
$table->addColumn($c['name'], $c['type'], array_filter([
|
||||||
|
'length' => $c['length'], 'precision' => $c['precision'], 'scale' => $c['scale'],
|
||||||
|
'unsigned' => $c['unsigned'], 'fixed' => $c['fixed'], 'notnull' => $c['notnull'],
|
||||||
|
'default' => $c['default'], 'autoincrement' => $c['autoincrement'],
|
||||||
|
], fn ($v) => $v !== null));
|
||||||
|
}
|
||||||
|
if ($structure['pk'] !== []) {
|
||||||
|
$table->setPrimaryKey($structure['pk']);
|
||||||
|
}
|
||||||
|
foreach ($structure['indexes'] as $i) {
|
||||||
|
$i['unique']
|
||||||
|
? $table->addUniqueIndex($i['columns'], $i['name'], $i['options'] ?? [])
|
||||||
|
: $table->addIndex($i['columns'], $i['name'], $i['flags'] ?? [], $i['options'] ?? []);
|
||||||
|
}
|
||||||
|
return $table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop checking foreign keys while rows are loaded in name order. PostgreSQL has no session
|
||||||
|
* switch for this without superuser rights, so its foreign keys of Nextcloud tables are dropped
|
||||||
|
* here and added back (and so checked) by resumeForeignKeys(), inside the same transaction.
|
||||||
|
*
|
||||||
|
* @return list<string> statements that restore the foreign keys
|
||||||
|
*/
|
||||||
|
private function suspendForeignKeys(): array {
|
||||||
|
$platform = $this->db->getDatabasePlatform();
|
||||||
|
if ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
|
$rows = $this->db->fetchAllAssociative(
|
||||||
|
"SELECT conrelid::regclass::text AS tbl, conname, pg_get_constraintdef(oid) AS def
|
||||||
|
FROM pg_constraint WHERE contype = 'f' AND connamespace = current_schema()::regnamespace");
|
||||||
|
$restore = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (!str_starts_with(trim($r['tbl'], '"'), $this->prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->db->executeStatement(sprintf('ALTER TABLE %s DROP CONSTRAINT %s', $r['tbl'], $this->db->quoteIdentifier($r['conname'])));
|
||||||
|
$restore[] = sprintf('ALTER TABLE %s ADD CONSTRAINT %s %s', $r['tbl'], $this->db->quoteIdentifier($r['conname']), $r['def']);
|
||||||
|
}
|
||||||
|
return $restore;
|
||||||
|
}
|
||||||
|
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||||
|
// MySQL does not check rows again when FOREIGN_KEY_CHECKS goes back to 1, and its DDL
|
||||||
|
// would commit the transaction halfway. So every foreign key is checked explicitly,
|
||||||
|
// with a query that finds rows pointing at nothing (resumeForeignKeys()).
|
||||||
|
$rows = $this->db->fetchAllAssociative(
|
||||||
|
'SELECT TABLE_NAME AS tbl, CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS rtbl, REFERENCED_COLUMN_NAME AS rcol
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
|
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION');
|
||||||
|
$keys = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (str_starts_with((string)$r['tbl'], $this->prefix)) {
|
||||||
|
$keys[$r['tbl'] . "\0" . $r['name']][] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$q = fn (string $i): string => $this->db->quoteIdentifier($i);
|
||||||
|
$checks = ['SET FOREIGN_KEY_CHECKS = 1'];
|
||||||
|
foreach ($keys as $parts) {
|
||||||
|
$join = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' = p.' . $q($r['rcol']), $parts));
|
||||||
|
$set = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' IS NOT NULL', $parts));
|
||||||
|
$checks[] = sprintf('SELECT COUNT(*) FROM %s c LEFT JOIN %s p ON %s WHERE %s AND p.%s IS NULL /* %s */',
|
||||||
|
$q($parts[0]['tbl']), $q($parts[0]['rtbl']), $join, $set, $q($parts[0]['rcol']), $parts[0]['name']);
|
||||||
|
}
|
||||||
|
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
if ($platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform) {
|
||||||
|
$this->db->executeStatement('PRAGMA defer_foreign_keys = ON'); // checked at commit
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the foreign keys again; a row that breaks one fails the restore here, before it commits.
|
||||||
|
* Statements add them back (PostgreSQL) or switch the checks on (MySQL); queries count the rows
|
||||||
|
* that point at nothing (MySQL) and must find none.
|
||||||
|
*/
|
||||||
|
private function resumeForeignKeys(array $statements): void {
|
||||||
|
foreach ($statements as $sql) {
|
||||||
|
try {
|
||||||
|
if (str_starts_with($sql, 'SELECT ')) {
|
||||||
|
$orphans = (int)$this->db->fetchOne($sql);
|
||||||
|
if ($orphans > 0) {
|
||||||
|
throw new \RuntimeException("$orphans rows point at nothing");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->db->executeStatement($sql);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new \RuntimeException('The restored data breaks a foreign key (' . $sql . '): ' . $e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
|
/** Same checksum as dump(): sha256 over the row lines in primary-key order. */
|
||||||
public function checksum(string $table): array {
|
public function checksum(string $table): array {
|
||||||
$hash = hash_init('sha256');
|
$hash = hash_init('sha256');
|
||||||
@@ -352,11 +597,21 @@ final class DbDumper {
|
|||||||
return $name;
|
return $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function restoreSequences(array $sequences): void {
|
/**
|
||||||
|
* @param list<string> $leftOut tables not restored (--skip-missing-tables): they do not exist here
|
||||||
|
* @param list<string> $restored tables restored or unchanged: their other counters go after their data
|
||||||
|
*/
|
||||||
|
private function restoreSequences(array $sequences, array $leftOut = [], array $restored = []): void {
|
||||||
if (!$this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
if (!$this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$leftOut = array_flip($leftOut);
|
||||||
|
$set = [];
|
||||||
foreach ($sequences as $info) {
|
foreach ($sequences as $info) {
|
||||||
|
if (isset($leftOut[$info['table']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$set[$info['table'] . '.' . $info['column']] = true;
|
||||||
$seq = $this->db->fetchOne('SELECT pg_get_serial_sequence(quote_ident(?), ?)', [$info['table'], $info['column']]);
|
$seq = $this->db->fetchOne('SELECT pg_get_serial_sequence(quote_ident(?), ?)', [$info['table'], $info['column']]);
|
||||||
if ($seq === false || $seq === null) {
|
if ($seq === false || $seq === null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -371,5 +626,26 @@ final class DbDumper {
|
|||||||
[\Doctrine\DBAL\ParameterType::STRING, \Doctrine\DBAL\ParameterType::INTEGER, \Doctrine\DBAL\ParameterType::BOOLEAN]);
|
[\Doctrine\DBAL\ParameterType::STRING, \Doctrine\DBAL\ParameterType::INTEGER, \Doctrine\DBAL\ParameterType::BOOLEAN]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A backup from MySQL/MariaDB or SQLite records no counters (those derive them from the
|
||||||
|
// data): every other counter of a restored table goes after its data, or new rows (a new
|
||||||
|
// file, an activity) would collide with restored ones.
|
||||||
|
$restored = array_flip($restored);
|
||||||
|
$rows = $this->db->fetchAllAssociative(
|
||||||
|
"SELECT c.table_name, c.column_name, pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) AS seq
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = current_schema() AND pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) IS NOT NULL");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if (!isset($restored[$r['table_name']]) || isset($set[$r['table_name'] . '.' . $r['column_name']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$max = (int)$this->db->fetchOne(sprintf('SELECT COALESCE(MAX(%s), 0) FROM %s',
|
||||||
|
$this->db->quoteIdentifier($r['column_name']), $this->db->quoteIdentifier($r['table_name'])));
|
||||||
|
// never backwards: a counter already past the data stays where it is
|
||||||
|
$state = $this->db->fetchAssociative('SELECT last_value, is_called FROM ' . self::sequenceIdentifier($r['seq']));
|
||||||
|
$next = $state === false ? 1 : (int)$state['last_value'] + ($state['is_called'] ? 1 : 0);
|
||||||
|
if ($next <= $max) {
|
||||||
|
$this->db->executeQuery('SELECT setval(CAST(? AS regclass), ?, false)', [$r['seq'], $max + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-7
@@ -30,19 +30,91 @@ final class DbIndexCache implements IIndexCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function put(string $repositoryId, string $packId, array $entries): void {
|
public function put(string $repositoryId, string $packId, array $entries): void {
|
||||||
|
try {
|
||||||
|
$this->insert($repositoryId, $packId, $entries);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In one transaction: on PostgreSQL with its default settings every statement of its own
|
||||||
|
* waits for a disk sync, which made 27,547 rows take more than 6 minutes. Rows already there
|
||||||
|
* are replaced. Should the transaction fail (another process writing the same rows), the
|
||||||
|
* rows are written one by one, keeping rows that are already there.
|
||||||
|
*/
|
||||||
|
public function putMany(string $repositoryId, array $packs): void {
|
||||||
|
if ($packs === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->transaction(function () use ($repositoryId, $packs): void {
|
||||||
|
$this->remove($repositoryId, array_keys($packs));
|
||||||
|
$this->insertAll($repositoryId, $packs);
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($this->db->inTransaction()) {
|
||||||
|
throw $e; // a transaction of the caller's, which may be spoilt now
|
||||||
|
}
|
||||||
|
foreach ($packs as $packId => $entries) {
|
||||||
|
$this->put($repositoryId, $packId, $entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached index of a repository that no location uses any more. */
|
||||||
|
public function forget(string $repositoryId): void {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
|
||||||
|
$qb->executeStatement();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All or nothing: when it fails, the cache stays as it was. */
|
||||||
|
public function replaceAll(string $repositoryId, array $packs): void {
|
||||||
|
$this->transaction(function () use ($repositoryId, $packs): void {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
|
||||||
|
$qb->executeStatement();
|
||||||
|
$this->insertAll($repositoryId, $packs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run $write in a transaction of its own, or as part of the caller's when there is one. */
|
||||||
|
private function transaction(\Closure $write): void {
|
||||||
|
if ($this->db->inTransaction()) {
|
||||||
|
$write();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$write();
|
||||||
|
$this->db->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($this->db->inTransaction()) {
|
||||||
|
$this->db->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs */
|
||||||
|
private function insertAll(string $repositoryId, array $packs): void {
|
||||||
|
foreach ($packs as $packId => $entries) {
|
||||||
|
$this->insert($repositoryId, $packId, $entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||||
|
private function insert(string $repositoryId, string $packId, array $entries): void {
|
||||||
$qb = $this->db->getQueryBuilder();
|
$qb = $this->db->getQueryBuilder();
|
||||||
$qb->insert('ngb_index')->values([
|
$qb->insert('ngb_index')->values([
|
||||||
'repository_id' => $qb->createNamedParameter($repositoryId),
|
'repository_id' => $qb->createNamedParameter($repositoryId),
|
||||||
'pack_id' => $qb->createNamedParameter($packId),
|
'pack_id' => $qb->createNamedParameter($packId),
|
||||||
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
|
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
|
||||||
]);
|
]);
|
||||||
try {
|
$qb->executeStatement();
|
||||||
$qb->executeStatement();
|
|
||||||
} catch (Exception $e) {
|
|
||||||
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function remove(string $repositoryId, array $packIds): void {
|
public function remove(string $repositoryId, array $packIds): void {
|
||||||
|
|||||||
@@ -17,6 +17,36 @@ final class RunMapper extends QBMapper {
|
|||||||
parent::__construct($db, 'ngb_runs', Run::class);
|
parent::__construct($db, 'ngb_runs', Run::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write $run only if it is still running and unchanged since it was loaded (updated_at
|
||||||
|
* $loadedAt), in one statement: two processes that loaded the same run cannot both write it.
|
||||||
|
* The new updated_at is always later than $loadedAt, so every write is seen as a change.
|
||||||
|
*
|
||||||
|
* @return Run|null the written run, or null when another process wrote it first
|
||||||
|
*/
|
||||||
|
public function updateIfUnchanged(Run $run, int $loadedAt): ?Run {
|
||||||
|
$run->setUpdatedAt(max(time(), $loadedAt + 1));
|
||||||
|
$types = $run->getFieldTypes();
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->update($this->getTableName());
|
||||||
|
foreach (array_keys($run->getUpdatedFields()) as $field) {
|
||||||
|
if ($field === 'id') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $run->{'get' . ucfirst($field)}();
|
||||||
|
$type = ($types[$field] ?? 'string') === 'integer' ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR;
|
||||||
|
$qb->set($run->propertyToColumn($field), $qb->createNamedParameter($value, $value === null ? IQueryBuilder::PARAM_NULL : $type));
|
||||||
|
}
|
||||||
|
$qb->where($qb->expr()->eq('id', $qb->createNamedParameter($run->getId(), IQueryBuilder::PARAM_INT)))
|
||||||
|
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)))
|
||||||
|
->andWhere($qb->expr()->eq('updated_at', $qb->createNamedParameter($loadedAt, IQueryBuilder::PARAM_INT)));
|
||||||
|
if ($qb->executeStatement() !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$run->resetUpdatedFields();
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
public function find(int $id): Run {
|
public function find(int $id): Run {
|
||||||
$qb = $this->db->getQueryBuilder();
|
$qb = $this->db->getQueryBuilder();
|
||||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||||
@@ -43,6 +73,40 @@ final class RunMapper extends QBMapper {
|
|||||||
return $this->findEntities($qb);
|
return $this->findEntities($qb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runs to list, newest first: those started at or after $since, those still running, and
|
||||||
|
* at least the newest $atLeast, so the list is never empty after a quiet week. All runs when
|
||||||
|
* $since is null.
|
||||||
|
*
|
||||||
|
* @return list<Run>
|
||||||
|
*/
|
||||||
|
public function findShown(?int $since, int $atLeast = 5): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC');
|
||||||
|
if ($since === null) {
|
||||||
|
return $this->findEntities($qb);
|
||||||
|
}
|
||||||
|
$qb->where($qb->expr()->orX(
|
||||||
|
$qb->expr()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)),
|
||||||
|
$qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)),
|
||||||
|
));
|
||||||
|
$runs = [];
|
||||||
|
foreach ([...$this->findEntities($qb), ...$this->findRecent($atLeast)] as $r) {
|
||||||
|
$runs[$r->getId()] = $r;
|
||||||
|
}
|
||||||
|
krsort($runs);
|
||||||
|
return array_values($runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countAll(): int {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select($qb->func()->count('*', 'n'))->from($this->getTableName());
|
||||||
|
$result = $qb->executeQuery();
|
||||||
|
$n = (int)$result->fetchOne();
|
||||||
|
$result->closeCursor();
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<Run> runs of $kind started at or after $since, newest first */
|
/** @return list<Run> runs of $kind started at or after $since, newest first */
|
||||||
public function startedSince(int $targetId, string $kind, int $since): array {
|
public function startedSince(int $targetId, string $kind, int $since): array {
|
||||||
$qb = $this->db->getQueryBuilder();
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup;
|
||||||
|
|
||||||
|
final class Format {
|
||||||
|
/** "12:10:13", or "1d 12:10:13" from a day on (a long first backup takes days). */
|
||||||
|
public static function duration(int $seconds): string {
|
||||||
|
$seconds = max(0, $seconds);
|
||||||
|
$days = intdiv($seconds, 86400);
|
||||||
|
$time = gmdate('H:i:s', $seconds % 86400);
|
||||||
|
return $days > 0 ? $days . 'd ' . $time : $time;
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-6
@@ -140,26 +140,58 @@ final class BackupRun {
|
|||||||
$state['parentPos'] = $parent->position();
|
$state['parentPos'] = $parent->position();
|
||||||
$state['stats'] = $stats;
|
$state['stats'] = $stats;
|
||||||
if ($finishedWalk) {
|
if ($finishedWalk) {
|
||||||
|
// The snapshot is written in a step of its own, with its own time: after a long
|
||||||
|
// run it reads back thousands of segments, and the run reports that it got here.
|
||||||
$state['phase'] = 'finish';
|
$state['phase'] = 'finish';
|
||||||
}
|
}
|
||||||
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state['phase'] === 'finish') {
|
if ($state['phase'] === 'finish') {
|
||||||
$segments = $state['segments'];
|
// Every segment ends with a newline, so they read as one stream: fetched together,
|
||||||
|
// several at a time where the location allows it.
|
||||||
$repo->putSnapshot($state['snapshot'],
|
$repo->putSnapshot($state['snapshot'],
|
||||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
||||||
'warnings' => self::warnings($state)] + $state['meta'],
|
'warnings' => self::warnings($state)] + $state['meta'],
|
||||||
(function () use ($repo, $segments) {
|
$repo->readLines(array_values(array_merge(...$state['segments']))));
|
||||||
foreach ($segments as $blobs) {
|
|
||||||
yield from $repo->readLines($blobs);
|
|
||||||
}
|
|
||||||
})());
|
|
||||||
$state['phase'] = 'commit';
|
$state['phase'] = 'commit';
|
||||||
}
|
}
|
||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress of the files phase as a fraction (at most 0.99 until it is done), or null without an
|
||||||
|
* estimate. Bytes of a large file that is still being read count too.
|
||||||
|
*
|
||||||
|
* @return array{fraction: float, approximate: bool}|null
|
||||||
|
*/
|
||||||
|
public static function progress(array $state): ?array {
|
||||||
|
$estimate = $state['estimate'] ?? null;
|
||||||
|
if (($state['phase'] ?? null) !== 'files' || !is_array($estimate) || ($estimate['bytes'] ?? 0) <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$done = (int)($state['stats']['bytes'] ?? 0) + (int)($state['cur']['offset'] ?? 0);
|
||||||
|
return ['fraction' => min(0.99, $done / (int)$estimate['bytes']), 'approximate' => (bool)($estimate['approximate'] ?? true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* progress() of a run, while it is in its files phase.
|
||||||
|
*
|
||||||
|
* @return array{fraction: float, approximate: bool}|null
|
||||||
|
*/
|
||||||
|
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
|
||||||
|
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || $run->getPhase() !== 'files') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return is_array($state['files'] ?? null) ? self::progress($state['files']) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file or directory that could not be read: it is missing from this snapshot. Remembered (first
|
* A file or directory that could not be read: it is missing from this snapshot. Remembered (first
|
||||||
* 1000, deduplicated across steps) so the run can report it instead of silently succeeding.
|
* 1000, deduplicated across steps) so the run can report it instead of silently succeeding.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Migration;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Migration\IOutput;
|
||||||
|
use OCP\Migration\SimpleMigrationStep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removing a location left the cached index of its repository behind (tens of thousands of rows
|
||||||
|
* for a large backup). Drop the cache of repositories no location uses any more.
|
||||||
|
*/
|
||||||
|
final class Version000006Date20261018000000 extends SimpleMigrationStep {
|
||||||
|
public function __construct(
|
||||||
|
private IDBConnection $db,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||||
|
$used = $this->db->getQueryBuilder();
|
||||||
|
$used->select('repository_id')->from('ngb_targets')->where($used->expr()->isNotNull('repository_id'));
|
||||||
|
$keep = array_column($used->executeQuery()->fetchAll(), 'repository_id');
|
||||||
|
$delete = $this->db->getQueryBuilder();
|
||||||
|
$delete->delete('ngb_index');
|
||||||
|
if ($keep !== []) {
|
||||||
|
$delete->where($delete->expr()->notIn('repository_id', $delete->createNamedParameter($keep, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
|
||||||
|
}
|
||||||
|
$removed = $delete->executeStatement();
|
||||||
|
if ($removed > 0) {
|
||||||
|
$output->info("Removed $removed cached index rows of locations that no longer exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,10 @@ final class Notifier implements INotifier {
|
|||||||
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
|
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
|
||||||
break;
|
break;
|
||||||
case 'backup_stale':
|
case 'backup_stale':
|
||||||
$notification->setParsedSubject($l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
|
// 'never' was stored by 0.9.4 and earlier
|
||||||
|
$notification->setParsedSubject(in_array($p['since'] ?? '', ['', 'never'], true)
|
||||||
|
? $l->t('No successful backup to %s yet', [$p['target']])
|
||||||
|
: $l->t('No successful backup to %1$s since %2$s', [$p['target'], $p['since']]));
|
||||||
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
|
$notification->setParsedMessage($l->t('Check the backup settings and the server log.'));
|
||||||
break;
|
break;
|
||||||
case 'security_event':
|
case 'security_event':
|
||||||
@@ -59,6 +62,7 @@ final class Notifier implements INotifier {
|
|||||||
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
||||||
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
||||||
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
|
'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']),
|
||||||
|
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
|
||||||
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
|
'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']),
|
||||||
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
|
'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']),
|
||||||
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
|
'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']),
|
||||||
|
|||||||
+183
-26
@@ -7,6 +7,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace OCA\NgBackup\Repository;
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
use OCA\NgBackup\Backend\IBackend;
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
use OCA\NgBackup\Crypto\StreamCipher;
|
use OCA\NgBackup\Crypto\StreamCipher;
|
||||||
|
|
||||||
@@ -25,9 +26,15 @@ final class BlobIndex {
|
|||||||
private array $pending = [];
|
private array $pending = [];
|
||||||
|
|
||||||
private ?IIndexCache $cache = null;
|
private ?IIndexCache $cache = null;
|
||||||
|
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
|
||||||
|
private ?array $heldCacheWrites = null;
|
||||||
private string $repositoryId = '';
|
private string $repositoryId = '';
|
||||||
|
/** Index files written to the cache per database transaction while loading. */
|
||||||
|
private const CACHE_LOT = 256;
|
||||||
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
||||||
public int $downloaded = 0;
|
public int $downloaded = 0;
|
||||||
|
/** @var array<string, true> packs whose index file is loaded (refresh() fetches only others) */
|
||||||
|
private array $loaded = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
@@ -40,52 +47,124 @@ final class BlobIndex {
|
|||||||
$this->repositoryId = $repositoryId;
|
$this->repositoryId = $repositoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold back cache writes until releaseCache(). The database dump reads inside a read-only
|
||||||
|
* transaction on the same connection that the cache writes to: a write there fails on
|
||||||
|
* PostgreSQL and would be rolled back with the dump transaction elsewhere.
|
||||||
|
*/
|
||||||
|
public function holdCache(): void {
|
||||||
|
$this->heldCacheWrites ??= [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the cache changes held back since holdCache(). */
|
||||||
|
public function releaseCache(): void {
|
||||||
|
$writes = $this->heldCacheWrites ?? [];
|
||||||
|
$this->heldCacheWrites = null;
|
||||||
|
foreach ($writes as $write) {
|
||||||
|
$write();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||||
|
private function cachePut(string $packId, array $entries): void {
|
||||||
|
if ($this->heldCacheWrites !== null) {
|
||||||
|
$this->heldCacheWrites[] = function () use ($packId, $entries): void {
|
||||||
|
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $packIds */
|
||||||
|
private function cacheRemove(array $packIds): void {
|
||||||
|
if ($this->heldCacheWrites !== null) {
|
||||||
|
$this->heldCacheWrites[] = function () use ($packIds): void {
|
||||||
|
$this->cache?->remove($this->repositoryId, $packIds);
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->cache?->remove($this->repositoryId, $packIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the index: one listing of index/, entries from the local cache where available,
|
* Load the index: one listing of index/, entries from the local cache where available,
|
||||||
* and only the index files that are not cached yet are downloaded. Cached packs that no
|
* and only the index files that are not cached yet are downloaded. Cached packs that no
|
||||||
* longer exist on the backend are dropped from the cache.
|
* longer exist on the backend are dropped from the cache.
|
||||||
*/
|
*/
|
||||||
public function load(bool $verify = false): void {
|
public function load(bool $verify = false, ?callable $progress = null): void {
|
||||||
$this->blobs = [];
|
$hadIndex = $this->blobs !== [];
|
||||||
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||||
$present = [];
|
$present = [];
|
||||||
$this->downloaded = 0;
|
$this->downloaded = 0;
|
||||||
foreach ($this->backend->list('index') as $path) {
|
$order = []; // pack ids in listing order: where a blob is in two packs, the last listed wins
|
||||||
|
$fetch = [];
|
||||||
|
$known = array_keys($this->loaded + $cached + ($verify ? ($this->cache?->all($this->repositoryId) ?? []) : []));
|
||||||
|
foreach ($this->listIndex($known) as $path) {
|
||||||
$packId = basename($path);
|
$packId = basename($path);
|
||||||
$present[$packId] = true;
|
$present[$packId] = true;
|
||||||
if (isset($cached[$packId])) {
|
$order[] = $packId;
|
||||||
$entries = $cached[$packId];
|
if (!isset($cached[$packId])) {
|
||||||
} else {
|
$fetch[] = $path;
|
||||||
$fh = $this->backend->get($path);
|
|
||||||
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
fclose($fh);
|
|
||||||
$this->downloaded++;
|
|
||||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
|
||||||
}
|
|
||||||
foreach ($entries as $e) {
|
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($verify && $this->cache !== null) {
|
// only now: an empty listing that listIndex() refused leaves the index as it was
|
||||||
// Refresh the cache from what was just verified.
|
$this->blobs = [];
|
||||||
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
|
$this->loaded = $present;
|
||||||
foreach ($this->packs() as $packId => $_) {
|
// The rest is downloaded several at a time where the location allows it: on a large
|
||||||
$entries = [];
|
// repository these are tens of thousands of small files. getMany() keeps their order, so
|
||||||
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
|
// the cached packs listed before each one are added first.
|
||||||
if ($pack === $packId) {
|
$next = 0;
|
||||||
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
|
$total = count($fetch);
|
||||||
}
|
$toCache = []; // written to the cache a few hundred at a time, each lot in one transaction
|
||||||
|
foreach ($this->backend->getMany($fetch) as $path => $data) {
|
||||||
|
$packId = basename($path);
|
||||||
|
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->downloaded++;
|
||||||
|
if ($this->cache !== null) {
|
||||||
|
$toCache[$packId] = $entries;
|
||||||
|
// a check writes the whole cache afresh at the end
|
||||||
|
if (!$verify && count($toCache) >= self::CACHE_LOT) {
|
||||||
|
$this->cache->putMany($this->repositoryId, $toCache);
|
||||||
|
$toCache = [];
|
||||||
}
|
}
|
||||||
$this->cache->put($this->repositoryId, $packId, $entries);
|
|
||||||
}
|
}
|
||||||
|
for (; $next < count($order) && $order[$next] !== $packId; $next++) {
|
||||||
|
$this->add($order[$next], $cached[$order[$next]] ?? []);
|
||||||
|
}
|
||||||
|
$this->add($packId, $entries);
|
||||||
|
$next++;
|
||||||
|
if ($progress !== null) {
|
||||||
|
$progress($this->downloaded, $total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (; $next < count($order); $next++) {
|
||||||
|
$this->add($order[$next], $cached[$order[$next]] ?? []);
|
||||||
|
}
|
||||||
|
if ($verify && $this->cache !== null) {
|
||||||
|
// Refresh the cache from what was just verified, as the index files list it: a blob in
|
||||||
|
// two packs is in both rows, so that removing one of the packs leaves the other right.
|
||||||
|
// In one transaction: one row at a time took more than 6 minutes on a 1 TB repository
|
||||||
|
// (27,547 packs), long enough for the location to drop the connection.
|
||||||
|
$this->cache->replaceAll($this->repositoryId, $toCache);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($toCache !== []) {
|
||||||
|
$this->cache?->putMany($this->repositoryId, $toCache);
|
||||||
|
}
|
||||||
$stale = array_keys(array_diff_key($cached, $present));
|
$stale = array_keys(array_diff_key($cached, $present));
|
||||||
if ($stale !== []) {
|
if ($stale !== []) {
|
||||||
$this->cache?->remove($this->repositoryId, $stale);
|
$this->cache?->remove($this->repositoryId, $stale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||||
|
private function add(string $packId, array $entries): void {
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function has(string $id): bool {
|
public function has(string $id): bool {
|
||||||
return isset($this->blobs[$id]) || isset($this->pending[$id]);
|
return isset($this->blobs[$id]) || isset($this->pending[$id]);
|
||||||
}
|
}
|
||||||
@@ -112,13 +191,81 @@ final class BlobIndex {
|
|||||||
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
||||||
rewind($stream);
|
rewind($stream);
|
||||||
$this->backend->put('index/' . $packId, $stream);
|
$this->backend->put('index/' . $packId, $stream);
|
||||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
$this->cachePut($packId, $entries);
|
||||||
|
$this->loaded[$packId] = true;
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
unset($this->pending[$e['id']]);
|
unset($this->pending[$e['id']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the index files that appeared on the location since load(), such as those of packs
|
||||||
|
* another server's cleanup wrote while a restore runs. Only adds and updates: a blob is
|
||||||
|
* never dropped, as a listing that misses files (a connection that failed) must not make a
|
||||||
|
* restore lose blobs it knows. A blob in a new pack takes that pack's place.
|
||||||
|
*/
|
||||||
|
public function refresh(): void {
|
||||||
|
$fetch = [];
|
||||||
|
$listed = 0;
|
||||||
|
foreach ($this->listIndex(array_keys($this->loaded)) as $path) {
|
||||||
|
$listed++;
|
||||||
|
if (!isset($this->loaded[basename($path)])) {
|
||||||
|
$fetch[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$toCache = [];
|
||||||
|
foreach ($this->backend->getMany($fetch) as $path => $data) {
|
||||||
|
$packId = basename($path);
|
||||||
|
/** @var list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries authenticated: written by NG Backup */
|
||||||
|
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->downloaded++;
|
||||||
|
$this->add($packId, $entries);
|
||||||
|
$this->loaded[$packId] = true;
|
||||||
|
$toCache[$packId] = $entries;
|
||||||
|
}
|
||||||
|
if ($toCache !== []) {
|
||||||
|
$this->cache?->putMany($this->repositoryId, $toCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The index files on the location. An empty listing while index files are known ($known,
|
||||||
|
* pack ids) is usually a connection that failed quietly; taken as is, it would empty the
|
||||||
|
* index and the cache, make a check report every blob missing, or make a cleanup wipe the
|
||||||
|
* cache. So the connection is checked (the repository's config must be there) and the folder
|
||||||
|
* listed once more. Empty again, three of the known index files are looked up one by one:
|
||||||
|
* only when they are gone too is the repository taken as empty (another server removed its
|
||||||
|
* last snapshot and cleaned up). Even when that is wrong, nothing on the location is lost:
|
||||||
|
* only this index and the cache are empty until the next load.
|
||||||
|
*
|
||||||
|
* @param list<string> $known
|
||||||
|
* @return list<string>
|
||||||
|
* @throws BackendException when the location does not answer as it should
|
||||||
|
*/
|
||||||
|
private function listIndex(array $known): array {
|
||||||
|
$paths = $this->backend->listFiles('index');
|
||||||
|
if ($paths !== [] || $known === []) {
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
if (!$this->backend->exists('config')) {
|
||||||
|
throw new BackendException('The location listed no index files and has no repository config: not trusting that listing');
|
||||||
|
}
|
||||||
|
$paths = $this->backend->listFiles('index');
|
||||||
|
if ($paths !== []) {
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
// spread over the list: first, middle and last (another server's cleanup removes packs
|
||||||
|
// here and there, not in this order)
|
||||||
|
$n = count($known);
|
||||||
|
foreach (array_unique([$known[0], $known[intdiv($n, 2)], $known[$n - 1]]) as $packId) {
|
||||||
|
if ($this->backend->exists('index/' . $packId)) {
|
||||||
|
throw new BackendException('The location listed no index files, while index/' . $packId . ' is there: not trusting that listing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
|
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
|
||||||
public function packs(): array {
|
public function packs(): array {
|
||||||
$packs = [];
|
$packs = [];
|
||||||
@@ -131,7 +278,8 @@ final class BlobIndex {
|
|||||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
||||||
public function removePack(string $packId): void {
|
public function removePack(string $packId): void {
|
||||||
$this->backend->delete('index/' . $packId);
|
$this->backend->delete('index/' . $packId);
|
||||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
$this->cacheRemove([$packId]);
|
||||||
|
unset($this->loaded[$packId]);
|
||||||
foreach ($this->blobs as $id => $loc) {
|
foreach ($this->blobs as $id => $loc) {
|
||||||
if ($loc[0] === $packId) {
|
if ($loc[0] === $packId) {
|
||||||
unset($this->blobs[$id]);
|
unset($this->blobs[$id]);
|
||||||
@@ -139,6 +287,15 @@ final class BlobIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Number of packs that hold blobs of the index. */
|
||||||
|
public function packCount(): int {
|
||||||
|
$packs = [];
|
||||||
|
foreach ($this->blobs as [$pack]) {
|
||||||
|
$packs[$pack] = true;
|
||||||
|
}
|
||||||
|
return count($packs);
|
||||||
|
}
|
||||||
|
|
||||||
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
|
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
|
||||||
public function storedBytes(): int {
|
public function storedBytes(): int {
|
||||||
$total = 0;
|
$total = 0;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ final class Catalog {
|
|||||||
|
|
||||||
/** @return list<int> */
|
/** @return list<int> */
|
||||||
private function generations(): array {
|
private function generations(): array {
|
||||||
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
|
return array_map(fn ($p) => (int)basename($p), $this->backend->listFiles('catalog'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function path(int $gen): string {
|
private function path(int $gen): string {
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ interface IIndexCache {
|
|||||||
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||||
public function put(string $repositoryId, string $packId, array $entries): void;
|
public function put(string $repositoryId, string $packId, array $entries): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* put() for many packs at once (one database transaction).
|
||||||
|
*
|
||||||
|
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
|
||||||
|
*/
|
||||||
|
public function putMany(string $repositoryId, array $packs): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace everything cached for the repository with $packs (one database transaction).
|
||||||
|
*
|
||||||
|
* @param array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> $packs packId => entries
|
||||||
|
*/
|
||||||
|
public function replaceAll(string $repositoryId, array $packs): void;
|
||||||
|
|
||||||
/** @param list<string> $packIds */
|
/** @param list<string> $packIds */
|
||||||
public function remove(string $repositoryId, array $packIds): void;
|
public function remove(string $repositoryId, array $packIds): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads blobs from packs. Restores and deep verifies read the blobs of a snapshot in the order
|
||||||
|
* they were written, so the stream of the pack read last is kept open and read on from where it
|
||||||
|
* is: one request per pack instead of one per blob (a round trip each, and on most remote
|
||||||
|
* storages a new connection or file handle too). Anything else opens the pack at the offset,
|
||||||
|
* as a single read always did.
|
||||||
|
*
|
||||||
|
* The open stream must not overlap other requests on the location (SFTP cannot read and write
|
||||||
|
* on one connection at once): Repository reaches the location through a PackReaderGuard, which
|
||||||
|
* closes it first.
|
||||||
|
*/
|
||||||
|
final class PackReader {
|
||||||
|
/** A gap up to this size in the open stream is read past instead of opening the pack again. */
|
||||||
|
private const SKIP_MAX = 8 * 1048576;
|
||||||
|
private const CHUNK = 1048576;
|
||||||
|
|
||||||
|
/** @var resource|null */
|
||||||
|
private $stream = null;
|
||||||
|
private string $pack = '';
|
||||||
|
private int $pos = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $backend,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $pack, int $offset, int $length): string {
|
||||||
|
if ($this->stream !== null && $this->pack === $pack && $offset >= $this->pos && $offset - $this->pos <= self::SKIP_MAX) {
|
||||||
|
try {
|
||||||
|
$this->skipTo($offset);
|
||||||
|
return $this->readExactly($length);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// The open stream broke (e.g. a timeout); open the pack again below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->close();
|
||||||
|
$fh = $this->backend->get('packs/' . substr($pack, 0, 2) . '/' . $pack);
|
||||||
|
$this->stream = $fh;
|
||||||
|
$this->pack = $pack;
|
||||||
|
$this->pos = 0;
|
||||||
|
try {
|
||||||
|
if ($offset > 0 && (stream_get_meta_data($fh)['seekable'] ?? false) && fseek($fh, $offset) === 0) {
|
||||||
|
$this->pos = $offset;
|
||||||
|
}
|
||||||
|
$this->skipTo($offset); // a stream that cannot seek is read up to the offset
|
||||||
|
return $this->readExactly($length);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->close();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void {
|
||||||
|
$fh = $this->stream;
|
||||||
|
$this->stream = null;
|
||||||
|
if ($fh !== null) {
|
||||||
|
@fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct() {
|
||||||
|
$this->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipTo(int $offset): void {
|
||||||
|
while ($this->pos < $offset) {
|
||||||
|
$this->readExactly(min($offset - $this->pos, self::CHUNK));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read $length bytes from the open stream; a short read closes it. */
|
||||||
|
private function readExactly(int $length): string {
|
||||||
|
$fh = $this->stream ?? throw new RepositoryException('No open pack stream');
|
||||||
|
$data = '';
|
||||||
|
while (strlen($data) < $length) {
|
||||||
|
$chunk = fread($fh, min($length - strlen($data), self::CHUNK));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
$this->close();
|
||||||
|
throw new RepositoryException('Short read in pack ' . $this->pack);
|
||||||
|
}
|
||||||
|
$data .= $chunk;
|
||||||
|
}
|
||||||
|
$this->pos += $length;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\IReadsAhead;
|
||||||
|
|
||||||
|
/** The location as Repository sees it: closes the PackReader's open stream before every other request. */
|
||||||
|
final class PackReaderGuard implements IBackend, IReadsAhead {
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
private PackReader $reader,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $stream */
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMany(array $paths): \Generator {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->getMany($paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRanges(array $ranges): \Generator {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->getRanges($ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canReadAhead(): bool {
|
||||||
|
return $this->inner instanceof IReadsAhead && $this->inner->canReadAhead();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Over connections of the location's own: the open stream may stay open. */
|
||||||
|
public function readAhead(array $ranges, string $spool): ?\Generator {
|
||||||
|
return $this->inner instanceof IReadsAhead ? $this->inner->readAhead($ranges, $spool) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ranges of packs a restore reads, in order, for IReadsAhead::readAhead(), and the reading of
|
||||||
|
* blobs from them as the restore goes.
|
||||||
|
*
|
||||||
|
* add() takes the blobs in the order the restore needs them. A blob in the same pack as the range
|
||||||
|
* before it, not before that range and at most GAP after its end, extends that range; anything
|
||||||
|
* else starts a new one. read() takes the same blobs in the same order: a blob is in the current
|
||||||
|
* range or in the next one, which then becomes current; a blob not in the plan is read elsewhere.
|
||||||
|
*/
|
||||||
|
final class ReadAheadPlan {
|
||||||
|
/** Unused bytes between two blobs of a pack that are read along rather than asked for apart. */
|
||||||
|
public const GAP = 4 * 1048576;
|
||||||
|
/** Longest range: packs are 32 MiB, repacked ones too. */
|
||||||
|
public const MAX_RANGE = 64 * 1048576;
|
||||||
|
/** Ranges after the current one that read() looks in for a blob. */
|
||||||
|
private const SEARCH = 16;
|
||||||
|
|
||||||
|
/** @var list<array{0:string, 1:int, 2:int}> [path, offset, length] */
|
||||||
|
private array $ranges = [];
|
||||||
|
/** @var \Generator<int, resource|null>|null */
|
||||||
|
private ?\Generator $streams = null;
|
||||||
|
private int $current = -1;
|
||||||
|
/** @var resource|null */
|
||||||
|
private $stream = null;
|
||||||
|
|
||||||
|
/** @param string $spool local folder for the ranges read ahead (see IReadsAhead::readAhead()) */
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $spool,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(string $path, int $offset, int $length): void {
|
||||||
|
$last = array_pop($this->ranges);
|
||||||
|
if ($last !== null) {
|
||||||
|
[$p, $start, $len] = $last;
|
||||||
|
if ($p === $path && $offset >= $start && $offset <= $start + $len + self::GAP
|
||||||
|
&& $offset + $length - $start <= self::MAX_RANGE) {
|
||||||
|
$this->ranges[] = [$p, $start, max($len, $offset + $length - $start)];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->ranges[] = $last;
|
||||||
|
}
|
||||||
|
$this->ranges[] = [$path, $offset, $length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{0:string, 1:int, 2:int}> */
|
||||||
|
public function ranges(): array {
|
||||||
|
return $this->ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param \Generator<int, resource|null> $streams what IReadsAhead::readAhead() gave for ranges() */
|
||||||
|
public function readFrom(\Generator $streams): void {
|
||||||
|
$this->streams = $streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes of a blob, or null when its range could not be read ahead (read it the usual way). */
|
||||||
|
public function read(string $path, int $offset, int $length): ?string {
|
||||||
|
if ($this->streams === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!$this->contains($this->current, $path, $offset, $length)) {
|
||||||
|
// Normally the next range. Further on when blobs before it were read elsewhere: the
|
||||||
|
// index was reloaded after another server's cleanup moved them to new packs.
|
||||||
|
$to = null;
|
||||||
|
for ($i = $this->current + 1; $i <= $this->current + self::SEARCH && $to === null; $i++) {
|
||||||
|
if ($this->contains($i, $path, $offset, $length)) {
|
||||||
|
$to = $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($to === null) {
|
||||||
|
return null; // not where the plan has it
|
||||||
|
}
|
||||||
|
if ($this->current < 0) {
|
||||||
|
$this->streams->current();
|
||||||
|
$this->current = 0;
|
||||||
|
}
|
||||||
|
while ($this->current < $to) {
|
||||||
|
$this->streams->next();
|
||||||
|
$this->current++;
|
||||||
|
}
|
||||||
|
$stream = $this->streams->valid() ? $this->streams->current() : null;
|
||||||
|
$this->stream = is_resource($stream) ? $stream : null;
|
||||||
|
}
|
||||||
|
$fh = $this->stream;
|
||||||
|
if ($fh === null || fseek($fh, $offset - $this->ranges[$this->current][1]) !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = '';
|
||||||
|
while (strlen($data) < $length) {
|
||||||
|
$chunk = fread($fh, $length - strlen($data));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data .= $chunk;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the reads ahead, also halfway: closes their streams and connections. */
|
||||||
|
public function close(): void {
|
||||||
|
$this->stream = null;
|
||||||
|
$streams = $this->streams;
|
||||||
|
$this->streams = null;
|
||||||
|
unset($streams); // the generator's finally blocks run when it is destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
private function contains(int $i, string $path, int $offset, int $length): bool {
|
||||||
|
if ($i < 0 || !isset($this->ranges[$i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
[$p, $start, $len] = $this->ranges[$i];
|
||||||
|
return $p === $path && $offset >= $start && $offset + $length <= $start + $len;
|
||||||
|
}
|
||||||
|
}
|
||||||
+322
-29
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Repository;
|
|||||||
|
|
||||||
use OCA\NgBackup\Backend\BackendException;
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
use OCA\NgBackup\Backend\IBackend;
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\IReadsAhead;
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
use OCA\NgBackup\Crypto\StreamCipher;
|
use OCA\NgBackup\Crypto\StreamCipher;
|
||||||
|
|
||||||
@@ -28,14 +29,23 @@ final class Repository {
|
|||||||
public const FORMAT = 1;
|
public const FORMAT = 1;
|
||||||
public const BLOB_SIZE = 4 * 1048576;
|
public const BLOB_SIZE = 4 * 1048576;
|
||||||
private const FLAG_DEFLATE = 1;
|
private const FLAG_DEFLATE = 1;
|
||||||
|
/** Blobs from this size on get a quick compression test before the full compression. */
|
||||||
|
private const DEFLATE_PROBE_FROM = 256 * 1024;
|
||||||
|
/** Bytes of each of the three slices (start, middle, end) in that test. */
|
||||||
|
private const DEFLATE_PROBE_SLICE = 32 * 1024;
|
||||||
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
|
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
|
||||||
private const VERIFY_READ_ATTEMPTS = 3;
|
private const VERIFY_READ_ATTEMPTS = 3;
|
||||||
|
/** Largest read that loadBlobs() makes of blobs that follow each other in a pack. */
|
||||||
|
private const MERGED_READ_MAX = 16 * 1048576;
|
||||||
|
|
||||||
private StreamCipher $cipher;
|
private StreamCipher $cipher;
|
||||||
private BlobIndex $index;
|
private BlobIndex $index;
|
||||||
private ?PackWriter $runPacks = null;
|
private ?PackWriter $runPacks = null;
|
||||||
private ?int $maxBytes = null;
|
private ?int $maxBytes = null;
|
||||||
private Catalog $catalog;
|
private Catalog $catalog;
|
||||||
|
private PackReader $packReader;
|
||||||
|
/** verify() lists the pack folders from this many packs on (see presentPacks()); tests lower it. */
|
||||||
|
public int $listPacksAbove = 256;
|
||||||
|
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
@@ -43,7 +53,9 @@ final class Repository {
|
|||||||
private string $repositoryId,
|
private string $repositoryId,
|
||||||
) {
|
) {
|
||||||
$this->cipher = new StreamCipher($keys);
|
$this->cipher = new StreamCipher($keys);
|
||||||
$this->index = new BlobIndex($backend, $this->cipher);
|
$this->packReader = new PackReader($backend);
|
||||||
|
$this->backend = new PackReaderGuard($backend, $this->packReader);
|
||||||
|
$this->index = new BlobIndex($this->backend, $this->cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
@@ -60,6 +72,14 @@ final class Repository {
|
|||||||
if ($backend->exists('config')) {
|
if ($backend->exists('config')) {
|
||||||
throw new RepositoryException('A repository already exists at this location');
|
throw new RepositoryException('A repository already exists at this location');
|
||||||
}
|
}
|
||||||
|
// No config, but backup data: the config is missing (or could not be seen just now, a
|
||||||
|
// connection that failed). A new repository here would write a config over the one that
|
||||||
|
// opens this data; refuse instead.
|
||||||
|
foreach (['index', 'snapshots', 'trash/snapshots', 'catalog'] as $dir) {
|
||||||
|
if ($backend->listFiles($dir) !== []) {
|
||||||
|
throw new RepositoryException("This location holds backup data ($dir/) but no repository config could be read: not creating a new repository over it. Check the connection and the folder; a missing config can be restored from the recovery kit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
$id = bin2hex(random_bytes(16));
|
$id = bin2hex(random_bytes(16));
|
||||||
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
|
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
|
||||||
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
|
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
|
||||||
@@ -70,7 +90,8 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
|
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
|
||||||
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null): self {
|
/** @param ?callable(int, int): void $progress index files downloaded so far, and in all (see BlobIndex::load()) */
|
||||||
|
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null, ?callable $progress = null): self {
|
||||||
$config = self::readConfig($backend);
|
$config = self::readConfig($backend);
|
||||||
try {
|
try {
|
||||||
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
|
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
|
||||||
@@ -84,7 +105,7 @@ final class Repository {
|
|||||||
if ($cache !== null) {
|
if ($cache !== null) {
|
||||||
$repo->index->setCache($cache, $config['id']);
|
$repo->index->setCache($cache, $config['id']);
|
||||||
}
|
}
|
||||||
$repo->index->load();
|
$repo->index->load(false, $progress);
|
||||||
$repo->loadCatalog($anchor);
|
$repo->loadCatalog($anchor);
|
||||||
return $repo;
|
return $repo;
|
||||||
}
|
}
|
||||||
@@ -92,8 +113,8 @@ final class Repository {
|
|||||||
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
||||||
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
||||||
$this->catalog->load(
|
$this->catalog->load(
|
||||||
fn () => array_map('basename', $this->backend->list('snapshots')),
|
fn () => array_map('basename', $this->backend->listFiles('snapshots')),
|
||||||
fn () => array_map('basename', $this->backend->list('trash/info')),
|
fn () => array_map('basename', $this->backend->listFiles('trash/info')),
|
||||||
fn () => $this->backend->list('users'));
|
fn () => $this->backend->list('users'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +225,66 @@ final class Repository {
|
|||||||
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);
|
||||||
|
try {
|
||||||
|
return $this->restoreEntries($snapshotId, $target, $prefix, $stripPrefix, $heartbeat, $plan);
|
||||||
|
} finally {
|
||||||
|
$plan?->close();
|
||||||
|
if ($plan !== null) {
|
||||||
|
@rmdir($plan->spool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start reading the packs of a restore ahead, when the location can (IReadsAhead): a first
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private function readAhead(string $snapshotId, string $prefix, string $target): ?ReadAheadPlan {
|
||||||
|
if (!$this->backend instanceof IReadsAhead || !$this->backend->canReadAhead()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// what a restore that was stopped left behind: empty, its files were unlinked when opened
|
||||||
|
$stale = glob(rtrim($target, '/') . '/.ngb-restore-spool-*', GLOB_ONLYDIR);
|
||||||
|
foreach ($stale === false ? [] : $stale as $dir) {
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
$plan = new ReadAheadPlan(rtrim($target, '/') . '/.ngb-restore-spool-' . bin2hex(random_bytes(4)));
|
||||||
|
try {
|
||||||
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
|
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($entry['b'] as $id) {
|
||||||
|
try {
|
||||||
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
|
} catch (RepositoryException) {
|
||||||
|
continue; // the restore reports it when it gets there
|
||||||
|
}
|
||||||
|
$plan->add('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null; // reading ahead is only faster: the restore itself reads the snapshot again and reports what is wrong
|
||||||
|
}
|
||||||
|
if (count($plan->ranges()) < 2 || (!is_dir($plan->spool) && !@mkdir($plan->spool, 0700, true))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$streams = $this->backend->readAhead($plan->ranges(), $plan->spool);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$streams = null; // the usual reads report what is wrong
|
||||||
|
}
|
||||||
|
if ($streams === null) {
|
||||||
|
@rmdir($plan->spool);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$plan->readFrom($streams);
|
||||||
|
return $plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restoreEntries(string $snapshotId, string $target, string $prefix, bool $stripPrefix, ?callable $heartbeat, ?ReadAheadPlan $plan): int {
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
|
if ($prefix !== '' && !str_starts_with($entry['p'], $prefix)) {
|
||||||
@@ -228,7 +309,7 @@ final class Repository {
|
|||||||
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
|
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
|
||||||
}
|
}
|
||||||
foreach ($entry['b'] as $id) {
|
foreach ($entry['b'] as $id) {
|
||||||
fwrite($out, $this->loadBlob($id));
|
fwrite($out, $this->restoreBlob($id, $plan));
|
||||||
if ($heartbeat !== null) {
|
if ($heartbeat !== null) {
|
||||||
$heartbeat();
|
$heartbeat();
|
||||||
}
|
}
|
||||||
@@ -262,6 +343,15 @@ final class Repository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See BlobIndex::holdCache(): no database writes until releaseIndexCache(). */
|
||||||
|
public function holdIndexCache(): void {
|
||||||
|
$this->index->holdCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseIndexCache(): void {
|
||||||
|
$this->index->releaseCache();
|
||||||
|
}
|
||||||
|
|
||||||
public function flushPacks(): void {
|
public function flushPacks(): void {
|
||||||
$this->runPacks?->flush();
|
$this->runPacks?->flush();
|
||||||
}
|
}
|
||||||
@@ -274,8 +364,8 @@ final class Repository {
|
|||||||
*/
|
*/
|
||||||
public function readLines(array $blobIds): \Generator {
|
public function readLines(array $blobIds): \Generator {
|
||||||
$carry = '';
|
$carry = '';
|
||||||
foreach ($blobIds as $id) {
|
foreach ($this->loadBlobs($blobIds) as $blob) {
|
||||||
$data = $carry . $this->loadBlob($id);
|
$data = $carry . $blob;
|
||||||
$lines = explode("\n", $data);
|
$lines = explode("\n", $data);
|
||||||
$carry = array_pop($lines);
|
$carry = array_pop($lines);
|
||||||
yield from $lines;
|
yield from $lines;
|
||||||
@@ -634,7 +724,7 @@ final class Repository {
|
|||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public function listSnapshots(): array {
|
public function listSnapshots(): array {
|
||||||
$present = array_flip(array_map('basename', $this->backend->list('snapshots')));
|
$present = array_flip(array_map('basename', $this->backend->listFiles('snapshots')));
|
||||||
$listed = array_keys($this->catalog->snapshots);
|
$listed = array_keys($this->catalog->snapshots);
|
||||||
foreach ($listed as $id) {
|
foreach ($listed as $id) {
|
||||||
if (!isset($present[$id])) {
|
if (!isset($present[$id])) {
|
||||||
@@ -687,7 +777,7 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
$flags = 0;
|
$flags = 0;
|
||||||
$payload = $data;
|
$payload = $data;
|
||||||
$deflated = gzdeflate($data, 6);
|
$deflated = self::worthDeflating($data) ? gzdeflate($data, 6) : false;
|
||||||
if ($deflated !== false && strlen($deflated) < strlen($data) * 0.95) {
|
if ($deflated !== false && strlen($deflated) < strlen($data) * 0.95) {
|
||||||
$payload = $deflated;
|
$payload = $deflated;
|
||||||
$flags |= self::FLAG_DEFLATE;
|
$flags |= self::FLAG_DEFLATE;
|
||||||
@@ -697,9 +787,162 @@ final class Repository {
|
|||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photos, video and archives are compressed already: deflating them takes most of a backup's
|
||||||
|
* CPU time (about 24 MiB/s) only to be thrown away, as it saves less than the 5% that keeps
|
||||||
|
* it. A fast deflate of three small slices tells first; below 10% savings there, the full
|
||||||
|
* deflate is skipped.
|
||||||
|
*/
|
||||||
|
private static function worthDeflating(string $data): bool {
|
||||||
|
$n = strlen($data);
|
||||||
|
if ($n < self::DEFLATE_PROBE_FROM) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$k = self::DEFLATE_PROBE_SLICE;
|
||||||
|
$probe = substr($data, 0, $k) . substr($data, intdiv($n - $k, 2), $k) . substr($data, $n - $k);
|
||||||
|
$deflated = gzdeflate($probe, 1);
|
||||||
|
return $deflated !== false && strlen($deflated) * 10 < strlen($probe) * 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blobs spread over many packs (the tree segments of a run, one per step), through
|
||||||
|
* IBackend::getRanges(): on a remote location one at a time is a round trip each, which for
|
||||||
|
* a long run made writing its snapshot take a quarter of an hour. Blobs that follow each
|
||||||
|
* other in a pack are read as one range.
|
||||||
|
*
|
||||||
|
* @param list<string> $ids
|
||||||
|
* @return \Generator<int, string> verified blob contents, in order
|
||||||
|
*/
|
||||||
|
private function loadBlobs(array $ids): \Generator {
|
||||||
|
/** @var list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] of each read */
|
||||||
|
$ranges = [];
|
||||||
|
/** @var list<list<array{0:string, 1:int, 2:int}>> $parts per read: [blob id, offset in the read, length] */
|
||||||
|
$parts = [];
|
||||||
|
// the read being extended
|
||||||
|
$curPath = null;
|
||||||
|
$curOffset = 0;
|
||||||
|
$curLength = 0;
|
||||||
|
$curParts = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
|
$path = 'packs/' . substr($pack, 0, 2) . '/' . $pack;
|
||||||
|
if ($curPath === $path && $curOffset + $curLength === $offset && $curLength + $length <= self::MERGED_READ_MAX) {
|
||||||
|
$curParts[] = [$id, $curLength, $length];
|
||||||
|
$curLength += $length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($curPath !== null) {
|
||||||
|
$ranges[] = [$curPath, $curOffset, $curLength];
|
||||||
|
$parts[] = $curParts;
|
||||||
|
}
|
||||||
|
[$curPath, $curOffset, $curLength, $curParts] = [$path, $offset, $length, [[$id, 0, $length]]];
|
||||||
|
}
|
||||||
|
if ($curPath !== null) {
|
||||||
|
$ranges[] = [$curPath, $curOffset, $curLength];
|
||||||
|
$parts[] = $curParts;
|
||||||
|
}
|
||||||
|
$done = 0;
|
||||||
|
try {
|
||||||
|
foreach ($this->backend->getRanges($ranges) as $i => $data) {
|
||||||
|
foreach ($parts[$i] as [$id, $at, $length]) {
|
||||||
|
try {
|
||||||
|
$blob = $this->decodeBlob($id, substr($data, $at, $length));
|
||||||
|
} catch (\Exception) {
|
||||||
|
// a read that went wrong on the way (see loadBlob()): this blob on its own
|
||||||
|
$blob = $this->loadBlob($id);
|
||||||
|
// its pack stream is closed before the next range is read on the same connection
|
||||||
|
$this->packReader->close();
|
||||||
|
}
|
||||||
|
yield $blob;
|
||||||
|
$done++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (BackendException) {
|
||||||
|
// A pack moved, or the read failed for a while (see loadBlob()): the rest one blob at a
|
||||||
|
// time, each read once more after a failure.
|
||||||
|
foreach (array_slice($ids, $done) as $id) {
|
||||||
|
yield $this->loadBlob($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A blob for restore(): read ahead when it could be, otherwise (or when that copy is bad) the usual way. */
|
||||||
|
private function restoreBlob(string $id, ?ReadAheadPlan $plan): string {
|
||||||
|
if ($plan !== null) {
|
||||||
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
|
$encrypted = $plan->read('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
||||||
|
if ($encrypted !== null) {
|
||||||
|
try {
|
||||||
|
return $this->decodeBlob($id, $encrypted);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// read again from the location below, which reports what is wrong
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->loadBlob($id);
|
||||||
|
}
|
||||||
|
|
||||||
private function loadBlob(string $id): string {
|
private function loadBlob(string $id): string {
|
||||||
[$pack, $offset, $length] = $this->index->get($id);
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
return $this->decodeBlob($id, $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length));
|
try {
|
||||||
|
$encrypted = $this->packReader->read($pack, $offset, $length);
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
// Another server's cleanup may have repacked this blob into a new pack since the index
|
||||||
|
// was loaded (a disaster recovery test while the original server still runs): add the
|
||||||
|
// new index files, and read the blob where it is now. Or the read failed for a while
|
||||||
|
// (a connection that died; the backend has replaced it by now): then read it once
|
||||||
|
// more where it was. A second failure is the real one.
|
||||||
|
try {
|
||||||
|
$this->index->refresh();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// the location could not be listed either: read again where the blob was
|
||||||
|
}
|
||||||
|
[$moved, $offset, $length] = $this->index->get($id);
|
||||||
|
try {
|
||||||
|
$encrypted = $this->packReader->read($moved, $offset, $length);
|
||||||
|
} catch (BackendException) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$pack = $moved;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return $this->decodeBlob($id, $encrypted);
|
||||||
|
} catch (\Exception $damaged) {
|
||||||
|
// Bytes that do not decrypt: damaged on the location, or a read that went wrong on the
|
||||||
|
// way (a stream that lost its place: an SMB restore stopped after 17 hours like this).
|
||||||
|
// Read exactly this blob once more, on a request of its own; only a second failure
|
||||||
|
// means the data itself is damaged.
|
||||||
|
$this->packReader->close();
|
||||||
|
try {
|
||||||
|
$again = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
||||||
|
} catch (\Exception) {
|
||||||
|
throw $damaged;
|
||||||
|
}
|
||||||
|
$data = $this->decodeBlob($id, $again);
|
||||||
|
if (function_exists('OCP\Log\logger')) {
|
||||||
|
\OCP\Log\logger('ng_backup')->warning('NG Backup: blob ' . $id . ' did not decrypt as first read from pack ' . $pack . ' at ' . $offset . '; read again, it is fine. First read: ' . self::howItDiffered($encrypted, $again));
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a read that went wrong looked like next to the right bytes, for the log: where it starts
|
||||||
|
* to differ, and whether the rest is zeros or the right bytes from another place (a stream that
|
||||||
|
* lost its place).
|
||||||
|
*/
|
||||||
|
private static function howItDiffered(string $wrong, string $right): string {
|
||||||
|
$at = strspn($wrong ^ $right, "\0");
|
||||||
|
$text = strlen($wrong) . ' bytes, differ from byte ' . $at . ' of ' . strlen($right);
|
||||||
|
$tail = substr($wrong, $at, 64);
|
||||||
|
if ($tail === '') {
|
||||||
|
return $text . ' (too short)';
|
||||||
|
}
|
||||||
|
if (trim($tail, "\0") === '') {
|
||||||
|
return $text . ' (zeros)';
|
||||||
|
}
|
||||||
|
$found = strpos($right, $tail);
|
||||||
|
return $text . ($found !== false ? ' (the right bytes from ' . ($found - $at) . ' bytes further on)' : ' (other bytes)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */
|
/** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */
|
||||||
@@ -738,17 +981,23 @@ final class Repository {
|
|||||||
* referenced by more than one file is only checked once; a missing database manifest is a
|
* referenced by more than one file is only checked once; a missing database manifest is a
|
||||||
* failure, not something to silently skip over.
|
* failure, not something to silently skip over.
|
||||||
*
|
*
|
||||||
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
|
* @param ?callable(array{phase:string, done:int, total:?int, blobs?:int, bytes?:int}):void $heartbeat
|
||||||
* callers use it to keep a time-boxed lease alive during a large audit
|
* called often during the check, with how far it is: phase "index" (index files
|
||||||
|
* downloaded), "packs" (pack folders listed), "files" and "database" (files of the
|
||||||
|
* snapshot checked); callers use it to keep a time-boxed lease alive and to report
|
||||||
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
||||||
*/
|
*/
|
||||||
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
|
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
|
||||||
$heartbeat ??= static function (): void {
|
$heartbeat ??= static function (array $progress): void {
|
||||||
};
|
};
|
||||||
$this->index->load(true);
|
$meta = $this->snapshotMeta($snapshotId);
|
||||||
|
$filesTotal = isset($meta['stats']['files']) ? (int)$meta['stats']['files'] : null;
|
||||||
|
$this->index->load(true, static function (int $done, int $total) use ($heartbeat): void {
|
||||||
|
$heartbeat(['phase' => 'index', 'done' => $done, 'total' => $total]);
|
||||||
|
});
|
||||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
|
[$packSeen, $listedDirs] = $this->presentPacks($heartbeat);
|
||||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
$stats['filesChecked']++;
|
$stats['filesChecked']++;
|
||||||
foreach ($entry['b'] as $id) {
|
foreach ($entry['b'] as $id) {
|
||||||
@@ -756,11 +1005,11 @@ final class Repository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
|
||||||
$heartbeat();
|
$heartbeat(['phase' => 'files', 'done' => $stats['filesChecked'], 'total' => $filesTotal,
|
||||||
|
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$meta = $this->snapshotMeta($snapshotId);
|
|
||||||
if (isset($meta['db']) && is_string($meta['db'])) {
|
if (isset($meta['db']) && is_string($meta['db'])) {
|
||||||
if (!$this->backend->exists($meta['db'])) {
|
if (!$this->backend->exists($meta['db'])) {
|
||||||
$stats['missing'][] = $meta['db'];
|
$stats['missing'][] = $meta['db'];
|
||||||
@@ -772,8 +1021,9 @@ final class Repository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
|
||||||
$heartbeat();
|
$heartbeat(['phase' => 'database', 'done' => $stats['filesChecked'], 'total' => $filesTotal,
|
||||||
|
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,6 +1031,40 @@ final class Repository {
|
|||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which packs are on the location, from one listing per pack folder instead of a request per
|
||||||
|
* pack. A folder that cannot be listed, or comes back empty (which a storage also reports when
|
||||||
|
* it could not look), is left out: its packs are then checked one by one. With no more packs
|
||||||
|
* than folders nothing is listed: a request per pack is fewer requests then, and on FTP every
|
||||||
|
* request takes a data connection, of which a server may have only a few.
|
||||||
|
*
|
||||||
|
* @return array{0: array<string, bool>, 1: array<string, true>} pack id => present, and the
|
||||||
|
* folders that were listed
|
||||||
|
*/
|
||||||
|
private function presentPacks(callable $heartbeat): array {
|
||||||
|
$present = [];
|
||||||
|
$listed = [];
|
||||||
|
if ($this->index->packCount() <= $this->listPacksAbove) {
|
||||||
|
return [$present, $listed];
|
||||||
|
}
|
||||||
|
for ($i = 0; $i < 256; $i++) {
|
||||||
|
$dir = sprintf('%02x', $i);
|
||||||
|
try {
|
||||||
|
$files = $this->backend->listFiles('packs/' . $dir);
|
||||||
|
} catch (BackendException) {
|
||||||
|
$files = [];
|
||||||
|
}
|
||||||
|
foreach ($files as $path) {
|
||||||
|
$present[basename($path)] = true;
|
||||||
|
}
|
||||||
|
if ($files !== []) {
|
||||||
|
$listed[$dir] = true;
|
||||||
|
}
|
||||||
|
$heartbeat(['phase' => 'packs', 'done' => $i + 1, 'total' => 256]);
|
||||||
|
}
|
||||||
|
return [$present, $listed];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same checks as verify(), for a user export: every blob its manifest references must be in
|
* Same checks as verify(), for a user export: every blob its manifest references must be in
|
||||||
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
|
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
|
||||||
@@ -791,9 +1075,11 @@ final class Repository {
|
|||||||
if (!$this->isRecordedUserExport($manifestPath)) {
|
if (!$this->isRecordedUserExport($manifestPath)) {
|
||||||
throw new RepositoryException("$manifestPath is not a recorded user export for this location");
|
throw new RepositoryException("$manifestPath is not a recorded user export for this location");
|
||||||
}
|
}
|
||||||
$heartbeat ??= static function (): void {
|
$heartbeat ??= static function (array $progress): void {
|
||||||
};
|
};
|
||||||
$this->index->load(true);
|
$this->index->load(true, static function (int $done, int $total) use ($heartbeat): void {
|
||||||
|
$heartbeat(['phase' => 'index', 'done' => $done, 'total' => $total]);
|
||||||
|
});
|
||||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
$packSeen = [];
|
$packSeen = [];
|
||||||
@@ -809,20 +1095,27 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
$seen[$id] = true;
|
$seen[$id] = true;
|
||||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
||||||
$heartbeat();
|
$heartbeat(['phase' => 'files', 'done' => $stats['filesChecked'], 'total' => null,
|
||||||
|
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, bool> $packSeen */
|
/**
|
||||||
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen): void {
|
* @param array<string, bool> $packSeen pack id => present; filled in as packs are checked
|
||||||
|
* @param array<string, true> $listedDirs pack folders whose listing is in $packSeen: a pack
|
||||||
|
* not in it is not there
|
||||||
|
*/
|
||||||
|
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen, array $listedDirs = []): void {
|
||||||
if (!$this->index->has($id)) {
|
if (!$this->index->has($id)) {
|
||||||
$stats['missing'][] = $id;
|
$stats['missing'][] = $id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[$pack] = $this->index->get($id);
|
[$pack] = $this->index->get($id);
|
||||||
$packExists = $packSeen[$pack] ??= $this->backend->exists('packs/' . substr($pack, 0, 2) . '/' . $pack);
|
$packExists = $packSeen[$pack] ??= isset($listedDirs[substr($pack, 0, 2)])
|
||||||
|
? false
|
||||||
|
: $this->backend->exists('packs/' . substr($pack, 0, 2) . '/' . $pack);
|
||||||
if (!$packExists) {
|
if (!$packExists) {
|
||||||
$stats['missing'][] = $id;
|
$stats['missing'][] = $id;
|
||||||
return;
|
return;
|
||||||
@@ -839,7 +1132,7 @@ final class Repository {
|
|||||||
$encrypted = null;
|
$encrypted = null;
|
||||||
for ($attempt = 1; $attempt <= self::VERIFY_READ_ATTEMPTS && $encrypted === null; $attempt++) {
|
for ($attempt = 1; $attempt <= self::VERIFY_READ_ATTEMPTS && $encrypted === null; $attempt++) {
|
||||||
try {
|
try {
|
||||||
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
$encrypted = $this->packReader->read($pack, $offset, $length);
|
||||||
} catch (\Exception $e) { // storage adapters throw their own exceptions; \Error (a bug) is not retried
|
} catch (\Exception $e) { // storage adapters throw their own exceptions; \Error (a bug) is not retried
|
||||||
$stats['readError'] = $e->getMessage();
|
$stats['readError'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
@@ -850,7 +1143,7 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$stats['bytesChecked'] += strlen($this->decodeBlob($id, $encrypted));
|
$stats['bytesChecked'] += strlen($this->decodeBlob($id, $encrypted));
|
||||||
} catch (\Throwable) {
|
} catch (\Exception) { // damage; an \Error is a bug and must not be reported as damage
|
||||||
$stats['failed'][] = $id;
|
$stats['failed'][] = $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ final class AlertService {
|
|||||||
}
|
}
|
||||||
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
|
$this->appConfig->setValueString(Application::APP_ID, $key, date('Y-m-d'));
|
||||||
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
|
$this->notifyAdmins('backup_stale', 'stale-' . $target->getId() . '-' . date('Y-m-d'),
|
||||||
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : 'never']);
|
['target' => $target->getName(), 'since' => $last ? date('Y-m-d H:i', $lastTime) : '']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function notifyAdmins(string $subject, string $objectId, array $params): void {
|
private function notifyAdmins(string $subject, string $objectId, array $params): void {
|
||||||
|
|||||||
+205
-23
@@ -14,9 +14,12 @@ use OCA\NgBackup\Db\Snapshot;
|
|||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
use OCA\NgBackup\Db\SnapshotMapper;
|
||||||
use OCA\NgBackup\Db\Target;
|
use OCA\NgBackup\Db\Target;
|
||||||
use OCA\NgBackup\Job\BackupRun;
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
use OCA\NgBackup\Repository\RepositoryException;
|
use OCA\NgBackup\Repository\RepositoryException;
|
||||||
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use OCP\App\IAppManager;
|
use OCP\App\IAppManager;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
use OCP\Lock\ILockingProvider;
|
use OCP\Lock\ILockingProvider;
|
||||||
use OCP\Lock\LockedException;
|
use OCP\Lock\LockedException;
|
||||||
@@ -32,6 +35,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
final class BackupService {
|
final class BackupService {
|
||||||
public const KIND_FULL = 'full';
|
public const KIND_FULL = 'full';
|
||||||
|
|
||||||
|
/** @var \WeakMap<Repository, string> the location settings each kept repository was opened with */
|
||||||
|
private \WeakMap $openedWith;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private KeyService $keys,
|
private KeyService $keys,
|
||||||
@@ -45,6 +51,9 @@ final class BackupService {
|
|||||||
private AlertService $alerts,
|
private AlertService $alerts,
|
||||||
private LeaseService $leases,
|
private LeaseService $leases,
|
||||||
) {
|
) {
|
||||||
|
/** @var \WeakMap<Repository, string> $opened */
|
||||||
|
$opened = new \WeakMap();
|
||||||
|
$this->openedWith = $opened;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function start(Target $target, string $label = ''): Run {
|
public function start(Target $target, string $label = ''): Run {
|
||||||
@@ -68,6 +77,46 @@ final class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expected size of the files phase, for the progress shown while it runs: the size of the
|
||||||
|
* previous snapshot, or for a first backup the users' files, versions and trash as Nextcloud's
|
||||||
|
* file cache knows them (approximate: other data in the data directory is not counted).
|
||||||
|
*
|
||||||
|
* @return array{bytes: int, approximate: bool}
|
||||||
|
*/
|
||||||
|
private function estimateBytes(Run $run, array $files): array {
|
||||||
|
if (($files['parent'] ?? null) !== null) {
|
||||||
|
try {
|
||||||
|
return ['bytes' => $this->snapshots->findOne($run->getTargetId(), (string)$files['parent'])->getBytes() ?? 0, 'approximate' => false];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('numeric_id')->from('storages')
|
||||||
|
->where($qb->expr()->like('id', $qb->createNamedParameter('home::%')))
|
||||||
|
->orWhere($qb->expr()->like('id', $qb->createNamedParameter('object::user:%')));
|
||||||
|
$result = $qb->executeQuery();
|
||||||
|
$storages = [];
|
||||||
|
while (($id = $result->fetchOne()) !== false) {
|
||||||
|
$storages[] = (int)$id;
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
$bytes = 0;
|
||||||
|
foreach (array_chunk($storages, 500) as $chunk) {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select($qb->func()->sum('size'))->from('filecache')
|
||||||
|
->where($qb->expr()->in('storage', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))
|
||||||
|
->andWhere($qb->expr()->in('path_hash', $qb->createNamedParameter(array_map('md5', ['files', 'files_versions', 'files_trashbin']), IQueryBuilder::PARAM_STR_ARRAY)))
|
||||||
|
->andWhere($qb->expr()->gt('size', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||||
|
$bytes += (int)$qb->executeQuery()->fetchOne();
|
||||||
|
}
|
||||||
|
return ['bytes' => $bytes, 'approximate' => true];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ['bytes' => 0, 'approximate' => true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function createRun(Target $target, string $label): Run {
|
private function createRun(Target $target, string $label): Run {
|
||||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||||
if ($running !== []) {
|
if ($running !== []) {
|
||||||
@@ -102,10 +151,22 @@ final class BackupService {
|
|||||||
/**
|
/**
|
||||||
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
|
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
|
||||||
* run locked and returns immediately.
|
* run locked and returns immediately.
|
||||||
|
*
|
||||||
|
* @param Repository|null $repo the location's repository, opened by an earlier step of this run
|
||||||
|
* in this process (see runToCompletion()); set when this step opens it
|
||||||
*/
|
*/
|
||||||
public function step(Run $run, float $deadline): Run {
|
public function step(Run $run, float $deadline, ?Repository &$repo = null): Run {
|
||||||
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
|
// Leases expire on their own: a step killed by a time limit or OOM blocks nothing for long.
|
||||||
$ttl = (int)min(3600, max(120, $deadline - microtime(true) + 120));
|
// A step can run well past its deadline (the commit of a large snapshot writes its tree and
|
||||||
|
// index), so the lease holds at least 15 minutes; should it still expire and another process
|
||||||
|
// take the run over, the checks below keep the two from overwriting each other.
|
||||||
|
$ttl = (int)min(3600, max(900, $deadline - microtime(true) + 600));
|
||||||
|
// No new step once a cancel has begun: it waits for the step in progress, if any.
|
||||||
|
try {
|
||||||
|
$this->leases->release($this->leases->acquire(self::cancelKey($run->getId()), LeaseService::SHARED, 60));
|
||||||
|
} catch (LockedException) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
||||||
} catch (LockedException) {
|
} catch (LockedException) {
|
||||||
@@ -118,13 +179,28 @@ final class BackupService {
|
|||||||
$this->leases->release($runLease);
|
$this->leases->release($runLease);
|
||||||
return $run;
|
return $run;
|
||||||
}
|
}
|
||||||
|
$loadedAt = null;
|
||||||
|
$savedBefore = $run->getUpdatedAt();
|
||||||
try {
|
try {
|
||||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||||
return $run;
|
return $run;
|
||||||
}
|
}
|
||||||
|
$loadedAt = $run->getUpdatedAt();
|
||||||
$target = $this->targets->get((string)$run->getTargetId());
|
$target = $this->targets->get((string)$run->getTargetId());
|
||||||
$repo = $this->targets->repository($target);
|
// Reopen when another process took a step since ours (its packs are not in our copy of
|
||||||
|
// the index) or the location was edited (password, folder, size limit).
|
||||||
|
$settings = json_encode([$target->getBackend(), $target->getAuth(), $target->getOptions(),
|
||||||
|
$target->getBasePath(), $target->getRepositoryId(), $target->getMaxBytes()], JSON_THROW_ON_ERROR);
|
||||||
|
if ($repo === null || $loadedAt !== $savedBefore || ($this->openedWith[$repo] ?? null) !== $settings) {
|
||||||
|
// Opening reads the index listing, which takes minutes on a slow location with many
|
||||||
|
// packs: that time does not count, or each step would store one blob and stop.
|
||||||
|
$opening = microtime(true);
|
||||||
|
$repo = null;
|
||||||
|
$repo = $this->targets->repository($target);
|
||||||
|
$this->openedWith[$repo] = $settings;
|
||||||
|
$deadline += microtime(true) - $opening;
|
||||||
|
}
|
||||||
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
if ($run->getPhase() === 'db') {
|
if ($run->getPhase() === 'db') {
|
||||||
@@ -147,7 +223,11 @@ final class BackupService {
|
|||||||
$state['dump'] = $dump;
|
$state['dump'] = $dump;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
$state['files']['estimate'] ??= $this->estimateBytes($run, $state['files']);
|
||||||
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
|
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
|
||||||
|
if (in_array($state['files']['phase'], ['finish', 'commit'], true)) {
|
||||||
|
$run->setPhase('commit'); // all files read: writing and recording the snapshot
|
||||||
|
}
|
||||||
if ($state['files']['phase'] === 'commit') {
|
if ($state['files']['phase'] === 'commit') {
|
||||||
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
|
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
|
||||||
// export or restore may have written the catalog since this step opened $repo.
|
// export or restore may have written the catalog since this step opened $repo.
|
||||||
@@ -162,14 +242,20 @@ final class BackupService {
|
|||||||
}
|
}
|
||||||
if ($state['files']['phase'] === 'done') {
|
if ($state['files']['phase'] === 'done') {
|
||||||
$this->recordSnapshot($run, $state['files']);
|
$this->recordSnapshot($run, $state['files']);
|
||||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
// The snapshot is committed: what follows only reports on it and must not turn
|
||||||
$stats = $state['files']['stats'];
|
// the run into a failure.
|
||||||
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
try {
|
||||||
$warnings = BackupRun::warnings($state['files']);
|
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||||
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
|
$stats = $state['files']['stats'];
|
||||||
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
|
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
||||||
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
|
$warnings = BackupRun::warnings($state['files']);
|
||||||
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
|
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
|
||||||
|
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
|
||||||
|
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
|
||||||
|
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('NG Backup run ' . $run->getId() . ': reporting on the finished backup failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
}
|
}
|
||||||
$run->setStatus(Run::DONE);
|
$run->setStatus(Run::DONE);
|
||||||
$run->setPhase('done');
|
$run->setPhase('done');
|
||||||
@@ -178,15 +264,33 @@ final class BackupService {
|
|||||||
}
|
}
|
||||||
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
||||||
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
||||||
$run->setUpdatedAt(time());
|
// Another process may have taken the run over after this step's lease expired: then its
|
||||||
return $this->runs->update($run);
|
// writes stand and this step's are dropped.
|
||||||
|
$saved = $this->runs->updateIfUnchanged($run, $loadedAt);
|
||||||
|
if ($saved === null) {
|
||||||
|
$repo = null; // the run is another process's now: its packs are not in our index
|
||||||
|
return $this->runs->find($run->getId());
|
||||||
|
}
|
||||||
|
return $saved;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
$repo = null; // may hold blobs of a half-written pack: the next step opens it afresh
|
||||||
|
if ($loadedAt === null) {
|
||||||
|
// The run could not even be loaded: there is nothing fresh to mark as failed, and
|
||||||
|
// the caller's copy may be stale.
|
||||||
|
$this->logger->error('NG Backup run ' . $run->getId() . ': step could not load the run: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
$run->setStatus(Run::FAILED);
|
$run->setStatus(Run::FAILED);
|
||||||
$run->setError($e->getMessage());
|
$run->setError($e->getMessage());
|
||||||
$run->setFinishedAt(time());
|
$run->setFinishedAt(time());
|
||||||
$run->setUpdatedAt(time());
|
$failed = $this->runs->updateIfUnchanged($run, $loadedAt);
|
||||||
$run = $this->runs->update($run);
|
if ($failed === null) {
|
||||||
|
// Another process that took the run over may have finished it: its result stands.
|
||||||
|
$this->logger->warning('NG Backup run ' . $run->getId() . ': a step that another process had taken over ended with: ' . $e->getMessage());
|
||||||
|
return $this->runs->find($run->getId());
|
||||||
|
}
|
||||||
|
$run = $failed;
|
||||||
|
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
try {
|
try {
|
||||||
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
|
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
@@ -200,14 +304,16 @@ final class BackupService {
|
|||||||
|
|
||||||
/** Run to completion in this process (occ, system cron), reporting progress. */
|
/** Run to completion in this process (occ, system cron), reporting progress. */
|
||||||
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
||||||
|
$repo = null; // opened once, by the first step, and kept for the next ones
|
||||||
while ($run->getStatus() === Run::RUNNING) {
|
while ($run->getStatus() === Run::RUNNING) {
|
||||||
$before = $run->getUpdatedAt();
|
$before = $run->getUpdatedAt();
|
||||||
$run = $this->step($run, microtime(true) + $stepSeconds);
|
$run = $this->step($run, microtime(true) + $stepSeconds, $repo);
|
||||||
if ($run->getStatus() === Run::RUNNING && $run->getUpdatedAt() === $before) {
|
if ($run->getStatus() === Run::RUNNING && $run->getUpdatedAt() === $before) {
|
||||||
// Step was not possible (another process holds the run, or a crashed step's lease has
|
// Step was not possible (another process holds the run, or a crashed step's lease has
|
||||||
// not expired yet): wait instead of spinning.
|
// not expired yet): wait instead of spinning.
|
||||||
sleep(5);
|
sleep(5);
|
||||||
$run = $this->runs->find($run->getId());
|
$run = $this->runs->find($run->getId());
|
||||||
|
$repo = null; // another process may have stored packs in the meantime
|
||||||
}
|
}
|
||||||
if ($progress !== null) {
|
if ($progress !== null) {
|
||||||
$progress($run);
|
$progress($run);
|
||||||
@@ -216,14 +322,78 @@ final class BackupService {
|
|||||||
return $run;
|
return $run;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Run $run): Run {
|
/**
|
||||||
$run->setStatus(Run::CANCELLED);
|
* Stop a running backup. First no new step may start, then this waits (up to $wait seconds)
|
||||||
$run->setFinishedAt(time());
|
* for the step in progress, so a step that is recording the snapshot finishes first. A process
|
||||||
$run->setUpdatedAt(time());
|
* running the backup then finds it cancelled and stops. The packs it stored stay until the
|
||||||
return $this->runs->update($run);
|
* next cleanup.
|
||||||
|
*
|
||||||
|
* A process of an earlier version does not hold back its next step and may never leave a gap:
|
||||||
|
* after $wait the run is marked cancelled while it works, unless it is recording the snapshot.
|
||||||
|
* Its step then fails to save and the process stops.
|
||||||
|
*/
|
||||||
|
public function cancel(Run $run, int $wait = 120): Run {
|
||||||
|
$id = $run->getId();
|
||||||
|
$until = time() + $wait;
|
||||||
|
$hold = null;
|
||||||
|
for ($try = 0; $hold === null; $try++) {
|
||||||
|
try {
|
||||||
|
$hold = $this->leases->acquire(self::cancelKey($id), LeaseService::EXCLUSIVE, $wait + 300);
|
||||||
|
} catch (LockedException) {
|
||||||
|
if ($try >= 20) {
|
||||||
|
throw new \RuntimeException("Run $id is being cancelled already");
|
||||||
|
}
|
||||||
|
usleep(100000); // a step checking whether it may start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$lease = null;
|
||||||
|
while ($lease === null && time() < $until) {
|
||||||
|
try {
|
||||||
|
$lease = $this->leases->acquire('run/' . $id, LeaseService::EXCLUSIVE, 60);
|
||||||
|
} catch (LockedException) {
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for ($try = 0; $try < 10; $try++) {
|
||||||
|
$run = $this->runs->find($id);
|
||||||
|
if ($run->getStatus() !== Run::RUNNING) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
$state = json_decode($run->getState(), true) ?: [];
|
||||||
|
if ($lease === null && ($state['files']['phase'] ?? '') === 'commit') {
|
||||||
|
throw new \RuntimeException("Run $id is recording its snapshot; try again in a minute");
|
||||||
|
}
|
||||||
|
$loadedAt = $run->getUpdatedAt();
|
||||||
|
$run->setStatus(Run::CANCELLED);
|
||||||
|
$run->setFinishedAt(time());
|
||||||
|
if (($cancelled = $this->runs->updateIfUnchanged($run, $loadedAt)) !== null) {
|
||||||
|
return $cancelled;
|
||||||
|
}
|
||||||
|
usleep(200000); // a step of another process saved just now
|
||||||
|
}
|
||||||
|
throw new \RuntimeException("Run $id keeps changing; try again");
|
||||||
|
} finally {
|
||||||
|
if ($lease !== null) {
|
||||||
|
$this->leases->release($lease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$this->leases->release($hold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cancelKey(int $runId): string {
|
||||||
|
return 'run/' . $runId . '/cancel';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordSnapshot(Run $run, array $files): void {
|
private function recordSnapshot(Run $run, array $files): void {
|
||||||
|
try {
|
||||||
|
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
|
||||||
|
return; // already recorded, by an earlier attempt at this step
|
||||||
|
} catch (DoesNotExistException) {
|
||||||
|
}
|
||||||
$s = new Snapshot();
|
$s = new Snapshot();
|
||||||
$s->setTargetId($run->getTargetId());
|
$s->setTargetId($run->getTargetId());
|
||||||
$s->setSnapshotId($files['snapshot']);
|
$s->setSnapshotId($files['snapshot']);
|
||||||
@@ -232,7 +402,19 @@ final class BackupService {
|
|||||||
$s->setCreatedAt(time());
|
$s->setCreatedAt(time());
|
||||||
$s->setFiles($files['stats']['files']);
|
$s->setFiles($files['stats']['files']);
|
||||||
$s->setBytes($files['stats']['bytes']);
|
$s->setBytes($files['stats']['bytes']);
|
||||||
$this->snapshots->insert($s);
|
try {
|
||||||
|
$this->snapshots->insert($s);
|
||||||
|
} catch (\OCP\DB\Exception $e) {
|
||||||
|
// Recorded by another process between the check above and this insert?
|
||||||
|
if ($e->getReason() !== \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
|
||||||
|
} catch (DoesNotExistException) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ final class KeyService {
|
|||||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||||
private const KEY_KIT_CODE = 'recovery_kit_code';
|
private const KEY_KIT_CODE = 'recovery_kit_code';
|
||||||
public const DEFAULT_DELETE_DELAY = 7;
|
public const DEFAULT_DELETE_DELAY = 7;
|
||||||
|
/**
|
||||||
|
* The location holds the passphrase-wrapped key too, so whoever can read it can try
|
||||||
|
* passphrases offline: the passphrase is what really protects the backups.
|
||||||
|
*/
|
||||||
|
public const MIN_PASSPHRASE = 16;
|
||||||
|
|
||||||
private ?KeyRing $cached = null;
|
private ?KeyRing $cached = null;
|
||||||
|
|
||||||
@@ -56,9 +61,7 @@ final class KeyService {
|
|||||||
if ($this->isInitialized()) {
|
if ($this->isInitialized()) {
|
||||||
throw new \RuntimeException('A backup key already exists');
|
throw new \RuntimeException('A backup key already exists');
|
||||||
}
|
}
|
||||||
if (strlen($passphrase) < 12) {
|
self::checkPassphrase($passphrase);
|
||||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
|
||||||
}
|
|
||||||
$keys = KeyRing::generate();
|
$keys = KeyRing::generate();
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||||
@@ -234,8 +237,8 @@ final class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static function checkPassphrase(string $passphrase): void {
|
private static function checkPassphrase(string $passphrase): void {
|
||||||
if (strlen($passphrase) < 12) {
|
if (strlen($passphrase) < self::MIN_PASSPHRASE) {
|
||||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
throw new \InvalidArgumentException('The passphrase must be at least ' . self::MIN_PASSPHRASE . ' characters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ final class RestoreFullService {
|
|||||||
* Config keys that must keep pointing at THIS installation's actual, already-working
|
* Config keys that must keep pointing at THIS installation's actual, already-working
|
||||||
* database and data directory (set by occ maintenance:install), never at the old server's.
|
* database and data directory (set by occ maintenance:install), never at the old server's.
|
||||||
*/
|
*/
|
||||||
|
/** App setting: when a full restore started that has not completed (see restoreFull()). */
|
||||||
|
private const INTERRUPTED_KEY = 'restore_full_started';
|
||||||
|
|
||||||
private const CONNECTION_KEYS = [
|
private const CONNECTION_KEYS = [
|
||||||
'dbtype', 'dbhost', 'dbport', 'dbname', 'dbuser', 'dbpassword', 'dbpersistent',
|
'dbtype', 'dbhost', 'dbport', 'dbname', 'dbuser', 'dbpassword', 'dbpersistent',
|
||||||
'dbtableprefix', 'dbdriveroptions', 'datadirectory',
|
'dbtableprefix', 'dbdriveroptions', 'datadirectory',
|
||||||
|
// set by restoreFull() itself for the length of the restore
|
||||||
|
'maintenance',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -43,34 +48,93 @@ final class RestoreFullService {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apps that only exist in a particular setup and are never needed elsewhere. */
|
||||||
|
private const ENVIRONMENT_APPS = ['nextcloud-aio'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this installation can receive $snapshotId: same Nextcloud major version, and every
|
* Whether this installation can receive $snapshotId, all problems at once:
|
||||||
* app that was enabled at backup time already installed here (its tables must exist before
|
* - the same Nextcloud major version is required;
|
||||||
* the database restore can write into them).
|
* - apps that were enabled at backup time but are not here (they work again once installed);
|
||||||
|
* - tables in the backup that do not exist here: a backup made from 1.0.0-rc.2 on records
|
||||||
|
* their structure and the restore creates them, an older one cannot (missingTables).
|
||||||
*
|
*
|
||||||
* @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list<string>}
|
* @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list<string>, missingTables:list<string>}
|
||||||
*/
|
*/
|
||||||
public function checkCompatibility(Target $target, string $snapshotId): array {
|
public function checkCompatibility(Target $target, string $snapshotId, bool $skipMissingTables = false): array {
|
||||||
$meta = $this->targets->repository($target)->snapshotMeta($snapshotId);
|
$repo = $this->targets->repository($target);
|
||||||
|
$meta = $repo->snapshotMeta($snapshotId);
|
||||||
$currentMajor = explode('.', $this->config->getSystemValueString('version'))[0];
|
$currentMajor = explode('.', $this->config->getSystemValueString('version'))[0];
|
||||||
$snapshotMajor = explode('.', (string)($meta['nextcloud'] ?? ''))[0];
|
$snapshotMajor = explode('.', (string)($meta['nextcloud'] ?? ''))[0];
|
||||||
$enabled = array_flip($this->appManager->getEnabledApps());
|
$enabled = array_flip($this->appManager->getEnabledApps());
|
||||||
$missingApps = array_values(array_diff(array_keys($meta['apps'] ?? []), array_keys($enabled)));
|
$missingApps = array_values(array_diff(array_keys($meta['apps'] ?? []), array_keys($enabled), self::ENVIRONMENT_APPS));
|
||||||
|
$missingTables = [];
|
||||||
|
$prefixMismatch = null;
|
||||||
|
if (isset($meta['db'])) {
|
||||||
|
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
||||||
|
$sourcePrefix = DbDumper::dumpPrefix($manifest);
|
||||||
|
if ($sourcePrefix !== null && $sourcePrefix !== $prefix) {
|
||||||
|
$prefixMismatch = [$sourcePrefix, $prefix];
|
||||||
|
}
|
||||||
|
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
||||||
|
$missingTables = array_values(array_filter($dumper->missingTables($manifest),
|
||||||
|
fn (string $t) => !isset($manifest['tables'][$t]['schema']) && !str_starts_with($t, $prefix . 'ngb_')));
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'ok' => $currentMajor === $snapshotMajor && $missingApps === [],
|
'ok' => $currentMajor === $snapshotMajor && $prefixMismatch === null && ($missingTables === [] || $skipMissingTables),
|
||||||
'currentMajor' => $currentMajor,
|
'currentMajor' => $currentMajor,
|
||||||
'snapshotMajor' => $snapshotMajor,
|
'snapshotMajor' => $snapshotMajor,
|
||||||
'missingApps' => $missingApps,
|
'missingApps' => $missingApps,
|
||||||
|
'missingTables' => $prefixMismatch === null ? $missingTables : [],
|
||||||
|
'prefixMismatch' => $prefixMismatch,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */
|
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>, maintenanceLeftOn:bool} */
|
||||||
public function restoreFull(Target $target, string $snapshotId): array {
|
public function restoreFull(Target $target, string $snapshotId, bool $skipMissingTables = false): array {
|
||||||
$check = $this->checkCompatibility($target, $snapshotId);
|
// Maintenance mode for the whole restore: background jobs (cron.php stops in maintenance
|
||||||
|
// mode), page visits (AJAX cron) and users must not write into tables or the data
|
||||||
|
// directory while they are being replaced; a row written in between would collide with a
|
||||||
|
// restored one or be silently lost. Switched off after a restore that completed. After an
|
||||||
|
// error it stays on: the instance may be partly restored, and must not be used like that.
|
||||||
|
// occ only offers app commands outside maintenance mode, so the admin switches it off
|
||||||
|
// right before running the restore again (see RestoreFull). Already on before: left on.
|
||||||
|
// On before the checks below, so that a run again after an error leaves the partly
|
||||||
|
// restored instance open for seconds at most; off again when they refuse (nothing changed).
|
||||||
|
// A marker in ng_backup's own app settings (which the restore keeps) says a restore started
|
||||||
|
// and did not complete: then the instance may be partly restored, and a check that refuses
|
||||||
|
// a run again must not open it.
|
||||||
|
$appConfig = \OCP\Server::get(\OCP\IAppConfig::class);
|
||||||
|
$interrupted = $appConfig->getValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, 0) > 0;
|
||||||
|
$wasInMaintenance = $this->config->getSystemValueBool('maintenance', false);
|
||||||
|
$this->config->setSystemValue('maintenance', true);
|
||||||
|
$reopen = function () use ($wasInMaintenance, $interrupted): void {
|
||||||
|
if (!$wasInMaintenance && !$interrupted) {
|
||||||
|
$this->config->setSystemValue('maintenance', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
$check = $this->checkCompatibility($target, $snapshotId, $skipMissingTables);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$reopen();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
if (!$check['ok']) {
|
if (!$check['ok']) {
|
||||||
|
$reopen();
|
||||||
throw new \RuntimeException($this->describeBlockers($check));
|
throw new \RuntimeException($this->describeBlockers($check));
|
||||||
}
|
}
|
||||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId) {
|
$appConfig->setValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, time());
|
||||||
|
$result = $this->restoreInMaintenance($target, $snapshotId, $skipMissingTables, $check);
|
||||||
|
$appConfig->deleteKey(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY);
|
||||||
|
if (!$wasInMaintenance) {
|
||||||
|
$this->config->setSystemValue('maintenance', false);
|
||||||
|
}
|
||||||
|
return $result + ['maintenanceLeftOn' => $wasInMaintenance];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>} */
|
||||||
|
private function restoreInMaintenance(Target $target, string $snapshotId, bool $skipMissingTables, array $check): array {
|
||||||
|
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $skipMissingTables, $check) {
|
||||||
$beat = LeaseService::throttled($refresh);
|
$beat = LeaseService::throttled($refresh);
|
||||||
$repo = $this->targets->repository($target);
|
$repo = $this->targets->repository($target);
|
||||||
$meta = $repo->snapshotMeta($snapshotId);
|
$meta = $repo->snapshotMeta($snapshotId);
|
||||||
@@ -102,7 +166,7 @@ final class RestoreFullService {
|
|||||||
$ownAppConfig = $this->preserveOwnAppConfig();
|
$ownAppConfig = $this->preserveOwnAppConfig();
|
||||||
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
||||||
try {
|
try {
|
||||||
$dumper->restore($repo, $manifest, $dbReport, $beat);
|
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||||
} finally {
|
} finally {
|
||||||
$this->restoreOwnAppConfig($ownAppConfig);
|
$this->restoreOwnAppConfig($ownAppConfig);
|
||||||
}
|
}
|
||||||
@@ -125,8 +189,8 @@ final class RestoreFullService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
||||||
'dbConsistent' => (bool)($manifest['consistent'] ?? true)];
|
'dbConsistent' => (bool)($manifest['consistent'] ?? true), 'missingApps' => $check['missingApps']];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +273,15 @@ final class RestoreFullService {
|
|||||||
if ($check['currentMajor'] !== $check['snapshotMajor']) {
|
if ($check['currentMajor'] !== $check['snapshotMajor']) {
|
||||||
$parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)";
|
$parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)";
|
||||||
}
|
}
|
||||||
if ($check['missingApps'] !== []) {
|
if (($check['prefixMismatch'] ?? null) !== null) {
|
||||||
$parts[] = 'install these apps first (their tables must exist before the database restore can use them): '
|
[$source, $here] = $check['prefixMismatch'];
|
||||||
. implode(', ', array_map(fn (string $a) => "occ app:install $a", $check['missingApps']));
|
$parts[] = "the backup's tables use the prefix '$source', this installation '$here': install Nextcloud again with 'dbtableprefix' => '$source' in config/config.php before occ maintenance:install, and restore again";
|
||||||
}
|
}
|
||||||
return implode('; ', $parts);
|
if ($check['missingTables'] !== []) {
|
||||||
|
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
||||||
|
$parts[] = DbDumper::describeMissing($check['missingTables'], $prefix);
|
||||||
|
}
|
||||||
|
return implode("\n", $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function withSharedLock(Target $target, callable $fn): mixed {
|
private function withSharedLock(Target $target, callable $fn): mixed {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IGroupManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daily backup time: "HH:MM" in a time zone. Nextcloud runs PHP in UTC, so a time without a
|
||||||
|
* zone meant UTC (issue #19: 03:00 ran at 05:00 in Berlin). A time saved before there was a zone
|
||||||
|
* is converted once to the zone of the server or of an administrator, at today's offset: it then
|
||||||
|
* shows as it effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer).
|
||||||
|
*/
|
||||||
|
final class Schedule {
|
||||||
|
public const TIME_KEY = 'schedule_time';
|
||||||
|
public const ZONE_KEY = 'schedule_timezone';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IAppConfig $appConfig,
|
||||||
|
private IConfig $config,
|
||||||
|
private IGroupManager $groups,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{time: string, timezone: non-empty-string} time "" = no schedule */
|
||||||
|
public function get(): array {
|
||||||
|
$time = $this->appConfig->getValueString(Application::APP_ID, self::TIME_KEY, '');
|
||||||
|
$zone = $this->appConfig->getValueString(Application::APP_ID, self::ZONE_KEY, '');
|
||||||
|
if ($zone === '' || !self::validZone($zone)) {
|
||||||
|
$zone = $this->guessZone();
|
||||||
|
if ($time !== '' && self::validTime($time)) {
|
||||||
|
$time = self::convert($time, 'UTC', $zone);
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time);
|
||||||
|
}
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone);
|
||||||
|
}
|
||||||
|
return ['time' => $time, 'timezone' => $zone];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws \InvalidArgumentException for a malformed time or an unknown zone */
|
||||||
|
public function set(string $time, string $zone): void {
|
||||||
|
if ($time !== '' && !self::validTime($time)) {
|
||||||
|
throw new \InvalidArgumentException('Use HH:MM or empty');
|
||||||
|
}
|
||||||
|
if (!self::validZone($zone)) {
|
||||||
|
throw new \InvalidArgumentException('Unknown time zone: ' . $zone);
|
||||||
|
}
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone);
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's scheduled moment as a Unix time, in the schedule's zone; null without a schedule. */
|
||||||
|
public function todayAt(?int $now = null): ?int {
|
||||||
|
['time' => $time, 'timezone' => $zone] = $this->get();
|
||||||
|
if ($time === '' || !self::validTime($time)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tz = new \DateTimeZone($zone);
|
||||||
|
$day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone($tz)->format('Y-m-d');
|
||||||
|
return (new \DateTimeImmutable($day . ' ' . $time, $tz))->getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "HH:MM" in zone $from as "HH:MM" in zone $to, at today's offsets.
|
||||||
|
*
|
||||||
|
* @param non-empty-string $from
|
||||||
|
* @param non-empty-string $to
|
||||||
|
*/
|
||||||
|
public static function convert(string $time, string $from, string $to, ?int $now = null): string {
|
||||||
|
$day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone(new \DateTimeZone($from))->format('Y-m-d');
|
||||||
|
return (new \DateTimeImmutable($day . ' ' . $time, new \DateTimeZone($from)))->setTimezone(new \DateTimeZone($to))->format('H:i');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validTime(string $time): bool {
|
||||||
|
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @psalm-assert-if-true non-empty-string $zone */
|
||||||
|
public static function validZone(string $zone): bool {
|
||||||
|
return in_array($zone, \DateTimeZone::listIdentifiers(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's default_timezone when set, else the first administrator's, else UTC. A server
|
||||||
|
* zone of UTC is passed over on purpose: it is mostly what an image or installer left there,
|
||||||
|
* while the administrator who set the time read it as their own (#19).
|
||||||
|
*
|
||||||
|
* @return non-empty-string
|
||||||
|
*/
|
||||||
|
private function guessZone(): string {
|
||||||
|
$zone = $this->config->getSystemValueString('default_timezone', '');
|
||||||
|
if ($zone !== '' && $zone !== 'UTC' && self::validZone($zone)) {
|
||||||
|
return $zone;
|
||||||
|
}
|
||||||
|
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $user) {
|
||||||
|
$zone = $this->config->getUserValue($user->getUID(), 'core', 'timezone', '');
|
||||||
|
if ($zone !== '' && self::validZone($zone)) {
|
||||||
|
return $zone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'UTC';
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
-20
@@ -17,6 +17,7 @@ use OCA\NgBackup\Db\TargetMapper;
|
|||||||
use OCA\NgBackup\Repository\Repository;
|
use OCA\NgBackup\Repository\Repository;
|
||||||
use OCP\App\IAppManager;
|
use OCP\App\IAppManager;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
use OCP\Security\ICrypto;
|
use OCP\Security\ICrypto;
|
||||||
use OCP\Server;
|
use OCP\Server;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ final class TargetService {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
|
/** @return list<array{id:string, name:string, auth:list<array{id:string, name:string, parameters:array}>, parameters:array}> */
|
||||||
public function availableBackends(): array {
|
public function availableBackends(): array {
|
||||||
return $this->factory()->describeBackends();
|
return $this->factory()->describeBackends();
|
||||||
}
|
}
|
||||||
@@ -47,27 +48,16 @@ final class TargetService {
|
|||||||
* installation's key), then save it.
|
* installation's key), then save it.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $options
|
* @param array<string, mixed> $options
|
||||||
|
* @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all
|
||||||
* @return array{target: Target, created: bool}
|
* @return array{target: Target, created: bool}
|
||||||
*/
|
*/
|
||||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null): array {
|
||||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
$this->validateName($name);
|
||||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
$this->requireUsableBackend($backend);
|
||||||
}
|
|
||||||
// Without the native extension files_external falls back to the smbclient binary, which
|
|
||||||
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
|
|
||||||
// up front instead of letting the admin find out from a failed run.
|
|
||||||
if ($backend === 'smb' && !extension_loaded('smbclient')) {
|
|
||||||
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$this->mapper->findByName($name);
|
|
||||||
throw new \InvalidArgumentException("A target named '$name' already exists");
|
|
||||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
|
||||||
}
|
|
||||||
$keys = $this->keys->keyRing();
|
$keys = $this->keys->keyRing();
|
||||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
||||||
if ($storageBackend->exists('config')) {
|
if ($storageBackend->exists('config')) {
|
||||||
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor);
|
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress);
|
||||||
$created = false;
|
$created = false;
|
||||||
} else {
|
} else {
|
||||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
||||||
@@ -97,6 +87,103 @@ final class TargetService {
|
|||||||
return ['target' => $target, 'created' => $created];
|
return ['target' => $target, 'created' => $created];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change how an existing location is reached (credentials, host, folder, even the connection
|
||||||
|
* type) or its name. The new settings must lead to the same repository: the history stays
|
||||||
|
* and a typo cannot silently start a new, empty repository. A null option value keeps the
|
||||||
|
* stored one, so the web interface never has to send stored passwords back.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*/
|
||||||
|
public function update(Target $target, string $name, string $backend, string $auth, array $options, string $basePath): Target {
|
||||||
|
$this->validateName($name, $target->getId());
|
||||||
|
$this->requireUsableBackend($backend);
|
||||||
|
$stored = $this->options($target);
|
||||||
|
foreach ($options as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
if (array_key_exists($key, $stored)) {
|
||||||
|
$options[$key] = $stored[$key];
|
||||||
|
} else {
|
||||||
|
unset($options[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Do not create the folder: a typo in the path must not leave an empty folder behind.
|
||||||
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
||||||
|
if (!$storageBackend->exists('config')) {
|
||||||
|
if (!$storageBackend->canConnect()) {
|
||||||
|
throw new \InvalidArgumentException('Cannot connect with these settings. Check the host, user name and password');
|
||||||
|
}
|
||||||
|
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'. To start an empty repository, add a new location instead");
|
||||||
|
}
|
||||||
|
$repo = Repository::openWithKey($storageBackend, $this->keys->keyRing(), $this->indexCache, $this->anchor);
|
||||||
|
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
||||||
|
throw new \InvalidArgumentException('These settings lead to a different repository than the one of this location');
|
||||||
|
}
|
||||||
|
$target->setName($name);
|
||||||
|
$target->setBackend($backend);
|
||||||
|
$target->setAuth($auth);
|
||||||
|
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
||||||
|
$target->setBasePath(trim($basePath, '/'));
|
||||||
|
return $this->mapper->update($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored options for an edit form: password-type values are replaced by null, which
|
||||||
|
* update() reads as "keep the stored value".
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function editableOptions(Target $target): array {
|
||||||
|
$secret = [];
|
||||||
|
foreach ($this->availableBackends() as $b) {
|
||||||
|
if ($b['id'] !== $target->getBackend()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$params = $b['parameters'];
|
||||||
|
foreach ($b['auth'] as $a) {
|
||||||
|
if ($a['id'] === $target->getAuth()) {
|
||||||
|
$params += $a['parameters'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($params as $key => $p) {
|
||||||
|
if (($p['type'] ?? 0) === 2) {
|
||||||
|
$secret[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$options = $this->options($target);
|
||||||
|
foreach ($options as $key => $value) {
|
||||||
|
if (isset($secret[$key])) {
|
||||||
|
$options[$key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateName(string $name, ?int $ownId = null): void {
|
||||||
|
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||||
|
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$other = $this->mapper->findByName($name);
|
||||||
|
} catch (DoesNotExistException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($other->getId() !== $ownId) {
|
||||||
|
throw new \InvalidArgumentException("A target named '$name' already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requireUsableBackend(string $backend): void {
|
||||||
|
// Without the native extension files_external falls back to the smbclient binary, which
|
||||||
|
// registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
|
||||||
|
// up front instead of letting the admin find out from a failed run.
|
||||||
|
if ($backend === 'smb' && !extension_loaded('smbclient')) {
|
||||||
|
throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
|
* A target's options are encrypted with Nextcloud's own instance secret (ICrypto), not
|
||||||
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
|
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
|
||||||
@@ -141,7 +228,8 @@ final class TargetService {
|
|||||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||||
$this->snapshots->insert($s);
|
$this->snapshots->insert($s);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable $e) {
|
||||||
|
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +261,11 @@ final class TargetService {
|
|||||||
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
||||||
public function remove(Target $target): void {
|
public function remove(Target $target): void {
|
||||||
$this->mapper->delete($target);
|
$this->mapper->delete($target);
|
||||||
|
// The cached index of its repository is only of use while a location points at it
|
||||||
|
$repositoryId = $target->getRepositoryId();
|
||||||
|
if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) {
|
||||||
|
$this->indexCache->forget($repositoryId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backend(Target $target): IBackend {
|
public function backend(Target $target): IBackend {
|
||||||
@@ -193,8 +286,12 @@ final class TargetService {
|
|||||||
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
|
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
|
||||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
$create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
|
||||||
|
$storage = new StorageBackend($create(), $basePath, $createBase, $create);
|
||||||
|
$storage->readAheadConnections = Server::get(\OCP\IAppConfig::class)
|
||||||
|
->getValueInt('ng_backup', 'restore_sftp_connections', \OCA\NgBackup\Backend\SftpRangeFetcher::DEFAULT_CONNECTIONS);
|
||||||
|
return $storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ?ExternalStorageFactory $factory = null;
|
private ?ExternalStorageFactory $factory = null;
|
||||||
|
|||||||
+123
-13
@@ -7,42 +7,152 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace OCA\NgBackup\Service;
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
use OCA\NgBackup\Db\Target;
|
use OCA\NgBackup\Db\Target;
|
||||||
|
use OCP\IAppConfig;
|
||||||
use OCP\Lock\LockedException;
|
use OCP\Lock\LockedException;
|
||||||
|
|
||||||
/** Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). */
|
/**
|
||||||
|
* Checksum audit of a snapshot (occ backup:verify), see Repository::verify(). On a large
|
||||||
|
* repository a check takes hours (a deep one days), so how far it is gets stored every
|
||||||
|
* PROGRESS_SECONDS, together with how it ended: `occ backup:status` shows it.
|
||||||
|
*/
|
||||||
final class VerifyService {
|
final class VerifyService {
|
||||||
|
public const PROGRESS_SECONDS = 30;
|
||||||
|
private const KEY_PREFIX = 'verify_progress_';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private LeaseService $leases,
|
private LeaseService $leases,
|
||||||
|
private IAppConfig $appConfig,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
|
/**
|
||||||
public function verify(Target $target, string $snapshotId, bool $deep = false): array {
|
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
|
||||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
|
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
||||||
|
*/
|
||||||
|
public function verify(Target $target, string $snapshotId, bool $deep = false, ?callable $onProgress = null): array {
|
||||||
|
return $this->run($target, $snapshotId, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
|
/**
|
||||||
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
|
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
|
||||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
|
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
||||||
|
*/
|
||||||
|
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false, ?callable $onProgress = null): array {
|
||||||
|
return $this->run($target, $manifestPath, $deep, $onProgress, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function run(Target $target, bool $deep, callable $fn): array {
|
/** One line on a check's progress or outcome, for occ (see progress()). */
|
||||||
|
public static function describe(array $s): string {
|
||||||
|
$size = static fn (int $b): string => $b >= 1073741824 ? sprintf('%.1f GiB', (float)$b / 1073741824.0) : sprintf('%.1f MiB', (float)$b / 1048576.0);
|
||||||
|
$count = static fn (int $done, mixed $total): string => number_format($done)
|
||||||
|
. (is_int($total) && $total > 0 ? '/' . number_format($total) . sprintf(' (%d%%)', intdiv(100 * $done, $total)) : '');
|
||||||
|
$deep = ($s['deep'] ?? false) === true;
|
||||||
|
$kind = $deep ? 'deep check' : 'check';
|
||||||
|
if (($s['status'] ?? '') === 'running') {
|
||||||
|
$phase = match ($s['phase'] ?? '') {
|
||||||
|
'index' => 'index files ' . $count((int)$s['done'], $s['total'] ?? null),
|
||||||
|
'packs' => 'pack folders ' . $count((int)$s['done'], $s['total'] ?? null),
|
||||||
|
'files', 'database' => 'files ' . $count((int)$s['done'], $s['total'] ?? null) . ', ' . number_format((int)($s['blobs'] ?? 0)) . ' blobs'
|
||||||
|
. ($deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
|
||||||
|
default => 'starting',
|
||||||
|
};
|
||||||
|
$age = time() - (int)($s['updated'] ?? 0);
|
||||||
|
return sprintf('%s running since %s: %s; updated %s ago%s', $kind, date('Y-m-d H:i', (int)$s['started']), $phase,
|
||||||
|
$age < 120 ? $age . ' s' : intdiv($age, 60) . ' min',
|
||||||
|
$age > 600 ? ' (no progress for a while: the process may have stopped)' : '');
|
||||||
|
}
|
||||||
|
$took = \OCA\NgBackup\Format::duration((int)($s['finished'] ?? 0) - (int)($s['started'] ?? 0));
|
||||||
|
return match ($s['status'] ?? '') {
|
||||||
|
'ok' => sprintf('%s OK on %s (took %s): %s files, %s blobs%s', $kind, date('Y-m-d H:i', (int)$s['finished']), $took,
|
||||||
|
number_format((int)($s['files'] ?? 0)), number_format((int)($s['blobs'] ?? 0)), $deep ? ', ' . $size((int)($s['bytes'] ?? 0)) . ' decrypted' : ''),
|
||||||
|
'problems' => sprintf('%s found PROBLEMS on %s: %d missing, %d failed, %d unreadable blob(s); run occ backup:verify again for the details',
|
||||||
|
$kind, date('Y-m-d H:i', (int)$s['finished']), (int)($s['missing'] ?? 0), (int)($s['failedBlobs'] ?? 0), (int)($s['unreadable'] ?? 0)),
|
||||||
|
default => sprintf('%s FAILED on %s: %s', $kind, date('Y-m-d H:i', (int)($s['finished'] ?? 0)), (string)($s['error'] ?? 'unknown error')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored state of the last check per location: running (with how far it is) or how it
|
||||||
|
* ended.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>> by location id
|
||||||
|
*/
|
||||||
|
public function progress(): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($this->appConfig->getKeys(Application::APP_ID) as $key) {
|
||||||
|
if (!str_starts_with($key, self::KEY_PREFIX)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$state = json_decode($this->appConfig->getValueString(Application::APP_ID, $key, '', lazy: true), true);
|
||||||
|
if (is_array($state)) {
|
||||||
|
$result[(int)substr($key, strlen(self::KEY_PREFIX))] = $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function run(Target $target, string $what, bool $deep, ?callable $onProgress, callable $fn): array {
|
||||||
$ttl = $deep ? 600 : 300;
|
$ttl = $deep ? 600 : 300;
|
||||||
|
$state = ['what' => $what, 'deep' => $deep, 'status' => 'running', 'started' => time(), 'updated' => time(),
|
||||||
|
'pid' => getmypid(), 'phase' => 'starting', 'done' => 0, 'total' => null, 'blobs' => 0, 'bytes' => 0];
|
||||||
try {
|
try {
|
||||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $deep, $ttl, $fn): array {
|
$this->store($target, $state);
|
||||||
$last = time();
|
} catch (\Throwable) {
|
||||||
$heartbeat = function () use ($refresh, $ttl, &$last): void {
|
// progress reports are a convenience; the check itself goes ahead
|
||||||
if (time() - $last >= intdiv($ttl, 2)) {
|
}
|
||||||
|
try {
|
||||||
|
$result = $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $ttl, $fn, $onProgress, &$state): array {
|
||||||
|
$lastRefresh = time();
|
||||||
|
$lastStore = time();
|
||||||
|
$heartbeat = function (?array $progress = null) use ($refresh, $ttl, $target, $onProgress, &$lastRefresh, &$lastStore, &$state): void {
|
||||||
|
$now = time();
|
||||||
|
if ($now - $lastRefresh >= intdiv($ttl, 2)) {
|
||||||
$refresh();
|
$refresh();
|
||||||
$last = time();
|
$lastRefresh = $now;
|
||||||
|
}
|
||||||
|
if ($progress !== null && $now - $lastStore >= self::PROGRESS_SECONDS) {
|
||||||
|
$state = array_merge($state, $progress, ['updated' => $now]);
|
||||||
|
try {
|
||||||
|
$this->store($target, $state);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// a progress report that cannot be stored must not stop the check
|
||||||
|
}
|
||||||
|
$lastStore = $now;
|
||||||
|
if ($onProgress !== null) {
|
||||||
|
$onProgress($state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return $fn($this->targets->repository($target), $heartbeat);
|
return $fn($this->targets->repository($target), $heartbeat);
|
||||||
});
|
});
|
||||||
} catch (LockedException) {
|
} catch (LockedException) {
|
||||||
|
$this->finish($target, $state, 'failed', ['error' => 'A cleanup of the location was in progress']);
|
||||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->finish($target, $state, 'failed', ['error' => $e->getMessage()]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$problems = count($result['missing']) + count($result['failed']) + count($result['unreadable']);
|
||||||
|
$this->finish($target, $state, $problems === 0 ? 'ok' : 'problems', [
|
||||||
|
'phase' => 'done', 'files' => $result['filesChecked'], 'blobs' => $result['blobsChecked'], 'bytes' => $result['bytesChecked'],
|
||||||
|
'missing' => count($result['missing']), 'failedBlobs' => count($result['failed']), 'unreadable' => count($result['unreadable']),
|
||||||
|
]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finish(Target $target, array $state, string $status, array $extra): void {
|
||||||
|
try {
|
||||||
|
$this->store($target, array_merge($state, $extra, ['status' => $status, 'updated' => time(), 'finished' => time()]));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// the check's own result counts; its stored summary is a convenience
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function store(Target $target, array $state): void {
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_PREFIX . $target->getId(), json_encode($state, JSON_THROW_ON_ERROR), lazy: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-16
@@ -153,10 +153,6 @@
|
|||||||
</PossiblyFalseArgument>
|
</PossiblyFalseArgument>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Db/DbDumper.php">
|
<file src="lib/Db/DbDumper.php">
|
||||||
<InvalidArrayOffset>
|
|
||||||
<code><![CDATA[$info['sha256']]]></code>
|
|
||||||
<code><![CDATA[$manifest['sequences']]]></code>
|
|
||||||
</InvalidArrayOffset>
|
|
||||||
<MissingClosureReturnType>
|
<MissingClosureReturnType>
|
||||||
<code><![CDATA[fn ($c) => $end[array_search($c, $columns, true)]]]></code>
|
<code><![CDATA[fn ($c) => $end[array_search($c, $columns, true)]]]></code>
|
||||||
<code><![CDATA[fn ($c) => $q($c)]]></code>
|
<code><![CDATA[fn ($c) => $q($c)]]></code>
|
||||||
@@ -189,6 +185,12 @@
|
|||||||
<code><![CDATA[$this->db]]></code>
|
<code><![CDATA[$this->db]]></code>
|
||||||
<code><![CDATA[$this->db]]></code>
|
<code><![CDATA[$this->db]]></code>
|
||||||
<code><![CDATA[$this->db]]></code>
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
|
<code><![CDATA[$this->db]]></code>
|
||||||
<code><![CDATA[TransactionIsolationLevel]]></code>
|
<code><![CDATA[TransactionIsolationLevel]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
||||||
@@ -196,6 +198,11 @@
|
|||||||
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
||||||
|
<code><![CDATA[\Doctrine\DBAL\Platforms\PostgreSQLPlatform]]></code>
|
||||||
|
<code><![CDATA[\Doctrine\DBAL\Schema\ForeignKeyConstraint]]></code>
|
||||||
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
<code><![CDATA[\Doctrine\DBAL\Schema\Table]]></code>
|
||||||
@@ -218,16 +225,11 @@
|
|||||||
</PropertyNotSetInConstructor>
|
</PropertyNotSetInConstructor>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Job/BackupRun.php">
|
<file src="lib/Job/BackupRun.php">
|
||||||
<ArgumentTypeCoercion>
|
|
||||||
<code><![CDATA[$blobs]]></code>
|
|
||||||
</ArgumentTypeCoercion>
|
|
||||||
<MissingParamType>
|
<MissingParamType>
|
||||||
<code><![CDATA[$fh]]></code>
|
<code><![CDATA[$fh]]></code>
|
||||||
</MissingParamType>
|
</MissingParamType>
|
||||||
<PossiblyUndefinedArrayOffset>
|
<PossiblyUndefinedArrayOffset>
|
||||||
<code><![CDATA[$mtime]]></code>
|
<code><![CDATA[$mtime]]></code>
|
||||||
<code><![CDATA[$state['phase']]]></code>
|
|
||||||
<code><![CDATA[$state['stats']]]></code>
|
|
||||||
</PossiblyUndefinedArrayOffset>
|
</PossiblyUndefinedArrayOffset>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Job/ParentCursor.php">
|
<file src="lib/Job/ParentCursor.php">
|
||||||
@@ -395,7 +397,7 @@
|
|||||||
</file>
|
</file>
|
||||||
<file src="lib/Service/BackupService.php">
|
<file src="lib/Service/BackupService.php">
|
||||||
<InvalidOperand>
|
<InvalidOperand>
|
||||||
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
|
<code><![CDATA[$deadline - microtime(true) + 600]]></code>
|
||||||
</InvalidOperand>
|
</InvalidOperand>
|
||||||
<UndefinedClass>
|
<UndefinedClass>
|
||||||
<code><![CDATA[\OC]]></code>
|
<code><![CDATA[\OC]]></code>
|
||||||
@@ -443,6 +445,7 @@
|
|||||||
</EmptyArrayAccess>
|
</EmptyArrayAccess>
|
||||||
<UndefinedInterfaceMethod>
|
<UndefinedInterfaceMethod>
|
||||||
<code><![CDATA[getInner]]></code>
|
<code><![CDATA[getInner]]></code>
|
||||||
|
<code><![CDATA[getInner]]></code>
|
||||||
</UndefinedInterfaceMethod>
|
</UndefinedInterfaceMethod>
|
||||||
<UnresolvableInclude>
|
<UnresolvableInclude>
|
||||||
<code><![CDATA[include $path]]></code>
|
<code><![CDATA[include $path]]></code>
|
||||||
@@ -474,12 +477,6 @@
|
|||||||
</RedundantCastGivenDocblockType>
|
</RedundantCastGivenDocblockType>
|
||||||
</file>
|
</file>
|
||||||
<file src="lib/Service/TargetService.php">
|
<file src="lib/Service/TargetService.php">
|
||||||
<InvalidReturnStatement>
|
|
||||||
<code><![CDATA[$this->factory()->describeBackends()]]></code>
|
|
||||||
</InvalidReturnStatement>
|
|
||||||
<InvalidReturnType>
|
|
||||||
<code><![CDATA[list<array{id:string, name:string, auth:list<string>, parameters:array}>]]></code>
|
|
||||||
</InvalidReturnType>
|
|
||||||
<UndefinedDocblockClass>
|
<UndefinedDocblockClass>
|
||||||
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
|
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
|
||||||
</UndefinedDocblockClass>
|
</UndefinedDocblockClass>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 227 KiB |
+26
-3
@@ -6,6 +6,10 @@
|
|||||||
<div class="ng-backup">
|
<div class="ng-backup">
|
||||||
<NcSettingsSection :name="t('ng_backup', 'NG Backup')"
|
<NcSettingsSection :name="t('ng_backup', 'NG Backup')"
|
||||||
:description="t('ng_backup', 'Encrypted, incremental backups of this Nextcloud (database, configuration and data). Backup locations are never mounted and are not visible in Files.')">
|
:description="t('ng_backup', 'Encrypted, incremental backups of this Nextcloud (database, configuration and data). Backup locations are never mounted and are not visible in Files.')">
|
||||||
|
<p class="ngb-feedback">
|
||||||
|
{{ t('ng_backup', 'NG Backup is in beta. We would like to hear how it works for you, also when everything went fine.') }}
|
||||||
|
<a :href="feedbackUrl" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'Share your experience ↗') }}</a>
|
||||||
|
</p>
|
||||||
<NcLoadingIcon v-if="!status" :size="32" />
|
<NcLoadingIcon v-if="!status" :size="32" />
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
</NcSettingsSection>
|
</NcSettingsSection>
|
||||||
@@ -22,7 +26,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="status.key.everConfirmed">
|
<template v-else-if="status.key.everConfirmed">
|
||||||
<template v-if="status.targets.length">
|
<template v-if="status.targets.length">
|
||||||
<Backups :status="status" @changed="refresh" />
|
<Backups :status="status" :all-runs="allRuns" @changed="refresh" @show-all-runs="showAllRuns" />
|
||||||
<RestoreBrowser :status="status" @changed="refresh" />
|
<RestoreBrowser :status="status" @changed="refresh" />
|
||||||
</template>
|
</template>
|
||||||
<NcNoteCard v-else type="info">{{ t('ng_backup', 'Add a backup location under "Settings" first.') }}</NcNoteCard>
|
<NcNoteCard v-else type="info">{{ t('ng_backup', 'Add a backup location under "Settings" first.') }}</NcNoteCard>
|
||||||
@@ -45,15 +49,24 @@ import KeySetup from './components/KeySetup.vue'
|
|||||||
import Locations from './components/Locations.vue'
|
import Locations from './components/Locations.vue'
|
||||||
import RestoreBrowser from './components/RestoreBrowser.vue'
|
import RestoreBrowser from './components/RestoreBrowser.vue'
|
||||||
|
|
||||||
|
// Opens the experience report form in .github/ISSUE_TEMPLATE
|
||||||
|
const feedbackUrl = 'https://github.com/dillardblom/ng_backup/issues/new?template=experience.yml'
|
||||||
|
|
||||||
const status = ref(null)
|
const status = ref(null)
|
||||||
|
const allRuns = ref(false) // runs older than the last days too
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const tab = ref('settings')
|
const tab = ref('settings')
|
||||||
let tabInitialized = false
|
let tabInitialized = false
|
||||||
let timer = null
|
let timer = null
|
||||||
|
|
||||||
|
function showAllRuns(all) {
|
||||||
|
allRuns.value = all
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
status.value = await api.status()
|
status.value = await api.status(allRuns.value)
|
||||||
error.value = ''
|
error.value = ''
|
||||||
// Default to the daily-use tab once the key is set up; stay on Settings otherwise.
|
// Default to the daily-use tab once the key is set up; stay on Settings otherwise.
|
||||||
// Only decided on first load, so switching tabs ourselves isn't undone by polling.
|
// Only decided on first load, so switching tabs ourselves isn't undone by polling.
|
||||||
@@ -76,8 +89,18 @@ onBeforeUnmount(() => clearTimeout(timer))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* NcSettingsSection sets its own width (at most 900px): the runs table needs more */
|
||||||
.ng-backup :deep(.settings-section) {
|
.ng-backup :deep(.settings-section) {
|
||||||
max-width: 900px;
|
width: min(1400px, 100% - var(--default-grid-baseline) * 7 * 2);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.ngb-feedback {
|
||||||
|
color: var(--color-text-maxcontrast);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.ngb-feedback a {
|
||||||
|
color: var(--color-primary-element);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.ngb-tabs {
|
.ngb-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+4
-2
@@ -16,7 +16,7 @@ async function call(method, path, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
status: () => call('get', '/status'),
|
status: (allRuns = false) => call('get', '/status', allRuns ? { allRuns: true } : undefined),
|
||||||
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
|
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
|
||||||
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
|
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
|
||||||
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
|
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
|
||||||
@@ -27,11 +27,13 @@ export default {
|
|||||||
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
|
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
|
||||||
backends: () => call('get', '/backends'),
|
backends: () => call('get', '/backends'),
|
||||||
addTarget: (target) => call('post', '/targets', target),
|
addTarget: (target) => call('post', '/targets', target),
|
||||||
|
targetSettings: (id) => call('get', `/targets/${id}/settings`),
|
||||||
|
updateTarget: (id, target) => call('put', `/targets/${id}`, target),
|
||||||
removeTarget: (id) => call('delete', `/targets/${id}`),
|
removeTarget: (id) => call('delete', `/targets/${id}`),
|
||||||
testTarget: (id) => call('post', `/targets/${id}/test`),
|
testTarget: (id) => call('post', `/targets/${id}/test`),
|
||||||
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
||||||
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
||||||
setSchedule: (time) => call('put', '/schedule', { time }),
|
setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }),
|
||||||
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
||||||
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
||||||
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
||||||
|
|||||||
+32
-14
@@ -6,8 +6,8 @@
|
|||||||
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
||||||
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
|
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, {zone}, empty = off)', { zone: browserZone })" />
|
||||||
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
<NcButton :disabled="schedule === status.schedule && browserZone === status.scheduleTimezone" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="item in trash[target.id]" :key="item.id">
|
<li v-for="item in trash[target.id]" :key="item.id">
|
||||||
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
||||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
|
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
|
||||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -40,42 +40,58 @@
|
|||||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||||
|
<th :title="t('ng_backup', 'Total size of the files in this backup, unchanged files included')">{{ t('ng_backup', 'Size') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
<td>{{ formatDateTime(run.startedAt) }}</td>
|
||||||
<td>{{ targetName(run.targetId) }}</td>
|
<td>{{ targetName(run.targetId) }}</td>
|
||||||
<td>{{ run.kind }}</td>
|
<td>{{ run.kind }}</td>
|
||||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + percent(run) + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||||
|
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
|
||||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="allRuns || status.olderRuns" class="ngb-muted">
|
||||||
|
<template v-if="allRuns">
|
||||||
|
{{ t('ng_backup', 'All runs.') }}
|
||||||
|
<NcButton variant="tertiary" @click="emit('show-all-runs', false)">{{ n('ng_backup', 'Only the last day', 'Only the last %n days', status.recentDays) }}</NcButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ n('ng_backup', 'Runs of the last day.', 'Runs of the last %n days.', status.recentDays) }}
|
||||||
|
<NcButton variant="tertiary" @click="emit('show-all-runs', true)">{{ n('ng_backup', 'Show %n older run', 'Show %n older runs', status.olderRuns) }}</NcButton>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
</NcSettingsSection>
|
</NcSettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { n, t } from '@nextcloud/l10n'
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
import { formatSize } from '../format.js'
|
import { formatDateTime, formatDate, formatSize, toZone } from '../format.js'
|
||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
|
||||||
const emit = defineEmits(['changed'])
|
const emit = defineEmits(['changed', 'show-all-runs'])
|
||||||
const schedule = ref(props.status.schedule)
|
// 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.
|
||||||
|
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||||
|
const shown = (s) => (s && props.status.scheduleTimezone && props.status.scheduleTimezone !== browserZone) ? toZone(s, props.status.scheduleTimezone, browserZone) : s
|
||||||
|
const schedule = ref(shown(props.status.schedule))
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
watch(() => [props.status.schedule, props.status.scheduleTimezone], ([s]) => { schedule.value = shown(s) })
|
||||||
|
|
||||||
const trash = reactive({})
|
const trash = reactive({})
|
||||||
async function loadTrash() {
|
async function loadTrash() {
|
||||||
@@ -101,6 +117,8 @@ async function untrash(target, item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// " 68%", or " ~68%" when the total is estimated from Nextcloud's file cache (first backup)
|
||||||
|
const percent = (run) => run.progress ? ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%' : ''
|
||||||
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
|
||||||
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||||
|
|
||||||
@@ -117,7 +135,7 @@ async function start(target) {
|
|||||||
async function saveSchedule() {
|
async function saveSchedule() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await api.setSchedule(schedule.value.trim())
|
await api.setSchedule(schedule.value.trim(), browserZone)
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
<!-- 1. Create the key -->
|
<!-- 1. Create the key -->
|
||||||
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
|
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
|
||||||
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
|
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
|
||||||
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
|
||||||
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
|
||||||
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
||||||
<NcTextField v-model="delay" type="number" min="1" max="365"
|
<NcTextField v-model="delay" type="number" min="1" max="365"
|
||||||
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
|
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
|
||||||
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
|
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
|
||||||
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
|
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 16 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
|
||||||
{{ t('ng_backup', 'Create backup key') }}
|
{{ t('ng_backup', 'Create backup key') }}
|
||||||
</NcButton>
|
</NcButton>
|
||||||
</form>
|
</form>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<tr v-for="slot in status.key.slots" :key="slot.slot">
|
<tr v-for="slot in status.key.slots" :key="slot.slot">
|
||||||
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
||||||
<td><strong>{{ slot.label }}</strong></td>
|
<td><strong>{{ slot.label }}</strong></td>
|
||||||
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
|
<td class="ngb-muted">{{ slot.created ? formatDate(slot.created) : '' }}</td>
|
||||||
<td class="ngb-actions">
|
<td class="ngb-actions">
|
||||||
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
||||||
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
|
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
|
||||||
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
|
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
|
||||||
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
|
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
|
||||||
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
|
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 16 characters)')" autocomplete="new-password" />
|
||||||
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
|
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
|
||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
|
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 16 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||||
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
|
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
|
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: formatDateTime(status.key.confirmation.time) }) }}
|
||||||
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
|
<NcButton variant="tertiary" @click="downloadKit">{{ t('ng_backup', 'Download the kit again') }}</NcButton>
|
||||||
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
|
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -90,6 +90,7 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
|||||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
import { formatDateTime, formatDate } from '../format.js'
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['changed'])
|
const emit = defineEmits(['changed'])
|
||||||
|
|||||||
@@ -14,12 +14,16 @@
|
|||||||
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
|
{{ tests[target.id].ok ? t('ng_backup', 'OK, {n} snapshots', { n: tests[target.id].snapshots }) : tests[target.id].error }}
|
||||||
</span>
|
</span>
|
||||||
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
<NcButton variant="tertiary" @click="test(target)">{{ t('ng_backup', 'Test') }}</NcButton>
|
||||||
|
<NcButton variant="tertiary" @click="startEdit(target)">{{ t('ng_backup', 'Edit') }}</NcButton>
|
||||||
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
<NcButton variant="tertiary" @click="remove(target)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
<NcButton v-if="!adding" @click="startAdd()">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||||
<form v-else class="ngb-form" @submit.prevent="add">
|
<form v-else class="ngb-form" @submit.prevent="save">
|
||||||
|
<NcNoteCard v-if="editing" type="info">
|
||||||
|
{{ t('ng_backup', 'Editing "{name}". The new settings must lead to the same repository, so its backups and history are kept. Leave a password field empty to keep the stored password.', { name: editing.name }) }}
|
||||||
|
</NcNoteCard>
|
||||||
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
<NcTextField v-model="form.name" :label="t('ng_backup', 'Name')" />
|
||||||
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
<NcSelect v-model="backend" :options="backends" label="name" :input-label="t('ng_backup', 'Type')" :clearable="false" />
|
||||||
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
<NcSelect v-if="backend" v-model="auth" :options="backend.auth" label="name" :input-label="t('ng_backup', 'Authentication')" :clearable="false" />
|
||||||
@@ -32,8 +36,8 @@
|
|||||||
<NcTextField v-model="form.maxGb" type="number" min="0" :label="t('ng_backup', 'Maximum size in GiB')"
|
<NcTextField v-model="form.maxGb" type="number" min="0" :label="t('ng_backup', 'Maximum size in GiB')"
|
||||||
:helper-text="form.maxGb ? t('ng_backup', 'Backups stop at this size; administrators are warned at 80%.') : t('ng_backup', 'Empty = unlimited: everything stored is billed by your provider, and nothing stops a backup that keeps growing.')" />
|
:helper-text="form.maxGb ? t('ng_backup', 'Backups stop at this size; administrators are warned at 80%.') : t('ng_backup', 'Empty = unlimited: everything stored is billed by your provider, and nothing stops a backup that keeps growing.')" />
|
||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
|
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ editing ? t('ng_backup', 'Connect and save') : t('ng_backup', 'Connect and add') }}</NcButton>
|
||||||
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
<NcButton @click="adding = false; editing = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||||
import { t } from '@nextcloud/l10n'
|
import { t } from '@nextcloud/l10n'
|
||||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||||
@@ -58,6 +62,7 @@ defineProps({ status: { type: Object, required: true } })
|
|||||||
const emit = defineEmits(['changed'])
|
const emit = defineEmits(['changed'])
|
||||||
|
|
||||||
const adding = ref(false)
|
const adding = ref(false)
|
||||||
|
const editing = ref(null) // the target being edited, or null while adding
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const backends = ref([])
|
const backends = ref([])
|
||||||
@@ -83,35 +88,83 @@ const toFields = (params) => Object.entries(params || {})
|
|||||||
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
.map(([key, p]) => ({ key, label: p.value, type: p.type, optional: !!(p.flags & 1) }))
|
||||||
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
||||||
|
|
||||||
|
let prefilling = false
|
||||||
watch(backend, (b) => {
|
watch(backend, (b) => {
|
||||||
|
if (prefilling) {
|
||||||
|
return
|
||||||
|
}
|
||||||
auth.value = b?.auth?.[0] ?? null
|
auth.value = b?.auth?.[0] ?? null
|
||||||
form.options = {}
|
form.options = {}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function startAdd() {
|
async function loadBackends() {
|
||||||
adding.value = true
|
|
||||||
error.value = ''
|
|
||||||
if (!backends.value.length) {
|
if (!backends.value.length) {
|
||||||
try {
|
backends.value = await api.backends()
|
||||||
backends.value = await api.backends()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function add() {
|
async function startAdd() {
|
||||||
busy.value = true
|
error.value = ''
|
||||||
|
editing.value = null
|
||||||
|
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||||
|
backend.value = null
|
||||||
|
adding.value = true
|
||||||
|
try {
|
||||||
|
await loadBackends()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startEdit(target) {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
await confirmPassword()
|
await confirmPassword()
|
||||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
|
await loadBackends()
|
||||||
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
|
const settings = await api.targetSettings(target.id)
|
||||||
|
prefilling = true
|
||||||
|
backend.value = backends.value.find((b) => b.id === settings.backend) ?? null
|
||||||
|
auth.value = backend.value?.auth.find((a) => a.id === settings.auth) ?? null
|
||||||
|
Object.assign(form, { name: settings.name, path: settings.path, options: Object.fromEntries(Object.entries(settings.options).map(([k, v]) => [k, v ?? ''])),
|
||||||
|
maxGb: target.maxBytes ? String(+(target.maxBytes / 1073741824).toFixed(2)) : '' })
|
||||||
|
await nextTick()
|
||||||
|
prefilling = false
|
||||||
|
editing.value = target
|
||||||
|
adding.value = true
|
||||||
|
} catch (e) {
|
||||||
|
prefilling = false
|
||||||
|
error.value = e?.message || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
const maxGb = form.maxGb === '' ? null : Number(form.maxGb)
|
||||||
|
try {
|
||||||
|
await confirmPassword()
|
||||||
|
if (editing.value) {
|
||||||
|
// An empty password field sends null: the server keeps the stored password.
|
||||||
|
const options = { ...form.options }
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (field.type === 2 && !options[field.key]) {
|
||||||
|
options[field.key] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.updateTarget(editing.value.id, { name: form.name, backend: backend.value.id, auth: auth.value.id, options, path: form.path })
|
||||||
|
if (maxGb !== (editing.value.maxBytes ? Number((editing.value.maxBytes / 1073741824).toFixed(2)) : null)) {
|
||||||
|
await api.setTargetLimit(editing.value.id, maxGb)
|
||||||
|
}
|
||||||
|
delete tests[editing.value.id]
|
||||||
|
} else {
|
||||||
|
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path, maxGb })
|
||||||
|
}
|
||||||
adding.value = false
|
adding.value = false
|
||||||
|
editing.value = null
|
||||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e?.message || ''
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
|||||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||||
import '@nextcloud/password-confirmation/style.css'
|
import '@nextcloud/password-confirmation/style.css'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
import { formatSize } from '../format.js'
|
import { formatDateTime, formatSize } from '../format.js'
|
||||||
|
|
||||||
const props = defineProps({ status: { type: Object, required: true } })
|
const props = defineProps({ status: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['changed'])
|
const emit = defineEmits(['changed'])
|
||||||
@@ -74,7 +74,7 @@ const modes = [
|
|||||||
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const snapshotLabel = (s) => new Date(s.createdAt * 1000).toLocaleString() + (s.label ? ' – ' + s.label : '')
|
const snapshotLabel = (s) => formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '')
|
||||||
const join = (name) => path.value + '/' + name
|
const join = (name) => path.value + '/' + name
|
||||||
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
||||||
const crumbs = computed(() => {
|
const crumbs = computed(() => {
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
import { getCanonicalLocale } from '@nextcloud/l10n'
|
||||||
|
|
||||||
|
// The locale of the user's Nextcloud settings (Personal info > Locale), not the browser's:
|
||||||
|
// a browser set to English would otherwise show 4:00 PM to someone with a 24-hour locale.
|
||||||
|
const locale = getCanonicalLocale()
|
||||||
|
|
||||||
|
// A number is a unix timestamp in seconds, a string an ISO 8601 date (snapshot times).
|
||||||
|
const toDate = (value) => typeof value === 'number' ? new Date(value * 1000) : new Date(value)
|
||||||
|
|
||||||
|
export const formatDateTime = (value) => toDate(value).toLocaleString(locale)
|
||||||
|
|
||||||
|
export const formatDate = (value) => toDate(value).toLocaleDateString(locale)
|
||||||
|
|
||||||
export function formatSize(bytes) {
|
export function formatSize(bytes) {
|
||||||
if (bytes === null || bytes === undefined) {
|
if (bytes === null || bytes === undefined) {
|
||||||
return ''
|
return ''
|
||||||
@@ -13,3 +26,30 @@ export function formatSize(bytes) {
|
|||||||
}
|
}
|
||||||
return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}`
|
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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\SftpRangeFetcher;
|
||||||
|
use OCA\NgBackup\Backend\SftpReadStream;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/SftpReadStreamTest.php'; // FakeSftpConnection, FakeSftpStorage
|
||||||
|
|
||||||
|
/** SftpRangeFetcher against the fake SFTP server of SftpReadStreamTest, several connections at once. */
|
||||||
|
class SftpRangeFetcherTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $files = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
SftpReadStream::reset();
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$this->files["/home/repo/packs/p$i"] = random_bytes(3 * 1048576 + $i * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<FakeSftpConnection> */
|
||||||
|
private function connections(int $n, array $options = []): array {
|
||||||
|
$result = [];
|
||||||
|
for ($c = 0; $c < $n; $c++) {
|
||||||
|
$result[] = new FakeSftpConnection($this->files, ...($options[$c] ?? []));
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<object> what StorageBackend passes on: each with its SFTP channel open */
|
||||||
|
private static function opened(array $connections): array {
|
||||||
|
return array_map(fn (FakeSftpConnection $c): object => SftpReadStream::connection(new FakeSftpStorage($c)), $connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{0:string, 1:int, 2:int}> */
|
||||||
|
private function ranges(): array {
|
||||||
|
$ranges = [];
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$ranges[] = ["repo/packs/p$i", 1000 * $i, 2 * 1048576 + 333];
|
||||||
|
}
|
||||||
|
return $ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string|null> sha1 of the contents by position (a failing comparison of MiBs would not fit in memory) */
|
||||||
|
private static function collect(\Generator $streams): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($streams as $i => $fh) {
|
||||||
|
$result[$i] = $fh === null ? null : sha1(stream_get_contents($fh));
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expected(array $ranges): array {
|
||||||
|
return array_map(fn (array $r): string => sha1(substr($this->files['/home/' . $r[0]], $r[1], $r[2])), $ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsEveryRangeInOrderOverAllConnections(): void {
|
||||||
|
$connections = $this->connections(4);
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
$spool = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame($this->expected($ranges), self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $spool)));
|
||||||
|
|
||||||
|
foreach ($connections as $c) {
|
||||||
|
$this->assertGreaterThan(0, count($c->reads), 'every connection reads');
|
||||||
|
$this->assertLessThanOrEqual(SftpRangeFetcher::DEPTH, $c->maxInFlight);
|
||||||
|
$this->assertSame([], $c->openHandles);
|
||||||
|
$this->assertSame(0, $c->pending());
|
||||||
|
$this->assertTrue($c->disconnected, 'its own connections end with it');
|
||||||
|
}
|
||||||
|
$this->assertSame([], glob("$spool/*"), 'nothing left in the spool');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsSeveralRequestsInFlightOnEachConnection(): void {
|
||||||
|
$this->files['/home/repo/packs/big'] = random_bytes(10 * 1048576);
|
||||||
|
$connections = $this->connections(2);
|
||||||
|
self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', [['repo/packs/big', 0, 10 * 1048576], ['repo/packs/big', 5, 10 * 1048576 - 5]], $this->tempDir()));
|
||||||
|
|
||||||
|
$this->assertSame(SftpRangeFetcher::DEPTH, $connections[0]->maxInFlight);
|
||||||
|
$this->assertSame(SftpRangeFetcher::DEPTH, $connections[1]->maxInFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnAnswerPhpseclibHasReadAlreadyIsTakenWithoutWaitingOnTheSocket(): void {
|
||||||
|
// The fake's answers are all "read already": its socket never becomes readable, and
|
||||||
|
// phpseclib's packet buffer holds data. Waiting on the socket would take a second a step.
|
||||||
|
$connections = $this->connections(2);
|
||||||
|
$sockets = [];
|
||||||
|
foreach ($connections as $c) {
|
||||||
|
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
|
||||||
|
$sockets[] = $pair;
|
||||||
|
$c->fsock = $pair[0];
|
||||||
|
$c->packet_buffer = pack('N', 4) . 'more'; // a whole packet
|
||||||
|
}
|
||||||
|
$ranges = array_slice($this->ranges(), 0, 3);
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$this->assertSame($this->expected($ranges), self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir())));
|
||||||
|
|
||||||
|
$this->assertLessThan(1.0, microtime(true) - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerThatCapsAReadStillGivesEveryByte(): void {
|
||||||
|
$connections = $this->connections(3, array_fill(0, 3, ['cap' => 100000]));
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
|
||||||
|
$this->assertSame($this->expected($ranges), self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir())));
|
||||||
|
// learned: after the first short answers (and asking for their rest) it asks for no more than the server gives
|
||||||
|
foreach ($connections as $c) {
|
||||||
|
$this->assertLessThanOrEqual(2 * SftpRangeFetcher::DEPTH, count(array_filter($c->reads, fn (int $n): bool => $n > 100000)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingFileIsLeftToTheCaller(): void {
|
||||||
|
$connections = $this->connections(2);
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
$ranges[3][0] = 'repo/packs/gone';
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir()));
|
||||||
|
|
||||||
|
$expected = $this->expected($this->ranges());
|
||||||
|
$expected[3] = null;
|
||||||
|
$this->assertSame($expected, $result);
|
||||||
|
$this->assertSame([], $connections[0]->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARangePastTheEndOfTheFileIsLeftToTheCaller(): void {
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
$ranges[5][2] = 10 * 1048576;
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($this->connections(2)), '/home/', $ranges, $this->tempDir()));
|
||||||
|
|
||||||
|
$this->assertNull($result[5]);
|
||||||
|
$this->assertSame($this->expected($this->ranges())[6], $result[6]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerErrorFailsOnlyThatRange(): void {
|
||||||
|
// every read from 1 MiB on fails on connection 0: its ranges, not those of the others
|
||||||
|
$connections = $this->connections(2, [['failAt' => 1048576]]);
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir()));
|
||||||
|
|
||||||
|
$expected = $this->expected($ranges);
|
||||||
|
$failed = array_keys(array_filter($result, fn (?string $r): bool => $r === null));
|
||||||
|
$this->assertNotSame([], $failed);
|
||||||
|
foreach ($result as $i => $data) {
|
||||||
|
if ($data !== null) {
|
||||||
|
$this->assertSame($expected[$i], $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertLessThan(10, count($failed), 'the other connection goes on');
|
||||||
|
$this->assertSame([], $connections[0]->openHandles, 'a failed range is closed too');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testALostConnectionIsNotUsedAgainAndTheOthersGoOn(): void {
|
||||||
|
$connections = $this->connections(3, [['loseAt' => 1048576]]);
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir()));
|
||||||
|
|
||||||
|
$expected = $this->expected($ranges);
|
||||||
|
$this->assertSame(1, count(array_filter($result, fn (?string $r): bool => $r === null)), 'only the range it was reading');
|
||||||
|
foreach ($result as $i => $data) {
|
||||||
|
if ($data !== null) {
|
||||||
|
$this->assertSame($expected[$i], $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertTrue($connections[0]->disconnected);
|
||||||
|
$this->assertLessThanOrEqual(1048576 / 261120 + SftpRangeFetcher::DEPTH + 1, count($connections[0]->reads), 'not used after it broke');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWhenEveryConnectionIsLostTheRestIsLeftToTheCaller(): void {
|
||||||
|
$connections = $this->connections(2, [['throwAt' => 1048576], ['loseAt' => 1048576]]);
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $this->ranges(), $this->tempDir()));
|
||||||
|
|
||||||
|
$this->assertSame(array_fill(0, 10, null), $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABrokenConnectionIsReplacedAndTheRestIsReadAhead(): void {
|
||||||
|
$connections = $this->connections(1, [['loseAt' => 1048576]]);
|
||||||
|
$made = [];
|
||||||
|
$connect = function () use (&$made): object {
|
||||||
|
$made[] = $c = new FakeSftpConnection($this->files);
|
||||||
|
return SftpReadStream::connection(new FakeSftpStorage($c));
|
||||||
|
};
|
||||||
|
$ranges = $this->ranges();
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($connections), '/home/', $ranges, $this->tempDir(), $connect));
|
||||||
|
|
||||||
|
$expected = $this->expected($ranges);
|
||||||
|
$expected[0] = null; // the range it was reading when it broke: read the usual way
|
||||||
|
$this->assertSame($expected, $result);
|
||||||
|
$this->assertCount(1, $made);
|
||||||
|
$this->assertTrue($made[0]->disconnected, 'the new one ends with the fetcher too');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnectionsThatKeepBreakingAreReplacedOnlySoOften(): void {
|
||||||
|
$made = 0;
|
||||||
|
$connect = function () use (&$made): object {
|
||||||
|
$made++;
|
||||||
|
return SftpReadStream::connection(new FakeSftpStorage(new FakeSftpConnection($this->files, loseAt: 0)));
|
||||||
|
};
|
||||||
|
$ranges = array_merge($this->ranges(), $this->ranges(), $this->ranges());
|
||||||
|
|
||||||
|
$result = self::collect(SftpRangeFetcher::fetch(self::opened($this->connections(1, [['loseAt' => 0]])), '/home/', $ranges, $this->tempDir(), $connect));
|
||||||
|
|
||||||
|
$this->assertSame(array_fill(0, count($ranges), null), $result);
|
||||||
|
$this->assertSame(SftpRangeFetcher::MAX_RECONNECTS, $made);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStoppingHalfwayEndsTheConnectionsAndLeavesNothingBehind(): void {
|
||||||
|
$connections = $this->connections(2);
|
||||||
|
$spool = $this->tempDir();
|
||||||
|
$streams = SftpRangeFetcher::fetch(self::opened($connections), '/home/', $this->ranges(), $spool);
|
||||||
|
$this->assertIsResource($streams->current());
|
||||||
|
|
||||||
|
unset($streams);
|
||||||
|
|
||||||
|
$this->assertTrue($connections[0]->disconnected);
|
||||||
|
$this->assertTrue($connections[1]->disconnected);
|
||||||
|
$this->assertSame([], glob("$spool/*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsNoMoreThanAheadOfTheCaller(): void {
|
||||||
|
$connections = $this->connections(4);
|
||||||
|
$streams = SftpRangeFetcher::fetch(self::opened($connections), '/home/', $this->ranges(), $this->tempDir());
|
||||||
|
$streams->current();
|
||||||
|
|
||||||
|
$opened = array_sum(array_map(fn (FakeSftpConnection $c): int => count(array_unique(array_values($c->opened))), $connections));
|
||||||
|
$this->assertLessThanOrEqual(SftpRangeFetcher::AHEAD, $opened);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\SftpReadStream;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpReadStream against a fake of the phpseclib connection: an SFTP server that answers READ
|
||||||
|
* requests from in-memory files, optionally capped. Every answer must be asked for by its request
|
||||||
|
* id, as phpseclib matches them: a server may answer out of order.
|
||||||
|
*/
|
||||||
|
class SftpReadStreamTest extends TestCase {
|
||||||
|
protected function setUp(): void {
|
||||||
|
SftpReadStream::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsAWholeFileWithSeveralRequestsInFlight(): void {
|
||||||
|
$data = random_bytes(5 * 1048576 + 12345);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/repo/packs/ab/abcd' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'repo/packs/ab/abcd');
|
||||||
|
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
$this->assertTrue(feof($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(SftpReadStream::DEPTH, $sftp->maxInFlight);
|
||||||
|
$this->assertSame(0, $sftp->pending(), 'nothing may be left on the connection after close');
|
||||||
|
$this->assertSame([], $sftp->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerThatCapsAReadStillGivesEveryByteInOrder(): void {
|
||||||
|
$data = random_bytes(3 * 1048576 + 7);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], cap: 100000);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
|
||||||
|
// The cap is learned: a next stream asks for no more than the server gives.
|
||||||
|
$sftp->reads = [];
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([100000], array_values(array_unique($sftp->reads)));
|
||||||
|
|
||||||
|
// Only for that connection.
|
||||||
|
$other = new FakeSftpConnection(['/home/f' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($other), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(261120, max($other->reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATinyAnswerDoesNotShrinkTheReadsBelowWhatEveryServerSupports(): void {
|
||||||
|
$data = random_bytes(1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], cap: 1000);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(32768, min($sftp->reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeekingReadsFromTheNewOffset(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
|
||||||
|
$this->assertSame(0, fseek($fh, 2000000));
|
||||||
|
$this->assertSame(2000000, ftell($fh));
|
||||||
|
$this->assertSame(substr($data, 2000000, 1000), fread($fh, 1000));
|
||||||
|
$this->assertSame(0, fseek($fh, 10)); // backwards
|
||||||
|
$this->assertSame(substr($data, 10, 70000), $this->readExactly($fh, 70000));
|
||||||
|
$this->assertSame(0, fseek($fh, 100, SEEK_CUR)); // within what is already buffered
|
||||||
|
$this->assertSame(substr($data, 70110, 5000), $this->readExactly($fh, 5000));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASmallReadDoesNotFetchTheWholePipelineAhead(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(10 * 1048576)]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
fread($fh, 100);
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertCount(1, $sftp->reads, 'the first request goes alone, the pipeline grows while reading on');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyFileIsAtEndAtOnce(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => '']);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame('', stream_get_contents($fh));
|
||||||
|
$this->assertTrue(feof($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingFileCannotBeOpened(): void {
|
||||||
|
$sftp = new FakeSftpConnection([]);
|
||||||
|
$this->assertFalse(SftpReadStream::open(new FakeSftpStorage($sftp), 'nope'));
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerErrorGoesOnWithTheStoragesOwnStreamFromTheSamePosition(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], failAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
$this->assertSame(['f'], $storage->fopens);
|
||||||
|
$this->assertSame(strlen($data), ftell($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending(), 'the connection is clean for the storage\'s own stream');
|
||||||
|
$this->assertSame([], $sftp->openHandles, 'the failed handle is closed');
|
||||||
|
|
||||||
|
// One failure is no reason to give up on the fast reads.
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyFailuresInARowSwitchTheFastReadsOff(): void {
|
||||||
|
$data = random_bytes(2 * 1048576);
|
||||||
|
$failing = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => $data], failAt: 1048576));
|
||||||
|
$working = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => $data]));
|
||||||
|
for ($i = 1; $i < SftpReadStream::MAX_FAILURES; $i++) {
|
||||||
|
$fh = SftpReadStream::open($failing, 'f');
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
$fh = SftpReadStream::open($working, 'f'); // a stream that works resets the count
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
for ($i = 1; $i <= SftpReadStream::MAX_FAILURES; $i++) {
|
||||||
|
$fh = SftpReadStream::open($failing, 'f');
|
||||||
|
$this->assertIsResource($fh, "failure $i");
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
$this->assertNull(SftpReadStream::open($working, 'f'));
|
||||||
|
$this->assertSame(SftpReadStream::MAX_FAILURES * 2 - 1, count($failing->fopens), 'every failed stream still gave all its data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerErrorAfterASeekGoesOnFromTheSeekedPosition(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], failAt: 2500000);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertSame(0, fseek($fh, 2400000));
|
||||||
|
$this->assertSame(substr($data, 2400000), $this->readAll($fh));
|
||||||
|
$this->assertSame(0, fseek($fh, 100)); // seeks go to the storage's stream now
|
||||||
|
$this->assertSame(substr($data, 100, 1000), $this->readExactly($fh, 1000));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorDoesNotFallBackOnTheSameConnection(): void {
|
||||||
|
// After an exception, answers may still be on their way; files_external's stream would
|
||||||
|
// take them for its own.
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], throwAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testALostConnectionDoesNotFallBackEither(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], loseAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWhenTheStoragesOwnStreamFailsTooTheReadFails(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], failAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp, fopenFails: true);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(['f'], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorWhileOpeningIsAnErrorNotAMissingFile(): void {
|
||||||
|
// The OPEN request went out: the storage's own stream must not be tried on this connection.
|
||||||
|
$storage = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], throwAt: -1));
|
||||||
|
try {
|
||||||
|
SftpReadStream::open($storage, 'f');
|
||||||
|
$this->fail('expected a BackendException');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringContainsString('connection', $e->getMessage());
|
||||||
|
}
|
||||||
|
$lost = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], loseAt: -1));
|
||||||
|
$this->expectException(BackendException::class);
|
||||||
|
SftpReadStream::open($lost, 'f');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorWhileOpeningDoesNotStopTheNextFile(): void {
|
||||||
|
try {
|
||||||
|
SftpReadStream::open(new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], throwAt: -1)), 'f');
|
||||||
|
} catch (BackendException) {
|
||||||
|
}
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'])), 'f');
|
||||||
|
$this->assertSame('x', stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedCloseDoesNotFallBackOnTheSameConnection(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], failAt: 1048576, closeFails: true);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAStorageThatThrowsIsNotUsed(): void {
|
||||||
|
$storage = new class {
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
throw new \RuntimeException('Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyReadsSmallFilesInBatches(): void {
|
||||||
|
$files = [];
|
||||||
|
for ($i = 0; $i < 70; $i++) {
|
||||||
|
// one empty file on purpose (random_bytes(0) throws)
|
||||||
|
$files["/home/index/$i"] = $i === 0 ? '' : random_bytes(random_int(1, 5000));
|
||||||
|
}
|
||||||
|
$files['/home/index/big'] = random_bytes(600000); // more than one READ
|
||||||
|
$sftp = new FakeSftpConnection($files);
|
||||||
|
$paths = array_map(fn ($p) => substr($p, 6), array_keys($files));
|
||||||
|
|
||||||
|
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), $paths);
|
||||||
|
|
||||||
|
$this->assertSame(array_combine($paths, array_values($files)), $got);
|
||||||
|
$this->assertSame(SftpReadStream::BATCH, $sftp->maxInFlight, 'a batch goes out at once');
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
$this->assertSame([], $sftp->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyLeavesAMissingOrFailingFileToTheCaller(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/a' => 'aaa', '/home/b' => random_bytes(400000), '/home/c' => 'ccc'], failAt: 300000);
|
||||||
|
|
||||||
|
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'missing', 'b', 'c']);
|
||||||
|
|
||||||
|
$this->assertSame(['a' => 'aaa', 'missing' => null, 'b' => null, 'c' => 'ccc'], $got);
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
$this->assertSame([], $sftp->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyAcrossACappedServer(): void {
|
||||||
|
$data = random_bytes(300000);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/a' => $data, '/home/b' => 'b'], cap: 100000);
|
||||||
|
$this->assertSame(['a' => $data, 'b' => 'b'], SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyEndsTheConnectionOnAConnectionError(): void {
|
||||||
|
// Answers to requests already out may still come: files_external must not get them.
|
||||||
|
$sftp = new FakeSftpConnection(['/home/a' => random_bytes(10), '/home/b' => 'b'], loseAt: 0);
|
||||||
|
try {
|
||||||
|
SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']);
|
||||||
|
$this->fail('expected a BackendException');
|
||||||
|
} catch (BackendException) {
|
||||||
|
}
|
||||||
|
$this->assertTrue($sftp->disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyStopsBatchingWhenCloseIsNotConfirmed(): void {
|
||||||
|
$files = [];
|
||||||
|
for ($i = 0; $i < 40; $i++) {
|
||||||
|
$files["/home/$i"] = "file $i";
|
||||||
|
}
|
||||||
|
$sftp = new FakeSftpConnection($files, closeStatus: 4);
|
||||||
|
$got = SftpReadStream::readMany(new FakeSftpStorage($sftp), array_map('strval', range(0, 39)));
|
||||||
|
$this->assertSame('file 0', $got['0']);
|
||||||
|
$this->assertSame('file 31', $got['31']);
|
||||||
|
$this->assertNull($got['32'], 'no second batch after unconfirmed closes');
|
||||||
|
$this->assertCount(SftpReadStream::BATCH, array_filter($got, fn ($v) => $v !== null));
|
||||||
|
// Nor on a later call over the same connection.
|
||||||
|
$this->assertNull(SftpReadStream::readMany(new FakeSftpStorage($sftp), ['0']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyLeavesAMalformedAnswerToTheCaller(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/a' => 'aaaa', '/home/b' => 'bbbb'], malformed: '/home/a');
|
||||||
|
$this->assertSame(['a' => null, 'b' => 'bbbb'], SftpReadStream::readMany(new FakeSftpStorage($sftp), ['a', 'b']));
|
||||||
|
$this->assertSame([], $sftp->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadManyOnAnUnsupportedConnectionIsNull(): void {
|
||||||
|
$this->assertNull(SftpReadStream::readMany(new \stdClass(), ['a']));
|
||||||
|
$this->assertNull(SftpReadStream::readMany(new FakeSftpStorage(new FakeSftpConnection(['/home/a' => 'a'], channelFails: true)), ['a']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFreshConnectionHasItsSftpChannelOpenedFirst(): void {
|
||||||
|
// phpseclib 2 logs in at connect time but opens the SFTP channel on the first SFTP call.
|
||||||
|
$sftp = new FakeSftpConnection(['/home/config' => 'repository config']);
|
||||||
|
$this->assertFalse($sftp->initialised);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'config');
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
$this->assertSame('repository config', stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAChannelThatCannotBeOpenedLeavesItToTheStoragesOwnStream(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/config' => 'x'], channelFails: true);
|
||||||
|
$this->assertNull(SftpReadStream::open(new FakeSftpStorage($sftp), 'config'));
|
||||||
|
$this->assertSame(0, $sftp->sent, 'nothing may be sent on a connection without an SFTP channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionWithoutTheNeededMethodsIsNotUsed(): void {
|
||||||
|
$storage = new class {
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return new \stdClass();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
$this->assertNull(SftpReadStream::open(new \stdClass(), 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPhpseclib3ConnectionIsNotUsed(): void {
|
||||||
|
// phpseclib 3 has no _-prefixed methods; its packet_type and packet methods are private.
|
||||||
|
$v3 = new class {
|
||||||
|
private int $packet_type = -1;
|
||||||
|
|
||||||
|
private function _send_sftp_packet(): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _get_sftp_packet(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function realpath(string $path): string {
|
||||||
|
return $path . (string)$this->packet_type . $this->_send_sftp_packet() . $this->_get_sftp_packet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$storage = new class($v3) {
|
||||||
|
public function __construct(
|
||||||
|
private object $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPrivatePacketTypeIsNotUsed(): void {
|
||||||
|
$conn = new class {
|
||||||
|
private int $packet_type = -1;
|
||||||
|
|
||||||
|
public function _send_sftp_packet(): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _get_sftp_packet(): string {
|
||||||
|
return (string)$this->packet_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$storage = new class($conn) {
|
||||||
|
public function __construct(
|
||||||
|
private object $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $fh */
|
||||||
|
private function readAll($fh): string {
|
||||||
|
$data = '';
|
||||||
|
while (!feof($fh)) {
|
||||||
|
$chunk = fread($fh, 65536);
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$data .= $chunk;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $fh */
|
||||||
|
private function readExactly($fh, int $length): string {
|
||||||
|
$data = '';
|
||||||
|
while (strlen($data) < $length && !feof($fh)) {
|
||||||
|
$data .= fread($fh, $length - strlen($data));
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeSftpStorage {
|
||||||
|
/** @var list<string> paths opened through the storage's own stream */
|
||||||
|
public array $fopens = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private FakeSftpConnection $connection,
|
||||||
|
private bool $fopenFails = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** files_external's own read stream: here the file from memory, seekable. */
|
||||||
|
public function fopen(string $path, string $mode) {
|
||||||
|
$this->fopens[] = $path;
|
||||||
|
$data = $this->connection->file('/home/' . $path);
|
||||||
|
if ($this->fopenFails || $data === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$fh = fopen('php://memory', 'r+');
|
||||||
|
fwrite($fh, $data);
|
||||||
|
rewind($fh);
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/home/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): FakeSftpConnection {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of phpseclib\Net\SFTP (2.x) that SftpReadStream uses. */
|
||||||
|
final class FakeSftpConnection {
|
||||||
|
public int $packet_type = -1;
|
||||||
|
public int $maxInFlight = 0;
|
||||||
|
/** @var list<int> lengths asked for by READ requests */
|
||||||
|
public array $reads = [];
|
||||||
|
/** @var array<string, string> */
|
||||||
|
public array $openHandles = [];
|
||||||
|
/** @var list<string> paths of every OPEN request */
|
||||||
|
public array $opened = [];
|
||||||
|
/** @var resource|null like phpseclib's socket (SftpRangeFetcher waits on it) */
|
||||||
|
public $fsock = null;
|
||||||
|
/** like phpseclib's: what came in with the last packet and is not read yet */
|
||||||
|
public string $packet_buffer = '';
|
||||||
|
/** Whether the SFTP channel is open: phpseclib 2 opens it on the first SFTP call. */
|
||||||
|
public bool $initialised = false;
|
||||||
|
/** Packets sent */
|
||||||
|
public int $sent = 0;
|
||||||
|
/** @var array<int, array{int, string}> answers by request id, in the order the server sent them */
|
||||||
|
private array $answers = [];
|
||||||
|
private int $handles = 0;
|
||||||
|
|
||||||
|
/** @param array<string, string> $files */
|
||||||
|
public function __construct(
|
||||||
|
private array $files,
|
||||||
|
private int $cap = 261120,
|
||||||
|
private ?int $failAt = null,
|
||||||
|
/** READ at or past this offset throws; -1: OPEN throws */
|
||||||
|
private ?int $throwAt = null,
|
||||||
|
/** READ at or past this offset finds the connection gone (phpseclib returns false); -1: OPEN */
|
||||||
|
private ?int $loseAt = null,
|
||||||
|
private bool $closeFails = false,
|
||||||
|
private bool $channelFails = false,
|
||||||
|
/** status code the server answers CLOSE with */
|
||||||
|
private int $closeStatus = 0,
|
||||||
|
/** a file whose DATA answers declare a wrong length */
|
||||||
|
private ?string $malformed = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool $disconnected = false;
|
||||||
|
|
||||||
|
public function disconnect(): void {
|
||||||
|
$this->disconnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pwd(): string|false {
|
||||||
|
if ($this->channelFails) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->initialised = true;
|
||||||
|
return '/home';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(string $path): ?string {
|
||||||
|
return $this->files[$path] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pending(): int {
|
||||||
|
return count($this->answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return '/' . trim(preg_replace('#/+#', '/', $path), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _send_sftp_packet(int $type, string $data, int $id = 1): bool {
|
||||||
|
$this->sent++;
|
||||||
|
if (!$this->initialised) {
|
||||||
|
return false; // like phpseclib 2: no SFTP channel yet
|
||||||
|
}
|
||||||
|
switch ($type) {
|
||||||
|
case 3: // OPEN
|
||||||
|
if ($this->throwAt === -1) {
|
||||||
|
throw new \RuntimeException('Connection closed by server');
|
||||||
|
}
|
||||||
|
if ($this->loseAt === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$path = substr($data, 4, unpack('N', $data)[1]);
|
||||||
|
$this->opened[] = $path;
|
||||||
|
if (!isset($this->files[$path])) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 2)];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$handle = 'h' . ++$this->handles;
|
||||||
|
$this->openHandles[$handle] = $path;
|
||||||
|
$this->answers[$id] = [102, pack('Na*', strlen($handle), $handle)];
|
||||||
|
break;
|
||||||
|
case 4: // CLOSE
|
||||||
|
if ($this->closeFails) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$handle = substr($data, 4, unpack('N', $data)[1]);
|
||||||
|
unset($this->openHandles[$handle]);
|
||||||
|
$this->answers[$id] = [101, pack('N', $this->closeStatus)];
|
||||||
|
break;
|
||||||
|
case 5: // READ
|
||||||
|
$len = unpack('N', $data)[1];
|
||||||
|
$handle = substr($data, 4, $len);
|
||||||
|
['hi' => $hi, 'lo' => $lo, 'n' => $n] = unpack('Nhi/Nlo/Nn', substr($data, 4 + $len));
|
||||||
|
$offset = $hi * 4294967296 + $lo;
|
||||||
|
$this->reads[] = $n;
|
||||||
|
if ($this->throwAt !== null && $this->throwAt >= 0 && $offset >= $this->throwAt) {
|
||||||
|
throw new \RuntimeException('Connection closed by server');
|
||||||
|
}
|
||||||
|
if ($this->loseAt !== null && $this->loseAt >= 0 && $offset >= $this->loseAt) {
|
||||||
|
$this->answers = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$file = $this->files[$this->openHandles[$handle]];
|
||||||
|
if ($this->failAt !== null && $offset >= $this->failAt) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 4)]; // FX_FAILURE
|
||||||
|
} elseif ($offset >= strlen($file)) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 1)]; // FX_EOF
|
||||||
|
} else {
|
||||||
|
$chunk = substr($file, $offset, min($n, $this->cap));
|
||||||
|
$declared = $this->openHandles[$handle] === $this->malformed ? strlen($chunk) + 7 : strlen($chunk);
|
||||||
|
$this->answers[$id] = [103, pack('Na*', $declared, $chunk)];
|
||||||
|
}
|
||||||
|
$this->maxInFlight = max($this->maxInFlight, count($this->answers));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \LogicException("unexpected packet type $type");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _get_sftp_packet(?int $id = null): string|false {
|
||||||
|
if ($this->answers === []) {
|
||||||
|
throw new \LogicException('waiting for an answer that will never come');
|
||||||
|
}
|
||||||
|
if ($id === null || !isset($this->answers[$id])) {
|
||||||
|
throw new \LogicException("no answer for request $id");
|
||||||
|
}
|
||||||
|
[$this->packet_type, $payload] = $this->answers[$id];
|
||||||
|
unset($this->answers[$id]);
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\StorageBackend;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../stubs/SmbStorage.php';
|
||||||
|
|
||||||
|
/** On a remote storage every is_dir() is a round trip: listing index/ must not cost one per pack. */
|
||||||
|
class StorageBackendListTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
public function testListFilesChecksOnlyTheFolderItself(): void {
|
||||||
|
$dir = $this->tempDir();
|
||||||
|
foreach (['b1', 'a2', 'c3.part-0123abcd'] as $name) {
|
||||||
|
touch("$dir/$name");
|
||||||
|
}
|
||||||
|
$isDirCalls = 0;
|
||||||
|
$storage = $this->createMock(IStorage::class);
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturnCallback(function (string $path) use (&$isDirCalls): bool {
|
||||||
|
$isDirCalls++;
|
||||||
|
return $path === 'repo' || $path === 'repo/index';
|
||||||
|
});
|
||||||
|
$storage->method('opendir')->willReturnCallback(fn (string $path) => $path === 'repo/index' ? opendir($dir) : false);
|
||||||
|
|
||||||
|
$backend = new StorageBackend($storage, 'repo');
|
||||||
|
$isDirCalls = 0; // the constructor checks the base folder
|
||||||
|
$this->assertSame(['index/a2', 'index/b1'], $backend->listFiles('index'));
|
||||||
|
$this->assertSame(1, $isDirCalls);
|
||||||
|
|
||||||
|
$isDirCalls = 0;
|
||||||
|
$this->assertSame(['index/a2', 'index/b1'], $backend->list('index'));
|
||||||
|
$this->assertSame(4, $isDirCalls, 'list() checks every entry: the reason listFiles() exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListFilesOfAMissingFolderIsEmpty(): void {
|
||||||
|
$storage = $this->createMock(IStorage::class);
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturnCallback(fn (string $path): bool => $path === 'repo');
|
||||||
|
$storage->expects($this->never())->method('opendir');
|
||||||
|
$this->assertSame([], (new StorageBackend($storage, 'repo'))->listFiles('snapshots'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnreadableFolderIsAnErrorNotEmpty(): void {
|
||||||
|
$storage = $this->createMock(IStorage::class);
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
$storage->method('opendir')->willReturn(false);
|
||||||
|
$this->expectException(BackendException::class);
|
||||||
|
(new StorageBackend($storage, 'repo'))->listFiles('catalog');
|
||||||
|
}
|
||||||
|
public function testSmbFoldersAreListedInOneRequest(): void {
|
||||||
|
$calls = new \ArrayObject();
|
||||||
|
$share = new class($calls) {
|
||||||
|
public function __construct(private \ArrayObject $calls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dir(string $path): array {
|
||||||
|
$this->calls[] = "dir $path";
|
||||||
|
return array_map(fn (string $n) => new class($n, $this->calls) {
|
||||||
|
public function __construct(private string $n, private \ArrayObject $calls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHidden(): bool {
|
||||||
|
$this->calls[] = "attributes {$this->n}";
|
||||||
|
return $this->n === 'desktop.ini';
|
||||||
|
}
|
||||||
|
}, ['b1', 'a2', '.hidden', 'desktop.ini', 'c3.part-0123abcd']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
|
||||||
|
(fn () => $this->share = $share)->call($storage);
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
$storage->expects($this->never())->method('opendir');
|
||||||
|
|
||||||
|
$this->assertSame(['index/a2', 'index/b1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
|
||||||
|
$this->assertSame(['dir repo/index', 'attributes desktop.ini', 'attributes c3.part-0123abcd'], $calls->getArrayCopy(),
|
||||||
|
'only names a repository does not use cost a request');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSmbWithAccessControlListsIsListedTheUsualWay(): void {
|
||||||
|
$dir = $this->tempDir();
|
||||||
|
touch("$dir/a1");
|
||||||
|
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock();
|
||||||
|
(fn () => $this->checkAcl = true)->call($storage);
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
$storage->expects($this->once())->method('opendir')->willReturnCallback(fn () => opendir($dir));
|
||||||
|
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSmbFallsBackToTheUsualListing(): void {
|
||||||
|
$dir = $this->tempDir();
|
||||||
|
touch("$dir/a1");
|
||||||
|
$storage = $this->getMockBuilder(\OCA\Files_External\Lib\Storage\SMB::class)->disableOriginalConstructor()->getMock(); // no share
|
||||||
|
$storage->method('instanceOfStorage')->willReturn(false);
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
$storage->method('opendir')->willReturnCallback(fn () => opendir($dir));
|
||||||
|
$this->assertSame(['index/a1'], (new StorageBackend($storage, 'repo'))->listFiles('index'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\SftpRangeFetcher;
|
||||||
|
use OCA\NgBackup\Backend\SftpReadStream;
|
||||||
|
use OCA\NgBackup\Backend\StorageBackend;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/SftpReadStreamTest.php'; // FakeSftpConnection
|
||||||
|
require_once __DIR__ . '/StorageBackendReconnectTest.php'; // SftpLikeStorage
|
||||||
|
|
||||||
|
/** StorageBackend::readAhead(): over SFTP on connections of its own, built with the reconnect function. */
|
||||||
|
class StorageBackendReadAheadTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
/** @var list<FakeSftpConnection> */
|
||||||
|
private array $connections = [];
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $files = [];
|
||||||
|
/** New storages after this many fail to connect. */
|
||||||
|
private ?int $connectLimit = null;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
SftpReadStream::reset();
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
$this->files["/home/base/repo/packs/p$i"] = random_bytes(1048576 + $i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storage(): IStorage {
|
||||||
|
if ($this->connectLimit !== null && count($this->connections) >= $this->connectLimit) {
|
||||||
|
throw new \RuntimeException('Too many connections');
|
||||||
|
}
|
||||||
|
$sftp = new FakeSftpConnection($this->files);
|
||||||
|
$this->connections[] = $sftp;
|
||||||
|
$storage = $this->createMock(SftpLikeRootStorage::class);
|
||||||
|
$storage->method('instanceOfStorage')->willReturnCallback(fn (string $class): bool => $class === \OCA\Files_External\Lib\Storage\SFTP::class);
|
||||||
|
$storage->method('getConnection')->willReturn($sftp);
|
||||||
|
$storage->method('getRoot')->willReturn('/home/');
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
return $storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{0:string, 1:int, 2:int}> */
|
||||||
|
private static function ranges(): array {
|
||||||
|
return array_map(fn (int $i): array => ["packs/p$i", 7, 1048576 - 7], range(0, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsAheadOnNewConnectionsOnly(): void {
|
||||||
|
$backend = new StorageBackend($this->storage(), 'base/repo', false, fn (): IStorage => $this->storage());
|
||||||
|
$backend->readAheadConnections = 4;
|
||||||
|
$this->assertTrue($backend->canReadAhead());
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($backend->readAhead(self::ranges(), $this->tempDir()) as $i => $fh) {
|
||||||
|
$result[$i] = sha1(stream_get_contents($fh));
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = array_map(fn (int $i): string => sha1(substr($this->files["/home/base/repo/packs/p$i"], 7, 1048576 - 7)), range(0, 5));
|
||||||
|
$this->assertSame($expected, $result);
|
||||||
|
$this->assertCount(1 + 4, $this->connections);
|
||||||
|
$this->assertSame(0, $this->connections[0]->sent, 'the storage\'s own connection stays free');
|
||||||
|
foreach (array_slice($this->connections, 1) as $c) {
|
||||||
|
$this->assertTrue($c->disconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGoesOnWithTheConnectionsTheServerAllows(): void {
|
||||||
|
$this->connectLimit = 3;
|
||||||
|
$backend = new StorageBackend($this->storage(), 'base/repo', false, fn (): IStorage => $this->storage());
|
||||||
|
$backend->readAheadConnections = 4;
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($backend->readAhead(self::ranges(), $this->tempDir()) as $fh) {
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(6, $count);
|
||||||
|
$this->assertCount(3, $this->connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testByDefaultOneConnectionNextToTheStoragesOwn(): void {
|
||||||
|
$backend = new StorageBackend($this->storage(), 'base/repo', false, fn (): IStorage => $this->storage());
|
||||||
|
foreach ($backend->readAhead(self::ranges(), $this->tempDir()) as $fh) {
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(1, SftpRangeFetcher::DEFAULT_CONNECTIONS);
|
||||||
|
$this->assertCount(2, $this->connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoNewConnectionMeansNoReadingAhead(): void {
|
||||||
|
$this->connectLimit = 1;
|
||||||
|
$backend = new StorageBackend($this->storage(), 'base/repo', false, fn (): IStorage => $this->storage());
|
||||||
|
|
||||||
|
$this->assertNull($backend->readAhead(self::ranges(), $this->tempDir()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutAWayToConnectAgainItDoesNotReadAhead(): void {
|
||||||
|
$backend = new StorageBackend($this->storage(), 'base/repo', false, null);
|
||||||
|
|
||||||
|
$this->assertFalse($backend->canReadAhead());
|
||||||
|
$this->assertNull($backend->readAhead(self::ranges(), $this->tempDir()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SftpLikeRootStorage extends SftpLikeStorage {
|
||||||
|
public function getRoot(): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\StorageBackend;
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
|
||||||
|
* quiet for a few minutes; files_external keeps using the dead client, on which every file
|
||||||
|
* looks missing. The backend builds the storage anew instead.
|
||||||
|
*/
|
||||||
|
class StorageBackendReconnectTest extends TestCase {
|
||||||
|
/** @var list<ReconnectFakeSftp> */
|
||||||
|
private array $connections = [];
|
||||||
|
/** New connections come up dead (the location is gone). */
|
||||||
|
private bool $deadOnArrival = false;
|
||||||
|
|
||||||
|
private function storage(): IStorage {
|
||||||
|
$sftp = new ReconnectFakeSftp();
|
||||||
|
$sftp->dropped = $this->deadOnArrival;
|
||||||
|
$this->connections[] = $sftp;
|
||||||
|
$storage = $this->createMock(SftpLikeStorage::class);
|
||||||
|
$storage->method('instanceOfStorage')->willReturnCallback(fn (string $class): bool => $class === \OCA\Files_External\Lib\Storage\SFTP::class);
|
||||||
|
$storage->method('getConnection')->willReturn($sftp);
|
||||||
|
$storage->method('is_dir')->willReturn(true);
|
||||||
|
$storage->method('file_exists')->willReturnCallback(fn (string $path): bool => $sftp->request() && $path === 'repo/config');
|
||||||
|
$storage->method('fopen')->willReturnCallback(function (string $path) use ($sftp) {
|
||||||
|
if (!$sftp->request() || $path !== 'repo/config') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$fh = fopen('php://memory', 'w+b');
|
||||||
|
fwrite($fh, 'config');
|
||||||
|
rewind($fh);
|
||||||
|
return $fh;
|
||||||
|
});
|
||||||
|
return $storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backend(bool $reconnect = true): StorageBackend {
|
||||||
|
return new StorageBackend($this->storage(), 'repo', false, $reconnect ? fn (): IStorage => $this->storage() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function idle(StorageBackend $backend): void {
|
||||||
|
(new \ReflectionProperty($backend, 'lastUsed'))->setValue($backend, microtime(true) - StorageBackend::IDLE_CHECK - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADeadConnectionIsReplacedAfterAPause(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->connections[0]->dropped = true;
|
||||||
|
self::idle($backend);
|
||||||
|
$this->assertTrue($backend->exists('config'));
|
||||||
|
$this->assertSame(1, $backend->reconnects);
|
||||||
|
$this->assertSame(1, $this->connections[0]->requests, 'only the check went over the old connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPauseOnALiveConnectionCostsOneCheck(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
self::idle($backend);
|
||||||
|
$this->assertTrue($backend->exists('config'));
|
||||||
|
$this->assertSame(0, $backend->reconnects);
|
||||||
|
$this->assertSame(2, $this->connections[0]->requests);
|
||||||
|
$this->assertTrue($backend->exists('config'));
|
||||||
|
$this->assertSame(3, $this->connections[0]->requests, 'no check without a pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailureOnAConnectionThatJustDiedIsRepeated(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->connections[0]->dropped = true; // without a pause: not checked first
|
||||||
|
$this->assertTrue($backend->exists('config'));
|
||||||
|
$fh = $backend->get('config');
|
||||||
|
$this->assertSame('config', stream_get_contents($fh));
|
||||||
|
$this->assertSame(1, $backend->reconnects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASilentlyDroppedConnectionIsReplacedAfterAPause(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->connections[0]->dropped = true;
|
||||||
|
$this->connections[0]->silent = true; // phpseclib still says connected
|
||||||
|
self::idle($backend);
|
||||||
|
$this->assertTrue($backend->exists('config'));
|
||||||
|
$this->assertSame(1, $backend->reconnects, 'the check failed, so the connection was replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailureOnASilentlyDroppedConnectionIsRepeated(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->connections[0]->dropped = true;
|
||||||
|
$this->connections[0]->silent = true;
|
||||||
|
$fh = $backend->get('config');
|
||||||
|
$this->assertSame('config', stream_get_contents($fh));
|
||||||
|
$this->assertSame(1, $backend->reconnects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingFileOnALiveConnectionIsJustMissing(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->assertFalse($backend->exists('nothing'));
|
||||||
|
try {
|
||||||
|
$backend->get('nothing');
|
||||||
|
$this->fail('missing');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringContainsString('Not found', $e->getMessage());
|
||||||
|
}
|
||||||
|
$this->assertSame(0, $backend->reconnects);
|
||||||
|
$this->assertCount(1, $this->connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testANewConnectionThatFailsToo(): void {
|
||||||
|
$backend = $this->backend();
|
||||||
|
$this->connections[0]->dropped = true;
|
||||||
|
$backend->exists('config'); // reconnects
|
||||||
|
$this->connections[1]->dropped = true;
|
||||||
|
$this->deadOnArrival = true;
|
||||||
|
self::idle($backend);
|
||||||
|
try {
|
||||||
|
$backend->get('config');
|
||||||
|
$this->fail('no connection');
|
||||||
|
} catch (BackendException) {
|
||||||
|
}
|
||||||
|
$this->assertSame(3, $backend->reconnects, 'once after the pause, once after the failure, no more');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutAWayToReconnectNothingChanges(): void {
|
||||||
|
$backend = $this->backend(false);
|
||||||
|
$this->connections[0]->dropped = true;
|
||||||
|
self::idle($backend);
|
||||||
|
$this->assertFalse($backend->exists('config'));
|
||||||
|
$this->assertCount(1, $this->connections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SftpLikeStorage extends IStorage {
|
||||||
|
public function getConnection(): object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public phpseclib calls the backend uses to tell a dead connection. */
|
||||||
|
final class ReconnectFakeSftp {
|
||||||
|
public bool $dropped = false;
|
||||||
|
/** Dropped without phpseclib noticing (as a Hetzner Storage Box does): it still says connected. */
|
||||||
|
public bool $silent = false;
|
||||||
|
private bool $connected = true;
|
||||||
|
public int $requests = 0;
|
||||||
|
|
||||||
|
public function isConnected(): bool {
|
||||||
|
return $this->connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearStatCache(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stat(string $path): array|false {
|
||||||
|
return $this->request() ? ['type' => 2] : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request: false (and from then on disconnected) when the server has dropped the connection. */
|
||||||
|
public function request(): bool {
|
||||||
|
$this->requests++;
|
||||||
|
if ($this->dropped) {
|
||||||
|
if ($this->silent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->connected = false;
|
||||||
|
}
|
||||||
|
return $this->connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Format;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class FormatTest extends TestCase {
|
||||||
|
public function testDurationShowsDaysFromOneDayOn(): void {
|
||||||
|
$this->assertSame('00:00:00', Format::duration(-5));
|
||||||
|
$this->assertSame('12:10:13', Format::duration(12 * 3600 + 10 * 60 + 13));
|
||||||
|
$this->assertSame('1d 00:00:00', Format::duration(86400));
|
||||||
|
$this->assertSame('1d 12:10:13', Format::duration(86400 + 12 * 3600 + 10 * 60 + 13));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Job;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BackupProgressTest extends TestCase {
|
||||||
|
public function testCountsFinishedFilesAndTheFileBeingRead(): void {
|
||||||
|
$state = ['phase' => 'files', 'estimate' => ['bytes' => 1000, 'approximate' => false],
|
||||||
|
'stats' => ['bytes' => 500], 'cur' => ['offset' => 180]];
|
||||||
|
$this->assertSame(['fraction' => 0.68, 'approximate' => false], BackupRun::progress($state));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStaysBelowHundredPercentWhenTheEstimateIsTooLow(): void {
|
||||||
|
$state = ['phase' => 'files', 'estimate' => ['bytes' => 1000, 'approximate' => true], 'stats' => ['bytes' => 5000], 'cur' => null];
|
||||||
|
$this->assertSame(['fraction' => 0.99, 'approximate' => true], BackupRun::progress($state));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoProgressWithoutEstimateOrOutsideTheFilesPhase(): void {
|
||||||
|
$this->assertNull(BackupRun::progress(['phase' => 'files', 'stats' => ['bytes' => 1]]));
|
||||||
|
$this->assertNull(BackupRun::progress(['phase' => 'files', 'estimate' => ['bytes' => 0, 'approximate' => true], 'stats' => ['bytes' => 1]]));
|
||||||
|
$this->assertNull(BackupRun::progress(['phase' => 'commit', 'estimate' => ['bytes' => 10, 'approximate' => false], 'stats' => ['bytes' => 1]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,61 @@ class BackupRunTest extends TestCase {
|
|||||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheSnapshotIsWrittenInAStepOfItsOwnFromAllSegmentsAtOnce(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 60; $i++) {
|
||||||
|
file_put_contents("$src/f$i.txt", random_bytes(200000));
|
||||||
|
}
|
||||||
|
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
|
||||||
|
$state = BackupRun::start($src);
|
||||||
|
for ($i = 0; $i < 500 && $state['phase'] === 'files'; $i++) {
|
||||||
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
|
||||||
|
}
|
||||||
|
$this->assertSame('finish', $state['phase'], 'the step that read the last file does not write the snapshot');
|
||||||
|
$this->assertGreaterThan(2, count($state['segments']));
|
||||||
|
$backend->batches = [];
|
||||||
|
|
||||||
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
|
||||||
|
|
||||||
|
$this->assertSame('commit', $state['phase']);
|
||||||
|
// One batch; the blobs of a segment follow each other in its pack and are one read.
|
||||||
|
$this->assertSame([count($state['segments'])], $backend->batches);
|
||||||
|
Repository::openWithKey($backend, $keys)->recordSnapshot($state['snapshot']);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlobsThatFollowEachOtherInAPackAreReadAsOneRange(): void {
|
||||||
|
$backend = new RangeCountingBackend(new LocalBackend($this->tempDir()));
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
$stats = [];
|
||||||
|
$segments = [];
|
||||||
|
$expected = [];
|
||||||
|
for ($s = 0; $s < 3; $s++) {
|
||||||
|
$writer = new \OCA\NgBackup\Repository\BlobWriter(fn (string $data): string => $repo->storeData($data, $stats), 1000);
|
||||||
|
$lines = [];
|
||||||
|
for ($i = 0; $i < 100; $i++) {
|
||||||
|
$lines[] = json_encode(['segment' => $s, 'line' => $i, 'pad' => bin2hex(random_bytes(20))]);
|
||||||
|
}
|
||||||
|
$writer->write(implode("\n", $lines) . "\n");
|
||||||
|
$segments[] = $writer->finish();
|
||||||
|
$repo->flushPacks(); // one pack per segment, as one per step
|
||||||
|
array_push($expected, ...$lines);
|
||||||
|
}
|
||||||
|
$this->assertGreaterThan(3, count(array_merge(...$segments)));
|
||||||
|
$backend->batches = [];
|
||||||
|
|
||||||
|
$read = iterator_to_array(Repository::openWithKey($backend, $keys)->readLines(array_merge(...$segments)), false);
|
||||||
|
|
||||||
|
$this->assertSame($expected, array_values(array_filter($read, fn (string $l): bool => $l !== '')));
|
||||||
|
$this->assertSame([3], $backend->batches, 'one read per segment, all in one batch');
|
||||||
|
}
|
||||||
|
|
||||||
public function testFileChangedBetweenStepsIsReadAgain(): void {
|
public function testFileChangedBetweenStepsIsReadAgain(): void {
|
||||||
$src = $this->tempDir();
|
$src = $this->tempDir();
|
||||||
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
|
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
|
||||||
@@ -100,3 +155,54 @@ class BackupRunTest extends TestCase {
|
|||||||
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
|
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class RangeCountingBackend implements \OCA\NgBackup\Backend\IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne {
|
||||||
|
getRanges as private oneByOne;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<int> number of ranges per getRanges() call */
|
||||||
|
public array $batches = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private \OCA\NgBackup\Backend\IBackend $inner,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRanges(array $ranges): \Generator {
|
||||||
|
$this->batches[] = count($ranges);
|
||||||
|
return $this->oneByOne($ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pack stream that hands out wrong bytes once (an SMB restore stopped after 17 hours with
|
||||||
|
* "Decryption failed"): the blob is read once more on a request of its own; only data that is
|
||||||
|
* damaged on the location stops the restore.
|
||||||
|
*/
|
||||||
|
class DamagedReadTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private KeyRing $keys;
|
||||||
|
private string $src;
|
||||||
|
private string $snapshot;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->root = $this->tempDir();
|
||||||
|
$this->keys = KeyRing::generate();
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
file_put_contents("$this->src/f$i", random_bytes(3 * 1048576));
|
||||||
|
}
|
||||||
|
$repo = Repository::initWithKey(new LocalBackend($this->root), $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABlobThatCameInDamagedOnceIsReadAgain(): void {
|
||||||
|
$backend = new GarblingBackend(new LocalBackend($this->root), streams: 1);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(6, Repository::openWithKey($backend, $this->keys)->restore($this->snapshot, $target));
|
||||||
|
|
||||||
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
||||||
|
$this->assertSame(0, $backend->streams, 'the damaged read happened');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATableDumpThatCameInDamagedOnceIsReadAgain(): void {
|
||||||
|
$repo = Repository::openWithKey(new LocalBackend($this->root), $this->keys);
|
||||||
|
$stats = [];
|
||||||
|
$writer = $repo->blobWriter($stats);
|
||||||
|
$lines = [];
|
||||||
|
for ($i = 0; $i < 20000; $i++) {
|
||||||
|
$lines[] = json_encode(['i' => $i, 'pad' => str_repeat('x', $i % 400)]);
|
||||||
|
}
|
||||||
|
$writer->write(implode("\n", $lines) . "\n");
|
||||||
|
$blobs = $writer->finish();
|
||||||
|
$repo->flushPacks();
|
||||||
|
$backend = new GarblingBackend(new LocalBackend($this->root), ranges: 1);
|
||||||
|
|
||||||
|
$this->assertSame($lines, iterator_to_array(Repository::openWithKey($backend, $this->keys)->readLines($blobs), false));
|
||||||
|
$this->assertSame(0, $backend->ranges, 'the damaged read happened');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDataDamagedOnTheLocationStillStopsTheRestore(): void {
|
||||||
|
$backend = new GarblingBackend(new LocalBackend($this->root), streams: 1000, ranges: 1000);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
Repository::openWithKey($backend, $this->keys)->restore($this->snapshot, $this->tempDir());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pack reads that flip a byte: the first $streams pack streams and $ranges range reads. */
|
||||||
|
final class GarblingBackend implements IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
public int $streams = 0,
|
||||||
|
public int $ranges = 0,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
$fh = $this->inner->get($path);
|
||||||
|
if (!str_starts_with($path, 'packs/') || $this->streams <= 0) {
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
$this->streams--;
|
||||||
|
$data = stream_get_contents($fh);
|
||||||
|
fclose($fh);
|
||||||
|
for ($k = 100; $k < strlen($data); $k += 65536) {
|
||||||
|
$data[$k] = chr(ord($data[$k]) ^ 0x55);
|
||||||
|
}
|
||||||
|
$out = fopen('php://temp', 'w+b');
|
||||||
|
fwrite($out, $data);
|
||||||
|
rewind($out);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$data = $this->inner->getRange($path, $offset, $length);
|
||||||
|
if (str_starts_with($path, 'packs/') && $this->ranges > 0) {
|
||||||
|
$this->ranges--;
|
||||||
|
$data[0] = chr(ord($data[0]) ^ 0x55);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ class IndexCacheTest extends TestCase {
|
|||||||
public function all(string $r): array { return $this->data[$r] ?? []; }
|
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||||
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
|
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
|
||||||
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||||
|
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
|
||||||
|
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
|
||||||
};
|
};
|
||||||
$src = $this->tempDir();
|
$src = $this->tempDir();
|
||||||
for ($i = 0; $i < 12; $i++) {
|
for ($i = 0; $i < 12; $i++) {
|
||||||
@@ -51,4 +53,95 @@ class IndexCacheTest extends TestCase {
|
|||||||
$fourth->restore($r1['snapshot'], $target);
|
$fourth->restore($r1['snapshot'], $target);
|
||||||
$this->assertCount(12, self::hashTree($target));
|
$this->assertCount(12, self::hashTree($target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Connecting a new server to a location with backups reports how far reading the index is. */
|
||||||
|
public function testOpeningReportsIndexProgress(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
file_put_contents("$src/f$i", random_bytes(3 * 1048576));
|
||||||
|
}
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'))->backupDirectory($src);
|
||||||
|
$files = count($backend->list('index'));
|
||||||
|
$seen = [];
|
||||||
|
Repository::openWithKey($backend, $keys, null, null, function (int $done, int $total) use (&$seen): void {
|
||||||
|
$seen[] = [$done, $total];
|
||||||
|
});
|
||||||
|
$this->assertCount($files, $seen);
|
||||||
|
$this->assertSame([$files, $files], end($seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The database dump reads in a read-only transaction: a pack that fills up then must not write the cache yet. */
|
||||||
|
public function testHeldCacheWritesWaitForRelease(): void {
|
||||||
|
$cache = new class implements IIndexCache {
|
||||||
|
public array $data = [];
|
||||||
|
public bool $readOnly = false;
|
||||||
|
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||||
|
public function put(string $r, string $p, array $e): void {
|
||||||
|
if ($this->readOnly) {
|
||||||
|
throw new \RuntimeException('cannot execute INSERT in a read-only transaction');
|
||||||
|
}
|
||||||
|
$this->data[$r][$p] = $e;
|
||||||
|
}
|
||||||
|
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||||
|
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
|
||||||
|
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
|
||||||
|
};
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
$repo = Repository::openWithKey($backend, $keys, $cache);
|
||||||
|
|
||||||
|
$stats = [];
|
||||||
|
$repo->holdIndexCache();
|
||||||
|
$cache->readOnly = true;
|
||||||
|
$writer = $repo->blobWriter($stats);
|
||||||
|
for ($i = 0; $i < 40; $i++) {
|
||||||
|
$writer->write(random_bytes(1048576)); // more than one 32 MiB pack
|
||||||
|
}
|
||||||
|
$writer->finish();
|
||||||
|
$this->assertGreaterThan(0, count($backend->list('index')), 'a full pack was uploaded during the hold');
|
||||||
|
$this->assertSame([], $cache->data);
|
||||||
|
|
||||||
|
$cache->readOnly = false;
|
||||||
|
$repo->releaseIndexCache();
|
||||||
|
$repo->flushPacks();
|
||||||
|
$this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data)));
|
||||||
|
$this->assertSame(0, Repository::openWithKey($backend, $keys, $cache)->indexDownloads());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a check the cache holds every index file as it is, also a blob that is in two packs. */
|
||||||
|
public function testACheckCachesTheIndexFilesAsTheyAre(): void {
|
||||||
|
$cache = new class implements IIndexCache {
|
||||||
|
public array $data = [];
|
||||||
|
public int $replaced = 0;
|
||||||
|
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||||
|
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
|
||||||
|
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||||
|
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
|
||||||
|
public function replaceAll(string $r, array $packs): void { $this->replaced++; $this->data[$r] = $packs; }
|
||||||
|
};
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$cipher = new \OCA\NgBackup\Crypto\StreamCipher(KeyRing::generate());
|
||||||
|
$index = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
|
||||||
|
$index->setCache($cache, 'r');
|
||||||
|
$blob = static fn (string $id, int $offset): array => ['id' => $id, 'offset' => $offset, 'length' => 10, 'raw' => 8, 'flags' => 0];
|
||||||
|
$index->addPack('pack1', [$blob('a', 0), $blob('shared', 10)]);
|
||||||
|
$index->addPack('pack2', [$blob('shared', 0), $blob('b', 10)]);
|
||||||
|
$cache->data = ['r' => ['gone' => [$blob('x', 0)]]];
|
||||||
|
|
||||||
|
$index->load(true);
|
||||||
|
$this->assertSame(1, $cache->replaced);
|
||||||
|
$this->assertSame(['pack1', 'pack2'], array_keys($cache->data['r']));
|
||||||
|
$this->assertCount(2, $cache->data['r']['pack1'], 'the shared blob stays in the row of its first pack');
|
||||||
|
|
||||||
|
// the second pack goes: the shared blob is still found in the first
|
||||||
|
$index->removePack('pack2');
|
||||||
|
$again = new \OCA\NgBackup\Repository\BlobIndex($backend, $cipher);
|
||||||
|
$again->setCache($cache, 'r');
|
||||||
|
$again->load();
|
||||||
|
$this->assertSame(0, $again->downloaded);
|
||||||
|
$this->assertSame('pack1', $again->get('shared')[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\IIndexCache;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listing of the location that comes back empty because a connection failed quietly (an SFTP
|
||||||
|
* connection a Storage Box dropped) must not empty the index. In a 1 TB disaster recovery test a
|
||||||
|
* pack read failed after 13 hours, the reload of the index got an empty listing, and the restore
|
||||||
|
* stopped with "Blob not in index".
|
||||||
|
*/
|
||||||
|
class IndexListingTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private KeyRing $keys;
|
||||||
|
private string $src;
|
||||||
|
private string $snapshot;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->root = $this->tempDir();
|
||||||
|
$this->keys = KeyRing::generate();
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 12; $i++) {
|
||||||
|
file_put_contents("$this->src/f$i", random_bytes(3 * 1048576));
|
||||||
|
}
|
||||||
|
$repo = Repository::initWithKey(new LocalBackend($this->root), $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cache(): IIndexCache {
|
||||||
|
return indexCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARestoreGoesOnAfterAPackReadFailedAndTheListingCameBackEmpty(): void {
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys);
|
||||||
|
$backend->failPackReads = 1; // the read of a pack fails once
|
||||||
|
$backend->emptyIndexListings = 1; // and listing the index right after comes back empty
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(12, $repo->restore($this->snapshot, $target));
|
||||||
|
|
||||||
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
||||||
|
$this->assertSame(0, $backend->failPackReads, 'the failure happened');
|
||||||
|
$this->assertSame(0, $backend->emptyIndexListings, 'the empty listing was seen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackThatStaysUnreadableIsStillReportedAsSuch(): void {
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys);
|
||||||
|
$backend->failPackReads = 1000;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$repo->restore($this->snapshot, $this->tempDir());
|
||||||
|
$this->fail('the packs cannot be read');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringStartsWith('Not found: packs/', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testACheckWithAnEmptyListingIsAnErrorNotEveryBlobMissing(): void {
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys);
|
||||||
|
$backend->dead = true;
|
||||||
|
|
||||||
|
$this->expectException(BackendException::class);
|
||||||
|
$this->expectExceptionMessage('listed no index files');
|
||||||
|
$repo->verify($this->snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefreshingFetchesOnlyIndexFilesItHasNotLoaded(): void {
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys);
|
||||||
|
$index = (new \ReflectionProperty($repo, 'index'))->getValue($repo);
|
||||||
|
$index->downloaded = 0;
|
||||||
|
|
||||||
|
$index->refresh();
|
||||||
|
|
||||||
|
$this->assertSame(0, $index->downloaded, 'every index file is loaded already, also those whose blobs are elsewhere too');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARestoreGoesOnWhenTheLocationCannotBeListedAfterAFailedRead(): void {
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys);
|
||||||
|
$backend->failPackReads = 1;
|
||||||
|
$backend->dead = true; // listing and config look gone; reading packs works again
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(12, $repo->restore($this->snapshot, $target));
|
||||||
|
|
||||||
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyListingThatWasAHiccupIsListedAgain(): void {
|
||||||
|
$cache = self::cache();
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$cached = $cache->data;
|
||||||
|
$backend->emptyIndexListings = 1;
|
||||||
|
|
||||||
|
$again = Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
|
||||||
|
$this->assertSame($cached, $cache->data);
|
||||||
|
$this->assertSame(12, $again->restore($this->snapshot, $this->tempDir()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAListingThatStaysEmptyWhileIndexFilesAreThereIsNotTaken(): void {
|
||||||
|
$cache = self::cache();
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$cached = $cache->data;
|
||||||
|
$backend->emptyIndexListings = 5; // the config answers, the listing keeps lying
|
||||||
|
|
||||||
|
try {
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$this->fail('index files are there');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringContainsString('is there', $e->getMessage());
|
||||||
|
}
|
||||||
|
$this->assertSame($cached, $cache->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARepositoryThatReallyBecameEmptyIsTakenAsEmpty(): void {
|
||||||
|
$cache = self::cache();
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$this->assertNotSame([], array_filter($cache->data));
|
||||||
|
// another server forgot the last snapshot and cleaned up: no packs and index files left
|
||||||
|
foreach (array_merge($backend->list('index'), $backend->list('packs')) as $path) {
|
||||||
|
$backend->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
|
||||||
|
$this->assertSame([], array_filter($cache->data), 'the stale cache rows are gone');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOpeningWithAnEmptyListingKeepsTheCachedIndex(): void {
|
||||||
|
$cache = self::cache();
|
||||||
|
$backend = new FlakyBackend(new LocalBackend($this->root));
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$cached = $cache->data;
|
||||||
|
$this->assertNotSame([], $cached);
|
||||||
|
|
||||||
|
$backend->dead = true;
|
||||||
|
try {
|
||||||
|
Repository::openWithKey($backend, $this->keys, $cache);
|
||||||
|
$this->fail('an empty listing of a repository with an index must not be taken as is');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringContainsString('listed no index files', $e->getMessage());
|
||||||
|
}
|
||||||
|
$this->assertSame($cached, $cache->data, 'the cache is untouched');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
function indexCache(): IIndexCache {
|
||||||
|
return new class implements IIndexCache {
|
||||||
|
public array $data = [];
|
||||||
|
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||||
|
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
|
||||||
|
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||||
|
public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } }
|
||||||
|
public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A location whose pack reads fail, and whose index listings come back empty, a set number of times. */
|
||||||
|
final class FlakyBackend implements IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||||
|
|
||||||
|
public int $failPackReads = 0;
|
||||||
|
public int $emptyIndexListings = 0;
|
||||||
|
/** The connection is gone: also the repository config looks missing. */
|
||||||
|
public bool $dead = false;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
if (str_starts_with($path, 'packs/') && $this->failPackReads > 0) {
|
||||||
|
$this->failPackReads--;
|
||||||
|
throw new BackendException('Not found: ' . $path);
|
||||||
|
}
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
if (str_starts_with($path, 'packs/') && $this->failPackReads > 0) {
|
||||||
|
$this->failPackReads--;
|
||||||
|
throw new BackendException('Not found: ' . $path);
|
||||||
|
}
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
return !$this->dead && $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
if ($dir === 'index' && $this->dead) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($dir === 'index' && $this->emptyIndexListings > 0) {
|
||||||
|
$this->emptyIndexListings--;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** Restores and deep verifies read a pack as one stream instead of one request per blob. */
|
||||||
|
class PackReadTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private KeyRing $keys;
|
||||||
|
private string $src;
|
||||||
|
private string $snapshot;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->root = $this->tempDir();
|
||||||
|
$this->keys = KeyRing::generate();
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 200; $i++) {
|
||||||
|
file_put_contents(sprintf('%s/f%03d', $this->src, $i), random_bytes(20000));
|
||||||
|
}
|
||||||
|
$backend = new LocalBackend($this->root);
|
||||||
|
$repo = Repository::initWithKey($backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function open(PackOpenCountingBackend $backend): Repository {
|
||||||
|
return Repository::openWithKey($backend, $this->keys, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullRestoreOpensEachPackOnce(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(200, $this->open($backend)->restore($this->snapshot, $target));
|
||||||
|
|
||||||
|
for ($i = 0; $i < 200; $i++) {
|
||||||
|
$name = sprintf('f%03d', $i);
|
||||||
|
$this->assertSame(file_get_contents("$this->src/$name"), file_get_contents("$target/$name"));
|
||||||
|
}
|
||||||
|
// One pack: one stream instead of 200 requests.
|
||||||
|
$this->assertSame(1, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeepVerifyOpensEachPackOnce(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
|
||||||
|
$result = $this->open($backend)->verify($this->snapshot, deep: true);
|
||||||
|
|
||||||
|
$this->assertSame(200 * 20000, $result['bytesChecked']);
|
||||||
|
$this->assertSame(1, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadingBackwardsOpensThePackAgain(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$repo = $this->open($backend);
|
||||||
|
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f150'));
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f050'));
|
||||||
|
|
||||||
|
$this->assertSame(2, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThePackStreamIsClosedBeforeAnyOtherRequest(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$repo = $this->open($backend);
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f010'));
|
||||||
|
$this->assertTrue($backend->packStreamOpen(), 'kept open for the next read');
|
||||||
|
|
||||||
|
$repo->putObject('objects/after-restore', 'data');
|
||||||
|
|
||||||
|
$this->assertSame([false], $backend->packStreamOpenAtPut);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackStreamThatCannotSeekIsReadUpToTheOffset(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root), $this->root);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(1, $this->open($backend)->restore($this->snapshot, $target, 'f150'));
|
||||||
|
|
||||||
|
$this->assertSame(file_get_contents("$this->src/f150"), file_get_contents("$target/f150"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeepVerifyOfATruncatedPackStillChecksTheBlobsBeforeTheDamage(): void {
|
||||||
|
$packs = (new LocalBackend($this->root))->list('packs');
|
||||||
|
$this->assertCount(1, $packs);
|
||||||
|
$pack = $this->root . '/' . $packs[0];
|
||||||
|
$fh = fopen($pack, 'r+b');
|
||||||
|
ftruncate($fh, intdiv(filesize($pack), 2));
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
$result = $this->open(new PackOpenCountingBackend(new LocalBackend($this->root)))->verify($this->snapshot, deep: true);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(80 * 20000, $result['bytesChecked']);
|
||||||
|
$this->assertNotSame([], $result['unreadable']);
|
||||||
|
$this->assertSame([], $result['failed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PackOpenCountingBackend implements IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $gets = [];
|
||||||
|
/** @var list<bool> */
|
||||||
|
public array $packStreamOpenAtPut = [];
|
||||||
|
/** @var resource|null */
|
||||||
|
private $lastPackStream = null;
|
||||||
|
|
||||||
|
/** @param ?string $pipeFrom serve packs as pipes (streams that cannot seek) from this local repository root */
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
private ?string $pipeFrom = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packStreamOpen(): bool {
|
||||||
|
return is_resource($this->lastPackStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->packStreamOpenAtPut[] = $this->packStreamOpen();
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packOpens(): int {
|
||||||
|
return count(array_filter($this->gets, fn (string $p): bool => str_starts_with($p, 'packs/')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
$this->gets[] = $path;
|
||||||
|
if (!str_starts_with($path, 'packs/')) {
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
$fh = $this->pipeFrom !== null
|
||||||
|
? popen('cat ' . escapeshellarg($this->pipeFrom . '/' . $path), 'r')
|
||||||
|
: $this->inner->get($path);
|
||||||
|
$this->lastPackStream = $fh;
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->gets[] = $path;
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,33 @@ class PruneTest extends TestCase {
|
|||||||
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRestoreFollowsPacksRepackedByAnotherServer(): void {
|
||||||
|
// A disaster recovery test on a second server reads the location while the original
|
||||||
|
// server's weekly cleanup repacks it: the restore must not stop at a pack that moved.
|
||||||
|
$src = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
for ($i = 0; $i < 40; $i++) {
|
||||||
|
file_put_contents("$src/f$i", random_bytes(500000));
|
||||||
|
}
|
||||||
|
$a = $repo->backupDirectory($src);
|
||||||
|
for ($i = 0; $i < 40; $i++) {
|
||||||
|
if ($i % 5 !== 0) {
|
||||||
|
unlink("$src/f$i");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$b = $repo->backupDirectory($src, $a['snapshot']);
|
||||||
|
$reader = Repository::openWithKey($backend, $keys); // index loaded before the cleanup
|
||||||
|
$repo->forget($a['snapshot']);
|
||||||
|
$repo->purgeTrash(0);
|
||||||
|
$this->assertGreaterThan(0, $repo->prune(false, 0.5)['repacked']);
|
||||||
|
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$reader->restore($b['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
public function testUntrashRestoresAWorkingSnapshot(): void {
|
public function testUntrashRestoresAWorkingSnapshot(): void {
|
||||||
$src = $this->tempDir();
|
$src = $this->tempDir();
|
||||||
$backend = new LocalBackend($this->tempDir());
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\IReadsAhead;
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\ReadAheadPlan;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/PackReadTest.php'; // PackOpenCountingBackend
|
||||||
|
|
||||||
|
/** A full restore reads the packs ahead (IReadsAhead) and falls back to the usual reads per range. */
|
||||||
|
class ReadAheadTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private KeyRing $keys;
|
||||||
|
private string $src;
|
||||||
|
private string $snapshot;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->root = $this->tempDir();
|
||||||
|
$this->keys = KeyRing::generate();
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
// three packs of 32 MiB, and small files in between
|
||||||
|
for ($i = 0; $i < 4; $i++) {
|
||||||
|
file_put_contents(sprintf('%s/big%d', $this->src, $i), random_bytes(20 * 1048576));
|
||||||
|
for ($j = 0; $j < 20; $j++) {
|
||||||
|
file_put_contents(sprintf('%s/small%d-%02d', $this->src, $i, $j), random_bytes(30000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_put_contents("$this->src/same", file_get_contents("$this->src/small0-00")); // the same blob again, later
|
||||||
|
$repo = Repository::initWithKey(new LocalBackend($this->root), $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restore(ReadAheadBackend $backend, string $prefix = '', ?string $target = null): string {
|
||||||
|
$target ??= $this->tempDir();
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys, null, null);
|
||||||
|
$repo->restore($this->snapshot, $target, $prefix);
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertRestored(string $target, string $prefix = ''): void {
|
||||||
|
$expected = array_filter(self::hashTree($this->src), fn (string $p): bool => str_starts_with($p, $prefix), ARRAY_FILTER_USE_KEY);
|
||||||
|
$this->assertSame($expected, self::hashTree($target));
|
||||||
|
$this->assertSame([], glob("$target/.ngb-restore-spool-*"), 'the spool is removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARestoreReadsEveryPackAhead(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root));
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend));
|
||||||
|
|
||||||
|
$this->assertSame(0, $backend->counter->packOpens(), 'nothing read the usual way');
|
||||||
|
$this->assertGreaterThanOrEqual(3, count($backend->asked));
|
||||||
|
// in the order of the files, one range per pack the restore goes through (the same blob
|
||||||
|
// again later is a range of its own)
|
||||||
|
$this->assertLessThanOrEqual(count($backend->inner->list('packs')) + 1, count($backend->asked));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPlanThatCannotBeMadeLeavesTheRestoreToTheUsualReads(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root), failSnapshotReads: 1);
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend));
|
||||||
|
|
||||||
|
$this->assertSame([], $backend->asked, 'no reading ahead without a plan');
|
||||||
|
$this->assertSame(2, $backend->snapshotReads, 'the restore itself read the snapshot again');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheEmptySpoolOfAStoppedRestoreIsRemoved(): void {
|
||||||
|
$target = $this->tempDir();
|
||||||
|
mkdir("$target/.ngb-restore-spool-0badc0de", 0700); // a restore that was killed
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore(new ReadAheadBackend(new LocalBackend($this->root)), '', $target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARangeThatCouldNotBeReadAheadIsReadTheUsualWay(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root), fail: [1]);
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend));
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $backend->counter->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADamagedCopyIsReadAgainTheUsualWay(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root), damage: [0, 2]);
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend));
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $backend->counter->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPartialRestoreReadsOnlyWhatItNeeds(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root));
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend, 'big1'), 'big1');
|
||||||
|
|
||||||
|
$this->assertSame(0, $backend->counter->packOpens());
|
||||||
|
$this->assertCount(2, $backend->asked, 'the end of one pack and the start of the next');
|
||||||
|
$total = array_sum(array_map(fn (array $r): int => $r[2], $backend->asked));
|
||||||
|
$this->assertLessThan(21 * 1048576, $total, 'not the whole packs');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABackendThatCannotReadAheadNowRestoresAsBefore(): void {
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root), unavailable: true);
|
||||||
|
|
||||||
|
$this->assertRestored($this->restore($backend));
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $backend->counter->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailingRestoreStillEndsTheReadsAheadAndRemovesTheSpool(): void {
|
||||||
|
// the second range fails, and so does reading it the usual way
|
||||||
|
$backend = new ReadAheadBackend(new LocalBackend($this->root), fail: [1], packsGone: true);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$repo = Repository::openWithKey($backend, $this->keys, null, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$repo->restore($this->snapshot, $target);
|
||||||
|
$this->fail('restore should fail');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringStartsWith('Not found', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue($backend->ended, 'the generator was ended');
|
||||||
|
$this->assertSame([], glob("$target/.ngb-restore-spool-*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlanJoinsBlobsThatFollowEachOtherInAPack(): void {
|
||||||
|
$plan = new ReadAheadPlan('/nowhere');
|
||||||
|
$plan->add('packs/aa/a', 0, 100);
|
||||||
|
$plan->add('packs/aa/a', 100, 100);
|
||||||
|
$plan->add('packs/aa/a', 50, 10); // the same blob again: inside the range
|
||||||
|
$plan->add('packs/aa/a', 200 + ReadAheadPlan::GAP, 100); // a gap it reads along
|
||||||
|
$plan->add('packs/aa/a', 300 + 3 * ReadAheadPlan::GAP, 100); // too far: a new range
|
||||||
|
$plan->add('packs/bb/b', 0, 100);
|
||||||
|
$plan->add('packs/bb/b', 0, 100); // backwards is fine as long as it is inside
|
||||||
|
$plan->add('packs/aa/a', 0, 100); // back to an earlier pack: a new range
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
['packs/aa/a', 0, 300 + ReadAheadPlan::GAP],
|
||||||
|
['packs/aa/a', 300 + 3 * ReadAheadPlan::GAP, 100],
|
||||||
|
['packs/bb/b', 0, 100],
|
||||||
|
['packs/aa/a', 0, 100],
|
||||||
|
], $plan->ranges());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlanSkipsRangesWhoseBlobsWereReadElsewhere(): void {
|
||||||
|
$plan = new ReadAheadPlan('/nowhere');
|
||||||
|
foreach (range(0, 4) as $p) {
|
||||||
|
$plan->add("packs/0$p/p$p", 0, 10);
|
||||||
|
}
|
||||||
|
$plan->readFrom((function (): \Generator {
|
||||||
|
foreach (range(0, 4) as $p) {
|
||||||
|
$fh = fopen('php://temp', 'w+b');
|
||||||
|
fwrite($fh, str_repeat((string)$p, 10));
|
||||||
|
rewind($fh);
|
||||||
|
yield $p => $fh;
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
|
||||||
|
$this->assertSame('00000', $plan->read('packs/00/p0', 0, 5));
|
||||||
|
$this->assertNull($plan->read('packs/77/moved', 0, 5), 'moved: not in the plan');
|
||||||
|
$this->assertSame('333', $plan->read('packs/03/p3', 2, 3), 'the ranges in between were read elsewhere');
|
||||||
|
$this->assertNull($plan->read('packs/01/p1', 0, 5), 'never backwards');
|
||||||
|
$this->assertSame('44', $plan->read('packs/04/p4', 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlanKeepsARangeShorterThanTheMaximum(): void {
|
||||||
|
$plan = new ReadAheadPlan('/nowhere');
|
||||||
|
for ($o = 0; $o < 3 * ReadAheadPlan::MAX_RANGE; $o += 4 * 1048576) {
|
||||||
|
$plan->add('packs/aa/a', $o, 4 * 1048576);
|
||||||
|
}
|
||||||
|
$this->assertCount(3, $plan->ranges());
|
||||||
|
foreach ($plan->ranges() as $r) {
|
||||||
|
$this->assertLessThanOrEqual(ReadAheadPlan::MAX_RANGE, $r[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads ahead from a local repository, one range after the other; can fail or damage ranges. */
|
||||||
|
final class ReadAheadBackend implements IBackend, IReadsAhead {
|
||||||
|
public PackOpenCountingBackend $counter;
|
||||||
|
/** @var list<array{0:string, 1:int, 2:int}> */
|
||||||
|
public array $asked = [];
|
||||||
|
public bool $ended = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $fail ranges given as null
|
||||||
|
* @param list<int> $damage ranges given with a flipped byte in every blob
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public LocalBackend $inner,
|
||||||
|
private array $fail = [],
|
||||||
|
private array $damage = [],
|
||||||
|
private bool $unavailable = false,
|
||||||
|
/** reading a pack the usual way fails */
|
||||||
|
private bool $packsGone = false,
|
||||||
|
/** this many reads of a snapshot fail, then they work */
|
||||||
|
private int $failSnapshotReads = 0,
|
||||||
|
) {
|
||||||
|
$this->counter = new PackOpenCountingBackend($inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canReadAhead(): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readAhead(array $ranges, string $spool): ?\Generator {
|
||||||
|
if ($this->unavailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
\PHPUnit\Framework\Assert::assertDirectoryExists($spool);
|
||||||
|
$this->asked = $ranges;
|
||||||
|
return (function () use ($ranges): \Generator {
|
||||||
|
try {
|
||||||
|
foreach ($ranges as $i => [$path, $offset, $length]) {
|
||||||
|
if (in_array($i, $this->fail, true)) {
|
||||||
|
yield $i => null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$data = $this->inner->getRange($path, $offset, $length);
|
||||||
|
if (in_array($i, $this->damage, true)) {
|
||||||
|
for ($k = 0; $k < strlen($data); $k += 4096) {
|
||||||
|
$data[$k] = chr(ord($data[$k]) ^ 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fh = fopen('php://temp', 'w+b');
|
||||||
|
fwrite($fh, $data);
|
||||||
|
rewind($fh);
|
||||||
|
yield $i => $fh;
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$this->ended = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->counter->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int $snapshotReads = 0;
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
if (str_starts_with($path, 'snapshots/')) {
|
||||||
|
$this->snapshotReads++;
|
||||||
|
if ($this->snapshotReads <= $this->failSnapshotReads) {
|
||||||
|
throw new BackendException('Connection lost reading ' . $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($this->packsGone && str_starts_with($path, 'packs/')) {
|
||||||
|
throw new BackendException('Not found: ' . $path);
|
||||||
|
}
|
||||||
|
return $this->counter->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMany(array $paths): \Generator {
|
||||||
|
return $this->counter->getMany($paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
return $this->counter->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRanges(array $ranges): \Generator {
|
||||||
|
return $this->counter->getRanges($ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
return $this->counter->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->counter->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->counter->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
return $this->counter->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->counter->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,20 @@ class RepositoryTest extends TestCase {
|
|||||||
$this->assertSame(1, $r2['newBlobs']);
|
$this->assertSame(1, $r2['newBlobs']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCompressedDataSkipsDeflateAndOtherDataIsStillCompressed(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$text = str_repeat("a line of a log file, much like the one before\n", 30000); // ~1.4 MiB
|
||||||
|
$noise = random_bytes(1048576); // like a photo: deflate saves nothing
|
||||||
|
$stats = [];
|
||||||
|
$textId = $repo->storeData($text, $stats);
|
||||||
|
$textUploaded = $stats['uploaded'];
|
||||||
|
$noiseId = $repo->storeData($noise, $stats);
|
||||||
|
$repo->flushPacks();
|
||||||
|
$this->assertLessThan(strlen($text) / 10, $textUploaded);
|
||||||
|
$this->assertLessThan(strlen($noise) + 1024, $stats['uploaded'] - $textUploaded);
|
||||||
|
$this->assertSame($text . $noise, implode('', iterator_to_array(Repository::open($this->backend, 'pw')->readBlobs([$textId, $noiseId]), false)));
|
||||||
|
}
|
||||||
|
|
||||||
public function testPartialRestore(): void {
|
public function testPartialRestore(): void {
|
||||||
$repo = Repository::init($this->backend, 'pw');
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
$r = $repo->backupDirectory($this->src);
|
$r = $repo->backupDirectory($this->src);
|
||||||
@@ -85,6 +99,20 @@ class RepositoryTest extends TestCase {
|
|||||||
Repository::init($this->backend, 'pw');
|
Repository::init($this->backend, 'pw');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInitRefusesALocationWithBackupDataButNoConfig(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$repo->backupDirectory($this->src);
|
||||||
|
$this->backend->delete('config'); // missing, or not seen for a moment
|
||||||
|
|
||||||
|
try {
|
||||||
|
Repository::init($this->backend, 'pw');
|
||||||
|
$this->fail('a new repository over existing backup data');
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
$this->assertStringContainsString('holds backup data', $e->getMessage());
|
||||||
|
}
|
||||||
|
$this->assertFalse($this->backend->exists('config'), 'no new config written');
|
||||||
|
}
|
||||||
|
|
||||||
public function testTamperedPackIsDetected(): void {
|
public function testTamperedPackIsDetected(): void {
|
||||||
$repo = Repository::init($this->backend, 'pw');
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
$r = $repo->backupDirectory($this->src);
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
|||||||
@@ -78,6 +78,107 @@ class VerifyTest extends TestCase {
|
|||||||
$this->assertSame(['db/does-not-exist'], $result['missing']);
|
$this->assertSame(['db/does-not-exist'], $result['missing']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testVerifyReportsHowFarItIs(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
$open = $this->open();
|
||||||
|
$open->listPacksAbove = 0;
|
||||||
|
$open->verify($s['snapshot'], heartbeat: function (array $p) use (&$seen): void {
|
||||||
|
$seen[] = $p;
|
||||||
|
});
|
||||||
|
|
||||||
|
$phases = array_values(array_unique(array_column($seen, 'phase')));
|
||||||
|
$this->assertSame(['index', 'packs', 'files'], $phases);
|
||||||
|
$this->assertSame(['phase' => 'packs', 'done' => 256, 'total' => 256], $seen[array_key_last(array_filter($seen, fn ($p) => $p['phase'] === 'packs'))]);
|
||||||
|
$last = end($seen);
|
||||||
|
$this->assertSame(2, $last['done']);
|
||||||
|
$this->assertNull($last['total'], 'this snapshot has no file count in its metadata (a backup run records one)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists the pack folders however few packs there are. */
|
||||||
|
private static function listing(Repository $repo): Repository {
|
||||||
|
$repo->listPacksAbove = 0;
|
||||||
|
return $repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithNoMorePacksThanFoldersEveryPackIsCheckedInstead(): void {
|
||||||
|
// fewer requests; on FTP each takes a data connection, of which a server may have few
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$counting = new ListingBackend($this->backend);
|
||||||
|
|
||||||
|
$result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertSame([], $result['missing']);
|
||||||
|
$this->assertSame(0, $counting->packListings);
|
||||||
|
$this->assertGreaterThan(0, $counting->packExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyListsPackFoldersInsteadOfCheckingEveryPack(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$counting = new ListingBackend($this->backend);
|
||||||
|
|
||||||
|
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertSame([], $result['missing']);
|
||||||
|
$this->assertSame(0, $counting->packExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackFolderThatCannotBeListedIsCheckedPackByPack(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$counting = new ListingBackend($this->backend, unlistable: true);
|
||||||
|
|
||||||
|
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertSame([], $result['missing']);
|
||||||
|
$this->assertGreaterThan(0, $counting->packExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyFolderListingIsNoProofThatItsPacksAreGone(): void {
|
||||||
|
// A storage also reports an empty folder when it could not look (is_dir() false).
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$counting = new ListingBackend($this->backend, emptyListings: true);
|
||||||
|
|
||||||
|
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertSame([], $result['missing']);
|
||||||
|
$this->assertGreaterThan(0, $counting->packExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackMissingFromAFolderThatIsNotEmptyIsReportedWithoutAskingForIt(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
$packs = $this->backend->list('packs');
|
||||||
|
// Another file in the same folder keeps its listing non-empty.
|
||||||
|
$this->backend->put(dirname($packs[0]) . '/0000other', fopen('data://text/plain,x', 'r'));
|
||||||
|
foreach ($packs as $pack) {
|
||||||
|
$this->backend->delete($pack);
|
||||||
|
}
|
||||||
|
$counting = new ListingBackend($this->backend);
|
||||||
|
|
||||||
|
$result = self::listing(Repository::openWithKey($counting, $this->keys, null, null))->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertNotSame([], $result['missing']);
|
||||||
|
$this->assertSame(0, $counting->packExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackMissingFromAnEmptyFolderIsReportedToo(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$s = $repo->backupDirectory($this->src);
|
||||||
|
foreach ($this->backend->list('packs') as $pack) {
|
||||||
|
$this->backend->delete($pack);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = self::listing(Repository::openWithKey(new ListingBackend($this->backend), $this->keys, null, null))->verify($s['snapshot']);
|
||||||
|
|
||||||
|
$this->assertNotSame([], $result['missing']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testDeepVerifyDetectsATamperedPack(): void {
|
public function testDeepVerifyDetectsATamperedPack(): void {
|
||||||
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
$s = $repo->backupDirectory($this->src);
|
$s = $repo->backupDirectory($this->src);
|
||||||
@@ -129,8 +230,10 @@ class VerifyTest extends TestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delegates to a real backend, but getRange() throws for the first $failures calls. */
|
/** Delegates to a real backend, but reading a pack throws for the first $failures reads. */
|
||||||
final class FlakyRangeBackend implements IBackend {
|
final class FlakyRangeBackend implements IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IBackend $inner,
|
private IBackend $inner,
|
||||||
private int $failures,
|
private int $failures,
|
||||||
@@ -143,15 +246,22 @@ final class FlakyRangeBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $path) {
|
public function get(string $path) {
|
||||||
|
if (str_starts_with($path, 'packs/')) {
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
return $this->inner->get($path);
|
return $this->inner->get($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRange(string $path, int $offset, int $length): string {
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->fail();
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(): void {
|
||||||
if ($this->failures > 0) {
|
if ($this->failures > 0) {
|
||||||
$this->failures--;
|
$this->failures--;
|
||||||
throw $this->error ?? new BackendException('connection reset');
|
throw $this->error ?? new BackendException('connection reset');
|
||||||
}
|
}
|
||||||
return $this->inner->getRange($path, $offset, $length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exists(string $path): bool {
|
public function exists(string $path): bool {
|
||||||
@@ -166,6 +276,69 @@ final class FlakyRangeBackend implements IBackend {
|
|||||||
return $this->inner->list($prefix);
|
return $this->inner->list($prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delegates to a real backend; counts exists() on packs, and can refuse to list pack folders. */
|
||||||
|
final class ListingBackend implements IBackend {
|
||||||
|
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||||
|
|
||||||
|
public int $packExists = 0;
|
||||||
|
public int $packListings = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
private bool $unlistable = false,
|
||||||
|
private bool $emptyListings = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
if (str_starts_with($path, 'packs/')) {
|
||||||
|
$this->packExists++;
|
||||||
|
}
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
if (str_starts_with($dir, 'packs/')) {
|
||||||
|
$this->packListings++;
|
||||||
|
}
|
||||||
|
if ($this->unlistable && str_starts_with($dir, 'packs/')) {
|
||||||
|
throw new BackendException('Cannot list ' . $dir);
|
||||||
|
}
|
||||||
|
if ($this->emptyListings && str_starts_with($dir, 'packs/')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(string $path): void {
|
public function delete(string $path): void {
|
||||||
$this->inner->delete($path);
|
$this->inner->delete($path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Service\Schedule;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IGroup;
|
||||||
|
use OCP\IGroupManager;
|
||||||
|
use OCP\IUser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** The daily time in a time zone (issue #19), and the conversion of a time saved without one. */
|
||||||
|
class ScheduleTest extends TestCase {
|
||||||
|
private array $values = [];
|
||||||
|
|
||||||
|
private function schedule(string $serverZone = '', string $adminZone = ''): Schedule {
|
||||||
|
$app = $this->createMock(IAppConfig::class);
|
||||||
|
$app->method('getValueString')->willReturnCallback(fn (string $a, string $k, string $d = '') => $this->values[$k] ?? $d);
|
||||||
|
$app->method('setValueString')->willReturnCallback(function (string $a, string $k, string $v): bool {
|
||||||
|
$this->values[$k] = $v;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$config = $this->createMock(IConfig::class);
|
||||||
|
$config->method('getSystemValueString')->willReturn($serverZone);
|
||||||
|
$config->method('getUserValue')->willReturn($adminZone);
|
||||||
|
$user = $this->createMock(IUser::class);
|
||||||
|
$user->method('getUID')->willReturn('admin');
|
||||||
|
$group = $this->createMock(IGroup::class);
|
||||||
|
$group->method('getUsers')->willReturn([$user]);
|
||||||
|
$groups = $this->createMock(IGroupManager::class);
|
||||||
|
$groups->method('get')->willReturn($group);
|
||||||
|
return new Schedule($app, $config, $groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATimeSavedWithoutAZoneShowsAsItRanInTheAdministratorsZone(): void {
|
||||||
|
$this->values = [Schedule::TIME_KEY => '02:30'];
|
||||||
|
|
||||||
|
$s = $this->schedule(adminZone: 'Europe/Berlin')->get();
|
||||||
|
|
||||||
|
// it ran at 02:30 UTC: in summer that is 04:30 in Berlin, in winter 03:30
|
||||||
|
$summer = Schedule::convert('02:30', 'UTC', 'Europe/Berlin', strtotime('2026-07-01 12:00 UTC'));
|
||||||
|
$this->assertSame('04:30', $summer);
|
||||||
|
$this->assertSame('Europe/Berlin', $s['timezone']);
|
||||||
|
$this->assertSame(Schedule::convert('02:30', 'UTC', 'Europe/Berlin'), $s['time']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheServersDefaultZoneComesFirst(): void {
|
||||||
|
$this->values = [Schedule::TIME_KEY => '01:00'];
|
||||||
|
$this->assertSame('America/Toronto', $this->schedule('America/Toronto', 'Europe/Berlin')->get()['timezone']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutAnyZoneItStaysUtc(): void {
|
||||||
|
$this->values = [Schedule::TIME_KEY => '02:30'];
|
||||||
|
$this->assertSame(['time' => '02:30', 'timezone' => 'UTC'], $this->schedule()->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTodayAtIsTheMomentInTheSchedulesZone(): void {
|
||||||
|
$this->values = [Schedule::TIME_KEY => '03:00', Schedule::ZONE_KEY => 'Europe/Berlin'];
|
||||||
|
$at = $this->schedule()->todayAt(strtotime('2026-10-22 12:00 UTC'));
|
||||||
|
$this->assertSame(strtotime('2026-10-22 01:00 UTC'), $at, '03:00 in Berlin (summer time) is 01:00 UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownZoneOrABadTimeIsRefused(): void {
|
||||||
|
$s = $this->schedule();
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$s->set('03:00', 'Mars/Olympus');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,13 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// nextcloud/ocp ships the public interfaces without an autoloader; tests that mock them need one.
|
||||||
|
spl_autoload_register(static function (string $class): void {
|
||||||
|
if (str_starts_with($class, 'OCP\\')) {
|
||||||
|
$file = __DIR__ . '/../vendor/nextcloud/ocp/' . str_replace('\\', '/', $class) . '.php';
|
||||||
|
if (is_file($file)) {
|
||||||
|
require $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,11 +2,27 @@
|
|||||||
# 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>
|
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|to-pgsql|to-mysql|to-sqlite]
|
||||||
# e.g. full-restore-roundtrip.sh 35-apache
|
# e.g. full-restore-roundtrip.sh 35-apache pgsql
|
||||||
|
# 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.
|
||||||
|
# With "to-<db>" the restore instance runs that database whatever the origin runs (a MySQL or
|
||||||
|
# SQLite backup records no ID counters; after the restore new rows must not collide with restored
|
||||||
|
# ones, whatever the database on either side).
|
||||||
|
# With "legacy" the origin runs NG Backup 1.0.0-rc.1 (0.9.7), whose backups do not record the
|
||||||
|
# structure of their tables: the restore must then leave the tables of the disabled app out
|
||||||
|
# with --skip-missing-tables, as for a real backup made before 1.0.0-rc.2.
|
||||||
|
# With pgsql or mysql the origin also has what broke real disaster recoveries: the Mail app with a synced
|
||||||
|
# account (foreign keys between its tables) and Deck installed and then switched off (tables of
|
||||||
|
# an app that is not enabled). The restore instance has neither; --install-apps adds Mail.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IMAGE="nextcloud:${1:-35-apache}"
|
IMAGE="nextcloud:${1:-35-apache}"
|
||||||
|
DB="${2:-sqlite}"
|
||||||
|
MODE="${3:-}"
|
||||||
|
LEGACY=""
|
||||||
|
[ "$MODE" = legacy ] && LEGACY=legacy
|
||||||
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
ORIGIN_APP="$APP"
|
||||||
NET=ngb-frt-net
|
NET=ngb-frt-net
|
||||||
SHARED=$(mktemp -d)
|
SHARED=$(mktemp -d)
|
||||||
chmod 777 "$SHARED"
|
chmod 777 "$SHARED"
|
||||||
@@ -14,37 +30,107 @@ fail() { echo "FAIL: $*"; exit 1; }
|
|||||||
ok() { echo "ok $*"; }
|
ok() { echo "ok $*"; }
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
docker rm -fv ngb-frt-origin ngb-frt-restore >/dev/null 2>&1 || true
|
docker rm -fv ngb-frt-origin ngb-frt-restore ngb-frt-db-origin ngb-frt-db-restore ngb-frt-imap ngb-frt-sftp >/dev/null 2>&1 || true
|
||||||
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
|
||||||
|
[ -n "${LEGACY_DIR:-}" ] && rm -rf "$LEGACY_DIR"
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [ "$LEGACY" = legacy ]; then
|
||||||
|
LEGACY_DIR=$(mktemp -d)
|
||||||
|
curl -fsSL https://github.com/dillardblom/ng_backup/releases/download/v1.0.0-rc.1/ng_backup-0.9.7.tar.gz | tar -xz -C "$LEGACY_DIR"
|
||||||
|
ORIGIN_APP="$LEGACY_DIR/ng_backup"
|
||||||
|
fi
|
||||||
|
|
||||||
run_on() { docker exec -u www-data "$1" php occ "${@:2}"; }
|
run_on() { docker exec -u www-data "$1" php occ "${@:2}"; }
|
||||||
|
# The app store sends the whole app list (~38 MB) and Nextcloud gives up after 120 s: on a slow day
|
||||||
|
# an install fails for reasons outside this test. Up to three tries.
|
||||||
|
install_app() { # container app
|
||||||
|
for i in 1 2 3; do run_on "$1" app:install "$2" >/dev/null 2>&1 && return 0; sleep 20; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database settings for an instance: SQLite inside the container, or its own PostgreSQL container
|
||||||
|
db_env() {
|
||||||
|
if [ "$DB" = pgsql ]; then
|
||||||
|
docker run -d --name "ngb-frt-db-$1" --network "$NET" -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nextcloud \
|
||||||
|
-e POSTGRES_PASSWORD=nextcloud postgres:16-alpine >/dev/null
|
||||||
|
for i in $(seq 1 30); do docker exec "ngb-frt-db-$1" pg_isready -q 2>/dev/null && break; sleep 2; done
|
||||||
|
echo "-e POSTGRES_HOST=ngb-frt-db-$1 -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nextcloud -e POSTGRES_PASSWORD=nextcloud"
|
||||||
|
elif [ "$DB" = mysql ]; then
|
||||||
|
docker run -d --name "ngb-frt-db-$1" --network "$NET" -e MARIADB_DATABASE=nextcloud -e MARIADB_USER=nextcloud \
|
||||||
|
-e MARIADB_PASSWORD=nextcloud -e MARIADB_ROOT_PASSWORD=nextcloud mariadb:11 >/dev/null
|
||||||
|
for i in $(seq 1 60); do docker exec "ngb-frt-db-$1" mariadb-admin ping -unextcloud -pnextcloud --silent >/dev/null 2>&1 && break; sleep 2; done
|
||||||
|
echo "-e MYSQL_HOST=ngb-frt-db-$1 -e MYSQL_DATABASE=nextcloud -e MYSQL_USER=nextcloud -e MYSQL_PASSWORD=nextcloud"
|
||||||
|
else
|
||||||
|
echo "-e SQLITE_DATABASE=nextcloud"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
docker network create "$NET" >/dev/null
|
docker network create "$NET" >/dev/null
|
||||||
docker run -d --name ngb-frt-origin --network "$NET" -e SQLITE_DATABASE=nextcloud \
|
LOCATION=(local -a null::null -o datadir=/mnt/shared)
|
||||||
|
if [ "$MODE" = sftp ]; then
|
||||||
|
docker run -d --name ngb-frt-sftp --network "$NET" atmoz/sftp:alpine ngbackup:ngbackuppw:::upload >/dev/null
|
||||||
|
LOCATION=(sftp -a password::password -o host=ngb-frt-sftp -o root=/upload -o user=ngbackup -o password=ngbackuppw)
|
||||||
|
fi
|
||||||
|
ORIGIN_DB=$(db_env origin)
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run -d --name ngb-frt-origin --network "$NET" $ORIGIN_DB \
|
||||||
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \
|
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \
|
||||||
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
|
-v "$ORIGIN_APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
|
||||||
for i in $(seq 1 90); do run_on ngb-frt-origin status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
|
for i in $(seq 1 90); do run_on ngb-frt-origin status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
|
||||||
docker exec ngb-frt-origin chown www-data:www-data /var/www/html/custom_apps
|
docker exec ngb-frt-origin chown www-data:www-data /var/www/html/custom_apps
|
||||||
run_on ngb-frt-origin app:enable ng_backup >/dev/null
|
run_on ngb-frt-origin app:enable ng_backup >/dev/null
|
||||||
ok "origin instance up"
|
ok "origin instance up ($DB${LEGACY:+, NG Backup 0.9.7})"
|
||||||
|
|
||||||
docker exec ngb-frt-origin sh -c "printf 'full restore roundtrip passphrase 2026\n' > /tmp/pass"
|
docker exec ngb-frt-origin sh -c "printf 'full restore roundtrip passphrase 2026\n' > /tmp/pass"
|
||||||
run_on ngb-frt-origin backup:key:init --passphrase-file=/tmp/pass --label=CI >/dev/null || fail "key init"
|
run_on ngb-frt-origin backup:key:init --passphrase-file=/tmp/pass --label=CI >/dev/null || fail "key init"
|
||||||
run_on ngb-frt-origin backup:key:kit --output=/tmp/kit.json >/dev/null
|
run_on ngb-frt-origin backup:key:kit --output=/tmp/kit.json >/dev/null
|
||||||
CODE=$(run_on ngb-frt-origin backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
|
CODE=$(run_on ngb-frt-origin backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
|
||||||
run_on ngb-frt-origin backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
|
run_on ngb-frt-origin backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm"
|
||||||
run_on ngb-frt-origin backup:target:add offsite local -a null::null -o datadir=/mnt/shared --path=repo >/dev/null || fail "target add"
|
run_on ngb-frt-origin backup:target:add offsite "${LOCATION[@]}" --path=repo >/dev/null || fail "target add"
|
||||||
ok "key, kit and location on the origin"
|
ok "key, kit and location on the origin"
|
||||||
|
|
||||||
NC_PASS=$(openssl rand -hex 16)
|
NC_PASS=$(openssl rand -hex 16)
|
||||||
docker exec -u www-data -e OC_PASS="$NC_PASS" ngb-frt-origin php occ user:add --password-from-env frtest >/dev/null
|
docker exec -u www-data -e OC_PASS="$NC_PASS" ngb-frt-origin php occ user:add --password-from-env frtest >/dev/null
|
||||||
docker exec ngb-frt-origin sh -c "mkdir -p /var/www/html/data/frtest/files/Docs && head -c 200000 /dev/urandom > /var/www/html/data/frtest/files/Docs/report.bin && echo 'hello disaster recovery' > /var/www/html/data/frtest/files/Docs/note.txt && chown -R www-data:www-data /var/www/html/data/frtest"
|
docker exec ngb-frt-origin sh -c "mkdir -p /var/www/html/data/frtest/files/Docs && head -c 200000 /dev/urandom > /var/www/html/data/frtest/files/Docs/report.bin && echo 'hello disaster recovery' > /var/www/html/data/frtest/files/Docs/note.txt && chown -R www-data:www-data /var/www/html/data/frtest"
|
||||||
|
if [ "$MODE" = sftp ]; then
|
||||||
|
# about three packs of 32 MiB
|
||||||
|
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)
|
||||||
|
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() {
|
||||||
|
if [ "$DB" = pgsql ]; then docker exec "ngb-frt-db-$1" psql -U nextcloud -d nextcloud -Atc "$2"
|
||||||
|
else docker exec "ngb-frt-db-$1" mariadb -unextcloud -pnextcloud -N -B nextcloud -e "$2"; fi
|
||||||
|
}
|
||||||
|
if [ "$DB" != sqlite ]; then
|
||||||
|
# Mail with a synced account: oc_mail_accounts points at oc_mail_mailboxes (foreign keys),
|
||||||
|
# while the restore loads tables in name order, accounts before mailboxes.
|
||||||
|
docker run -d --name ngb-frt-imap --network "$NET" -e MAILNAME=mail.domain.tld -e MAIL_ADDRESS=user@domain.tld \
|
||||||
|
-e MAIL_PASS=mypassword ghcr.io/christophwurst/docker-imap-devel:latest >/dev/null
|
||||||
|
install_app ngb-frt-origin mail || fail "install mail on the origin (app store unreachable?)"
|
||||||
|
run_on ngb-frt-origin config:system:set app.mail.verify-tls-peer --type=boolean --value=false >/dev/null
|
||||||
|
sleep 10
|
||||||
|
run_on ngb-frt-origin mail:account:create frtest Test user@domain.tld ngb-frt-imap 993 ssl user@domain.tld mypassword \
|
||||||
|
ngb-frt-imap 25 tls user@domain.tld mypassword >/dev/null || fail "mail account"
|
||||||
|
run_on ngb-frt-origin mail:account:sync -f 1 >/dev/null || fail "mail sync"
|
||||||
|
[ "$(sql origin 'select count(*) from oc_mail_accounts where drafts_mailbox_id is not null')" = 1 ] \
|
||||||
|
|| fail "the mail account does not point at its mailboxes"
|
||||||
|
# Deck installed and then switched off: its tables stay in the database and in the backup
|
||||||
|
install_app ngb-frt-origin deck || fail "install deck on the origin (app store unreachable?)"
|
||||||
|
# a 4-byte character (emoji) in a table the restore has to create: utf8mb4 on MySQL/MariaDB
|
||||||
|
sql origin "insert into oc_deck_boards (title, owner, color, archived, deleted_at, last_modified) values ('Plan 📌', 'frtest', '0087C5', false, 0, 0)" >/dev/null
|
||||||
|
run_on ngb-frt-origin app:disable deck >/dev/null
|
||||||
|
ok "origin has a synced Mail account and the tables of a disabled app"
|
||||||
|
fi
|
||||||
|
|
||||||
RUN_OUT=$(run_on ngb-frt-origin backup:run offsite -l disaster-test)
|
RUN_OUT=$(run_on ngb-frt-origin backup:run offsite -l disaster-test)
|
||||||
SNAP=$(printf '%s\n' "$RUN_OUT" | head -1 | awk -F'snapshot ' '{print $2}' | tr -d ',')
|
SNAP=$(printf '%s\n' "$RUN_OUT" | head -1 | awk -F'snapshot ' '{print $2}' | tr -d ',')
|
||||||
[ -n "$SNAP" ] || fail "no snapshot id from backup:run"
|
[ -n "$SNAP" ] || fail "no snapshot id from backup:run"
|
||||||
@@ -52,7 +138,11 @@ ok "test data backed up, snapshot $SNAP"
|
|||||||
|
|
||||||
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
|
||||||
docker run -d --name ngb-frt-restore --network "$NET" -e SQLITE_DATABASE=nextcloud \
|
ORIGIN_DBTYPE=$DB
|
||||||
|
case "$MODE" in to-*) DB=${MODE#to-} ;; esac
|
||||||
|
RESTORE_DB=$(db_env restore)
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker run -d --name ngb-frt-restore --network "$NET" $RESTORE_DB \
|
||||||
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \
|
-e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \
|
||||||
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
|
-v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null
|
||||||
for i in $(seq 1 90); do run_on ngb-frt-restore status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
|
for i in $(seq 1 90); do run_on ngb-frt-restore status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done
|
||||||
@@ -64,12 +154,36 @@ docker cp /tmp/ngb-frt-pass ngb-frt-restore:/tmp/pass
|
|||||||
ok "fresh restore instance up"
|
ok "fresh restore instance up"
|
||||||
|
|
||||||
run_on ngb-frt-restore backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass >/dev/null || fail "key import"
|
run_on ngb-frt-restore backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass >/dev/null || fail "key import"
|
||||||
run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=/mnt/shared --path=repo >/dev/null || fail "target add on the restore instance"
|
ADD_OUT=$(run_on ngb-frt-restore backup:target:add offsite "${LOCATION[@]}" --path=repo 2>&1) || { printf '%s\n' "$ADD_OUT"; fail "target add on the restore instance"; }
|
||||||
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
LIST_OUT=$(run_on ngb-frt-restore backup:list offsite 2>&1)
|
||||||
|
printf '%s\n' "$LIST_OUT" | grep -q "$SNAP" || {
|
||||||
|
printf 'target:add said:\n%s\nbackup:list said:\n%s\n' "$ADD_OUT" "$LIST_OUT"
|
||||||
|
docker exec ngb-frt-restore sh -c 'grep -h ng_backup /var/www/html/data/nextcloud.log | tail -5' || true
|
||||||
|
docker exec ngb-frt-restore sh -c 'ls -la /mnt/shared/repo /mnt/shared/repo/snapshots' || true
|
||||||
|
fail "the origin's snapshot is not listed after connecting the location"
|
||||||
|
}
|
||||||
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
||||||
|
|
||||||
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction >/dev/null || fail "restore:full"
|
RESTORE_OPTS=(--no-interaction --install-apps)
|
||||||
ok "backup:restore:full completed"
|
if [ "$LEGACY" = legacy ] && [ "$DB" != sqlite ]; then
|
||||||
|
# Without --skip-missing-tables an older backup must be refused, naming the app's tables
|
||||||
|
REFUSED=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" "${RESTORE_OPTS[@]}" 2>&1) \
|
||||||
|
&& fail "an older backup with tables of unknown structure was restored without --skip-missing-tables"
|
||||||
|
printf '%s\n' "$REFUSED" | grep -q "oc_deck_boards" || { printf '%s\n' "$REFUSED" | tail -20; fail "the refusal does not name the missing tables"; }
|
||||||
|
ok "older backup refused without --skip-missing-tables, missing tables named"
|
||||||
|
RESTORE_OPTS+=(--skip-missing-tables)
|
||||||
|
fi
|
||||||
|
sftp_logins() { docker logs ngb-frt-sftp 2>&1 | grep -c "Accepted password" || true; }
|
||||||
|
[ "$MODE" = sftp ] && LOGINS_BEFORE=$(sftp_logins)
|
||||||
|
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" "${RESTORE_OPTS[@]}" 2>&1) || {
|
||||||
|
printf '%s\n' "$OUT" | tail -40
|
||||||
|
docker exec ngb-frt-restore sh -c 'tail -c 6000 /var/www/html/data/nextcloud.log' || true
|
||||||
|
fail "restore:full"
|
||||||
|
}
|
||||||
|
printf '%s\n' "$OUT" | grep -q "maintenance mode while the restore runs" || fail "the restore did not say it runs in maintenance mode"
|
||||||
|
run_on ngb-frt-restore status 2>&1 | grep -q "maintenance: false" || fail "maintenance mode is still on after the restore"
|
||||||
|
run_on ngb-frt-restore config:app:get ng_backup restore_full_started >/dev/null 2>&1 && fail "the restore left its 'started' marker behind"
|
||||||
|
ok "backup:restore:full completed, in maintenance mode and out of it again"
|
||||||
|
|
||||||
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
||||||
# earlier bugs here only showed up after config.php's secret was rewritten mid-restore.
|
# earlier bugs here only showed up after config.php's secret was rewritten mid-restore.
|
||||||
@@ -78,9 +192,42 @@ run_on ngb-frt-restore user:list | grep -q frtest || fail "the backed-up user is
|
|||||||
docker exec ngb-frt-restore test -f /var/www/html/data/frtest/files/Docs/note.txt || fail "restored file is missing"
|
docker exec ngb-frt-restore test -f /var/www/html/data/frtest/files/Docs/note.txt || fail "restored file is missing"
|
||||||
[ "$(docker exec ngb-frt-restore cat /var/www/html/data/frtest/files/Docs/note.txt)" = "hello disaster recovery" ] || fail "restored file content differs"
|
[ "$(docker exec ngb-frt-restore cat /var/www/html/data/frtest/files/Docs/note.txt)" = "hello disaster recovery" ] || fail "restored file content differs"
|
||||||
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after restore"
|
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after restore"
|
||||||
|
# New rows after the restore: a new file (file cache) and a new user must not collide with
|
||||||
|
# restored rows (ID counters after the data).
|
||||||
|
docker exec ngb-frt-restore sh -c "echo new > /var/www/html/data/frtest/files/Docs/after-restore.txt && chown www-data /var/www/html/data/frtest/files/Docs/after-restore.txt"
|
||||||
|
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "a new file cannot be added after the restore"
|
||||||
|
if [ "$DB" != sqlite ]; then
|
||||||
|
[ "$(sql restore "select count(*) from oc_filecache where path = 'files/Docs/after-restore.txt'")" = 1 ] \
|
||||||
|
|| fail "a new file did not make it into the file cache after the restore (ID counters?)"
|
||||||
|
fi
|
||||||
|
docker exec -u www-data -e OC_PASS="$NC_PASS" ngb-frt-restore php occ user:add --password-from-env frnew >/dev/null || fail "a new user cannot be added after the restore"
|
||||||
ok "database, files and ng_backup's own state are all intact and usable after the restore"
|
ok "database, files and ng_backup's own state are all intact and usable after the restore"
|
||||||
|
|
||||||
|
# Mail and Deck were only set up on an origin with a database server (see above)
|
||||||
|
if [ "$ORIGIN_DBTYPE" != sqlite ] && [ "$DB" != sqlite ] && [ "$LEGACY" = legacy ]; then
|
||||||
|
[ "$(sql restore "select count(*) from information_schema.tables where table_name = 'oc_deck_boards'")" = 0 ] || fail "a table of unknown structure was created"
|
||||||
|
[ "$(sql restore "select count(*) from oc_mail_accounts where drafts_mailbox_id is not null")" = 1 ] || fail "the mail account was not restored"
|
||||||
|
ok "older backup: tables of the disabled app left out, Mail restored"
|
||||||
|
elif [ "$ORIGIN_DBTYPE" != sqlite ] && [ "$DB" != sqlite ]; then
|
||||||
|
[ "$(sql restore "select count(*) from information_schema.tables where table_name = 'oc_deck_boards'")" = 1 ] || fail "the tables of the disabled app were not created"
|
||||||
|
[ "$(sql restore "select title from oc_deck_boards")" = "Plan 📌" ] || fail "a 4-byte character did not survive in a created table"
|
||||||
|
[ "$(sql restore "select count(*) from oc_mail_accounts where drafts_mailbox_id is not null")" = 1 ] || fail "the mail account was not restored"
|
||||||
|
[ "$(sql restore "select count(*) from information_schema.table_constraints where constraint_type = 'FOREIGN KEY' and table_name like 'oc_mail_%'")" -gt 0 ] \
|
||||||
|
|| fail "the foreign keys of Mail are missing after the restore"
|
||||||
|
printf '%s\n' "$OUT" | grep -q "Created .* tables" || fail "the restore did not report the tables it created"
|
||||||
|
ok "tables of a disabled app created, Mail restored with its foreign keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MODE" = sftp ]; then
|
||||||
|
[ "$(docker exec ngb-frt-restore sha256sum /var/www/html/data/frtest/files/Docs/large.bin | cut -d' ' -f1)" = "$LARGE_SUM" ] \
|
||||||
|
|| fail "the large file differs after the restore"
|
||||||
|
LOGINS=$(( $(sftp_logins) - LOGINS_BEFORE ))
|
||||||
|
[ "$LOGINS" -gt 1 ] || fail "the restore did not read ahead on connections of its own ($LOGINS SFTP logins)"
|
||||||
|
docker exec ngb-frt-restore sh -c 'ls -a /var/www/html/data' | grep -q ngb-restore-spool && fail "the spool folder was left behind"
|
||||||
|
ok "over SFTP: read ahead on connections of its own ($LOGINS SFTP logins during the restore), large file identical, no spool left"
|
||||||
|
fi
|
||||||
|
|
||||||
run_on ngb-frt-restore backup:run offsite -l post-restore-check >/dev/null || fail "a new backup after the restore fails"
|
run_on ngb-frt-restore backup:run offsite -l post-restore-check >/dev/null || fail "a new backup after the restore fails"
|
||||||
ok "the restored instance can run a new backup of its own"
|
ok "the restored instance can run a new backup of its own"
|
||||||
|
|
||||||
echo "FULL RESTORE ROUNDTRIP PASS"
|
echo "FULL RESTORE ROUNDTRIP PASS ($ORIGIN_DBTYPE${MODE:+, $MODE})"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Stand-in for the files_external SMB storage in unit tests: only what StorageBackend uses.
|
||||||
|
|
||||||
|
namespace OCA\Files_External\Lib\Storage;
|
||||||
|
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
|
|
||||||
|
if (!class_exists(SMB::class)) {
|
||||||
|
abstract class SMB implements IStorage {
|
||||||
|
/** @var object */
|
||||||
|
protected $share;
|
||||||
|
protected bool $showHidden = false;
|
||||||
|
protected bool $checkAcl = false;
|
||||||
|
|
||||||
|
protected function buildPath(string $path): string {
|
||||||
|
return '/' . trim($path, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user