Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 65f14b4380 | |||
| a01aa16ccd | |||
| cc20b9174f | |||
| 239aa1b923 | |||
| 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,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Full restore round trip
|
||||
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
|
||||
|
||||
+178
@@ -7,6 +7,181 @@ 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
|
||||
instead carried by the app description, the git tag, the release, and this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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.
|
||||
@@ -154,6 +329,9 @@ and not implemented yet.
|
||||
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
||||
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
|
||||
|
||||
@@ -377,6 +377,88 @@ in README "Known limitations":
|
||||
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.
|
||||
|
||||
**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.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.
|
||||
- **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
|
||||
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
||||
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
|
||||
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
||||
|
||||
**Status: beta (0.9.4).** Tested thoroughly, usable 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.
|
||||
**Status: release candidate for 1.0.0 (1.0.0-rc.1, app version 0.9.7).** Tested thoroughly, usable
|
||||
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. 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
|
||||
|
||||
@@ -23,6 +26,10 @@ License: AGPL-3.0-or-later.
|
||||
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.
|
||||
- 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
|
||||
removed files go to the trash.
|
||||
|
||||
@@ -47,9 +54,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
|
||||
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
|
||||
only gets hold of the backup location cannot.
|
||||
- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one
|
||||
passphrase**. The kit contains the master key, encrypted separately with each passphrase.
|
||||
only gets hold of the backup location cannot, as long as the passphrases are strong (see below).
|
||||
- The **location holds the key too**, by design (like restic and Borg in repokey mode): its
|
||||
`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
|
||||
lock the company out. A suggested setup:
|
||||
|
||||
@@ -158,6 +175,13 @@ 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 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/`).
|
||||
|
||||
- **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
|
||||
mount point; ng_backup never mounts or unmounts anything on its own, so an NFS mount dropping
|
||||
@@ -168,6 +192,11 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -193,8 +222,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:slots`, `backup:key:slot:add/replace/remove` | passphrase slots |
|
||||
| `backup:target:backends/add/list/remove/append-only` | backup locations |
|
||||
| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points |
|
||||
| `backup:target:backends/add/edit/list/remove/append-only` | backup locations |
|
||||
| `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:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
||||
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
||||
|
||||
+43
-9
@@ -3,21 +3,53 @@
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>ng_backup</id>
|
||||
<name>NG Backup</name>
|
||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
|
||||
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
|
||||
single users (via user_migration) and of the whole server onto a fresh installation. Tested
|
||||
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
|
||||
See the README for known limitations and the recovery kit before relying on it.]]></description>
|
||||
<version>0.9.4</version>
|
||||
<summary>Encrypted, incremental backups of your Nextcloud — files, database and config — with restore from the web interface</summary>
|
||||
<description><, which also offers paid help with
|
||||
restoring a backup after a crash.]]></description>
|
||||
<version>0.9.8</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Dillard Blom</author>
|
||||
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
||||
<namespace>NgBackup</namespace>
|
||||
<documentation>
|
||||
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
|
||||
</documentation>
|
||||
<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>
|
||||
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/1-backups.png</screenshot>
|
||||
@@ -37,9 +69,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\TargetBackends</command>
|
||||
<command>OCA\NgBackup\Command\TargetAdd</command>
|
||||
<command>OCA\NgBackup\Command\TargetEdit</command>
|
||||
<command>OCA\NgBackup\Command\TargetList</command>
|
||||
<command>OCA\NgBackup\Command\TargetRemove</command>
|
||||
<command>OCA\NgBackup\Command\RunBackup</command>
|
||||
<command>OCA\NgBackup\Command\CancelRun</command>
|
||||
<command>OCA\NgBackup\Command\Status</command>
|
||||
<command>OCA\NgBackup\Command\ListSnapshots</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#backends', 'url' => '/api/backends', 'verb' => 'GET'],
|
||||
['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#testTarget', 'url' => '/api/targets/{id}/test', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setTargetLimit', 'url' => '/api/targets/{id}/limit', 'verb' => 'PUT'],
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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.
|
||||
- [ ] 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. If it stops halfway, fix the cause and run the same command again 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"
|
||||
```
|
||||
|
||||
- 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="..."`.
|
||||
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).
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -50,6 +53,22 @@ occ backup:target:list
|
||||
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
|
||||
|
||||
```
|
||||
@@ -117,6 +136,8 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
||||
|
||||
## 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
|
||||
the same location available. For a test, use a **copy** of the location: two servers writing to one
|
||||
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
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
@@ -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 */
|
||||
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) */
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ declare(strict_types=1);
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
final class LocalBackend implements IBackend {
|
||||
use GetsOneByOne;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
) {
|
||||
|
||||
+235
-19
@@ -28,6 +28,10 @@ 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). */
|
||||
@@ -40,6 +44,7 @@ final class SftpReadStream {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -50,11 +55,20 @@ final class SftpReadStream {
|
||||
*/
|
||||
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;
|
||||
@@ -101,14 +115,8 @@ final class SftpReadStream {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
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']) || !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
$sftp = self::connection($storage);
|
||||
if ($sftp === null) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||
@@ -129,14 +137,191 @@ final class SftpReadStream {
|
||||
return null;
|
||||
}
|
||||
if ($fh === false && self::openBroke()) {
|
||||
throw new BackendException('SFTP connection failed while opening ' . $path);
|
||||
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 need; null otherwise.
|
||||
*/
|
||||
private 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;
|
||||
}
|
||||
|
||||
@@ -156,14 +341,14 @@ final class SftpReadStream {
|
||||
}
|
||||
$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();
|
||||
return $this->connectionFailed('the request could not be sent');
|
||||
}
|
||||
$response = $this->sftp->_get_sftp_packet($id);
|
||||
} catch (\Throwable) {
|
||||
return $this->connectionFailed();
|
||||
} catch (\Throwable $e) {
|
||||
return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
|
||||
}
|
||||
if (!is_string($response)) {
|
||||
return $this->connectionFailed();
|
||||
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
|
||||
@@ -173,12 +358,39 @@ final class SftpReadStream {
|
||||
}
|
||||
|
||||
/** Not a missing file but a connection that does not behave as expected. */
|
||||
private function connectionFailed(): bool {
|
||||
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;
|
||||
@@ -304,6 +516,9 @@ final class SftpReadStream {
|
||||
} catch (\Throwable) {
|
||||
$this->broken = true;
|
||||
}
|
||||
if ($this->broken) {
|
||||
self::disconnect($this->sftp);
|
||||
}
|
||||
}
|
||||
$this->handle = '';
|
||||
}
|
||||
@@ -385,19 +600,20 @@ final class SftpReadStream {
|
||||
private function drain(): void {
|
||||
$inFlight = $this->inFlight;
|
||||
$this->inFlight = [];
|
||||
if ($this->broken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
foreach ($inFlight as [$id]) {
|
||||
foreach ($this->broken ? [] : $inFlight as [$id]) {
|
||||
if (!is_string($this->sftp->_get_sftp_packet($id))) {
|
||||
$this->broken = true;
|
||||
return;
|
||||
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 {
|
||||
|
||||
@@ -19,16 +19,36 @@ final class StorageBackend implements IBackend {
|
||||
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;
|
||||
/** 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(
|
||||
private IStorage $storage,
|
||||
private string $base = '',
|
||||
bool $createBase = true,
|
||||
private ?\Closure $reconnect = null,
|
||||
) {
|
||||
$this->lastUsed = microtime(true);
|
||||
$this->base = trim($base, '/');
|
||||
self::validate($this->base, true);
|
||||
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
||||
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
|
||||
if ($this->base !== '') {
|
||||
$this->s3Reads = $storage instanceof \OCA\Files_External\Lib\Storage\AmazonS3;
|
||||
if ($createBase && $this->base !== '') {
|
||||
$this->mkdirs($this->base);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +59,8 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
|
||||
public function put(string $path, $stream): void {
|
||||
// not repeated on a new connection: the stream is (partly) read
|
||||
$this->idleCheck();
|
||||
$expected = self::remaining($stream);
|
||||
$target = $this->abs($path);
|
||||
$this->mkdirs(dirname($target));
|
||||
@@ -82,6 +104,11 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -97,7 +124,130 @@ final class StorageBackend implements IBackend {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
if (fseek($fh, $offset) !== 0) {
|
||||
@@ -120,23 +270,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 {
|
||||
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 {
|
||||
$result = [];
|
||||
$this->listInto($this->abs($prefix), $result);
|
||||
$abs = $this->abs($prefix);
|
||||
$result = $this->call(function () use ($abs): array {
|
||||
$result = [];
|
||||
$this->listInto($abs, $result);
|
||||
return $result;
|
||||
}, fn (array $found) => $found === []);
|
||||
sort($result);
|
||||
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||
return array_map(fn ($p) => substr($p, $cut), $result);
|
||||
}
|
||||
|
||||
public function delete(string $path): void {
|
||||
$this->idleCheck();
|
||||
$this->storage->unlink($this->abs($path));
|
||||
}
|
||||
|
||||
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);
|
||||
$this->mkdirs(dirname($target));
|
||||
if (!$this->storage->rename($this->abs($from), $target)) {
|
||||
@@ -146,15 +313,31 @@ 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);
|
||||
}
|
||||
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||
$result = [];
|
||||
while (($name = readdir($dh)) !== false) {
|
||||
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
|
||||
@@ -166,6 +349,134 @@ final class StorageBackend implements IBackend {
|
||||
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);
|
||||
}
|
||||
|
||||
private function reopenIfDead(): bool {
|
||||
if ($this->reconnect === null || !$this->sftpReads || $this->alive(false) !== 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
|
||||
}
|
||||
$sftp->stat('.');
|
||||
return (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 {
|
||||
if (!$this->storage->is_dir($dir)) {
|
||||
return;
|
||||
|
||||
@@ -34,7 +34,11 @@ final class BackupJob extends TimedJob {
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
} else {
|
||||
$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);
|
||||
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
||||
if ($pass !== $again) {
|
||||
|
||||
@@ -88,7 +88,7 @@ HELP;
|
||||
return rtrim((string)@file_get_contents($file), "\r\n");
|
||||
}
|
||||
$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))) {
|
||||
throw new \InvalidArgumentException('The passphrases do not match');
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ final class RestoreFull extends Command {
|
||||
private TargetService $targets,
|
||||
private RestoreFullService $restoreFull,
|
||||
private IUserManager $userManager,
|
||||
private \OCP\App\IAppManager $appManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -33,14 +34,19 @@ final class RestoreFull extends Command {
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->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('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'
|
||||
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
|
||||
uses this instance.
|
||||
|
||||
Every app that was enabled when the snapshot was taken must already be installed here (its
|
||||
database tables must exist before the restore can write into them); the command lists what is
|
||||
missing and refuses to continue rather than guess.
|
||||
Apps: --install-apps first installs (from the app store) or enables every app that was enabled
|
||||
when the snapshot was taken. Apps that are not in the app store (own builds) must be copied into
|
||||
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
|
||||
directly in place; config.php is merged in -- everything from the backup except the database
|
||||
@@ -75,7 +81,14 @@ HELP);
|
||||
try {
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$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);
|
||||
}
|
||||
}
|
||||
$result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables);
|
||||
} catch (\Throwable $e) {
|
||||
$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.');
|
||||
@@ -88,10 +101,46 @@ HELP);
|
||||
$result['configKeys'],
|
||||
$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']) {
|
||||
$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('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
||||
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\Service\KeyService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCA\NgBackup\Service\VerifyService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
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(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
private RunMapper $runs,
|
||||
private VerifyService $verify,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -42,15 +49,43 @@ final class Status extends Command {
|
||||
}
|
||||
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
||||
$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'] ?? [];
|
||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
|
||||
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||
$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() ? \OCA\NgBackup\Format::duration($r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||
$s['files'] ?? '', isset($s['bytes']) ? self::size((int)$s['bytes']) : '',
|
||||
isset($s['uploaded']) ? self::size((int)$s['uploaded']) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** " 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 {
|
||||
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;
|
||||
}
|
||||
$options = [];
|
||||
@@ -60,9 +63,16 @@ HELP);
|
||||
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
||||
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 {
|
||||
$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) {
|
||||
if ($output->isDecorated()) {
|
||||
$output->write("\r\033[K"); // clear an unfinished progress line
|
||||
}
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
@@ -70,4 +80,34 @@ HELP);
|
||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||
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
|
||||
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
|
||||
you'd need to rely on a restore.
|
||||
|
||||
@@ -51,9 +54,12 @@ HELP);
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$ref = (string)$input->getArgument('snapshot');
|
||||
$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/')
|
||||
? $this->verify->verifyUserExport($target, $ref, $deep)
|
||||
: $this->verify->verify($target, $ref, $deep);
|
||||
? $this->verify->verifyUserExport($target, $ref, $deep, $onProgress)
|
||||
: $this->verify->verify($target, $ref, $deep, $onProgress);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
|
||||
@@ -51,14 +51,16 @@ final class AdminApiController extends Controller {
|
||||
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 = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$targets[] = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||
'repositoryId' => $t->getRepositoryId(), 'createdAt' => $t->getCreatedAt(), 'appendOnly' => (bool)$t->getAppendOnly(),
|
||||
'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([
|
||||
'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(),
|
||||
'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(),
|
||||
@@ -68,6 +70,8 @@ final class AdminApiController extends Controller {
|
||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||
'targets' => $targets,
|
||||
'runs' => $runs,
|
||||
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
|
||||
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
|
||||
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
||||
]);
|
||||
}
|
||||
@@ -181,6 +185,28 @@ final class AdminApiController extends Controller {
|
||||
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. */
|
||||
#[PasswordConfirmationRequired]
|
||||
public function setTargetLimit(int $id, ?float $maxGb = null): JSONResponse {
|
||||
@@ -283,6 +309,6 @@ final class AdminApiController extends Controller {
|
||||
return ['id' => $r->getId(), 'targetId' => $r->getTargetId(), 'kind' => $r->getKind(), 'status' => $r->getStatus(),
|
||||
'phase' => $r->getPhase(), 'snapshotId' => $r->getSnapshotId(), 'snapshot' => $snapshot, 'startedAt' => $r->getStartedAt(),
|
||||
'updatedAt' => $r->getUpdatedAt(), 'finishedAt' => $r->getFinishedAt(), 'error' => $r->getError(),
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true)];
|
||||
'stats' => json_decode($r->getStats() ?? 'null', true), 'progress' => \OCA\NgBackup\Job\BackupRun::progressOf($r)];
|
||||
}
|
||||
}
|
||||
|
||||
+224
-10
@@ -23,8 +23,12 @@ use OCA\NgBackup\Repository\Repository;
|
||||
* 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
|
||||
* 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
|
||||
* restored into (same Nextcloud and app versions). The dump records those versions.
|
||||
* - The structure of each table (columns, types, primary key, indexes) is recorded in the manifest,
|
||||
* 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
|
||||
* introspection; to be replaced by information_schema queries or a public API.
|
||||
@@ -108,7 +112,7 @@ final class DbDumper {
|
||||
break;
|
||||
}
|
||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
||||
'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;
|
||||
@@ -137,18 +141,38 @@ final class DbDumper {
|
||||
* 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.
|
||||
*
|
||||
* @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 {
|
||||
$sm = $this->db->createSchemaManager();
|
||||
$existing = array_flip($sm->listTableNames());
|
||||
public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null, bool $skipMissing = false): int {
|
||||
$report = ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []];
|
||||
$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']));
|
||||
$report['created'][] = $table;
|
||||
} else {
|
||||
$report['missing'][] = $table;
|
||||
}
|
||||
}
|
||||
$total = 0;
|
||||
$report = ['skipped' => [], 'restored' => []];
|
||||
$foreignKeys = [];
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$foreignKeys = $this->suspendForeignKeys();
|
||||
foreach ($manifest['tables'] as $table => $info) {
|
||||
if (!isset($existing[$table])) {
|
||||
throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)");
|
||||
if (in_array($table, $report['missing'], true)) {
|
||||
continue;
|
||||
}
|
||||
// Unchanged since the backup: leave it alone (much faster than delete + insert).
|
||||
$live = $this->checksum($table);
|
||||
@@ -193,14 +217,204 @@ final class DbDumper {
|
||||
$report['restored'][] = $table;
|
||||
}
|
||||
$this->restoreSequences($manifest['sequences'] ?? []);
|
||||
$this->resumeForeignKeys($foreignKeys);
|
||||
$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) {
|
||||
$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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
private static function tableFromStructure(string $name, array $structure): \Doctrine\DBAL\Schema\Table {
|
||||
$table = new \Doctrine\DBAL\Schema\Table($name);
|
||||
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. */
|
||||
public function checksum(string $table): array {
|
||||
$hash = hash_init('sha256');
|
||||
|
||||
+79
-7
@@ -30,19 +30,91 @@ final class DbIndexCache implements IIndexCache {
|
||||
}
|
||||
|
||||
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->insert('ngb_index')->values([
|
||||
'repository_id' => $qb->createNamedParameter($repositoryId),
|
||||
'pack_id' => $qb->createNamedParameter($packId),
|
||||
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
|
||||
]);
|
||||
try {
|
||||
$qb->executeStatement();
|
||||
} catch (Exception $e) {
|
||||
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
public function remove(string $repositoryId, array $packIds): void {
|
||||
|
||||
@@ -17,6 +17,36 @@ final class RunMapper extends QBMapper {
|
||||
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 {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
public function startedSince(int $targetId, string $kind, int $since): array {
|
||||
$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['stats'] = $stats;
|
||||
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';
|
||||
}
|
||||
return $state;
|
||||
}
|
||||
|
||||
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'],
|
||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
||||
'warnings' => self::warnings($state)] + $state['meta'],
|
||||
(function () use ($repo, $segments) {
|
||||
foreach ($segments as $blobs) {
|
||||
yield from $repo->readLines($blobs);
|
||||
}
|
||||
})());
|
||||
$repo->readLines(array_values(array_merge(...$state['segments']))));
|
||||
$state['phase'] = 'commit';
|
||||
}
|
||||
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
|
||||
* 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.'));
|
||||
break;
|
||||
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.'));
|
||||
break;
|
||||
case 'security_event':
|
||||
@@ -59,6 +62,7 @@ final class Notifier implements INotifier {
|
||||
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
||||
'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_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'] ?? '']),
|
||||
'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'] ?? '']),
|
||||
|
||||
@@ -28,6 +28,8 @@ final class BlobIndex {
|
||||
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
|
||||
private ?array $heldCacheWrites = null;
|
||||
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) */
|
||||
public int $downloaded = 0;
|
||||
|
||||
@@ -87,47 +89,75 @@ final class BlobIndex {
|
||||
* 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.
|
||||
*/
|
||||
public function load(bool $verify = false): void {
|
||||
public function load(bool $verify = false, ?callable $progress = null): void {
|
||||
$this->blobs = [];
|
||||
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||
$present = [];
|
||||
$this->downloaded = 0;
|
||||
$order = []; // pack ids in listing order: where a blob is in two packs, the last listed wins
|
||||
$fetch = [];
|
||||
foreach ($this->backend->listFiles('index') as $path) {
|
||||
$packId = basename($path);
|
||||
$present[$packId] = true;
|
||||
if (isset($cached[$packId])) {
|
||||
$entries = $cached[$packId];
|
||||
} else {
|
||||
$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']];
|
||||
$order[] = $packId;
|
||||
if (!isset($cached[$packId])) {
|
||||
$fetch[] = $path;
|
||||
}
|
||||
}
|
||||
if ($verify && $this->cache !== null) {
|
||||
// Refresh the cache from what was just verified.
|
||||
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
|
||||
foreach ($this->packs() as $packId => $_) {
|
||||
$entries = [];
|
||||
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
|
||||
if ($pack === $packId) {
|
||||
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
|
||||
}
|
||||
// The rest is downloaded several at a time where the location allows it: on a large
|
||||
// repository these are tens of thousands of small files. getMany() keeps their order, so
|
||||
// the cached packs listed before each one are added first.
|
||||
$next = 0;
|
||||
$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;
|
||||
}
|
||||
if ($toCache !== []) {
|
||||
$this->cache?->putMany($this->repositoryId, $toCache);
|
||||
}
|
||||
$stale = array_keys(array_diff_key($cached, $present));
|
||||
if ($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 {
|
||||
return isset($this->blobs[$id]) || isset($this->pending[$id]);
|
||||
}
|
||||
@@ -181,6 +211,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). */
|
||||
public function storedBytes(): int {
|
||||
$total = 0;
|
||||
|
||||
@@ -15,6 +15,20 @@ interface IIndexCache {
|
||||
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||
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 */
|
||||
public function remove(string $repositoryId, array $packIds): void;
|
||||
}
|
||||
|
||||
@@ -28,11 +28,21 @@ final class PackReaderGuard implements IBackend {
|
||||
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 exists(string $path): bool {
|
||||
$this->reader->close();
|
||||
return $this->inner->exists($path);
|
||||
|
||||
+174
-22
@@ -28,8 +28,14 @@ final class Repository {
|
||||
public const FORMAT = 1;
|
||||
public const BLOB_SIZE = 4 * 1048576;
|
||||
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). */
|
||||
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 BlobIndex $index;
|
||||
@@ -37,6 +43,8 @@ final class Repository {
|
||||
private ?int $maxBytes = null;
|
||||
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 IBackend $backend,
|
||||
@@ -73,7 +81,8 @@ final class Repository {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
try {
|
||||
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
|
||||
@@ -87,7 +96,7 @@ final class Repository {
|
||||
if ($cache !== null) {
|
||||
$repo->index->setCache($cache, $config['id']);
|
||||
}
|
||||
$repo->index->load();
|
||||
$repo->index->load(false, $progress);
|
||||
$repo->loadCatalog($anchor);
|
||||
return $repo;
|
||||
}
|
||||
@@ -286,8 +295,8 @@ final class Repository {
|
||||
*/
|
||||
public function readLines(array $blobIds): \Generator {
|
||||
$carry = '';
|
||||
foreach ($blobIds as $id) {
|
||||
$data = $carry . $this->loadBlob($id);
|
||||
foreach ($this->loadBlobs($blobIds) as $blob) {
|
||||
$data = $carry . $blob;
|
||||
$lines = explode("\n", $data);
|
||||
$carry = array_pop($lines);
|
||||
yield from $lines;
|
||||
@@ -699,7 +708,7 @@ final class Repository {
|
||||
}
|
||||
$flags = 0;
|
||||
$payload = $data;
|
||||
$deflated = gzdeflate($data, 6);
|
||||
$deflated = self::worthDeflating($data) ? gzdeflate($data, 6) : false;
|
||||
if ($deflated !== false && strlen($deflated) < strlen($data) * 0.95) {
|
||||
$payload = $deflated;
|
||||
$flags |= self::FLAG_DEFLATE;
|
||||
@@ -709,9 +718,102 @@ final class Repository {
|
||||
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]) {
|
||||
yield $this->decodeBlob($id, substr($data, $at, $length));
|
||||
$done++;
|
||||
}
|
||||
}
|
||||
} catch (BackendException $e) {
|
||||
// A pack moved (see loadBlob()): the rest one blob at a time, from the fresh index.
|
||||
$this->refreshIndex($e);
|
||||
foreach (array_slice($ids, $done) as $id) {
|
||||
yield $this->loadBlob($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadBlob(string $id): string {
|
||||
[$pack, $offset, $length] = $this->index->get($id);
|
||||
return $this->decodeBlob($id, $this->packReader->read($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). Reload
|
||||
// the index from the location; read again only if the blob has moved.
|
||||
$this->refreshIndex($e);
|
||||
[$moved, $offset, $length] = $this->index->get($id);
|
||||
if ($moved === $pack) {
|
||||
throw $e;
|
||||
}
|
||||
$encrypted = $this->packReader->read($moved, $offset, $length);
|
||||
}
|
||||
return $this->decodeBlob($id, $encrypted);
|
||||
}
|
||||
|
||||
/** Reload the index from the location after a failed pack read; rethrows that failure if it cannot. */
|
||||
private function refreshIndex(BackendException $cause): void {
|
||||
try {
|
||||
$this->index->load();
|
||||
} catch (\Throwable) {
|
||||
throw $cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */
|
||||
@@ -750,17 +852,23 @@ final class Repository {
|
||||
* referenced by more than one file is only checked once; a missing database manifest is a
|
||||
* failure, not something to silently skip over.
|
||||
*
|
||||
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
|
||||
* callers use it to keep a time-boxed lease alive during a large audit
|
||||
* @param ?callable(array{phase:string, done:int, total:?int, blobs?:int, bytes?:int}):void $heartbeat
|
||||
* 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}
|
||||
*/
|
||||
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' => ''];
|
||||
$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) {
|
||||
$stats['filesChecked']++;
|
||||
foreach ($entry['b'] as $id) {
|
||||
@@ -768,11 +876,11 @@ final class Repository {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
||||
$heartbeat();
|
||||
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
|
||||
$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 (!$this->backend->exists($meta['db'])) {
|
||||
$stats['missing'][] = $meta['db'];
|
||||
@@ -784,8 +892,9 @@ final class Repository {
|
||||
continue;
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
||||
$heartbeat();
|
||||
$this->verifyBlob($id, $deep, $stats, $packSeen, $listedDirs);
|
||||
$heartbeat(['phase' => 'database', 'done' => $stats['filesChecked'], 'total' => $filesTotal,
|
||||
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,6 +902,40 @@ final class Repository {
|
||||
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
|
||||
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
|
||||
@@ -803,9 +946,11 @@ final class Repository {
|
||||
if (!$this->isRecordedUserExport($manifestPath)) {
|
||||
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' => ''];
|
||||
$seen = [];
|
||||
$packSeen = [];
|
||||
@@ -821,20 +966,27 @@ final class Repository {
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$this->verifyBlob($id, $deep, $stats, $packSeen);
|
||||
$heartbeat();
|
||||
$heartbeat(['phase' => 'files', 'done' => $stats['filesChecked'], 'total' => null,
|
||||
'blobs' => $stats['blobsChecked'], 'bytes' => $stats['bytesChecked']]);
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
$stats['missing'][] = $id;
|
||||
return;
|
||||
}
|
||||
[$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) {
|
||||
$stats['missing'][] = $id;
|
||||
return;
|
||||
|
||||
@@ -104,7 +104,7 @@ final class AlertService {
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, $key, 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 {
|
||||
|
||||
+205
-23
@@ -14,9 +14,12 @@ use OCA\NgBackup\Db\Snapshot;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Job\BackupRun;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCA\NgBackup\Repository\RepositoryException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
@@ -32,6 +35,9 @@ use Psr\Log\LoggerInterface;
|
||||
final class BackupService {
|
||||
public const KIND_FULL = 'full';
|
||||
|
||||
/** @var \WeakMap<Repository, string> the location settings each kept repository was opened with */
|
||||
private \WeakMap $openedWith;
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
@@ -45,6 +51,9 @@ final class BackupService {
|
||||
private AlertService $alerts,
|
||||
private LeaseService $leases,
|
||||
) {
|
||||
/** @var \WeakMap<Repository, string> $opened */
|
||||
$opened = new \WeakMap();
|
||||
$this->openedWith = $opened;
|
||||
}
|
||||
|
||||
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 {
|
||||
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||
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
|
||||
* 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.
|
||||
$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 {
|
||||
$runLease = $this->leases->acquire('run/' . $run->getId(), LeaseService::EXCLUSIVE, $ttl);
|
||||
} catch (LockedException) {
|
||||
@@ -118,13 +179,28 @@ final class BackupService {
|
||||
$this->leases->release($runLease);
|
||||
return $run;
|
||||
}
|
||||
$loadedAt = null;
|
||||
$savedBefore = $run->getUpdatedAt();
|
||||
try {
|
||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||
return $run;
|
||||
}
|
||||
$loadedAt = $run->getUpdatedAt();
|
||||
$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);
|
||||
|
||||
if ($run->getPhase() === 'db') {
|
||||
@@ -147,7 +223,11 @@ final class BackupService {
|
||||
$state['dump'] = $dump;
|
||||
}
|
||||
} else {
|
||||
$state['files']['estimate'] ??= $this->estimateBytes($run, $state['files']);
|
||||
$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') {
|
||||
// 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.
|
||||
@@ -162,14 +242,20 @@ final class BackupService {
|
||||
}
|
||||
if ($state['files']['phase'] === 'done') {
|
||||
$this->recordSnapshot($run, $state['files']);
|
||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||
$stats = $state['files']['stats'];
|
||||
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
||||
$warnings = BackupRun::warnings($state['files']);
|
||||
$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);
|
||||
// The snapshot is committed: what follows only reports on it and must not turn
|
||||
// the run into a failure.
|
||||
try {
|
||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||
$stats = $state['files']['stats'];
|
||||
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
||||
$warnings = BackupRun::warnings($state['files']);
|
||||
$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->setPhase('done');
|
||||
@@ -178,15 +264,33 @@ final class BackupService {
|
||||
}
|
||||
$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->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
// Another process may have taken the run over after this step's lease expired: then its
|
||||
// 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) {
|
||||
$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->setError($e->getMessage());
|
||||
$run->setFinishedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
$run = $this->runs->update($run);
|
||||
$failed = $this->runs->updateIfUnchanged($run, $loadedAt);
|
||||
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 {
|
||||
$this->alerts->runFailed($run, isset($target) ? $target->getName() : '#' . $run->getTargetId());
|
||||
} catch (\Throwable) {
|
||||
@@ -200,14 +304,16 @@ final class BackupService {
|
||||
|
||||
/** Run to completion in this process (occ, system cron), reporting progress. */
|
||||
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) {
|
||||
$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) {
|
||||
// Step was not possible (another process holds the run, or a crashed step's lease has
|
||||
// not expired yet): wait instead of spinning.
|
||||
sleep(5);
|
||||
$run = $this->runs->find($run->getId());
|
||||
$repo = null; // another process may have stored packs in the meantime
|
||||
}
|
||||
if ($progress !== null) {
|
||||
$progress($run);
|
||||
@@ -216,14 +322,78 @@ final class BackupService {
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function cancel(Run $run): Run {
|
||||
$run->setStatus(Run::CANCELLED);
|
||||
$run->setFinishedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
/**
|
||||
* Stop a running backup. First no new step may start, then this waits (up to $wait seconds)
|
||||
* for the step in progress, so a step that is recording the snapshot finishes first. A process
|
||||
* running the backup then finds it cancelled and stops. The packs it stored stay until the
|
||||
* 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 {
|
||||
try {
|
||||
$this->snapshots->findOne($run->getTargetId(), $files['snapshot']);
|
||||
return; // already recorded, by an earlier attempt at this step
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
$s = new Snapshot();
|
||||
$s->setTargetId($run->getTargetId());
|
||||
$s->setSnapshotId($files['snapshot']);
|
||||
@@ -232,7 +402,19 @@ final class BackupService {
|
||||
$s->setCreatedAt(time());
|
||||
$s->setFiles($files['stats']['files']);
|
||||
$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> */
|
||||
|
||||
@@ -31,6 +31,11 @@ final class KeyService {
|
||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||
private const KEY_KIT_CODE = 'recovery_kit_code';
|
||||
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;
|
||||
|
||||
@@ -56,9 +61,7 @@ final class KeyService {
|
||||
if ($this->isInitialized()) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
if (strlen($passphrase) < 12) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||
}
|
||||
self::checkPassphrase($passphrase);
|
||||
$keys = KeyRing::generate();
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||
@@ -234,8 +237,8 @@ final class KeyService {
|
||||
}
|
||||
|
||||
private static function checkPassphrase(string $passphrase): void {
|
||||
if (strlen($passphrase) < 12) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||
if (strlen($passphrase) < self::MIN_PASSPHRASE) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least ' . self::MIN_PASSPHRASE . ' characters');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,34 +43,55 @@ 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
|
||||
* app that was enabled at backup time already installed here (its tables must exist before
|
||||
* the database restore can write into them).
|
||||
* Whether this installation can receive $snapshotId, all problems at once:
|
||||
* - the same Nextcloud major version is required;
|
||||
* - 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 {
|
||||
$meta = $this->targets->repository($target)->snapshotMeta($snapshotId);
|
||||
public function checkCompatibility(Target $target, string $snapshotId, bool $skipMissingTables = false): array {
|
||||
$repo = $this->targets->repository($target);
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
$currentMajor = explode('.', $this->config->getSystemValueString('version'))[0];
|
||||
$snapshotMajor = explode('.', (string)($meta['nextcloud'] ?? ''))[0];
|
||||
$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 [
|
||||
'ok' => $currentMajor === $snapshotMajor && $missingApps === [],
|
||||
'ok' => $currentMajor === $snapshotMajor && $prefixMismatch === null && ($missingTables === [] || $skipMissingTables),
|
||||
'currentMajor' => $currentMajor,
|
||||
'snapshotMajor' => $snapshotMajor,
|
||||
'missingApps' => $missingApps,
|
||||
'missingTables' => $prefixMismatch === null ? $missingTables : [],
|
||||
'prefixMismatch' => $prefixMismatch,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */
|
||||
public function restoreFull(Target $target, string $snapshotId): array {
|
||||
$check = $this->checkCompatibility($target, $snapshotId);
|
||||
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>} */
|
||||
public function restoreFull(Target $target, string $snapshotId, bool $skipMissingTables = false): array {
|
||||
$check = $this->checkCompatibility($target, $snapshotId, $skipMissingTables);
|
||||
if (!$check['ok']) {
|
||||
throw new \RuntimeException($this->describeBlockers($check));
|
||||
}
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId) {
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $skipMissingTables, $check) {
|
||||
$beat = LeaseService::throttled($refresh);
|
||||
$repo = $this->targets->repository($target);
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
@@ -102,7 +123,7 @@ final class RestoreFullService {
|
||||
$ownAppConfig = $this->preserveOwnAppConfig();
|
||||
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
||||
try {
|
||||
$dumper->restore($repo, $manifest, $dbReport, $beat);
|
||||
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||
} finally {
|
||||
$this->restoreOwnAppConfig($ownAppConfig);
|
||||
}
|
||||
@@ -125,8 +146,8 @@ final class RestoreFullService {
|
||||
);
|
||||
}
|
||||
|
||||
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
||||
'dbConsistent' => (bool)($manifest['consistent'] ?? true)];
|
||||
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
||||
'dbConsistent' => (bool)($manifest['consistent'] ?? true), 'missingApps' => $check['missingApps']];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,11 +230,15 @@ final class RestoreFullService {
|
||||
if ($check['currentMajor'] !== $check['snapshotMajor']) {
|
||||
$parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)";
|
||||
}
|
||||
if ($check['missingApps'] !== []) {
|
||||
$parts[] = 'install these apps first (their tables must exist before the database restore can use them): '
|
||||
. implode(', ', array_map(fn (string $a) => "occ app:install $a", $check['missingApps']));
|
||||
if (($check['prefixMismatch'] ?? null) !== null) {
|
||||
[$source, $here] = $check['prefixMismatch'];
|
||||
$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 {
|
||||
|
||||
+112
-19
@@ -17,6 +17,7 @@ use OCA\NgBackup\Db\TargetMapper;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\Files\Storage\IStorage;
|
||||
use OCP\Security\ICrypto;
|
||||
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 {
|
||||
return $this->factory()->describeBackends();
|
||||
}
|
||||
@@ -47,27 +48,16 @@ final class TargetService {
|
||||
* installation's key), then save it.
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||
}
|
||||
// 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) {
|
||||
}
|
||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null): array {
|
||||
$this->validateName($name);
|
||||
$this->requireUsableBackend($backend);
|
||||
$keys = $this->keys->keyRing();
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
||||
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;
|
||||
} else {
|
||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
||||
@@ -97,6 +87,103 @@ final class TargetService {
|
||||
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
|
||||
* ng_backup's key, so changing that secret (occ backup:restore:full merging config.php)
|
||||
@@ -173,6 +260,11 @@ final class TargetService {
|
||||
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
||||
public function remove(Target $target): void {
|
||||
$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 {
|
||||
@@ -193,8 +285,9 @@ final class TargetService {
|
||||
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 {
|
||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath, bool $createBase = true): StorageBackend {
|
||||
$create = fn (): IStorage => $this->factory()->create($backend, $auth, $options);
|
||||
return new StorageBackend($create(), $basePath, $createBase, $create);
|
||||
}
|
||||
|
||||
private ?ExternalStorageFactory $factory = null;
|
||||
|
||||
+123
-13
@@ -7,42 +7,152 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCP\IAppConfig;
|
||||
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 {
|
||||
public const PROGRESS_SECONDS = 30;
|
||||
private const KEY_PREFIX = 'verify_progress_';
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
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 {
|
||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
|
||||
/**
|
||||
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
|
||||
* @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 {
|
||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
|
||||
/**
|
||||
* @param ?callable(array):void $onProgress called with the stored progress every PROGRESS_SECONDS
|
||||
* @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;
|
||||
$state = ['what' => $what, 'deep' => $deep, 'status' => 'running', 'started' => time(), 'updated' => time(),
|
||||
'pid' => getmypid(), 'phase' => 'starting', 'done' => 0, 'total' => null, 'blobs' => 0, 'bytes' => 0];
|
||||
try {
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $deep, $ttl, $fn): array {
|
||||
$last = time();
|
||||
$heartbeat = function () use ($refresh, $ttl, &$last): void {
|
||||
if (time() - $last >= intdiv($ttl, 2)) {
|
||||
$this->store($target, $state);
|
||||
} catch (\Throwable) {
|
||||
// progress reports are a convenience; the check itself goes ahead
|
||||
}
|
||||
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();
|
||||
$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);
|
||||
});
|
||||
} 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');
|
||||
} 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>
|
||||
</file>
|
||||
<file src="lib/Db/DbDumper.php">
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$info['sha256']]]></code>
|
||||
<code><![CDATA[$manifest['sequences']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MissingClosureReturnType>
|
||||
<code><![CDATA[fn ($c) => $end[array_search($c, $columns, true)]]]></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[TransactionIsolationLevel]]></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\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>
|
||||
@@ -218,16 +225,11 @@
|
||||
</PropertyNotSetInConstructor>
|
||||
</file>
|
||||
<file src="lib/Job/BackupRun.php">
|
||||
<ArgumentTypeCoercion>
|
||||
<code><![CDATA[$blobs]]></code>
|
||||
</ArgumentTypeCoercion>
|
||||
<MissingParamType>
|
||||
<code><![CDATA[$fh]]></code>
|
||||
</MissingParamType>
|
||||
<PossiblyUndefinedArrayOffset>
|
||||
<code><![CDATA[$mtime]]></code>
|
||||
<code><![CDATA[$state['phase']]]></code>
|
||||
<code><![CDATA[$state['stats']]]></code>
|
||||
</PossiblyUndefinedArrayOffset>
|
||||
</file>
|
||||
<file src="lib/Job/ParentCursor.php">
|
||||
@@ -395,7 +397,7 @@
|
||||
</file>
|
||||
<file src="lib/Service/BackupService.php">
|
||||
<InvalidOperand>
|
||||
<code><![CDATA[$deadline - microtime(true) + 120]]></code>
|
||||
<code><![CDATA[$deadline - microtime(true) + 600]]></code>
|
||||
</InvalidOperand>
|
||||
<UndefinedClass>
|
||||
<code><![CDATA[\OC]]></code>
|
||||
@@ -443,6 +445,7 @@
|
||||
</EmptyArrayAccess>
|
||||
<UndefinedInterfaceMethod>
|
||||
<code><![CDATA[getInner]]></code>
|
||||
<code><![CDATA[getInner]]></code>
|
||||
</UndefinedInterfaceMethod>
|
||||
<UnresolvableInclude>
|
||||
<code><![CDATA[include $path]]></code>
|
||||
@@ -474,12 +477,6 @@
|
||||
</RedundantCastGivenDocblockType>
|
||||
</file>
|
||||
<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>
|
||||
<code><![CDATA[Server::get(\OCA\Files_External\Service\BackendService::class)]]></code>
|
||||
</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 |
+23
-2
@@ -6,6 +6,10 @@
|
||||
<div class="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.')">
|
||||
<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" />
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
@@ -22,7 +26,7 @@
|
||||
</template>
|
||||
<template v-else-if="status.key.everConfirmed">
|
||||
<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" />
|
||||
</template>
|
||||
<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 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 allRuns = ref(false) // runs older than the last days too
|
||||
const error = ref('')
|
||||
const tab = ref('settings')
|
||||
let tabInitialized = false
|
||||
let timer = null
|
||||
|
||||
function showAllRuns(all) {
|
||||
allRuns.value = all
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await api.status()
|
||||
status.value = await api.status(allRuns.value)
|
||||
error.value = ''
|
||||
// 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.
|
||||
@@ -79,6 +92,14 @@ onBeforeUnmount(() => clearTimeout(timer))
|
||||
.ng-backup :deep(.settings-section) {
|
||||
max-width: 900px;
|
||||
}
|
||||
.ngb-feedback {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ngb-feedback a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ngb-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
+3
-1
@@ -16,7 +16,7 @@ async function call(method, path, data) {
|
||||
}
|
||||
|
||||
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 }),
|
||||
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
|
||||
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
|
||||
@@ -27,6 +27,8 @@ export default {
|
||||
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
|
||||
backends: () => call('get', '/backends'),
|
||||
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}`),
|
||||
testTarget: (id) => call('post', `/targets/${id}/test`),
|
||||
setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }),
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
|
||||
<ul>
|
||||
<li v-for="item in trash[target.id]" :key="item.id">
|
||||
{{ item.time ? new Date(item.time).toLocaleString() : 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>
|
||||
{{ item.time ? formatDateTime(item.time) : item.id }}{{ item.label ? ' – ' + item.label : '' }}
|
||||
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: formatDate(item.deleteAfter) }) }}</span>
|
||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -40,39 +40,51 @@
|
||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||
<th>{{ t('ng_backup', 'Status') }}</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>{{ run.kind }}</td>
|
||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (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>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + percent(run) + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ 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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { formatSize } from '../format.js'
|
||||
import { formatDateTime, formatDate, formatSize } from '../format.js'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['changed', 'show-all-runs'])
|
||||
const schedule = ref(props.status.schedule)
|
||||
const error = ref('')
|
||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
||||
@@ -101,6 +113,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 targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<!-- 1. Create the key -->
|
||||
<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)')" />
|
||||
<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"
|
||||
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
|
||||
<NcTextField v-model="delay" type="number" min="1" max="365"
|
||||
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
|
||||
: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') }}
|
||||
</NcButton>
|
||||
</form>
|
||||
@@ -35,7 +35,7 @@
|
||||
<tr v-for="slot in status.key.slots" :key="slot.slot">
|
||||
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</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">
|
||||
<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>
|
||||
@@ -46,10 +46,10 @@
|
||||
<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">
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -69,7 +69,7 @@
|
||||
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
|
||||
</div>
|
||||
<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>
|
||||
<span class="ngb-muted">{{ t('ng_backup', 'Requires your password; all administrators are notified.') }}</span>
|
||||
</p>
|
||||
@@ -90,6 +90,7 @@ import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import api from '../api.js'
|
||||
import { formatDateTime, formatDate } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
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 }}
|
||||
</span>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<NcButton v-if="!adding" @click="startAdd">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||
<form v-else class="ngb-form" @submit.prevent="add">
|
||||
<NcButton v-if="!adding" @click="startAdd()">{{ t('ng_backup', 'Add location') }}</NcButton>
|
||||
<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')" />
|
||||
<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" />
|
||||
@@ -32,8 +36,8 @@
|
||||
<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.')" />
|
||||
<div class="ngb-row">
|
||||
<NcButton type="submit" variant="primary" :disabled="busy || !form.name || !backend || !auth">{{ t('ng_backup', 'Connect and add') }}</NcButton>
|
||||
<NcButton @click="adding = false">{{ t('ng_backup', 'Cancel') }}</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; editing = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
@@ -41,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
@@ -58,6 +62,7 @@ defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const adding = ref(false)
|
||||
const editing = ref(null) // the target being edited, or null while adding
|
||||
const busy = ref(false)
|
||||
const error = 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) }))
|
||||
const fields = computed(() => [...toFields(backend.value?.parameters), ...toFields(auth.value?.parameters)])
|
||||
|
||||
let prefilling = false
|
||||
watch(backend, (b) => {
|
||||
if (prefilling) {
|
||||
return
|
||||
}
|
||||
auth.value = b?.auth?.[0] ?? null
|
||||
form.options = {}
|
||||
})
|
||||
|
||||
async function startAdd() {
|
||||
adding.value = true
|
||||
error.value = ''
|
||||
async function loadBackends() {
|
||||
if (!backends.value.length) {
|
||||
try {
|
||||
backends.value = await api.backends()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
backends.value = await api.backends()
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
busy.value = true
|
||||
async function startAdd() {
|
||||
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 = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
|
||||
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
|
||||
await loadBackends()
|
||||
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
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', path: 'ng_backup', options: {}, maxGb: '' })
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
error.value = e?.message || ''
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import '@nextcloud/password-confirmation/style.css'
|
||||
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 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') },
|
||||
]
|
||||
|
||||
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 restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
||||
const crumbs = computed(() => {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// 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) {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return ''
|
||||
|
||||
@@ -231,6 +231,93 @@ class SftpReadStreamTest extends TestCase {
|
||||
$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 {
|
||||
@@ -379,6 +466,10 @@ final class FakeSftpConnection {
|
||||
public array $reads = [];
|
||||
/** @var array<string, string> */
|
||||
public array $openHandles = [];
|
||||
/** 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;
|
||||
@@ -393,9 +484,28 @@ final class FakeSftpConnection {
|
||||
/** 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;
|
||||
}
|
||||
@@ -409,6 +519,10 @@ final class FakeSftpConnection {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -432,7 +546,7 @@ final class FakeSftpConnection {
|
||||
}
|
||||
$handle = substr($data, 4, unpack('N', $data)[1]);
|
||||
unset($this->openHandles[$handle]);
|
||||
$this->answers[$id] = [101, pack('N', 0)];
|
||||
$this->answers[$id] = [101, pack('N', $this->closeStatus)];
|
||||
break;
|
||||
case 5: // READ
|
||||
$len = unpack('N', $data)[1];
|
||||
@@ -454,7 +568,8 @@ final class FakeSftpConnection {
|
||||
$this->answers[$id] = [101, pack('N', 1)]; // FX_EOF
|
||||
} else {
|
||||
$chunk = substr($file, $offset, min($n, $this->cap));
|
||||
$this->answers[$id] = [103, pack('Na*', strlen($chunk), $chunk)];
|
||||
$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;
|
||||
|
||||
@@ -10,6 +10,8 @@ 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;
|
||||
@@ -54,4 +56,58 @@ class StorageBackendListTest extends TestCase {
|
||||
$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,146 @@
|
||||
<?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 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;
|
||||
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) {
|
||||
$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));
|
||||
}
|
||||
|
||||
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 {
|
||||
$src = $this->tempDir();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ class IndexCacheTest extends TestCase {
|
||||
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); }
|
||||
};
|
||||
$src = $this->tempDir();
|
||||
for ($i = 0; $i < 12; $i++) {
|
||||
@@ -52,6 +54,24 @@ class IndexCacheTest extends TestCase {
|
||||
$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 {
|
||||
@@ -65,6 +85,8 @@ class IndexCacheTest extends TestCase {
|
||||
$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();
|
||||
@@ -88,4 +110,38 @@ class IndexCacheTest extends TestCase {
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ class PackReadTest extends TestCase {
|
||||
}
|
||||
|
||||
final class PackOpenCountingBackend implements IBackend {
|
||||
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $gets = [];
|
||||
/** @var list<bool> */
|
||||
|
||||
@@ -88,6 +88,33 @@ class PruneTest extends TestCase {
|
||||
$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 {
|
||||
$src = $this->tempDir();
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
|
||||
@@ -66,6 +66,20 @@ class RepositoryTest extends TestCase {
|
||||
$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 {
|
||||
$repo = Repository::init($this->backend, 'pw');
|
||||
$r = $repo->backupDirectory($this->src);
|
||||
|
||||
@@ -78,6 +78,107 @@ class VerifyTest extends TestCase {
|
||||
$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 {
|
||||
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||
$s = $repo->backupDirectory($this->src);
|
||||
@@ -131,6 +232,8 @@ class VerifyTest extends TestCase {
|
||||
|
||||
/** Delegates to a real backend, but reading a pack throws for the first $failures reads. */
|
||||
final class FlakyRangeBackend implements IBackend {
|
||||
use \OCA\NgBackup\Backend\GetsOneByOne;
|
||||
|
||||
public function __construct(
|
||||
private IBackend $inner,
|
||||
private int $failures,
|
||||
@@ -181,3 +284,62 @@ final class FlakyRangeBackend implements IBackend {
|
||||
$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 {
|
||||
$this->inner->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
# 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
|
||||
# location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7.
|
||||
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag>
|
||||
# e.g. full-restore-roundtrip.sh 35-apache
|
||||
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql]
|
||||
# e.g. full-restore-roundtrip.sh 35-apache pgsql
|
||||
# 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
|
||||
IMAGE="nextcloud:${1:-35-apache}"
|
||||
DB="${2:-sqlite}"
|
||||
APP=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
NET=ngb-frt-net
|
||||
SHARED=$(mktemp -d)
|
||||
@@ -14,7 +18,7 @@ fail() { echo "FAIL: $*"; exit 1; }
|
||||
ok() { echo "ok $*"; }
|
||||
|
||||
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 >/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
|
||||
rmdir "$SHARED" 2>/dev/null || true
|
||||
@@ -24,14 +28,33 @@ trap cleanup EXIT
|
||||
|
||||
run_on() { docker exec -u www-data "$1" php occ "${@:2}"; }
|
||||
|
||||
# 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 run -d --name ngb-frt-origin --network "$NET" -e SQLITE_DATABASE=nextcloud \
|
||||
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 \
|
||||
-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-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
|
||||
run_on ngb-frt-origin app:enable ng_backup >/dev/null
|
||||
ok "origin instance up"
|
||||
ok "origin instance up ($DB)"
|
||||
|
||||
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"
|
||||
@@ -45,6 +68,31 @@ 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 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"
|
||||
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
|
||||
run_on ngb-frt-origin app:install mail >/dev/null || fail "install mail on the origin"
|
||||
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
|
||||
run_on ngb-frt-origin app:install deck >/dev/null || fail "install deck on the origin"
|
||||
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)
|
||||
SNAP=$(printf '%s\n' "$RUN_OUT" | head -1 | awk -F'snapshot ' '{print $2}' | tr -d ',')
|
||||
[ -n "$SNAP" ] || fail "no snapshot id from backup:run"
|
||||
@@ -52,7 +100,9 @@ ok "test data backed up, snapshot $SNAP"
|
||||
|
||||
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 run -d --name ngb-frt-restore --network "$NET" -e SQLITE_DATABASE=nextcloud \
|
||||
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 \
|
||||
-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
|
||||
@@ -68,7 +118,11 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=
|
||||
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
||||
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"
|
||||
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction --install-apps 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"
|
||||
}
|
||||
ok "backup:restore:full completed"
|
||||
|
||||
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
||||
@@ -80,7 +134,16 @@ docker exec ngb-frt-restore test -f /var/www/html/data/frtest/files/Docs/note.tx
|
||||
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after restore"
|
||||
ok "database, files and ng_backup's own state are all intact and usable after the restore"
|
||||
|
||||
if [ "$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 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
|
||||
|
||||
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"
|
||||
|
||||
echo "FULL RESTORE ROUNDTRIP PASS"
|
||||
echo "FULL RESTORE ROUNDTRIP PASS ($DB)"
|
||||
|
||||
@@ -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