Compare commits
246 Commits
v0.1.0-alpha.1
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c6fdaad487 | |||
| 6ae1b2277f | |||
| 2a72acb70b | |||
| fb8e8d0694 | |||
| 042fdae686 | |||
| 99f1655d04 | |||
| 373d7e0424 | |||
| c5024d3ddc | |||
| 345b1d545e | |||
| 98067f7746 | |||
| cd951af1a1 | |||
| cf9cf50286 | |||
| 2b21596b3a | |||
| 953312bf44 | |||
| 569077fa69 | |||
| 51a4a67bc4 | |||
| a8b5e85c9b | |||
| 36f2427e9a | |||
| e58b86fc48 | |||
| 193f65940b | |||
| 6540a9314a | |||
| f81c5e13e9 | |||
| 1b621b7149 | |||
| 1736005648 | |||
| c7a651f269 | |||
| 8897d8074f | |||
| 0b9fa1ae92 | |||
| ee406c481e | |||
| dd2e1af997 | |||
| d96229f370 | |||
| b14b16c66c | |||
| ce40053cec | |||
| 46e0186f35 | |||
| d5eb7abbff | |||
| 4c973d5cd8 | |||
| e9592cb6c6 | |||
| b86b07a49c | |||
| ecae6e9cdd | |||
| d36c40e77b | |||
| 50c2636926 | |||
| d1e9a53ce5 | |||
| b625fe7666 | |||
| 74bfa7052f | |||
| bf48d339e4 | |||
| 029b3b9cc7 | |||
| dce93aa75e | |||
| 3ec23a3072 | |||
| 4a395c75f2 | |||
| 36a30732ad | |||
| d7c7ca093b | |||
| 05d02f3c70 | |||
| dfe1fe9aad | |||
| f0293e6d01 | |||
| f7c9894b78 | |||
| be3746e1c3 | |||
| f5473495ce | |||
| 3694184674 | |||
| a0fc91a700 | |||
| a11e6c7254 | |||
| 6f1a37f4f4 | |||
| 5cde57bc1b | |||
| 3eec2e9554 | |||
| cdf0b70e25 | |||
| 66615c2a88 | |||
| cc75c7128c | |||
| a0c4abb60a | |||
| 4847ce293d | |||
| 90dd97977a | |||
| 08534975d7 | |||
| 95e3dfcf6d | |||
| 7f4cae0e42 | |||
| 965440d1f7 | |||
| 3a0f882a38 | |||
| 632d2a2838 | |||
| 9e9234e2a8 | |||
| 3ac3298313 | |||
| 384b2d2792 | |||
| 8032411789 | |||
| b4f20cb070 | |||
| 343389b98b | |||
| 133f5f9d34 | |||
| cf4bd9db7f | |||
| 67cc350b8c | |||
| fb5b234e8a | |||
| fd12c586b6 | |||
| 6ffe979ea7 | |||
| a03b7354b3 | |||
| be1c628f07 | |||
| a8355df12e | |||
| f0537f0f2c | |||
| fd0f786f5f | |||
| 2eabdf44d5 | |||
| 61e2a28b67 | |||
| 7ed53ad0eb | |||
| bdcb4f0cae | |||
| 2616b6e543 | |||
| 9bc4e4a4ab | |||
| 827e46381e | |||
| 8eb6c4c033 | |||
| d623416306 | |||
| cd251cffb3 | |||
| 0b4872599d | |||
| 95184dadc7 | |||
| 9b6bd7d2ab | |||
| 2fcd38f478 | |||
| b7d09c9784 | |||
| 177361acae | |||
| 24686897c3 | |||
| 85ea0acc50 | |||
| ac57e31c93 | |||
| 315e83b847 | |||
| e461c48056 | |||
| d504067cd8 | |||
| c37b26ea38 | |||
| b728db36c7 | |||
| 6ed0a555cf | |||
| 221ed9dc17 | |||
| 285153a968 | |||
| 6c8a78f6bb | |||
| 0accd5a3f2 | |||
| 583592f69b | |||
| a384ab41bb | |||
| 9b85221e50 | |||
| c95f552cc6 | |||
| 6121f684e9 | |||
| 43a71684f9 | |||
| 88b719879d | |||
| 1d7f7e6423 | |||
| ccbbf01c54 | |||
| b789d17636 | |||
| 8e5c8ccda4 | |||
| e0b79939f9 | |||
| 39b4fe359b | |||
| 40e147f3a0 | |||
| 7f6e5c15f8 | |||
| 11ac30635a | |||
| 9e1378c6ab | |||
| 04e91359a0 | |||
| 46eaceb928 | |||
| 9eeaefd938 | |||
| 4f6a2e5b70 | |||
| 7b735ea941 | |||
| d8ef50732f | |||
| 5af712acc3 | |||
| 01e16199f7 | |||
| 0924da48ac | |||
| 88477a1a9f | |||
| f633de59b4 | |||
| 523620acde | |||
| bbddc83f80 | |||
| 456dffc880 | |||
| 4d423825a0 | |||
| 29a56e432b | |||
| 7f29d009f3 | |||
| 511c0fc285 | |||
| 76e8bb0245 | |||
| f32568f83c | |||
| ff8619ae6b | |||
| 629b8dd315 | |||
| c7cbc0b058 | |||
| 942e8ec003 | |||
| 144097d159 | |||
| 5b7dd08e71 | |||
| 30f93fbb8a | |||
| ca003c88ba | |||
| 9a3bb99d12 | |||
| c38cc47534 | |||
| dfd3f38d8c | |||
| 92e693cfe6 | |||
| bec070b1b5 | |||
| 89a126999f | |||
| 1b986c9d31 | |||
| 025bbfbb62 | |||
| 8ed7455619 | |||
| 30a250bc11 | |||
| 9c06ab2841 | |||
| d659322e60 | |||
| 7726c179f0 | |||
| df732f185a | |||
| b3c8068f8d | |||
| 0a9bd963fb | |||
| 379b81db02 | |||
| 828f3f142a | |||
| c146d700e8 | |||
| 8e273ac259 | |||
| 6385925845 | |||
| 894258f836 | |||
| 54750e9742 | |||
| fe92ef70bf | |||
| b3e3054737 | |||
| c775e27dd2 | |||
| d8e772137f | |||
| bcfec17cfe | |||
| b309081d1a | |||
| 5f8d5faee7 | |||
| 65f14b4380 | |||
| 4fa7cbf6ef | |||
| f4abfe0a8d | |||
| fe1a289c2d | |||
| ab3265e42c | |||
| 466cff8857 | |||
| 6bba0b59fc | |||
| a01aa16ccd | |||
| cc20b9174f | |||
| 239aa1b923 | |||
| 62766e1e71 | |||
| 9775cdc475 | |||
| a8da8cb54a | |||
| bfd54447b0 | |||
| 0b50bb6b81 | |||
| f415bab519 | |||
| 27eb0b32fc | |||
| b1d4f6a469 | |||
| fe7ad9b4b5 | |||
| 695d972785 | |||
| 972a52e665 | |||
| 7c08a10621 | |||
| 209520165e | |||
| 9554e774e5 | |||
| 8b2dca28e3 | |||
| 5742f77253 | |||
| 550249570b | |||
| 0eb23f56e7 | |||
| a0c1f1cd58 | |||
| 26cc9a897e | |||
| fe0385ad8c | |||
| b37a953cfd | |||
| b89b5b5fa6 | |||
| 16e09f4fde | |||
| 45069994e9 | |||
| 0f4a238414 | |||
| f39ca71dab | |||
| e8ecb77df5 | |||
| 70965dd4bd | |||
| cc09d74942 | |||
| eb9100a74d | |||
| 614caf9f63 | |||
| 943c95bf07 | |||
| ba7f70e0f8 | |||
| d70a869070 | |||
| c9f82a79e4 | |||
| 932f60bc5e | |||
| 935c7b6a08 | |||
| f18b4f0862 | |||
| 6cbc5d79d4 | |||
| 58711b67ef |
@@ -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
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
@@ -29,6 +31,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
@@ -37,6 +41,20 @@ jobs:
|
||||
- run: composer install --no-interaction --no-progress
|
||||
- run: composer psalm
|
||||
|
||||
frontend:
|
||||
# Builds the admin page, so a broken Vue component fails CI instead of showing up after a release.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm run build
|
||||
|
||||
integration:
|
||||
# Full round trip (key, kit, location, backup, incremental, restore modes, database dump and
|
||||
# restore, retention, trash) on a fresh Nextcloud per version and database.
|
||||
@@ -48,6 +66,8 @@ jobs:
|
||||
database: ['sqlite', 'mariadb', 'pgsql']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Round trip on Nextcloud ${{ matrix.nextcloud }} / ${{ matrix.database }}
|
||||
run: tests/integration/matrix.sh "${{ matrix.nextcloud }}" "${{ matrix.database }}"
|
||||
|
||||
@@ -58,5 +78,67 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Round trip against S3 and SFTP
|
||||
run: tests/integration/targets-matrix.sh
|
||||
|
||||
integration-user:
|
||||
# Per-user backup/restore via user_migration (occ backup:user:backup / backup:user:restore),
|
||||
# an optional dependency installed from the app store. It calls user_migration's internal
|
||||
# UserMigrationService, whose API can change between Nextcloud versions, so this runs on every
|
||||
# supported version (not the database, which the main integration job already covers).
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
nextcloud: ['32', '33', '34', '35']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up a fresh Nextcloud instance
|
||||
run: |
|
||||
docker run -d --name ngbu -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin \
|
||||
-e NEXTCLOUD_ADMIN_PASSWORD=admin-user-roundtrip -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \
|
||||
-v "$PWD:/var/www/html/custom_apps/ng_backup:ro" nextcloud:${{ matrix.nextcloud }}-apache
|
||||
for i in $(seq 1 90); do docker exec -u www-data ngbu php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done
|
||||
docker exec ngbu chown www-data:www-data /var/www/html/custom_apps
|
||||
- name: Per-user backup/restore round trip on Nextcloud ${{ matrix.nextcloud }}
|
||||
run: tests/integration/user-roundtrip.sh "docker exec -u www-data ngbu" /var/www/html/data
|
||||
|
||||
integration-full-restore:
|
||||
# Disaster recovery (occ backup:restore:full) on a SECOND, fresh Nextcloud instance that
|
||||
# never saw the first one, sharing only the backup location. Does not depend on the database
|
||||
# beyond what the main integration job already covers, so this runs once, on one NC version.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on PostgreSQL
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql legacy
|
||||
- name: Full restore of a backup made by 1.0.0-rc.1 (no table structure) on MariaDB
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql legacy
|
||||
- name: Full restore over SFTP on PostgreSQL, reading packs ahead on several connections
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql sftp
|
||||
- name: Full restore of a MariaDB backup onto PostgreSQL (ID counters after the data)
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-pgsql
|
||||
- name: Full restore of a PostgreSQL backup onto MariaDB
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-mysql
|
||||
- name: Full restore of a PostgreSQL backup onto SQLite
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql to-sqlite
|
||||
- name: Full restore of a MariaDB backup onto SQLite
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache mysql to-sqlite
|
||||
- name: Full restore of a SQLite backup onto PostgreSQL
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite to-pgsql
|
||||
- name: Full restore with a damaged database dump (--salvage)
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage
|
||||
- name: Full restore with a damaged and a missing pack (--salvage)
|
||||
run: tests/integration/full-restore-roundtrip.sh 35-apache sqlite salvage-files
|
||||
|
||||
+582
-3
@@ -2,9 +2,572 @@
|
||||
|
||||
All notable changes to NG Backup are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows the stages in
|
||||
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric (Nextcloud's
|
||||
own `info.xsd` doesn't allow a `-alpha`/`-beta`/`-rc` suffix there); the stability stage is instead
|
||||
carried by the git tag, the release, and this file.
|
||||
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric on purpose:
|
||||
the app store accepts a `-beta` suffix, but Nextcloud servers then only offer the release on the
|
||||
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.
|
||||
|
||||
Each heading starts with that `<version>`, once: the app store shows a release the section(s)
|
||||
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
|
||||
shown under 1.0.0. The git tag of a release candidate goes after the date.
|
||||
|
||||
## [1.1.0] - 2026-11-09
|
||||
|
||||
### Added
|
||||
- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it
|
||||
alone, for example the one from before an upgrade. Unpinning notifies all administrators.
|
||||
- A note on a restore point that you can change afterwards, on the restore page or with
|
||||
`occ backup:note`. Pins and notes are stored encrypted on the location, so a new server sees them.
|
||||
An older version of NG Backup can still read the location, but drops them when it writes to it.
|
||||
- A schedule per location: off, once a day, or every N hours from a given time, on the page under
|
||||
Backups or with `occ backup:target:schedule`. After the update every location keeps the time that
|
||||
held for all of them; a new location starts with the schedule of the first one. A schedule is
|
||||
shown and changed in the time zone it was saved in, which the page names next to the time.
|
||||
- All folders get their original modification time back on a restore, not only the empty ones, from
|
||||
the first backup made with this version. An older version of NG Backup can still read new restore
|
||||
points. A restore through Nextcloud into a folder that is already there leaves its time alone.
|
||||
- Heavy background jobs (Memories, Recognize, Preview Generator, full text search, the antivirus
|
||||
background scan) pause while a backup runs, at most 6 hours in a row, then they get an hour. No app
|
||||
is turned off, and a job that is running goes on. On by default; turn it off under Backups or with
|
||||
`occ backup:pause-jobs off`.
|
||||
- Disaster recovery without the recovery kit: on a new server without a backup key,
|
||||
`occ backup:target:add` finds the backups on the location and takes their key from there with one
|
||||
of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The
|
||||
deletion delay is now also stored on the location, sealed with the key, from its first backup
|
||||
with this version; a location backed up only by an older version gives the default of 7 days.
|
||||
When the settings on the location cannot be read, the longest delay (365 days) is taken, so
|
||||
nothing leaves the Bin sooner than on the old server; the command says so.
|
||||
- A passphrase change that cannot reach a location (offline at the time) now reaches it at the
|
||||
location's next backup, so a new server can open it with the new passphrase.
|
||||
- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery
|
||||
kit writes it again (for locations backed up by this version or newer). Before, the message said
|
||||
this was possible, but it was not.
|
||||
- The key page warns against creating a new key when you are restoring a server. A key made there
|
||||
by mistake can still make way for the key of the backups, as long as it was never used (no
|
||||
location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or
|
||||
`occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that
|
||||
protected a location is never replaced, and neither is a key from before this version. All
|
||||
administrators are notified of a replacement.
|
||||
|
||||
### Changed
|
||||
- The list of backups stays within the page: a long label, note or error wraps onto more lines
|
||||
instead of making the table scroll sideways.
|
||||
- Reading files for a backup is much faster on disks where every request waits, such as network and
|
||||
cloud volumes: files are read in pieces of 4 MiB instead of 8 KiB. On a test server the disk
|
||||
delivered 27 MiB/s instead of 5 MiB/s.
|
||||
- Locations are backed up one after the other: a backup that has to wait shows as queued. A restore
|
||||
point has the time its backup really began, not the time it was queued.
|
||||
- Choosing a time no longer starts a backup right away when that time has already passed today.
|
||||
- A location without a schedule no longer gets the notification that it has had no recent backup.
|
||||
Instead, all administrators are told when a schedule is turned off, and reminded once a week while
|
||||
another location is still backed up, or every day when none is.
|
||||
- A file or folder that is left out because of its name now gets a notification of its own, in the
|
||||
log and in `occ backup:run`. It says the item is missing from the backup, gives example paths and
|
||||
asks to rename it; before, it was reported as unreadable, with advice about permissions.
|
||||
|
||||
## [1.0.3] - 2026-11-06
|
||||
|
||||
### Fixed
|
||||
- Empty folders are backed up and restored (#27), in `occ backup:restore`, `occ backup:restore:full`
|
||||
and the restore page, where they are shown too. They come back from the first backup made with
|
||||
this version; older restore points do not contain them. An older version of NG Backup can still
|
||||
read new restore points, and then only leaves the empty folders out.
|
||||
- A full disk during a restore no longer looks like a damaged backup. The restore now stops with a
|
||||
message about the disk, also with `--salvage`, and does not point at `--salvage` any more. The same
|
||||
goes for other trouble writing to the restore directory, such as permissions.
|
||||
- A file or folder whose name a snapshot cannot hold (a backslash, a control character, not UTF-8) is
|
||||
left out and reported as unreadable. Before, a backslash or control character could make the whole
|
||||
restore point unreadable, and a name that is not UTF-8 made the backup fail.
|
||||
- A restore that goes on after a stop also removes the temporary files in folders where every file
|
||||
is already in place, and checks that those folders are inside the restore directory before it
|
||||
leaves their files alone.
|
||||
- After a restore that was killed and then ran again, a backup no longer has to wait up to two hours
|
||||
for the locks of the killed run.
|
||||
- A restore only removes read-ahead folders of its own (`.ngb-restore-spool-` with 8 hex digits).
|
||||
- A restore keeps its lock on the location during a long row of files that are already in place.
|
||||
- A database restore that fails no longer leaves behind tables it created, which could stop the
|
||||
installation of that app later. When only NG Backup's own settings cannot be put back after the
|
||||
database was restored, `--salvage` no longer says the database is still the fresh one.
|
||||
- A connection that breaks while opening a pack on the location (seen with SMB) is retried once and
|
||||
is handled by `--salvage`, instead of stopping the restore with the error of the storage.
|
||||
- Without a system cron, several running backups take turns while the page is open.
|
||||
- The Bin tab shows how far it is while it reads the removed restore points, which can take a while.
|
||||
It also shows when it cannot read a location, instead of an empty bin, and refreshes when a run
|
||||
ends.
|
||||
|
||||
## [1.0.2] - 2026-11-06
|
||||
|
||||
### Added
|
||||
- A warning on the admin page and in `occ backup:status` when background jobs do not run on a
|
||||
system cron, or the cron stopped, with a link to how to set one up. Backups, also the scheduled
|
||||
ones, need it (#13).
|
||||
- Without a working system cron, a backup started with "Back up now" moves on while the NG Backup
|
||||
page stays open (#13).
|
||||
- `--salvage` for `occ backup:restore` and `occ backup:restore:full`: restore whatever can be
|
||||
read when a backup is damaged. Files that cannot be read or decrypted are left out and listed,
|
||||
and the command exits with code 2. When the database cannot be restored, `backup:restore:full`
|
||||
leaves the fresh database as it is and puts the data directory and config of the backup in
|
||||
`ngb-salvage-<snapshot>` in the data directory, to put back by hand.
|
||||
- The trash of restore points has a tab of its own, "Bin", instead of being shown below the
|
||||
backups.
|
||||
|
||||
### Changed
|
||||
- A backup in its database phase shows how far it is, for example "running (db: 47/183 tables)",
|
||||
and one that has not moved for 15 minutes says "waiting for background jobs" with when it last
|
||||
took a step. Before, it only said "running (db)", which looked the same as being stuck (#13).
|
||||
- Full restores from S3 read the packs ahead, eight ranges at a time, as restores over SFTP do
|
||||
since 1.0.0-rc.4. Over SMB the packs are read in 8 MiB reads instead of 1 MiB: over a line with
|
||||
a 130 ms round trip, reading was four times faster in a test (18 instead of 4.5 MiB/s).
|
||||
- Over SMB the index files, which a new server reads in `backup:target:add`, take fewer round
|
||||
trips: from Canada to a Hetzner Storage Box (130 ms), 0.66 instead of 1.05 s per file, so about
|
||||
5 instead of 8 hours for the 27,000 index files of a 1 TB repository.
|
||||
- `occ backup:restore:full` that runs again after it stopped (`--force`) goes on where it was: data
|
||||
files already in place with the size and modification time of the backup are left, and only
|
||||
the rest is read from the location. Before, a stopped restore of 1 TB started over.
|
||||
|
||||
### Fixed
|
||||
- A full restore over SMB could hang at 100% CPU when the connection to the server dropped: a
|
||||
failed read was counted as data. It is now read again over a new connection. Found in a
|
||||
disaster recovery test of 1 TB from Canada to a Hetzner Storage Box, after 392 GiB.
|
||||
- The read-ahead folder of a restore that was killed while it wrote is removed by the next run;
|
||||
it kept one file of up to 8 MiB.
|
||||
- `occ backup:target:add` only estimates the time left for reading the index once enough of it
|
||||
is in. The first file also carries the time to connect, which made it say "about 642 min left"
|
||||
for something done within a minute.
|
||||
- A restore that stops because a file is gone from the location now points at `--salvage`, as it
|
||||
already did for damaged data.
|
||||
|
||||
### Known issues
|
||||
- Empty folders are not backed up or restored; to be fixed in 1.0.3 (#27).
|
||||
- A full disk during a restore looks like a damaged backup ("Size mismatch"), and with `--salvage`
|
||||
the restore goes on and lists the remaining files. The backup itself is fine: make room and run
|
||||
the restore again. To be fixed in 1.0.3.
|
||||
|
||||
## [1.0.1] - 2026-11-01
|
||||
|
||||
Fixes for what was reported after 1.0.0 (#22, #24) and small improvements. Nothing changes in
|
||||
your backups or settings.
|
||||
|
||||
### Added
|
||||
- `occ backup:user:list [location] [user]` shows the user exports on a location, with the manifest
|
||||
path `backup:user:restore` expects. Before, that path was only printed once by
|
||||
`backup:user:backup` (#22).
|
||||
- A "Docs" tab on the admin page, with links to the how-to, the disaster recovery guide and the
|
||||
roadmap. It also shows before the backup key is set up.
|
||||
- A label for a backup started with "Back up now" (optional; `occ backup:run -l` already had one).
|
||||
It shows with the restore point.
|
||||
- An explanation in the trash of restore points of what "removed by retention policy" means: the
|
||||
policy keeps the newest restore points and the last one of each recent day, week and month, and
|
||||
moves the others to the trash, where they can be brought back until they are deleted
|
||||
permanently. `occ backup:trash` says the same below its list.
|
||||
|
||||
### Changed
|
||||
- `occ backup:status`, `backup:list`, `backup:trash` and `backup:browse` show times in the time
|
||||
zone of the daily schedule and name it, as the admin page shows them in the administrator's.
|
||||
Before, they showed the server's UTC, which read as a wrong start time.
|
||||
- The admin page no longer says NG Backup is in beta.
|
||||
- `occ backup:user:restore` also takes a user id instead of the manifest path, when that user has
|
||||
exactly one export on the location. With more, and for a manifest path that is not on the
|
||||
location, the error lists that user's exports; for a user without exports it names the
|
||||
expected form (#22). Before, `backup:user:restore <location> <uid>` only said "is not a
|
||||
recorded user export for this location".
|
||||
|
||||
### Fixed
|
||||
- An SFTP location with an RSA key in OpenSSH format ("BEGIN OPENSSH PRIVATE KEY") stopped
|
||||
working after an upgrade to Nextcloud 35 with "Unable to read key", and such a key could not be
|
||||
added there at all (#24). The "Private key" field on the admin page is a single line, so a
|
||||
pasted key lost its line breaks; Nextcloud 34 still read it, Nextcloud 35 reads keys with a
|
||||
stricter library. The line breaks are now restored before the key is used, so a stored location
|
||||
works again without re-entering it.
|
||||
- A full restore onto SQLite could stop at once with "database is locked" when another process
|
||||
wrote to the database just as the restore began (a background job that was already running).
|
||||
The restore now takes the write lock first and waits for the other process instead. The
|
||||
full-restore test on SQLite runs with a second process writing alongside.
|
||||
- `occ backup:restore ... --to-directory` of a single file restored nothing ("Restored 0
|
||||
files"); only a folder worked. A path to a file now restores that file.
|
||||
- A restore that was stopped (a crash, a timeout, a restart) left its half-written temporary file
|
||||
(`<name>.ngb-restore-<id>`) next to the file it was restoring, and a `files:scan` then showed it
|
||||
in Files. A restore now removes such leftovers in each folder it writes to.
|
||||
- The app store showed the notes of every release candidate under 1.0.0, because their headings
|
||||
started with "1.0.0-rc". Each heading now starts with the app version of that release.
|
||||
|
||||
## [1.0.0] - 2026-10-29
|
||||
|
||||
First stable release. NG Backup makes encrypted, incremental backups of your whole Nextcloud
|
||||
(files, database and configuration) to S3, SFTP, WebDAV, SMB and other storage, and restores them
|
||||
from the web interface or with occ. Disaster recovery has been tested on real installations; see
|
||||
"Tested disaster recoveries" in the README, and read "Known limitations" before you rely on it.
|
||||
|
||||
The code is the same as 1.0.0-rc.6 (app version 0.9.12): updating from it changes nothing in your
|
||||
backups or settings.
|
||||
|
||||
## [0.9.12] - 2026-10-24 (1.0.0-rc.6)
|
||||
|
||||
Sixth release candidate for 1.0.0; the app version is 0.9.12. Fixes from two disaster recovery
|
||||
simulations and a user report.
|
||||
|
||||
### Fixed
|
||||
- The daily backup time was taken as UTC (Nextcloud runs PHP in UTC): 03:00 ran at 05:00 in
|
||||
Berlin (#19). The time is now saved with the administrator's time zone and shown with it. A time
|
||||
saved before is converted once to the zone of the server or an administrator, so it shows as it
|
||||
effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer) and nothing moves.
|
||||
- A full restore over SMB stopped after 17 hours with "Decryption failed": a read over a long
|
||||
line handed out wrong bytes (the backup itself was fine, the same files restored identically
|
||||
afterwards). A blob that does not decrypt as first read, of a file or of the database, is now
|
||||
read once more on a request of its own, and only a second failure stops the restore.
|
||||
- A full restore onto MySQL/MariaDB stopped with "Incorrect string value" when it created a table
|
||||
(of an app that is not installed here) whose rows hold emoji or other 4-byte characters: the
|
||||
table got 3-byte utf8. Tables the restore creates now get this installation's character set and
|
||||
collation (utf8mb4). Found in a disaster recovery simulation onto MariaDB.
|
||||
|
||||
### Changed
|
||||
- The admin page uses more of the screen (sections up to 1400 pixels instead of 900), so the
|
||||
list of backup runs fits without scrolling sideways.
|
||||
- README: a roadmap, the tested disaster recoveries, measurements for OVH S3, the Storage Box over
|
||||
SFTP and SMB, how to build the SMB extension when PECL is down, and what works without shell
|
||||
access. The guide lists shell access among what you need.
|
||||
- The full-restore integration test checks a 4-byte character in a table the restore creates, and
|
||||
retries app installs from the app store.
|
||||
|
||||
## [0.9.11] - 2026-10-22 (1.0.0-rc.5)
|
||||
|
||||
Fifth release candidate for 1.0.0; the app version is 0.9.11. A full restore of 1 TB over SFTP
|
||||
stopped after 13 hours with "Blob not in index"; the backup itself was fine.
|
||||
|
||||
### Changed
|
||||
- README and app store description: NG Backup needs background jobs on system cron, with how to
|
||||
set that up (issues #13, #17).
|
||||
|
||||
### Fixed
|
||||
- A full restore no longer stops with "Blob not in index" after a pack could not be read for a
|
||||
moment. Reading the index again then got an empty listing (from a connection the location had
|
||||
dropped) and emptied the index. Reading the index again now only adds, an empty listing of a
|
||||
location that has an index is an error instead of an empty index, and a pack that could not be
|
||||
read is read once more before the restore gives up.
|
||||
- After a full restore of a MySQL/MariaDB or SQLite backup onto PostgreSQL, new files, users or
|
||||
activities could not be added ("duplicate key", "File entry could not be inserted"): such
|
||||
backups record no ID counters, and they stayed at 1. Every counter of a restored table now goes
|
||||
after its data. Found in a disaster recovery simulation of 138 GB from MariaDB to PostgreSQL,
|
||||
where all 15,772 files and every count were identical.
|
||||
- A full restore now runs in maintenance mode: a background job, a page visit or a user writing
|
||||
into the instance meanwhile could collide with restored rows ("duplicate key") or be lost. It
|
||||
is switched off when the restore completes; after an error it stays on, and the message gives
|
||||
the one line to switch it off and run the restore again. Maintenance mode that was on before
|
||||
the restore stays on.
|
||||
- Adding a location whose repository config could not be read, while backup data is there,
|
||||
no longer creates a new repository over it: it stops with an explanation. A snapshot that
|
||||
cannot be taken over when a location is added is now logged instead of left out silently.
|
||||
- An SFTP connection that a Hetzner Storage Box dropped without phpseclib noticing is now
|
||||
recognised and replaced: the check asks the server and counts its answer.
|
||||
- The extra connection a full restore reads ahead on is replaced when it breaks (up to 20 times),
|
||||
instead of reading the rest of the restore the slow way.
|
||||
|
||||
## [0.9.10] - 2026-10-19 (1.0.0-rc.4)
|
||||
|
||||
Fourth release candidate for 1.0.0; the app version is 0.9.10. 1.0.0-rc.3 (0.9.9) was not
|
||||
released: its fixes are in here.
|
||||
|
||||
### Changed
|
||||
- A full restore over SFTP reads the packs ahead on a connection of its own, 32 requests in
|
||||
flight, into temporary files in the restore folder. From a Hetzner Storage Box to a server in
|
||||
Canada (122 ms) packs now come in at about 14 MiB/s instead of 2.8. More connections can be set
|
||||
with `restore_sftp_connections` (README). A part that cannot be read this way is read as
|
||||
before. Restores from other locations work as before.
|
||||
- App store description: the line about Ensembia.
|
||||
|
||||
### Fixed
|
||||
- After a full restore stopped halfway, running it again with `--force` was refused when the
|
||||
database was already restored: Nextcloud then first wants `occ upgrade`. The guide and the
|
||||
error message now say so.
|
||||
- A full restore of a backup made before 1.0.0-rc.2 with `--skip-missing-tables` failed on
|
||||
PostgreSQL with "relation ... does not exist": the left-out tables were still looked up to set
|
||||
their ID counters. It now skips them. A new test restores a backup made by 1.0.0-rc.1 on
|
||||
PostgreSQL and MariaDB.
|
||||
- The app store showed old screenshots: it keeps a copy per URL, and the URLs always pointed at
|
||||
`main`. They now point at the release's tag.
|
||||
|
||||
## [0.9.8] - 2026-10-18 (1.0.0-rc.2)
|
||||
|
||||
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.
|
||||
|
||||
## [0.9.7] - 2026-10-17 (1.0.0-rc.1)
|
||||
|
||||
Release candidate for 1.0.0; the app version is 0.9.7. Only bug fixes until 1.0.0. Tested on a
|
||||
production instance (about 1 TB, 150,000 files): full and incremental backups and a verify on
|
||||
SFTP and SMB (Hetzner Storage Box) and S3 (OVH), a deep verify on SFTP, and partial restores from
|
||||
SMB and S3 checked against checksums.
|
||||
|
||||
### Added
|
||||
- The admin page asks for feedback, with a link to a short "Experience report" form on GitHub:
|
||||
which location, setup and size you used, and how it went.
|
||||
|
||||
### Changed
|
||||
- New passphrases must be at least 16 characters (was 12). The location stores the key
|
||||
encrypted with each passphrase, so the passphrase is what really protects the backups.
|
||||
Existing passphrases keep working. The README and the how-to now say so, and why the
|
||||
recovery kit must still be kept off the server.
|
||||
- Dates and times in the web interface follow the locale in the user's Nextcloud settings instead
|
||||
of the browser's language, so a 24-hour locale shows 16:00 rather than 4:00 PM.
|
||||
- The list of backup runs (web interface and `occ backup:status`) shows the runs of the last 7
|
||||
days, those still running, and at least the 5 newest; a failed run stays visible for that week.
|
||||
`occ backup:status --all` and "Show older runs" in the web interface list all of them.
|
||||
- `occ backup:status` and `occ backup:verify` show durations of a day or more with the days
|
||||
("1d 12:10:13" instead of "12:10:13").
|
||||
|
||||
### Fixed
|
||||
- With system cron, a running backup moved forward only every other cron run: Nextcloud
|
||||
skipped the background job when cron came a few seconds before its 5-minute interval was
|
||||
over, so a backup was idle 6 of every 10 minutes. The job now runs at every cron run.
|
||||
- Photos, video and other compressed data are no longer deflated only to throw the result away:
|
||||
a quick test on three small slices of each block skips it. Compressing was the slowest part of
|
||||
a backup (about 24 MiB/s).
|
||||
- Checking a backup on an S3 location (`occ backup:verify`) took hours on a large repository:
|
||||
the index files were downloaded one at a time, at about two a second. They are now downloaded
|
||||
16 at a time: on a production instance (27,585 index files, 1 TB) the check went from about
|
||||
4 hours to 3.5 minutes.
|
||||
- Writing the snapshot at the end of a long backup to S3 took a quarter of an hour without any
|
||||
progress shown: it read back the file list of every step one at a time. These reads are now
|
||||
done 16 at a time on S3 (96 reads: 82 seconds before, 2 now), blobs that follow each other in
|
||||
a pack are read at once on every location, the snapshot is written in a step of its own, and
|
||||
the run shows "commit" while it does so.
|
||||
|
||||
## [0.9.6-beta.7] - 2026-10-14
|
||||
|
||||
Bugfix release for SMB locations, with `occ backup:cancel` to stop a backup that does not get
|
||||
anywhere. Tested on a production instance: a first full backup of 151,021 files (807 GiB) to OVH
|
||||
S3 completed, and a backup to a Hetzner Storage Box over SMB that had slowed to 12 GiB in 35
|
||||
hours ran at about 5.6 MiB/s after the update, while the S3 backup was still reading.
|
||||
|
||||
### Added
|
||||
- The list of backup runs (web interface and `occ backup:status`) shows the total size of each
|
||||
backup next to what it uploaded: an incremental backup of 1 TB that uploads 800 MB now shows
|
||||
both. `occ backup:status` shows sizes in GiB where that reads better than MiB.
|
||||
- `occ backup:cancel <run>` stops a running backup; `occ backup:status` shows the run numbers.
|
||||
No new step of the run starts, the step in progress finishes (usually within a minute), then
|
||||
the process working on the run (`occ backup:run` or cron) stops. What it already stored is
|
||||
removed by the next cleanup. A backup started by an earlier version is stopped as well: after
|
||||
two minutes it is marked cancelled while it works, and its process stops at the end of the
|
||||
step.
|
||||
|
||||
### Fixed
|
||||
- Backups to SMB locations, and to other slow locations with many packs, slowed to a crawl: each
|
||||
30-second step opened the location again, and reading the list of index files took longer
|
||||
than the step itself. Each step then stored a single 4 MiB pack, which made the list longer
|
||||
still. A first full backup of 0.8 TB to a Hetzner Storage Box over SMB managed 12 GiB in 35
|
||||
hours. `occ backup:run` and system cron now open the location once per run, opening no longer
|
||||
counts towards the step's time, and SMB folders are listed in one request instead of one per
|
||||
file. A backup to an SMB location that is not getting anywhere keeps running in its old
|
||||
process after the update: update first, then stop it with `occ backup:cancel <run>` and start
|
||||
it again. The new backup reuses the packs the old one stored.
|
||||
|
||||
## [0.9.5-beta.6] - 2026-10-12
|
||||
|
||||
Bugfix release for SFTP locations and large repositories. Tested on a production instance with a
|
||||
1 TB repository on a Hetzner Storage Box over SFTP: a check in 5.5 minutes, a deep check of all
|
||||
316,529 blobs (815 GiB) in 11 hours, and an incremental backup in under 8 minutes.
|
||||
|
||||
### Added
|
||||
- Progress of a running backup in the web interface and `occ backup:status`, e.g.
|
||||
"running (files) 68%". The total is the size of the previous snapshot; for a first backup it
|
||||
is estimated from Nextcloud's file cache (users' files, versions and trash) and shown as
|
||||
"~68%".
|
||||
- Edit a backup location (web interface and `occ backup:target:edit`): change the password, host,
|
||||
folder, connection type or name without losing its history. Before this the only option was a
|
||||
new location, which starts an empty repository and, with a retention lock at the provider,
|
||||
keeps paying for the old one. The new settings are only saved when they lead to the same
|
||||
repository; stored passwords are never sent to the browser, and an empty password field keeps
|
||||
the stored one. Administrators are notified of the change.
|
||||
- `occ backup:verify` reports how far it is every 30 seconds, and `occ backup:status` shows the
|
||||
progress of a running check and how the last check of each location ended. On a large
|
||||
repository a check takes hours, a deep one days.
|
||||
|
||||
### Changed
|
||||
- Checking and opening a large repository over SFTP is much faster: the index files (one per
|
||||
pack, 27,547 for 1 TB) are downloaded 32 at a time instead of one after the other, 114 instead
|
||||
of 6 files per second to a Hetzner Storage Box (about 4 minutes instead of 77 for 1 TB). A check
|
||||
of a repository with more than 256 packs also lists the pack folders (256 requests) instead of
|
||||
asking for every pack.
|
||||
- App store listing: the author's e-mail address is shown.
|
||||
- App store listing: the homepage link points to the GitHub repository instead of back to the
|
||||
app store page itself.
|
||||
- The notification for a location without a successful backup read "No successful backup to …
|
||||
since never" when there had never been one; it now reads "No successful backup to … yet".
|
||||
|
||||
### Fixed
|
||||
- 0.9.4 could not open a repository at the root of an SFTP location (empty path): its faster
|
||||
SFTP reads sent their first request before phpseclib had opened the SFTP channel, which only
|
||||
happens on the first SFTP call after login. Every backup then failed with "No repository at
|
||||
this location". The channel is now opened first.
|
||||
- A check (`occ backup:verify`) did not renew its hold on the location while it downloaded the
|
||||
index; on a large repository that takes longer than the hold lasts, so a weekly cleanup could
|
||||
have started during the check.
|
||||
- A long first backup could end as failed although its snapshot was complete: when its final
|
||||
step took longer than the step's lease (150 s with `occ backup:run`), the background job took
|
||||
the run over, finished it a second time and failed on the snapshot it had already recorded,
|
||||
overwriting the result. The lease now lasts at least 15 minutes, a step writes the run only if
|
||||
no other process wrote it since the step loaded it (in one database statement), and recording
|
||||
a snapshot that is already recorded is no longer an error.
|
||||
- `occ backup:verify` on a large repository failed with "SFTP connection failed while opening
|
||||
snapshots/…" or "Not found: snapshots/…". A Hetzner Storage Box drops an SFTP connection that
|
||||
carried a lot of data (some 40 MB) and then stayed quiet for a few minutes, and the check went
|
||||
quiet after downloading the index: it refreshed the local index cache, first in 29 minutes (a
|
||||
pass over all blobs for every pack: 27,547 packs, 400,000 blobs for 1 TB), then still in more
|
||||
than 6 minutes (a database transaction per pack on PostgreSQL). Nextcloud's SFTP storage keeps
|
||||
using a dropped connection, on which every file looks missing. Now:
|
||||
- an SFTP connection that was not used for a minute is checked first, and one that turns out
|
||||
to be dead after a failed request is replaced, once per request; uploads, moves and deletes
|
||||
are only checked first, never repeated;
|
||||
- the index cache is written in one transaction (27,547 packs in 11 seconds on a test server,
|
||||
against about a minute row by row);
|
||||
- after a check the cache holds the index files as they are: before, a blob that was in two
|
||||
packs was only cached for one of them.
|
||||
|
||||
## [0.9.4-beta.5] - 2026-10-09
|
||||
|
||||
Bugfix release: faster restores.
|
||||
|
||||
### Fixed
|
||||
- Restores and `occ backup:verify --deep` fetched every blob with its own request, so on a
|
||||
remote location they ran far below the speed of the connection. The blobs of a pack are now
|
||||
read from one open stream, in the order they were written. Restoring 86 MB in 202 files from a
|
||||
Hetzner Storage Box over SMB went from 0.78 MB/s to 4.2 MB/s, the speed of reading the packs
|
||||
as plain files over that connection.
|
||||
- Reading from an SFTP location waited a full round trip for every 256 KiB, whatever the line
|
||||
speed. Reads now keep up to 8 requests in flight. Restoring 1.97 GB in 500 files from a Hetzner
|
||||
Storage Box (47 ms away) took 694 s before the fix above, 499 s with it and 142 s with both
|
||||
(2.8 → 3.9 → 13.9 MB/s); a full 51 GB restore ran at 13.0 MB/s. When a server error
|
||||
interrupts such a read, it goes on from the same position with Nextcloud's own SFTP stream;
|
||||
after three failing files in a row, or on a Nextcloud whose SFTP library lacks what this needs,
|
||||
only Nextcloud's own stream is used.
|
||||
- README: a WebDAV location on a slow upload needs a higher `davstorage.request_timeout`.
|
||||
|
||||
## [0.9.3-beta.4] - 2026-10-08
|
||||
|
||||
Bugfix release.
|
||||
|
||||
### Fixed
|
||||
- A backup failed with "cannot execute INSERT in a read-only transaction" on PostgreSQL as soon
|
||||
as the database dump filled a whole pack (32 MiB): the pack's index cache row was written
|
||||
inside the read-only dump transaction. On other databases that row was rolled back silently.
|
||||
- Backups to a remote location slowed down as the repository grew: every backup step listed the
|
||||
repository's index with a round trip per file. Over SFTP to a Hetzner Storage Box a 60 GiB
|
||||
repository already lost a large part of each step to it; backing up continuously
|
||||
(`occ backup:run`) went from 45 KiB/s to 5.8 MB/s with the fix.
|
||||
|
||||
## [0.9.2-beta.3] - 2026-10-08
|
||||
|
||||
Bugfix release.
|
||||
|
||||
### Added
|
||||
- README: notes on using a Hetzner Storage Box over WebDAV and SMB.
|
||||
|
||||
### Fixed
|
||||
- Adding a backup location in the web interface failed with "Request failed with status code 403"
|
||||
when the login was more than 30 minutes old: the form did not ask for password confirmation.
|
||||
- Settings page: the "Backup and restore" / "Settings" tabs line up with the sections below them.
|
||||
- Backups: the runs table scrolls horizontally on narrow screens instead of running off the page.
|
||||
- `occ backup:verify --deep`: a blob that could not be read (network or storage error) was reported
|
||||
as "corrupted or tampered". Reads are now retried, and a read that keeps failing is reported
|
||||
separately as unreadable, with the error (run the check again; if the same blobs stay unreadable,
|
||||
their pack may be damaged or truncated). Only a blob that was read but fails decryption or its
|
||||
hash check counts as corrupted. Found on a Hetzner Storage Box over WebDAV, where the blob
|
||||
turned out to be intact.
|
||||
|
||||
## [0.9.1-beta.2] - 2026-10-07
|
||||
|
||||
Second beta, the first release in the Nextcloud app store (signed).
|
||||
|
||||
### Added
|
||||
- App store listing: screenshots, website, repository and documentation link in `appinfo/info.xml`.
|
||||
|
||||
### Fixed
|
||||
- Restore: the "new folder" option showed `"Restored "` without the date pattern.
|
||||
- Restore: the restore-point dropdown cut off the time.
|
||||
- Settings: the passphrase actions are on one line instead of stacked.
|
||||
- `docs/HOWTO.md`: the SFTP example used a non-existent auth mechanism (`sftp::password`
|
||||
instead of `password::password`).
|
||||
|
||||
## [0.9.0-beta.1] - 2026-10-07
|
||||
|
||||
First beta. Tested thoroughly and usable **alongside** your current backup, but **not yet a
|
||||
drop-in replacement** for it. We test what we can ourselves (Nextcloud 32–35, SQLite, MariaDB and
|
||||
PostgreSQL, S3, SFTP, FTP, SMB and NFS), but we need field experience from other setups: please
|
||||
report what works and what doesn't, especially on large instances and on storage providers not
|
||||
listed in the README.
|
||||
|
||||
### Added
|
||||
- Disaster recovery: `occ backup:key:import` and `occ backup:restore:full` restore the database,
|
||||
data directory and config onto a fresh installation of the same major version.
|
||||
- Per-user backup and restore via user_migration: `occ backup:user:backup` and
|
||||
`occ backup:user:restore` (replace the account after a safety export, or restore as
|
||||
`<uid>-bak`), also onto a server where the user does not exist yet. The commands ask before
|
||||
installing user_migration (or take `--install-user-migration`).
|
||||
- Checksum audit: `occ backup:verify`, shallow or `--deep`, for snapshots and per-user exports.
|
||||
- Retention for per-user exports: `occ backup:retention --user-last=N`.
|
||||
- FTP and SMB covered by the integration tests; NFS through the `local` backend on a mounted share.
|
||||
- `docs/HOWTO.md`: step by step from setup to a tested restore.
|
||||
- Warnings when a backup finishes with unreadable files or folders, or with files that kept
|
||||
changing while being read (log, `occ`, notification, snapshot).
|
||||
|
||||
### Changed
|
||||
- `backup:restore:full` refuses on an installation with more than one user and asks first;
|
||||
`backup:user:restore --mode=replace` asks before deleting an existing account (`--force` for
|
||||
scripts in both cases).
|
||||
- A failed scheduled backup is retried at most three times a day, an hour apart.
|
||||
- The recovery kit carries the deletion delay; the kit file is created owner-only.
|
||||
- Adding a location through the web API needs password confirmation.
|
||||
|
||||
### Fixed
|
||||
- Exports of users whose id contains `.part-` could be deleted by cleanup on remote locations.
|
||||
- Cleanup no longer frees the data of a per-user export whose manifest is missing.
|
||||
- Crash safety of moving snapshots to and from the trash, of catalog writes by concurrent
|
||||
backups and per-user exports, and of short uploads.
|
||||
- A file that keeps changing no longer makes a backup restart forever.
|
||||
- Long restores and exports keep their lock on the location alive.
|
||||
- Floating-point database columns are dumped exactly.
|
||||
|
||||
### Known limitations
|
||||
See "Known limitations" in the README: memory use on very large instances, never two servers
|
||||
writing to one location, removing a passphrase slot does not revoke access, no rollback detection
|
||||
before a disaster recovery, and a few smaller points. None of them block the road to 1.0.0; they
|
||||
are on the backlog in `PLAN.md`.
|
||||
|
||||
## [0.1.0-alpha.1] - 2026-10-05
|
||||
|
||||
@@ -42,3 +605,19 @@ and not implemented yet.
|
||||
- No FTP/SMB coverage in CI yet, and no verification/checksum-audit command yet (phase 2).
|
||||
- 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.1]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.1
|
||||
[1.0.0]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0
|
||||
[0.9.12]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.6
|
||||
[0.9.11]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.5
|
||||
[0.9.10]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.4
|
||||
[0.9.8]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.2
|
||||
[0.9.7]: https://github.com/dillardblom/ng_backup/releases/tag/v1.0.0-rc.1
|
||||
[0.9.6-beta.7]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.6-beta.7
|
||||
[0.9.5-beta.6]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.5-beta.6
|
||||
[0.9.4-beta.5]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.4-beta.5
|
||||
[0.9.3-beta.4]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.3-beta.4
|
||||
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
|
||||
[0.9.1-beta.2]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.1-beta.2
|
||||
[0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1
|
||||
[0.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1
|
||||
|
||||
@@ -276,7 +276,7 @@ onderdelen wel en niet terugkomen, op basis van de geregistreerde migrators.
|
||||
| `backup:list [--target=]` | herstelpunten |
|
||||
| `backup:verify <snapshot> [--deep]` | checksums controleren, optioneel alles ontsleutelen |
|
||||
| `backup:restore:file <snapshot> <user> <pad> [--to=]` | bestand/map terugzetten |
|
||||
| `backup:restore:user <snapshot> <user> [--to-user=]` | gebruiker terugzetten via user_migration (fase 2) |
|
||||
| `backup:user:restore <snapshot> <user> [--to-user=]` | gebruiker terugzetten via user_migration (fase 2) |
|
||||
| `backup:restore:full <snapshot>` | volledige restore (fase 2, verse installatie) |
|
||||
| `backup:target:add/list/remove/test` | opslagdoelen beheren en testen |
|
||||
| `backup:key:export / import` | herstelkit |
|
||||
@@ -338,6 +338,374 @@ een backup-app die niet betrouwbaar is, is erger dan geen backup-app.
|
||||
Volgorde: fase 1 grondig testen, reviewen en debuggen; dan nadenken over 0.1.0-alpha.1. Een
|
||||
minimale volledige restore (`occ backup:restore:full` op een verse installatie) komt vóór de beta.
|
||||
|
||||
**Roadmap-indeling fase 2 (besluit 2026-10-05):** naar v1.2.0 toewerken met in fase 2:
|
||||
1. `backup:restore:full` (disaster recovery, verse installatie) — **klaar** (2026-10-05)
|
||||
2. FTP/SMB-ondersteuning — **klaar** (2026-10-06): beide liepen al via files_external's generieke
|
||||
adapter zonder enige ng_backup-code; alleen testdekking toegevoegd
|
||||
(`tests/integration/targets-matrix.sh`) + bevindingen in README ("Storage backend experiences").
|
||||
SMB vereist de `smbclient` PECL-extensie (niet standaard in de Nextcloud-image, in CI nu
|
||||
on-the-fly gebouwd). NFS is geen eigen backend: lokaal mounten + `local`-backend erop richten.
|
||||
3. Verificatie/checksum-audit-commando — **klaar** (2026-10-05)
|
||||
4. Retentie/prune voor user-exports — **klaar** (2026-10-06): `occ backup:retention --user-last=N`
|
||||
(standaard 3, per installatie, net als de bestaande snapshot-policy), toegepast door
|
||||
`occ backup:prune` (geen prullenbak voor user-exports, in tegenstelling tot snapshots — on-demand,
|
||||
niet het primaire vangnet).
|
||||
5. Automatische user-export in het reguliere schema — **naar backlog** (niet voor deze release; on-demand blijft)
|
||||
9. SMB-roundtrip (protocol, niet een specifieke server) — **klaar** (2026-10-06): CI-matrix, 2 GB-roundtrip en
|
||||
user-export via SMB; NFS via de OMV-share getest (`local`-backend op een gemounte share, 2026-10-06)
|
||||
10. `backup:user:restore` zonder user_migration: vraagt om toestemming om de app te installeren en te activeren (of
|
||||
`--install-user-migration`) — **klaar** (2026-10-06); waarschuwing over de settings-allowlist — **klaar**;
|
||||
agenda/contacten bij migratie naar een server zonder die gebruiker — **getest** (2026-10-06, naar `migrated-personal`/`migrated-contacts`)
|
||||
Backlog: (a) de originele weergavenaam herstellen (het label uit de export is niet getest als
|
||||
weergavenaam); (b) de interne `migrated-`-naam hernoemen (vereist directe databasewijziging, niet voor de beta).
|
||||
|
||||
**Backlog uit de pre-beta review (2026-10-07)** — geen belemmering voor 0.9.0-beta of 1.0.0, staat
|
||||
in README "Known limitations":
|
||||
1. Geheugen bij grote instanties (>1 TB, miljoenen bestanden): blob-index, prune-set, browse van een
|
||||
snapshot in de web-UI, `trashExtras` bij restore en het manifest van een user-export staan volledig
|
||||
in het geheugen; streaming/paginering nodig.
|
||||
2. Twee servers op één locatie: catalogus kan breken (generatie overschreven, of anchor >200 generaties
|
||||
achter). Docs-waarschuwing staat er; mogelijk later een echte put-if-absent per backend.
|
||||
3. Sleutelrotatie: een slot verwijderen trekt geen toegang in (master-key blijft gelijk).
|
||||
4. Rollback vóór DR niet detecteerbaar: referentie-generatie en -hash in de kit opnemen of tonen.
|
||||
5. Kit-velden buiten `wrapped_key` (o.a. `delete_delay_days`) zijn niet geauthenticeerd; nu alleen getoond.
|
||||
6. Repository-id in de AD van objecten (formaatwijziging).
|
||||
7. Wachtwoorden van locaties via `--option-file` of omgevingsvariabele i.p.v. de commandoregel.
|
||||
8. Tijdelijke restore-bestanden niet via de Files-API verwijderen (komen nu in de prullenbak).
|
||||
9. DB-restore in foreign-key-volgorde (cascades).
|
||||
10. Packs zonder index (index-upload mislukt) opruimen.
|
||||
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.0.0 (besluit 2026-10-18):** alleen betrouwbaarheid. rc2 uitgebracht; slaagt de grote
|
||||
DR-test op VM 980 (snapshot van 12-10, met `--skip-missing-tables`), dan 1.0.0. Zo niet: fix,
|
||||
rc3, en rc3 pas uitbrengen na een geslaagde volledige DR-test.
|
||||
- **rc4 (besluit 2026-10-19):** rc3 is niet uitgebracht. De DR-test met rc3 liep ~2,8 MiB/s
|
||||
(~3,5 dag); parallel lezen over SFTP haalde vanaf VM 980 ~7,6 MiB/s. Daarom rc4 = rc3 +
|
||||
vooruitlezen over SFTP (4 eigen verbindingen). Volgorde: volledige DR-test op VM 980 volgens de
|
||||
gids (SFTP, Storage Box) → rc4 uitbrengen en om tests vragen → zelf een volledige DR van een
|
||||
productie-instantie van ~250 GB via S3 en via SMB. Drie volledige DR-restores vóór 1.0.0.
|
||||
Problemen of meldingen van gebruikers: rc5.
|
||||
- **1.0.2** (plan akkoord 2026-11-04): punten 1, 2 en 5 hieronder, de Bin, nieuwe screenshots en
|
||||
het polish-punt over de schatting van `target:add`. Besluiten 2026-11-04:
|
||||
- `--salvage` haalt ook bij een kapotte database terug wat kan: bestanden zijn per gebruiker
|
||||
gescheiden en het waardevolst. Kan de database niet terug (dump onleesbaar, of past niet op
|
||||
deze installatie), dan blijft de verse database staan en gaan data en config van de backup
|
||||
naar `<datadir>/ngb-salvage-<snapshot>/`; vanaf daar is het handwerk. Exitcode 2.
|
||||
- De DR-test met een backup mét tabelstructuur, zonder `--skip-missing-tables` (punt 3), is
|
||||
nog niet gedaan en wordt ingepland vóór de release van 1.0.2. Ingepland (2026-11-04): een
|
||||
verse snapshot van nc01 (gemaakt met 1.0.1, dus met tabelstructuur) volgens de DR-gids
|
||||
terugzetten op VM 980 met de 1.0.2-build, over **SMB** (Storage Box; besluit 2026-11-04:
|
||||
SMB in plaats van SFTP, zodat de DR-test de nieuwe SMB-leescode op echte schaal en over de
|
||||
echte lijn vanuit Canada test; vorige meting 2,6 MiB/s, verwachting ~1 dag voor 1 TB), zonder
|
||||
`--skip-missing-tables`; daarna `--salvage` op een deel van de bestanden (moet hetzelfde
|
||||
resultaat geven, exitcode 0). Uitvoeren met Dillard, die VM 980 klaarzet; deze week, vóór
|
||||
de release. Screenshots akkoord (2026-11-04).
|
||||
**DR-test 1.0.2 over SMB (VM 980, snapshot a86271ed van nc01, 983 GiB, gestart 2026-11-04):**
|
||||
index lezen 8 uur (~1 bestand/s; daarna versneld naar 0,66 s/bestand, gemeten vanaf VM 980);
|
||||
restore zonder opties (geen `--install-apps`, geen `--skip-missing-tables`) liep ~12 MiB/s
|
||||
(SFTP eerder 2,8-7,6; SMB vóór 1.0.2 2,6). Na 392 GiB viel de SMB-verbinding weg en bleef de
|
||||
restore op 100% CPU hangen: de smbclient-extensie geeft bij een mislukte read een string met
|
||||
lengte -1. Opgelost (controle + nieuwe verbinding), plus hervatten: `--force` laat bestanden met
|
||||
dezelfde grootte en mtime staan. Hervat 2026-11-05 07:21 UTC na `maintenance:mode --off`
|
||||
volgens de gids; binnen 10 min voorbij de 392 GiB. SMB op sub5 na de test weer uitzetten.
|
||||
**Resultaat (klaar 2026-11-06 02:11 UTC, nazorg 02:27):** hervatte run 18 u 50 min, exit 0, 150.755
|
||||
bestanden; 7-13 MiB/s (langzamer vanaf ~18 uur Duitse tijd, oorzaak niet vastgesteld). Vergeleken
|
||||
met nc01: 150.467 van 150.758 bestanden gelijk in grootte en mtime, 397 op hash zonder verschil;
|
||||
gebruikers, agenda's, contacten, shares en Deck gelijk; bestanden, agenda en contacten via WebDAV
|
||||
getest als gebruiker. `backup:restore --salvage` op 16.904 bestanden: exit 0, geen verschil. Niet
|
||||
vastgesteld: de 50.080 "updated" bij `files:scan`. Zonder `--install-apps` 51 van de 81 apps aan.
|
||||
VM's 980-982 en de sim-backups op OVH en sub6 opgeruimd.
|
||||
**Naar 1.0.3 (besluit 2026-11-06):** lege mappen komen niet terug (3.982 op nc01, 2.076 daarvan in
|
||||
`files`, verklaart vrijwel zeker de 2.370 "removed" bij `files:scan`). De backup legt alleen
|
||||
bestanden vast (`walk()`), dus oudere snapshots hebben ze niet. Voor een DR acceptabel, wel
|
||||
fixen; issue #27. Daarna kleine volledige simulaties; een DR van 1 TB is hiervoor niet nodig.
|
||||
**Salvage vóór de release getest (besluit 2026-11-06):** nieuwe modus `salvage-files` in
|
||||
`full-restore-roundtrip.sh`, ook in CI: één pack beschadigd en één weg. Eerst stopt de restore,
|
||||
daarna `--salvage --force` volgens de gids. Vond één fout: bij een ontbrekend bestand wees de
|
||||
melding niet naar `--salvage` (opgelost).
|
||||
Stand 2026-11-04 (`polish`, gepusht): cron-waarschuwing + tick vanaf de pagina +
|
||||
voortgang van de db-fase; `--salvage` voor `backup:restore` en `backup:restore:full`; S3
|
||||
vooruitlezen (8 ranges tegelijk, met `SaveAs` naar de spool); SMB in leesacties van 8 MiB
|
||||
(gemeten met netem, 130 ms RTT: 4,5 → 18 MiB/s per leesactie; een volledige restore over SMB
|
||||
met vertraging is nog niet gemeten); Bin-tabblad; schatting `target:add`; nieuwe screenshots
|
||||
(1–3 vervangen, 4-bin erbij; in `info.xml` bij de release naar tag v1.0.2 laten wijzen en
|
||||
4-bin toevoegen). Getest: unit (208), psalm, integratie NC35 sqlite/pgsql, S3/SFTP/FTP/SMB,
|
||||
volledige restore met `--salvage`. **1.0.2 uitgebracht 2026-11-06** (tag `v1.0.2`, PR #28).
|
||||
- **Naar 1.0.3, uit de reviews van 1.0.2 (achteraf gedraaid, 2026-11-06):**
|
||||
1. Lege mappen (#27).
|
||||
2. Een volle schijf onder `--salvage` telt als beschadigde backup en de restore gaat door; zonder
|
||||
`--salvage` wijst de melding er ten onrechte naar. `fwrite` controleren, schrijffouten stoppen de
|
||||
restore (`Repository::restoreEntries`; ook `RestoreService::writeFile` nakijken). Beide reviews.
|
||||
3. Hervatten: controle op de doelmap vóór het overslaan, en tijdelijke bestanden ook opruimen in
|
||||
een map waar alles al klopt.
|
||||
4. Alleen `.ngb-restore-spool-<hex>` opruimen.
|
||||
5. Salvage-test: tijdelijke bestanden recursief zoeken; `admin` en `frtest` apart controleren.
|
||||
6. Nog nakijken: lease verversen bij overgeslagen bestanden; fout ná de database-commit
|
||||
(`restoreOwnAppConfig`) en tabellen die na een rollback blijven staan; backups zonder cron
|
||||
via de pagina (eerlijke beurten, stappen die de webtimeout halen); Bin verversen en fouten
|
||||
tonen; SMB-uitzondering bij het openen van een pack onder `--salvage`.
|
||||
Niet doen: bij hervatten de inhoud vergelijken (grootte en mtime is ook het criterium van de backup).
|
||||
**Stand op branch `1.0.3` (2026-11-06):** punten 1-6 gebouwd. Lege mappen staan in het metarecord
|
||||
van de snapshot, niet tussen de bestanden: 1.0.2 en ouder lezen zo'n snapshot gewoon en slaan alleen
|
||||
de lege mappen over. Geen webtimeout-wijziging zonder cron: alleen in de code nagelopen, niet getest
|
||||
(Nextcloud zet de PHP-tijdslimiet ruim, een gestopte stap wordt overgedaan); wel eerlijke beurten.
|
||||
Bin leest in delen met teller en balk (besluit 2026-11-06, ook met oog op meerdere backups per
|
||||
dag). Getest: unit (volle schijf op echte tmpfs), integratie NC35 sqlite/pgsql/mysql, sftp, salvage,
|
||||
salvage-files; Bin in een echte browser met vertraagde verzoeken (1 van 7 t/m 7). Nog: reviews
|
||||
vóór de PR, README-regels bij de versiecommit.
|
||||
**Simulaties (2026-11-06, NC 34.0.4, 2 GB, lokale SFTP, `~/dev-local/ngb-sim/sim.sh`):** normaal
|
||||
(pgsql), stop en hervat (pgsql → MariaDB, restore met kill -9 gestopt), volle schijf met `--salvage`:
|
||||
alle drie geslaagd, alle 3.101 gebruikersbestanden en 7 lege mappen gelijk, share, agenda en WebDAV
|
||||
werken. Vond één fout, opgelost: na een kill bleven vergrendelingen tot 2 uur staan, waardoor de
|
||||
backup van stap 11 werd geweigerd. `files:scan` "Updated" zijn in de gemeten run alleen mappen
|
||||
(mtime van mappen wordt niet teruggezet, dus nieuwe mtime en etag; bestanden ongemoeid). "Removed":
|
||||
o.a. `cache` van een gebruiker, die de backup uitsluit. Waarschijnlijk verklaart dit ook de 50.080
|
||||
"updated" op nc01, niet op nc01 zelf vastgesteld.
|
||||
**Naar 1.1.0 of 1.2.0 (besluit 2026-11-06):** alle mappen krijgen bij een restore hun oorspronkelijke
|
||||
tijd terug. Hoort zo, vooral bij oudere verzamelingen: de maptijd wordt gebruikt om in te schatten
|
||||
wanneer een map is aangemaakt. De backup moet dan de tijd van alle mappen vastleggen en de restore
|
||||
die aan het eind terugzetten; werkt pas vanaf de eerste backup met die versie. Op de roadmap onder 1.1.0.
|
||||
**1.0.3 uitgebracht (2026-11-06)**, ook op nc01 geïnstalleerd.
|
||||
- **Branches (besluit 2026-11-06):** werk voor een versie op een branch met die naam (`1.0.3`);
|
||||
`polish` alleen voor kleine cosmetische zaken, per geval bekijken of en wanneer die gemerged
|
||||
worden. SMB staat uit op sub5 en sub6; sub6 is het testaccount, eerst aanzetten voor een SMB-test.
|
||||
Na 1.0.3 gaat het werk naar `1.1.0`. Een spoedfix die niet kan wachten gaat op `1.0.4` vanaf `main`,
|
||||
krijgt een eigen merge en release; daarna `main` in `1.1.0` mergen, zodat daar met de fix getest wordt.
|
||||
- **1.0.1** (uitgebracht 2026-11-01): alleen de kleine punten (besluit 2026-10-30): 4, 6–13. Punten 1, 2 en 5 gaan naar
|
||||
1.0.2 (README-roadmap aangepast); punt 3 is een test, ingepland voor de week van 2 november,
|
||||
niet aangekondigd en los van de release. Voor 1.0.2 ook: nieuwe screenshots (het labelveld bij
|
||||
"Back up now" en het tabblad Docs staan er nog niet op; besluit 2026-11-01), en de prullenbak
|
||||
van herstelpunten naar een eigen tabblad "Bin" naast "Backup and restore", "Settings" en "Docs":
|
||||
onder de lijst met herstelpunten vervuilt hij het scherm (vooral met wat het bewaarbeleid erin
|
||||
zet; besluit 2026-11-01). Nieuwe screenshots daarna, zodat de Bin er ook op staat.
|
||||
1. **Naar 1.0.2.** (Naar rc4: herstelsnelheid over SFTP.) S3 leest al 16 tegelijk bij index en tree, maar een
|
||||
restore leest de packs nog één voor één: ook vooruitlezen via de S3-client.
|
||||
2. **Naar 1.0.2.** Achtergrondjobs zonder systeem-cron (issue #13; besluit 2026-10-19). De PR die dit
|
||||
oplost zegt "Fixes #13" (bevestigd: #13 was de cron; besluit 2026-10-21):
|
||||
a. **Waarschuwen** in de beheerpagina en `backup:status`: modus uit `core backgroundjobs_mode`
|
||||
(`ajax`/`webcron`/`cron`) en de laatste run uit `core lastcron`. Niet op `cron`, of de
|
||||
laatste run te lang geleden: melding met de cron-regel erbij ("Backups only move while
|
||||
someone has Nextcloud open"), en "waiting for background jobs" als een lopende backup lang
|
||||
geen stap zette.
|
||||
b. **De pagina laat de backup lopen** zolang ze openstaat: in `ajax`/`webcron` voert de
|
||||
statusaanroep die de pagina toch al herhaalt ook een korte, begrensde stap uit (zoals de
|
||||
korte stappen die AJAX-cron nu al krijgt). "Back up now" werkt dan ook zonder cron zolang
|
||||
het tabblad openstaat; de melding zegt dat geplande backups echte cron nodig hebben.
|
||||
De melding zegt (zonder werkende systeem-cron): backups hebben systeem-cron nodig, ook de
|
||||
gewone nachtelijke; zo stel je cron in (link naar de Nextcloud-documentatie over
|
||||
achtergrondjobs voor de versie van de server, nu
|
||||
https://docs.nextcloud.com/server/34/admin_manual/configuration_server/background_jobs_configuration.html,
|
||||
versienummer uit de server, niet vast); zolang er geen cron is, loopt een backup die je
|
||||
met de knop start alleen door terwijl de NG Backup-pagina openstaat.
|
||||
c. **Voortgang in de databasefase** (issue #13): nu staat er alleen "running (db)", wat er
|
||||
hetzelfde uitziet als vastzitten. Toon "running (db: 47/183 tables)" zoals de bestandsfase
|
||||
een percentage toont, en "waiting for background jobs (last ran 12 min ago)" als een run
|
||||
een tijd geen stap zette, ook bij een cron die is blijven staan.
|
||||
Niet doen: de cron-modus vanuit de app omzetten (zonder echte cron-regel stopt alles, en
|
||||
`cron.php` zet de modus zelf op `cron`), en geen `exec php occ backup:run &` vanuit de knop
|
||||
(vaak uitgeschakeld, niet op hosted, buiten Nextcloud om lastig te volgen en te stoppen).
|
||||
3. Volledige DR-test met een backup die al de tabelstructuur bevat (gemaakt met rc2 of later),
|
||||
zonder `--skip-missing-tables`: dat pad is op 1 TB nog niet getest.
|
||||
4. **Label bij "Back up now"** (besluit 2026-10-19): een optioneel tekstveld naast de knop; de
|
||||
tekst gaat als label van de snapshot mee (nu kan dat alleen met `occ backup:run -l`; de knop
|
||||
stuurt een leeg label, geplande runs krijgen "scheduled"). Raakt alleen de UI en een
|
||||
bestaande parameter. Ten hoogste 100 tekens — **klaar** (2026-10-30).
|
||||
5. **`--salvage`** voor `backup:restore` en `backup:restore:full` (besluit 2026-10-22): terughalen
|
||||
wat kan, een bestand dat niet te lezen of te ontsleutelen is overslaan en aan het eind
|
||||
opsommen, in plaats van stoppen. **Naar 1.0.2** (besluit 2026-10-30).
|
||||
6. **Tijdelijke bestanden van een afgebroken restore opruimen** (gevonden in de SMB-DR van
|
||||
rc6, 2026-10-24): de rc5-poging die stopte liet `VID_0009.mp4.ngb-restore-<id>` staan; de
|
||||
geslaagde restore daarna haalde het niet weg, en `files:scan` maakte het zichtbaar in Files.
|
||||
Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op (alleen bestanden met
|
||||
precies dat achtervoegsel en 6 of 12 hextekens) — **klaar** (2026-10-30).
|
||||
7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden
|
||||
terug (er komt een `/` achter); nu werkt alleen een map. Gold alleen voor `--to-directory`;
|
||||
naar de bestanden van een gebruiker werkte een los bestand al — **klaar** (2026-10-30).
|
||||
8. **Uitleg (i) bij de prullenbak van herstelpunten** (besluit 2026-10-25): bij "removed by
|
||||
retention policy" een infoknop met wat het betekent: het bewaarbeleid houdt voor oudere dagen
|
||||
één herstelpunt per dag (het laatste), weken en maanden; de rest gaat naar de prullenbak en
|
||||
wordt pas na de ingestelde wachttijd (`delete_delay_days`, standaard 30) echt verwijderd, en is
|
||||
tot die datum terug te zetten. Aanleiding: de beheerder zag "19-10 13:12 – after rc4 upgrade
|
||||
removed by retention policy" en dacht dat er iets was weggegooid. `occ backup:trash` zegt
|
||||
hetzelfde onder de lijst — **klaar** (2026-10-30).
|
||||
9. **Tijdzone in `occ backup:status`**: de tabel toont de servertijd (UTC), de webinterface de
|
||||
tijd van de beheerder; dat leest als een verkeerde starttijd. Zet de zone in de kolomkop
|
||||
("started (UTC)") of toon de tijden in de zone van het schema. Gedaan: beide, en ook in
|
||||
`backup:list`, `backup:trash` en `backup:browse` — **klaar** (2026-10-30).
|
||||
10. **User-restore op gebruikersnaam** (issue #22, 2026-10-27): `backup:user:restore <locatie>
|
||||
New_User` gaf alleen "is not a recorded user export"; het argument moet het manifestpad
|
||||
zijn, dat alleen `backup:user:backup` eenmalig toonde. Nu: `backup:user:list` toont de
|
||||
exports, een kale uid werkt bij precies één export, anders een lijst om uit te kiezen —
|
||||
**klaar** (2026-10-27).
|
||||
11. **Tabblad "Docs" in de beheerpagina** (besluit 2026-10-27): een derde knop naast "Backup and
|
||||
restore" en "Settings", met links naar de HOWTO (`docs/HOWTO.md`), de DR-gids
|
||||
(`docs/DISASTER-RECOVERY.md`) en de roadmap (README, `#roadmap`) op GitHub, elk met een
|
||||
regel wat erin staat. Nu zijn ze alleen via de README te vinden. Zichtbaar ook zonder
|
||||
sleutel, zodat de gids vindbaar is vóór de eerste installatie-stap. "NG Backup is in beta"
|
||||
gaat uit de feedbackregel — **klaar** (2026-10-27).
|
||||
12. **SFTP-sleutel zonder regeleinden** (issue #24, 2026-10-28): het veld "Private key" op de
|
||||
beheerpagina is één regel, dus een geplakte sleutel verliest zijn regeleinden. NC34
|
||||
(phpseclib 2) las dat nog, NC35 (phpseclib 3) weigert een OpenSSH-sleutel dan met "Unable
|
||||
to read key". De regeleinden worden nu teruggezet vlak voor gebruik — **klaar** (2026-10-28).
|
||||
13. **"database is locked" bij het begin van `backup:restore:full` op SQLite** (CI, 2026-10-26
|
||||
bij #22 en 2026-10-29 op `main` na de merge van 1.0.0): de restore stopt meteen na het
|
||||
aanzetten van de onderhoudsmodus. Niet altijd, dus een tweede proces dat op hetzelfde moment
|
||||
de database gebruikt (cron, een webverzoek of de opstart van de container). Kan ook bij een
|
||||
echte restore naar SQLite gebeuren. Oorzaak (2026-10-29): SQLite (WAL) begint een transactie
|
||||
als lezer; schrijft een ander proces tussen ons eerste lezen en ons eerste schrijven, dan
|
||||
faalt dat schrijven direct (geen wachttijd helpt). In CI was dat de installer, die
|
||||
"installed" zet en daarna nog seconden doorschrijft. Nu neemt de restore eerst het
|
||||
schrijfslot en wacht hij op de ander; de SQLite-test draait met een tweede schrijver
|
||||
ernaast — **klaar** (2026-10-29).
|
||||
- **DR-tests over databasegrenzen** (besluit 2026-10-20; productie nc01 draait PostgreSQL): na
|
||||
de MariaDB→PostgreSQL-fout (tellers, rc5) elke overstap in de integratietest (`to-pgsql`,
|
||||
`to-mysql`, `to-sqlite`, ook in CI). Volle DR-tests met echte data: vóór 1.0.0 de S3-simulatie
|
||||
(MariaDB-bron, 138 GB) nogmaals naar een MariaDB-doel en de SMB-simulatie naar een SQLite-doel;
|
||||
**na 1.0.0** de 1 TB van nc01 (PostgreSQL) naar een MariaDB-doel.
|
||||
- **Polish:** `target:add` toonde bij het eerste indexbestand al "about 642 min left" (S3,
|
||||
4.568 bestanden, daarna binnen een minuut klaar): een schatting pas na ~5% of na meer dan
|
||||
een paar bestanden.
|
||||
- **1.1.0** (geen harde datum: een week was een voorzet, langer mag; de releasefrequentie mag omlaag,
|
||||
besluit 2026-11-06):
|
||||
**Betere melding voor namen die een snapshot niet kan bevatten** (backslash, stuurteken, geen UTF-8):
|
||||
een eigen melding in plaats van "unreadable" met het advies over rechten. Zegt dat deze bestanden of
|
||||
mappen in de backup ontbreken, geeft voorbeelden van de paden en vraagt ze te hernoemen; de volgende
|
||||
backup neemt ze mee. Alleen als Nextcloud-melding: de app mailt niet zelf, of meldingen gemaild worden
|
||||
is de keuze van de beheerder (besluit 2026-11-06).
|
||||
**Stand op branch `1.1.0` (2026-11-07):** klaar: betere melding, punten 2, 4, 5, 6 en 8, en de maptijden
|
||||
(`files:scan` na een volledige restore: 0 "Updated" in de simulatie; opslag gepakt in het metarecord).
|
||||
Punt 2 reserveert de jobs in `oc_jobs` met één tijdstempel, alleen wat Nextcloud op dat moment zou kunnen
|
||||
starten; 6 uur aan, dan 1 uur vrij; bij een crash geeft Nextcloud de reservering na 12 uur zelf vrij;
|
||||
de logregels staan op niveau info, dus standaard niet zichtbaar. Pin en notitie staan
|
||||
in de catalogus, met een kopie in de database voor de lijsten. Een moment van vóór het instellen van
|
||||
een schema start niets. Geen schema: geen melding "geen recente backup", wel een melding aan alle
|
||||
beheerders bij het uitzetten en een herinnering, wekelijks zolang een andere locatie een schema heeft,
|
||||
anders dagelijks (besluit 2026-11-07). Nog niet getest: de update vanaf 1.0.3, de pagina in een browser.
|
||||
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.
|
||||
**Gebouwd (2026-11-07):** `backup:target:add` op een server zonder sleutel kijkt eerst of de
|
||||
locatie een repository heeft, vraagt dan een wachtwoordzin (of `--passphrase-file`), opent de
|
||||
slots uit `config`, controleert de sleutel tegen de `check` en slaat hem pas dan op; geen
|
||||
kitbevestiging, dus backups wachten op een nieuwe kit. Nieuw bestand `settings` op de locatie,
|
||||
versleuteld met de hoofdsleutel en eenmalig geschreven (bij `target:add` en bij het begin van
|
||||
een backup): de verwijdertermijn, anders de standaard 7 dagen. Getest: unit-tests en
|
||||
`full-restore-roundtrip.sh 35-apache pgsql no-kit`. Nog open: de DR-test op PVE02 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.
|
||||
**Gemeten 2026-11-07 op nc01 (alleen lezen):** de schijf (sdb, 2 TB, ROTA=1) haalt met `dd`
|
||||
21 MiB/s met 1 stroom, 37 met 4, 55 met 8. PHP las 8 KiB per verzoek: 5,2 MiB/s. Met
|
||||
`stream_set_read_buffer(0)` en blokken van 4 MiB: 26,8 MiB/s. Hulpprocessen die vooruitlezen
|
||||
gaven daarbovenop niets (20 MiB/s, ze concurreren). Gebouwd: grote leesverzoeken (commit
|
||||
"Read files ... 4 MiB"). Niet gebouwd: meerdere uploads tegelijk; SMB bundelt al per 1 MB
|
||||
(icewind/smb), dus winst over een lange lijn vraagt meerdere packs tegelijk.
|
||||
**Besluit 2026-11-08: meerdere uploads tegelijk in 1.2.0**, met een instelling (standaard 2)
|
||||
zodat de beheerder het afstemt op zijn server en de omvang van zijn backup.
|
||||
4. **Schema per locatie** (besluit 2026-10-19): in de instellingen van een locatie "uit",
|
||||
"dagelijks om HH:MM" of "elke N uur (vanaf HH:MM)". Nu is er één `schedule_time` voor alle
|
||||
locaties, één keer per dag. Bij de update wordt de huidige tijd het schema van elke
|
||||
bestaande locatie (voor bestaande gebruikers verandert er niets).
|
||||
5. **Locaties na elkaar laten starten** in plaats van tegelijk: twee runs die dezelfde schijven
|
||||
lezen zijn samen niet sneller (gezien op nc01); wachtende runs zichtbaar als "queued".
|
||||
7. **SMB sneller** (gemeten 2026-10-21): lokaal netwerk zonder vertraging (Samba-container op de
|
||||
laptop, 1,8 GB) back-up 24,9 MB/s, restore 27,6 MiB/s; vanaf Canada naar de Storage Box (130 ms)
|
||||
2,6 MiB/s, omdat SMB per blok op het antwoord wacht. Grotere schrijfblokken, en vooruitlezen
|
||||
bij een restore zoals SFTP (IReadsAhead) of meerdere packs tegelijk. Een NAS met 1 Gbit haalt
|
||||
~110 MB/s: daar is winst.
|
||||
Ook de index: `target:add` over SMB vanaf Canada haalt ~1 indexbestand per seconde (4.927
|
||||
bestanden ~85 min), want SMB krijgt geen `getMany()` met verzoeken tegelijk zoals S3 en SFTP.
|
||||
6. **Notitie achteraf bij een restorepunt**, te wijzigen in de lijst. Het label in de snapshot
|
||||
is versleuteld en ligt vast; de notitie apart op de locatie bewaren (bijv. in de catalogus),
|
||||
zodat een nieuwe server na een DR hem ook ziet.
|
||||
8. **Herstelpunt vastpinnen** (besluit 2026-10-25): een herstelpunt dat het bewaarbeleid
|
||||
nooit opruimt (bijv. "vóór de upgrade naar NC 35"), in de lijst en met
|
||||
`occ backup:pin <snapshot>` / `--unpin`. Op de locatie bewaren (catalogus), zodat de
|
||||
opschoning het ziet, ook na een DR; vastgepinde punten tellen niet mee in last/daily/weekly.
|
||||
9. **Feedbackvraag weghalen in 1.2.0** (besluit 2026-11-07, niet in 1.1.0): de regel "We would like
|
||||
to hear how NG Backup works for you ... Share your experience" in de beheerpagina blijft tot
|
||||
dan staan. Er komt weinig feedback bij zo'n 150 downloads per versie; geen bericht is goed
|
||||
bericht. Het formulier (`experience.yml`) in de issues blijft.
|
||||
- **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).
|
||||
|
||||
**Let op (ontdekt 2026-10-05):** Nextcloud's eigen `info.xsd` staat voor `<version>` alleen
|
||||
`[0-9]+(\.[0-9]+){0,2}` toe — geen `-alpha`/`-beta`/`-rc`-achtervoegsel. De stabiliteitsaanduiding
|
||||
uit deze tabel gaat dus niet in `info.xml` (dat blijft gewoon oplopend, bijv. `0.1.0`), maar in de
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
|
||||
Encrypted, incremental backups of Nextcloud (database, configuration and data) with restore
|
||||
from the web interface. Works without shell access: no external programs, no extra PHP
|
||||
extensions beyond sodium. Runs on Nextcloud All-in-One, regular installations and hosted
|
||||
Nextcloud.
|
||||
extensions beyond sodium for ng_backup itself. Runs on Nextcloud All-in-One, regular
|
||||
installations and hosted Nextcloud. (Choosing SMB as a storage backend is the one exception:
|
||||
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: in development (phase 1). Not ready for production use.** See `PLAN.md` for the design.
|
||||
**Status: 1.1.0, stable.** Disaster recovery has been tested on real installations (see "Tested
|
||||
disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and
|
||||
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
||||
`docs/DISASTER-RECOVERY.md`.
|
||||
|
||||
License: AGPL-3.0-or-later.
|
||||
If you like NG Backup, don't forget to give the app a star in the
|
||||
[app store](https://apps.nextcloud.com/apps/ng_backup) and this repository a star on GitHub!
|
||||
|
||||
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
|
||||
|
||||
@@ -19,16 +29,41 @@ 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.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Background jobs on system cron.** NG Backup runs backups in steps from Nextcloud's
|
||||
background job, so a backup only moves forward when that job runs. Choosing "Cron" under
|
||||
**Administration settings → Basic settings → Background jobs** is not enough on its own: it
|
||||
tells Nextcloud that the system will call it, so you also need a cron line that does. Without
|
||||
it scheduled backups do not run; the admin page and `occ backup:status` warn about it, and a
|
||||
backup started with "Back up now" only moves on while the NG Backup page stays open.
|
||||
`occ backup:run` in a terminal works without cron (it runs in the foreground).
|
||||
- Regular server: `crontab -u www-data -e` and add
|
||||
`*/5 * * * * php -f /var/www/nextcloud/cron.php` (your Nextcloud path).
|
||||
- Docker (the official `nextcloud` image): on the host, `crontab -e` as root and add
|
||||
`*/5 * * * * docker exec -u www-data <container> php -f /var/www/html/cron.php`, or add a
|
||||
service with the same image and `entrypoint: /cron.sh` to your `docker-compose.yml`.
|
||||
- Nextcloud All-in-One already runs cron.
|
||||
|
||||
After a few minutes, the Background jobs section shows that the last job ran just now. See the
|
||||
[Nextcloud documentation on background jobs](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html).
|
||||
- A storage location: S3 or compatible, SFTP, WebDAV, SMB (needs the PHP `smbclient` extension,
|
||||
see below), FTP or a local path.
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
occ backup:key:init --label="Safe" # first passphrase, deletion delay (default 7 days)
|
||||
occ backup:key:slot:add --label="CTO" # optional second and third passphrase
|
||||
occ backup:key:slot:add --label="Head of IT"
|
||||
occ backup:key:kit --output=/root/ng_backup-kit.json
|
||||
occ backup:key:kit --output=/var/tmp/ng_backup-kit.json
|
||||
occ backup:key:confirm
|
||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=...
|
||||
occ backup:run
|
||||
@@ -43,9 +78,20 @@ 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) **one passphrase** is enough: the new
|
||||
server takes the key from the location. With the **recovery kit** you can restore even when the
|
||||
location's `config` file is damaged or gone.
|
||||
- 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:
|
||||
|
||||
@@ -59,7 +105,8 @@ All backups of an installation are encrypted with one master key.
|
||||
a single slot is possible, but then that one passphrase is a single point of failure.
|
||||
- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download
|
||||
the new kit and confirm it. Note: an old kit together with the old passphrase still opens the
|
||||
same master key; to revoke it completely, start new locations with a new key.
|
||||
same master key; to revoke it completely, start new locations with a new key. This is noted on
|
||||
the backlog and will be addressed in a future version (key rotation).
|
||||
- Downloading the kit, and every change to the slots, requires the administrator's password, is
|
||||
written to the audit log and is announced to all administrators.
|
||||
|
||||
@@ -104,7 +151,33 @@ What you should do on the location (strongly recommended):
|
||||
- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder
|
||||
("Restored <date>"), merged into the original place, or replacing it.
|
||||
- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`.
|
||||
- **Whole server** (disaster recovery onto a fresh installation): planned for phase 2.
|
||||
- **Whole server** (disaster recovery onto a fresh installation): `occ backup:restore:full`, see
|
||||
[docs/DISASTER-RECOVERY.md](docs/DISASTER-RECOVERY.md). It runs in maintenance mode, so that
|
||||
nobody and no background job writes into the instance while it is being replaced.
|
||||
- **One user** (account, settings, files, calendars, contacts via user_migration): `occ backup:user:backup` and
|
||||
`occ backup:user:restore`, replacing the account or restoring it as `<uid>-bak`. See the how-to.
|
||||
|
||||
**Without shell access** (a hosted Nextcloud where you cannot run `occ`): files and folders can be
|
||||
restored in the web interface. Restoring a whole user or the whole server needs `occ`, so on such
|
||||
hosting it is something your provider (or whoever has shell access) runs. A restore mode that
|
||||
works without maintenance mode, and so from the web interface, is on the roadmap.
|
||||
|
||||
## Tested disaster recoveries
|
||||
|
||||
Full restores onto a fresh server, following only [the guide](docs/DISASTER-RECOVERY.md):
|
||||
|
||||
| Backup | Location | Database | Result |
|
||||
|---|---|---|---|
|
||||
| Production, about 1 TB, 150,000 files (Nextcloud All-in-One) | Hetzner Storage Box (SFTP), Germany → Canada | PostgreSQL → PostgreSQL | 150,622 files restored in about 25 h (13-14 MiB/s), sample identical |
|
||||
| The same, 1.0.2 | Hetzner Storage Box (SMB), Germany → Canada | PostgreSQL → PostgreSQL | 150,755 files restored in about 29 h (7-13 MiB/s), going on after a lost connection; sample identical, empty folders not restored ([#27](https://github.com/dillardblom/ng_backup/issues/27)) |
|
||||
| 1.0.3, small simulations of 2 GB: as planned, with the restore killed halfway, and with a disk that runs full | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files and all empty folders identical; users, a share and a calendar back |
|
||||
| 1.1.0, the same three simulations | Local SFTP server | PostgreSQL → PostgreSQL and MariaDB | all 3,101 user files identical in content, size and time, all empty folders back; users, a share and a calendar back |
|
||||
| 138 GB simulation: 20 users, calendars, contacts, mail, shares, team folders with an ACL, versions, trash | OVH S3, Warsaw → Canada | MariaDB → PostgreSQL | all 15,772 files and every database count identical |
|
||||
| The same | OVH S3 | MariaDB → MariaDB | all 15,772 files and every database count identical |
|
||||
| The same | Hetzner Storage Box (SMB), Germany → Canada | MariaDB → SQLite | all 15,772 files and every database count identical (27.6 h, about 1.5 MB/s) |
|
||||
| 51 GB test instance | OVH S3 | PostgreSQL → PostgreSQL | 23,519/23,519 checksums identical |
|
||||
|
||||
The integration tests restore across every pair of PostgreSQL, MariaDB and SQLite.
|
||||
|
||||
## Storage backend experiences
|
||||
|
||||
@@ -119,13 +192,145 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
||||
verified whether this also applies to a Hetzner Storage Box **main** account (only tested with a
|
||||
sub-account so far) — if you've tried a main account, please say so in a PR.
|
||||
|
||||
- **Hetzner Storage Box over WebDAV and SMB (sub-accounts, 2026-10-07):** both work end to end
|
||||
(backup, incremental run, restore of a folder, verify), tested from outside the Hetzner network
|
||||
with about 66 MiB of data. In the Hetzner Console, enable WebDAV or SMB support for the
|
||||
sub-account, and "External reachability" when your server is not in the Hetzner network.
|
||||
WebDAV: host `https://uXXXXX-subN.your-storagebox.de`, and the `root`
|
||||
must be a folder that already exists (or empty): WebDAV does not create missing parent folders,
|
||||
so a root that doesn't exist yet fails `backup:target:add` with `Sabre\HTTP\ClientHttpException:
|
||||
Conflict`. SMB: share = the sub-account name, port 445 must be reachable from your server (it was
|
||||
from a home connection here, but some providers block it), and the `smbclient` PECL extension is
|
||||
required (see the SMB entry below). Both have a noticeable fixed cost per run: an incremental run
|
||||
that uploads nothing took about 20 s over WebDAV and 50 s over SMB. Over WebDAV, Nextcloud gives
|
||||
every request at most 30 s by default, also an upload of a whole 32 MiB pack: on an upload
|
||||
slower than about 9 Mbit/s a backup fails with `cURL error 28: Operation timed out`. Raise the
|
||||
limit with `occ config:system:set davstorage.request_timeout --value=600 --type=integer`.
|
||||
|
||||
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
|
||||
absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style
|
||||
`vsftpd` test servers (chrooted) can reject that with a generic "create directory operation
|
||||
failed", even though a plain `ftp_mkdir()` with a *relative* path on the same server succeeds;
|
||||
`pure-ftpd` accepts the absolute form fine. If a working FTP server rejects ng_backup's writes,
|
||||
try a different FTP server implementation before assuming the backend itself is broken.
|
||||
|
||||
- **SMB/CIFS *lists* as available with just the `smbclient` CLI binary, but does not actually
|
||||
work for ng_backup without the `smbclient` PECL extension too.** files_external falls back to a
|
||||
shell-wrapped `smbclient` process when the native extension is missing, which registers the
|
||||
backend in `occ backup:target:backends` fine, but that fallback's stream cannot seek. ng_backup
|
||||
reads blobs back by byte range while backing up, so even the very first (small) backup fails
|
||||
with `Seek failed in packs/...`. Install the extension (`libsmbclient-dev` + `pecl install
|
||||
smbclient` + `docker-php-ext-enable smbclient` on Debian-based images), not just the CLI tool —
|
||||
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/`).
|
||||
A full restore over SFTP reads the packs ahead on a second connection, with many requests in
|
||||
flight. A server that handles parallel reads well (not a Hetzner Storage Box, which gets slower)
|
||||
can be read on more connections: `occ config:app:set ng_backup restore_sftp_connections
|
||||
--value=4` (at most 8; the server must allow that many plus one at once for the user).
|
||||
|
||||
- **OVH Object Storage (S3, Warsaw, 2026-10):** works with the `amazons3` backend (path style,
|
||||
region `waw`). A full backup of 1 TB from Germany ran at about 7 MiB/s, from Canada about
|
||||
6 MiB/s with BBR; a check of a 1 TB backup took 3.5 minutes (index files are fetched 16 at a
|
||||
time); a restore of 138 GB from Warsaw to Canada took about 6 hours. Turn on versioning and
|
||||
Object Lock on the bucket (see above); OVH charges no egress.
|
||||
|
||||
- **Hetzner Storage Box over SFTP, full restore:** about 13-14 MiB/s from Germany to Canada
|
||||
(122 ms) with the read-ahead of 1.0.0-rc.4; without it about 2.8 MiB/s. A Storage Box gets
|
||||
slower with several connections reading at once (4-6 MiB/s for 2-4 connections, it reads from
|
||||
disks), so one extra connection is the default. A Storage Box drops an SFTP connection that
|
||||
stayed quiet for a while without telling the client; NG Backup checks and replaces it.
|
||||
|
||||
- **SMB speed:** on a local network (Samba, no delay) a backup ran at about 25 MB/s and a
|
||||
restore at about 28 MiB/s. Over a long line SMB waits for an answer to each request: from
|
||||
Canada to a Storage Box in Germany (130 ms) about 2.6 MiB/s for a backup and 1.6 MiB/s for a
|
||||
restore, and connecting a location with an existing backup reads its index at about one file a
|
||||
second. SMB is meant for a local network; use SFTP or S3 for a location far away. When
|
||||
`pecl install smbclient` fails (pecl.php.net was unreachable for hours on 2026-10-21), the
|
||||
extension builds from its source: `libsmbclient-php` on GitHub, `phpize && ./configure && make
|
||||
&& make install`, then `docker-php-ext-enable smbclient`.
|
||||
|
||||
- **NFS is not a files_external backend at all.** Mount the NFS export on the server yourself
|
||||
(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
|
||||
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
||||
can't be driven from PHP directly.
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is planned, without dates. Ideas and votes are welcome in the
|
||||
[issues](https://github.com/dillardblom/ng_backup/issues); if something you need is listed here,
|
||||
a 👍 on its issue helps decide what comes first.
|
||||
|
||||
**1.2.0**
|
||||
- Faster backups over a long line: several uploads at the same time, with a setting
|
||||
(2 by default) to suit the server and the size of the backup.
|
||||
|
||||
**Later**
|
||||
- A full restore onto the existing installation, without first installing a fresh Nextcloud, in a
|
||||
restore mode of its own instead of maintenance mode, so that it can also run from the web
|
||||
interface (for hosted Nextcloud without shell access).
|
||||
- Users restoring their own files from the Files app.
|
||||
- Primary object storage and External Storage mounts (see Known limitations).
|
||||
- An option not to keep the backup key on the location (the recovery kit is then the only copy).
|
||||
|
||||
## Known limitations
|
||||
|
||||
On the backlog; know them before you rely on NG Backup:
|
||||
|
||||
- **Empty folders only come back from backups made with 1.0.3 or newer.** Older restore points do
|
||||
not contain them.
|
||||
- **Folders with files in them only get their original modification time back from backups made with
|
||||
1.1.0 or newer.** From older restore points they show the time of the restore; the files keep theirs.
|
||||
A restore through Nextcloud into a folder that is already there leaves that folder's time alone.
|
||||
- **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.
|
||||
- **One location, one server.** Never let two Nextcloud servers write to the same location (for
|
||||
example a disaster recovery test server pointed at the production location): they can break each
|
||||
other's catalog, and the location is then refused as possibly tampered with. Test a disaster
|
||||
recovery against a copy of the location, or only read from it.
|
||||
- **Removing a passphrase slot does not revoke access.** The master key stays the same, so someone
|
||||
with an old recovery kit and that slot's passphrase can still decrypt the location if they can
|
||||
read it. Key rotation is not available yet.
|
||||
- **Rollback before a disaster recovery is not detected.** A fresh server has no record of the
|
||||
newest catalog generation, so a location rolled back to an older state looks valid there.
|
||||
- Location passwords passed with `-o password=...` are visible in the process list and shell
|
||||
history while the command runs.
|
||||
- Temporary files of a restore into a user's files end up in that user's trash.
|
||||
- **Restoring a whole user or the whole server needs `occ`** (shell access); see "Without shell
|
||||
access" above.
|
||||
- **`--install-apps` depends on the app store.** Nextcloud downloads the whole app list (about
|
||||
38 MB) and gives up after 120 seconds; on a slow day apps are not installed. The restore then
|
||||
lists them: install them afterwards with `occ app:install`, their data is restored already.
|
||||
- A pack whose index upload failed stays on the location until removed by hand.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `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:target:schedule` | when a location is backed up: off, daily, or every N hours |
|
||||
| `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:pin`, `backup:note` | keep a restore point, a note you can change later |
|
||||
| `backup:pause-jobs` | whether heavy background jobs of other apps pause during a backup |
|
||||
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
||||
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
|
||||
| `backup:key:import` | import a recovery kit on a fresh installation |
|
||||
| `backup:user:backup`, `backup:user:list`, `backup:user:restore` | one user via user_migration (asks before installing it); list the exports |
|
||||
|
||||
+65
-8
@@ -3,18 +3,63 @@
|
||||
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[Alpha: feature-complete for scheduled, encrypted, incremental backups to
|
||||
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for
|
||||
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
|
||||
disaster recovery is not implemented yet. See the recovery kit section of the README before
|
||||
relying on this for anything.]]></description>
|
||||
<version>0.1.0</version>
|
||||
<summary>Encrypted, incremental backups of your Nextcloud — files, database and config — with restore from the web interface</summary>
|
||||
<description>< a star on GitHub!**
|
||||
|
||||
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
|
||||
you need help with restoring a backup after a crash.]]></description>
|
||||
<version>1.1.0</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://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/v1.1.0/screenshots/1-backups.png</screenshot>
|
||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/2-restore.png</screenshot>
|
||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/3-settings.png</screenshot>
|
||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.1.0/screenshots/4-bin.png</screenshot>
|
||||
<dependencies>
|
||||
<php min-version="8.1"/>
|
||||
<lib>sodium</lib>
|
||||
@@ -29,24 +74,36 @@ relying on this for anything.]]></description>
|
||||
<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>
|
||||
<command>OCA\NgBackup\Command\Restore</command>
|
||||
<command>OCA\NgBackup\Command\PauseJobs</command>
|
||||
<command>OCA\NgBackup\Command\Pin</command>
|
||||
<command>OCA\NgBackup\Command\Note</command>
|
||||
<command>OCA\NgBackup\Command\Prune</command>
|
||||
<command>OCA\NgBackup\Command\Retention</command>
|
||||
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
|
||||
<command>OCA\NgBackup\Command\TargetTrustCurrent</command>
|
||||
<command>OCA\NgBackup\Command\TargetLimit</command>
|
||||
<command>OCA\NgBackup\Command\TargetSchedule</command>
|
||||
<command>OCA\NgBackup\Command\Trash</command>
|
||||
<command>OCA\NgBackup\Command\TrashRestore</command>
|
||||
<command>OCA\NgBackup\Command\KeySlots</command>
|
||||
<command>OCA\NgBackup\Command\KeySlotAdd</command>
|
||||
<command>OCA\NgBackup\Command\KeySlotReplace</command>
|
||||
<command>OCA\NgBackup\Command\KeySlotRemove</command>
|
||||
<command>OCA\NgBackup\Command\BackupUser</command>
|
||||
<command>OCA\NgBackup\Command\RestoreUser</command>
|
||||
<command>OCA\NgBackup\Command\ListUserExports</command>
|
||||
<command>OCA\NgBackup\Command\KeyImport</command>
|
||||
<command>OCA\NgBackup\Command\RestoreFull</command>
|
||||
<command>OCA\NgBackup\Command\Verify</command>
|
||||
</commands>
|
||||
<settings>
|
||||
<admin>OCA\NgBackup\Settings\Admin</admin>
|
||||
|
||||
+7
-1
@@ -18,12 +18,18 @@ 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'],
|
||||
['name' => 'AdminApi#startBackup', 'url' => '/api/backups', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setPauseHeavyJobs', 'url' => '/api/settings/pause-heavy-jobs', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#setTargetSchedule', 'url' => '/api/targets/{id}/schedule', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#setSnapshotPinned', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/pin', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# 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
|
||||
|
||||
- [ ] **One passphrase** of the old server, and if you have it, the **recovery kit** file
|
||||
(`ng_backup-kit.json`).
|
||||
- [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder.
|
||||
- [ ] A new server with the **same Nextcloud major version** as the old one (for example 34),
|
||||
a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for
|
||||
the whole backup plus a margin.
|
||||
- [ ] **Shell access** to the new server, to run `occ` (on a hosted Nextcloud without it, your
|
||||
provider runs these steps).
|
||||
- [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by
|
||||
hand). Keep their packages somewhere safe beforehand.
|
||||
|
||||
No kit? Since version 1.1.0 the passphrase is enough: the key is also stored on the location (in
|
||||
its `config` file), locked with the same passphrases. Skip step 4. The kit is still the safer
|
||||
copy: it helps when that file on the location is damaged. Keep it 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** (with a kit; without one, go to step 5). 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=...
|
||||
|
||||
Without a kit (step 4 skipped), it finds the backups and asks for a passphrase of the old server;
|
||||
you type it yourself. It then shows the key fingerprint and the deletion delay of the old server.
|
||||
A wrong passphrase changes nothing: run the command again. Backups made before version 1.1.0 did
|
||||
not store the deletion delay on the location; it is then set to 7 days.
|
||||
|
||||
If it says that the location's **config file is gone**, the key is only in the recovery kit:
|
||||
import it (step 4), then add the location with `--repair-config` at the end of the same command.
|
||||
That writes the config again from the kit; it works for locations backed up by version 1.1.0 or
|
||||
newer.
|
||||
|
||||
If it says "No backup key yet, and no backups at this location", the connection details or
|
||||
`--path` differ from the old server's. Check them; do **not** run `backup:key:init`: 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").
|
||||
|
||||
**Made a new key by mistake** (with `backup:key:init` or on the page) before adding the location?
|
||||
As long as that key was never used (no location added with it, no backup), it can make way for the
|
||||
key of your backups. NG Backup says so when it refuses the location. Add `--replace-unused-key` to
|
||||
`occ backup:key:import` (with the kit) or to `occ backup:target:add` (without the kit; it asks for
|
||||
a passphrase of the old server). The passphrases and kit of the unused key then stop working. A key
|
||||
that was used for a location is never replaced: its own backups depend on it. A key made with a
|
||||
version before 1.1.0 always counts as used (those versions did not keep track), also when it never
|
||||
protected a location; recover onto a fresh Nextcloud installation instead.
|
||||
|
||||
**6. List the restore points** and pick one, normally the newest:
|
||||
|
||||
occ backup:list offsite
|
||||
|
||||
**7. Start the restore, and let it install the apps.**
|
||||
|
||||
occ backup:restore:full offsite <snapshot> --install-apps
|
||||
|
||||
`--install-apps` first installs every app the old server had enabled from the app store (or
|
||||
switches it on, if it is part of Nextcloud). Tables of apps that are missing or were removed long
|
||||
ago are created from the backup, so they do not stop the restore.
|
||||
|
||||
At the end it lists the apps it could not install, usually your own builds that are not in the
|
||||
app store. Copy those into `custom_apps` and enable them (`occ app:enable <app>`); their data is
|
||||
already restored.
|
||||
|
||||
Backups made before 1.0.0-rc.2 do not record the structure of their tables. If the restore lists
|
||||
tables that do not exist here, install the apps they belong to and run it again, or leave them
|
||||
out with `--skip-missing-tables`.
|
||||
|
||||
**8. Let the restore run.** It restores the database, then the data directory, and takes hours for
|
||||
a large backup. Nextcloud is in maintenance mode meanwhile, so nobody and no background job
|
||||
writes into it; the restore switches it off when it completes. (If you had switched maintenance
|
||||
mode on yourself before, it stays on: switch it off with `occ maintenance:mode --off` when ready.)
|
||||
|
||||
If it stops halfway, maintenance mode stays on (the instance may be partly restored). Fix the cause,
|
||||
then switch maintenance mode off and run the restore again with `--force`, in one line (the message
|
||||
prints it): `occ maintenance:mode --off && occ backup:restore:full <location> <snapshot> --force`.
|
||||
It goes on where it stopped: data files that are already in place with the size and modification
|
||||
time of the backup are left, so only the rest comes from the location again (since 1.0.2; before,
|
||||
it started over). The database is restored again in full, which takes minutes. Leave out `--install-apps` this time, so that the instance is open only for the seconds until the
|
||||
restore has switched maintenance mode on again; apps the first run could not install are listed at
|
||||
the end of the restore, install those afterwards.
|
||||
If occ then says that Nextcloud or one of the apps requires an upgrade, the database is already
|
||||
the old server's: run `occ upgrade` first, then the same command with `--force`.
|
||||
|
||||
**If the backup itself is damaged** (the restore stops with "Decryption failed", "Blob
|
||||
verification failed" or a file that is missing on the location), try an older restore point
|
||||
first. If none is better, `--salvage` restores whatever can be read:
|
||||
|
||||
occ backup:restore:full offsite <snapshot> --install-apps --salvage
|
||||
|
||||
If a restore already stopped halfway, run it as in step 8: maintenance mode off, with `--force` and
|
||||
without `--install-apps`:
|
||||
|
||||
occ maintenance:mode --off && occ backup:restore:full offsite <snapshot> --salvage --force
|
||||
|
||||
- Files that cannot be read are left out and listed at the end; everything else is restored.
|
||||
- A full disk is not damage: the restore stops, also with `--salvage`, and says so. Make room and
|
||||
run the restore again as in step 8.
|
||||
- **On version 1.0.2, check the disk first if the reason is "Size mismatch".** There a full disk
|
||||
looks like damaged data: the message points at `--salvage`, and with `--salvage` the restore goes
|
||||
on and lists the remaining files. The backup itself is fine. Update NG Backup, make room and run
|
||||
the restore again as in step 8.
|
||||
- If the database cannot be restored (its dump is damaged, or it does not fit this
|
||||
installation), the database stays as the fresh installation made it, and the data directory
|
||||
and config of the backup go to a folder of their own in the data directory,
|
||||
`ngb-salvage-<snapshot>`. Each user's files are in `data/<user>/files` there: create the users
|
||||
again and copy their files back by hand (then `occ files:scan --all`). Calendars, contacts and
|
||||
shares are in the database and cannot be recovered this way.
|
||||
|
||||
The command then ends with exit code 2 instead of 0. The same option exists for
|
||||
`occ backup:restore`, to restore single folders.
|
||||
|
||||
**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.
|
||||
- `occ files:scan` can report many folders as "Updated". That is expected: a restore does not yet put
|
||||
back the modification time of folders with files in them, so they show the time of the restore.
|
||||
The files themselves keep their time and content.
|
||||
- Empty folders only come back from backups made with version 1.0.3 or newer. With an older
|
||||
restore point they are gone: `occ files:scan` then removes them from the file cache, so it reports
|
||||
them as removed.
|
||||
- Start background jobs again (cron), as in step 2 in reverse.
|
||||
|
||||
**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.
|
||||
|
||||
Restored without a kit? Then backups wait until you have a new one: download it on the backup
|
||||
page (or `occ backup:key:kit --output=...`), store it off the server and confirm it
|
||||
(`occ backup:key:confirm`).
|
||||
|
||||
## 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.
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
# NG Backup how-to
|
||||
|
||||
Step by step: from first install to a tested restore. All commands run as the web user
|
||||
(`sudo -u www-data php occ ...`, or `docker exec -u www-data <container> php occ ...` in
|
||||
All-in-One and Docker setups). Replace names in `<angle brackets>` with your own.
|
||||
|
||||
## 1. Create the key and the recovery kit
|
||||
|
||||
```
|
||||
occ backup:key:init --label="Main" --delete-delay-days=7
|
||||
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: 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`. 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
|
||||
|
||||
NG Backup uses Nextcloud's External storage backends. Check what your server supports first:
|
||||
|
||||
```
|
||||
occ backup:target:backends
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret>
|
||||
occ backup:target:add nas sftp -a password::password -o host=<host> -o user=<user> -o password=<pw> -o root=home
|
||||
occ backup:target:add local local -a null::null -o datadir=/mnt/backup
|
||||
```
|
||||
|
||||
- **SFTP on a Hetzner Storage Box sub-account:** use `-o root=home`, not `/` (see README).
|
||||
- **FTP:** use a server that accepts absolute paths (for example `pure-ftpd`).
|
||||
- **SMB:** requires the native `smbclient` PHP extension. The CLI binary alone is not enough (see README).
|
||||
`backup:target:add` refuses SMB when the extension is missing.
|
||||
- **NFS:** not a backend of its own. Mount the export on the server and use the `local` backend
|
||||
with the mount point as `datadir`.
|
||||
|
||||
Verify the location before relying on it:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
```
|
||||
occ backup:run offsite -l first
|
||||
occ backup:list
|
||||
```
|
||||
|
||||
Later runs upload only changed data. An unchanged run uploads (almost) nothing; the output shows
|
||||
the number of uploaded bytes, so check that value when you test incremental behaviour.
|
||||
|
||||
Set when each location is backed up in the Administration settings, or with
|
||||
`occ backup:target:schedule offsite 02:00` (once a day), `02:00/6` (every 6 hours from 02:00) or `off`.
|
||||
Locations are backed up one after the other.
|
||||
|
||||
## 4. Check the backup regularly
|
||||
|
||||
```
|
||||
occ backup:verify offsite <snapshot> # shallow: all blocks are present
|
||||
occ backup:verify offsite <snapshot> --deep # also downloads and decrypts every block
|
||||
```
|
||||
|
||||
Run the deep check at least once after setting up a location, and after changing it.
|
||||
|
||||
## 5. Restore files and folders
|
||||
|
||||
In the web interface, or with occ:
|
||||
|
||||
```
|
||||
occ backup:browse offsite <snapshot>
|
||||
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=new-folder
|
||||
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=merge
|
||||
occ backup:restore offsite <snapshot> data/<uid>/files/Docs --mode=replace
|
||||
occ backup:restore offsite <snapshot> config --to-directory=/var/tmp/restore-config
|
||||
```
|
||||
|
||||
- `new-folder` (default): puts the files in "Restored <date>" next to the original. Safest choice.
|
||||
- `merge`: adds missing files, keeps current ones.
|
||||
- `replace`: overwrites the folder. Replaced files keep their old content as a version, and removed
|
||||
files go to the trash.
|
||||
- `--to-directory`: writes raw files to a local directory (for example `config/`).
|
||||
|
||||
## 6. Back up and restore one user
|
||||
|
||||
Needs the `user_migration` app. NG Backup asks before installing it:
|
||||
|
||||
```
|
||||
occ backup:user:backup offsite <uid>
|
||||
occ backup:user:list offsite [<uid>] # the exports and their manifest
|
||||
occ backup:user:restore offsite users/<uid>/<exportId> # replace (default)
|
||||
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
||||
```
|
||||
|
||||
- `backup:user:restore` needs the manifest path that `backup:user:backup` prints at the end
|
||||
(`users/<uid>/<exportId>`); `backup:user:list` shows it again. A user id alone also works when
|
||||
that user has exactly one export on the location. With more (a `replace` restore adds a safety
|
||||
export), the command lists them and you pass one.
|
||||
|
||||
- If `user_migration` is missing, the command asks to install and enable it. For non-interactive
|
||||
use, add `--install-user-migration` to approve that explicitly. The command stops after the
|
||||
installation; run it once more.
|
||||
- `replace` first makes a safety export of the current account, then recreates the user under the
|
||||
same id. When the account exists, the command asks first; in scripts, add `--force`. Shares to
|
||||
others and data of apps without a user_migration migrator are not restored, so prefer
|
||||
`as-backup` when in doubt: it restores into `<uid>-bak` and leaves the original untouched.
|
||||
- A restore onto a server where the user does not exist works. Calendars and address books get an
|
||||
internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from
|
||||
user_migration and cannot be turned off. The user can change the display name of these calendars and
|
||||
address books in the app as usual; the internal name stays as it is.
|
||||
- **Settings:** on restore, `user_migration` imports only the app settings on its own allowlist
|
||||
(for example calendar view and reminder settings). Other app settings of the user are skipped.
|
||||
NG Backup reuses `user_migration`'s importer, so this list is not extended.
|
||||
- Keep the last few exports per user: `occ backup:retention --user-last=3`.
|
||||
|
||||
## 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.
|
||||
|
||||
```
|
||||
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
|
||||
occ backup:target:add offsite <backend> ... # same details as on the original server
|
||||
occ backup:list offsite # the snapshots must be listed
|
||||
occ backup:restore:full offsite <snapshot>
|
||||
```
|
||||
|
||||
Without the kit, leave out `backup:key:import`: `backup:target:add` finds the backups and takes
|
||||
the key from the location with a passphrase (`--passphrase-file=/path/passphrase`, or it asks).
|
||||
Backups then wait until a new kit is downloaded and confirmed.
|
||||
|
||||
The command refuses on an installation that already has more than one user, and asks before it
|
||||
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
|
||||
|
||||
```
|
||||
occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12
|
||||
occ backup:prune offsite --dry-run # show first
|
||||
occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first
|
||||
occ backup:trash # list the trash
|
||||
occ backup:trash:restore offsite <id>
|
||||
occ backup:pin offsite <id> # keep this restore point; --unpin to undo
|
||||
occ backup:note offsite <id> "text" # a note you can change later; without text it is removed
|
||||
```
|
||||
|
||||
The retention policy leaves a pinned restore point alone, and a pinned one does not take the place of
|
||||
a daily, weekly or monthly one. Pin and note are stored on the location, so a new server sees them too.
|
||||
|
||||
Snapshots are kept according to the policy, and their data is deleted only after the deletion delay
|
||||
(set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be
|
||||
removed without changing anything.
|
||||
|
||||
## 9. Checklist before you rely on it
|
||||
|
||||
- [ ] Key created, passphrase stored off-server, recovery kit stored off-server
|
||||
- [ ] Location added; `backup:status` shows it reachable
|
||||
- [ ] First backup done and listed in `backup:list`
|
||||
- [ ] `backup:verify --deep` succeeds
|
||||
- [ ] One file restore tested (`new-folder`)
|
||||
- [ ] Disaster recovery tested on a second, fresh installation (or in a test environment)
|
||||
@@ -72,6 +72,10 @@ final class ExternalStorageFactory {
|
||||
throw new BackendException("Auth mechanism $authId does not fit backend $backendId");
|
||||
}
|
||||
|
||||
if (is_string($options['private_key'] ?? null)) {
|
||||
$options['private_key'] = self::normalizePrivateKey($options['private_key']);
|
||||
}
|
||||
|
||||
$config = new StorageConfig();
|
||||
$config->setBackend($backend);
|
||||
$config->setAuthMechanism($auth);
|
||||
@@ -86,4 +90,22 @@ final class ExternalStorageFactory {
|
||||
$storage = $backend->wrapStorage($storage);
|
||||
return $auth->wrapStorage($storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* An OpenSSH-format private key with its line breaks restored. Up to Nextcloud 34
|
||||
* (phpseclib 2) a key whose newlines were lost or turned into spaces, e.g. on its way through
|
||||
* a shell or a form, still loaded; Nextcloud 35 (phpseclib 3) refuses it with "Unable to read
|
||||
* key" (#24). The key itself is not changed, so it is fixed when it is used, not when stored.
|
||||
* Other formats (PEM, PKCS#8) still load as they are and are returned unchanged.
|
||||
*/
|
||||
public static function normalizePrivateKey(string $key): string {
|
||||
if (!preg_match('/-----BEGIN OPENSSH PRIVATE KEY-----(.*?)-----END OPENSSH PRIVATE KEY-----/s', $key, $m)) {
|
||||
return $key;
|
||||
}
|
||||
$body = (string)preg_replace('/\s+/', '', $m[1]);
|
||||
if (!preg_match('#^[A-Za-z0-9+/]+=*$#', $body)) {
|
||||
return $key;
|
||||
}
|
||||
return "-----BEGIN OPENSSH PRIVATE KEY-----\n" . chunk_split($body, 70, "\n") . "-----END OPENSSH PRIVATE KEY-----\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -37,5 +58,15 @@ interface IBackend {
|
||||
/** @return list<string> object paths below $prefix (recursive) */
|
||||
public function list(string $prefix): array;
|
||||
|
||||
/**
|
||||
* Object paths directly in $dir, for a folder that only holds objects (index/, snapshots/,
|
||||
* catalog/, trash/info/). One listing, without checking the type of every entry: on a remote
|
||||
* storage list() costs a round trip per entry, which for index/ (one file per pack) grows
|
||||
* with the size of the repository.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listFiles(string $dir): array;
|
||||
|
||||
public function delete(string $path): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
/**
|
||||
* A backend that can read many byte ranges ahead of use, several at a time over connections of
|
||||
* its own (a full restore, where one connection at a time is bound by the round trip).
|
||||
*/
|
||||
interface IReadsAhead {
|
||||
/** Whether readAhead() is worth planning for: false when it would return null anyway. */
|
||||
public function canReadAhead(): bool;
|
||||
|
||||
/**
|
||||
* The ranges in the order given, each as a local stream at offset 0 holding its bytes, read a
|
||||
* few ranges ahead of the one the caller is at. A stream is closed when the generator is
|
||||
* resumed. Null for a range that could not be read this way (a missing object, a server or
|
||||
* connection error): the caller reads it the usual way, which reports why.
|
||||
*
|
||||
* @param list<array{0:string, 1:int, 2:int}> $ranges [path, offset, length] each
|
||||
* @param string $spool an existing local directory for the files that hold the ranges
|
||||
* @return \Generator<int, resource|null>|null by position in $ranges; null when this backend
|
||||
* cannot read ahead now
|
||||
*/
|
||||
public function readAhead(array $ranges, string $spool): ?\Generator;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ declare(strict_types=1);
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
final class LocalBackend implements IBackend {
|
||||
use GetsOneByOne;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
) {
|
||||
@@ -33,11 +35,16 @@ final class LocalBackend implements IBackend {
|
||||
if ($out === false) {
|
||||
throw new BackendException('Cannot write ' . $path);
|
||||
}
|
||||
$expected = StorageBackend::remaining($stream);
|
||||
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
|
||||
throw new BackendException('Write failed for ' . $path);
|
||||
}
|
||||
fclose($out);
|
||||
$out = false;
|
||||
clearstatcache(true, $tmp);
|
||||
if ($expected !== null && filesize($tmp) !== $expected) {
|
||||
throw new BackendException('Incomplete write for ' . $path);
|
||||
}
|
||||
if (!rename($tmp, $target)) {
|
||||
throw new BackendException('Cannot finalise ' . $path);
|
||||
}
|
||||
@@ -60,6 +67,9 @@ final class LocalBackend implements IBackend {
|
||||
if ($fh === false) {
|
||||
throw new BackendException('Not found: ' . $path);
|
||||
}
|
||||
// Packs are read in large pieces: one request each, not 8 KiB at a time (see Repository::openForReading()).
|
||||
stream_set_read_buffer($fh, 0);
|
||||
stream_set_chunk_size($fh, 4 * 1048576);
|
||||
return $fh;
|
||||
}
|
||||
|
||||
@@ -94,7 +104,7 @@ final class LocalBackend implements IBackend {
|
||||
$result = [];
|
||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($it as $file) {
|
||||
if ($file->isFile() && !str_contains($file->getFilename(), '.part-')) {
|
||||
if ($file->isFile() && !StorageBackend::isTemporary($file->getFilename())) {
|
||||
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
|
||||
}
|
||||
}
|
||||
@@ -102,6 +112,25 @@ final class LocalBackend implements IBackend {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
$base = $this->abs($dir);
|
||||
if (!is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
$names = scandir($base);
|
||||
if ($names === false) {
|
||||
throw new BackendException('Cannot list ' . $dir);
|
||||
}
|
||||
foreach ($names as $name) {
|
||||
if ($name !== '.' && $name !== '..' && !StorageBackend::isTemporary($name)) {
|
||||
$result[] = trim($dir, '/') . '/' . $name;
|
||||
}
|
||||
}
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete(string $path): void {
|
||||
@unlink($this->abs($path));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
/**
|
||||
* Reads byte ranges of files on an SFTP location ahead of use, over several connections at once,
|
||||
* into local files (StorageBackend::readAhead(), for a full restore).
|
||||
*
|
||||
* A restore that reads its packs as it goes is bound by the round trip: from a Hetzner Storage
|
||||
* Box to Canada (RTT 122 ms) it reached about 2.8 MiB/s, with the PHP process mostly waiting.
|
||||
* Here every connection reads its own range with DEPTH requests in flight, ahead of the restore. PHP runs one thing at a time: while it waits for an answer on one connection, the
|
||||
* answers of the others arrive in the kernel's buffers, so the connections still transfer at the
|
||||
* same time.
|
||||
*
|
||||
* Uses the same internal phpseclib 2 methods as SftpReadStream (see SftpReadStream::connection()).
|
||||
* The connections are this class's own, never the storage's: no other request goes over them.
|
||||
* A range that fails (missing file, server error, a connection that breaks) comes out as null,
|
||||
* for the caller to read the usual way; a broken connection is not used again.
|
||||
*/
|
||||
final class SftpRangeFetcher {
|
||||
/**
|
||||
* Connections a full restore uses, unless the app setting restore_sftp_connections says
|
||||
* otherwise. One: a Hetzner Storage Box gave about 14 MiB/s on one connection with DEPTH
|
||||
* requests in flight, and 4-6 MiB/s on 2 or 4 (it reads from disks, and parallel readers make
|
||||
* them seek). On a line with packet loss to a server that handles parallel reads well, 4
|
||||
* connections did better (21 against 14 MiB/s).
|
||||
*/
|
||||
public const DEFAULT_CONNECTIONS = 1;
|
||||
public const MAX_CONNECTIONS = 8;
|
||||
/** READ requests in flight per connection. */
|
||||
public const DEPTH = 32;
|
||||
/** Ranges read ahead of the one the caller is at, in flight or waiting in the spool. */
|
||||
public const AHEAD = 8;
|
||||
/**
|
||||
* Seconds ready() waits for any connection to answer before it waits for each in turn: short,
|
||||
* in case a connection holds an answer where ready() does not look.
|
||||
*/
|
||||
private const SELECT_TIMEOUT = 1;
|
||||
/** See SftpReadStream::CHUNK and MIN_CHUNK. */
|
||||
private const CHUNK = 261120;
|
||||
private const MIN_CHUNK = 32768;
|
||||
|
||||
private const FXP_OPEN = 3;
|
||||
private const FXP_CLOSE = 4;
|
||||
private const FXP_READ = 5;
|
||||
private const FXP_STATUS = 101;
|
||||
private const FXP_HANDLE = 102;
|
||||
private const FXP_DATA = 103;
|
||||
private const FXF_READ = 1;
|
||||
private const FX_OK = 0;
|
||||
|
||||
/** Connections that broke and are replaced, at most, in one restore. */
|
||||
public const MAX_RECONNECTS = 20;
|
||||
|
||||
private const OPEN = 0;
|
||||
private const READ = 1;
|
||||
private const CLOSE = 2;
|
||||
|
||||
/** @var array<int, int> READ size per connection, lowered when a server answers less than asked */
|
||||
private array $chunks = [];
|
||||
/** @var array<int, SftpRangeJob> the range each busy connection reads */
|
||||
private array $jobs = [];
|
||||
/** @var array<int, resource|null> ranges read (or failed) and not yet given to the caller */
|
||||
private array $done = [];
|
||||
private int $requestId = 0x4e520000;
|
||||
/** Connections replaced so far (see MAX_RECONNECTS). */
|
||||
public int $reconnects = 0;
|
||||
|
||||
/**
|
||||
* @param array<int, object> $connections phpseclib 2 connections with their SFTP channel open
|
||||
* @param list<array{0:string, 1:int, 2:int}> $ranges paths below $root
|
||||
*/
|
||||
private function __construct(
|
||||
private array $connections,
|
||||
private string $root,
|
||||
private array $ranges,
|
||||
private string $spool,
|
||||
private ?\Closure $connect = null,
|
||||
) {
|
||||
foreach (array_keys($connections) as $c) {
|
||||
$this->chunks[$c] = self::CHUNK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* See IReadsAhead::readAhead(). The connections are ended when the generator ends.
|
||||
*
|
||||
* @param list<object> $connections
|
||||
* @param list<array{0:string, 1:int, 2:int}> $ranges
|
||||
* @param (\Closure():?object)|null $connect a new connection in place of one that broke
|
||||
* (null when it cannot be made)
|
||||
* @return \Generator<int, resource|null>
|
||||
*/
|
||||
public static function fetch(array $connections, string $root, array $ranges, string $spool, ?\Closure $connect = null): \Generator {
|
||||
$fetcher = new self($connections, rtrim($root, '/'), $ranges, $spool, $connect);
|
||||
try {
|
||||
yield from $fetcher->run();
|
||||
} finally {
|
||||
$fetcher->end();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return \Generator<int, resource|null> */
|
||||
private function run(): \Generator {
|
||||
$count = count($this->ranges);
|
||||
$next = 0;
|
||||
for ($want = 0; $want < $count; $want++) {
|
||||
while (!array_key_exists($want, $this->done)) {
|
||||
foreach (array_keys($this->connections) as $c) {
|
||||
if (!isset($this->jobs[$c]) && $next < $count && $next < $want + self::AHEAD) {
|
||||
$this->start($c, $next++);
|
||||
}
|
||||
}
|
||||
if ($this->jobs === [] && !array_key_exists($want, $this->done)) {
|
||||
// no connection left: the rest the usual way
|
||||
$this->done[$want] = null;
|
||||
$next = max($next, $want + 1);
|
||||
break;
|
||||
}
|
||||
foreach ($this->ready() as $c) {
|
||||
$this->step($c);
|
||||
}
|
||||
}
|
||||
$fh = $this->done[$want];
|
||||
unset($this->done[$want]);
|
||||
try {
|
||||
yield $want => $fh;
|
||||
} finally {
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The busy connections that have an answer waiting, so that a connection that waits for a
|
||||
* lost packet to be sent again does not hold up the others; all of them when the connections
|
||||
* do not tell (then they are taken in turn).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function ready(): array {
|
||||
$sockets = [];
|
||||
$ready = [];
|
||||
foreach ($this->jobs as $c => $job) {
|
||||
$sftp = $this->connections[$c];
|
||||
$vars = get_object_vars($sftp); // only what is public: phpseclib 2's are
|
||||
// Answers phpseclib has read already: the socket does not show them. Its request
|
||||
// buffer holds whole answers read while it waited for another; its packet buffer and
|
||||
// channel buffers hold what came in with the last packet it read (small answers, such
|
||||
// as a CLOSE and the next OPEN, often arrive together).
|
||||
$buffered = $vars['requestBuffer'] ?? null;
|
||||
$channels = $vars['channel_buffers'] ?? null;
|
||||
if ((is_array($buffered) && isset($job->queue[0]) && array_key_exists($job->queue[0][0], $buffered))
|
||||
|| (is_string($vars['packet_buffer'] ?? null) && self::wholePacket($vars['packet_buffer']))
|
||||
|| (is_array($channels) && array_filter($channels) !== [])) {
|
||||
$ready[] = $c;
|
||||
continue;
|
||||
}
|
||||
$socket = $vars['fsock'] ?? null;
|
||||
if (!is_resource($socket)) {
|
||||
return array_keys($this->jobs);
|
||||
}
|
||||
$sockets[$c] = $socket;
|
||||
}
|
||||
if ($ready !== [] || $sockets === []) {
|
||||
return $ready;
|
||||
}
|
||||
$read = $sockets;
|
||||
$write = null;
|
||||
$except = null;
|
||||
// Data in PHP's own buffer of a socket counts as readable. Nothing within the time:
|
||||
// each in turn, where phpseclib's own timeout applies.
|
||||
$n = @stream_select($read, $write, $except, self::SELECT_TIMEOUT);
|
||||
return $n === false || $n === 0 ? array_keys($this->jobs) : array_keys($read);
|
||||
}
|
||||
|
||||
/** Whether $buffer starts with a whole SFTP packet (length, then that many bytes). */
|
||||
private static function wholePacket(string $buffer): bool {
|
||||
$length = strlen($buffer) >= 4 ? unpack('N', $buffer) : false;
|
||||
return is_array($length) && strlen($buffer) >= 4 + $length[1];
|
||||
}
|
||||
|
||||
private function start(int $c, int $i): void {
|
||||
[$path, $offset, $length] = $this->ranges[$i];
|
||||
$fh = $this->spoolFile();
|
||||
if ($fh === null) {
|
||||
$this->done[$i] = null;
|
||||
return;
|
||||
}
|
||||
if ($length <= 0) {
|
||||
$this->done[$i] = $fh;
|
||||
return;
|
||||
}
|
||||
$this->jobs[$c] = new SftpRangeJob($i, $fh, $offset, $offset + $length);
|
||||
try {
|
||||
$remote = $this->connections[$c]->_realpath($this->root . '/' . ltrim($path, '/'));
|
||||
if (!is_string($remote)) {
|
||||
$this->finish($c);
|
||||
return;
|
||||
}
|
||||
$this->send($c, self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), self::OPEN);
|
||||
} catch (\Throwable) {
|
||||
$this->drop($c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Take the oldest answer on connection $c, then keep its requests in flight. */
|
||||
private function step(int $c): void {
|
||||
$job = $this->jobs[$c];
|
||||
[$id, $kind, $offset, $length] = array_shift($job->queue) ?? throw new \LogicException('no request in flight');
|
||||
$sftp = $this->connections[$c];
|
||||
try {
|
||||
$response = $sftp->_get_sftp_packet($id);
|
||||
if (!is_string($response)) {
|
||||
throw new BackendException('SFTP connection lost');
|
||||
}
|
||||
$type = $sftp->packet_type;
|
||||
if ($kind === self::OPEN) {
|
||||
if ($type === self::FXP_HANDLE) {
|
||||
$job->handle = substr($response, 4);
|
||||
} else {
|
||||
$job->failed = true; // missing, or no permission
|
||||
}
|
||||
} elseif ($kind === self::READ) {
|
||||
if (!$job->failed) {
|
||||
$this->received($c, $type, $response, $offset, $length);
|
||||
}
|
||||
} else {
|
||||
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||
$closed = is_array($status) && $status[1] === self::FX_OK;
|
||||
$this->finish($c);
|
||||
if (!$closed) {
|
||||
// handles might pile up on the server: not used again
|
||||
$this->drop($c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$this->refill($c);
|
||||
} catch (\Throwable) {
|
||||
$this->drop($c);
|
||||
}
|
||||
}
|
||||
|
||||
private function received(int $c, int $type, string $response, int $offset, int $length): void {
|
||||
$job = $this->jobs[$c];
|
||||
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||
$n = is_array($declared) ? $declared[1] : 0;
|
||||
if ($type !== self::FXP_DATA || $n <= 0 || $n !== strlen($response) - 4 || $n > $length) {
|
||||
// end of file before the end of the range, a server error or a malformed answer
|
||||
$job->failed = true;
|
||||
return;
|
||||
}
|
||||
if (fseek($job->fh, $offset - $job->start) !== 0 || fwrite($job->fh, substr($response, 4)) !== $n) {
|
||||
$job->failed = true;
|
||||
return;
|
||||
}
|
||||
$job->got += $n;
|
||||
if ($n < $length) {
|
||||
// a server that caps a READ: ask for the rest, and no more than it gives from now on
|
||||
$job->redo[] = [$offset + $n, $length - $n];
|
||||
$this->chunks[$c] = max(self::MIN_CHUNK, min($this->chunks[$c], $n));
|
||||
}
|
||||
}
|
||||
|
||||
/** Send READs until DEPTH are in flight; when nothing is left to ask for, close the file. */
|
||||
private function refill(int $c): void {
|
||||
$job = $this->jobs[$c];
|
||||
if ($job->handle !== '' && !$job->failed) {
|
||||
while (count($job->queue) < self::DEPTH) {
|
||||
if ($job->redo !== []) {
|
||||
[$offset, $length] = array_shift($job->redo);
|
||||
} elseif ($job->next < $job->end) {
|
||||
$offset = $job->next;
|
||||
$length = min($this->chunks[$c], $job->end - $offset);
|
||||
$job->next += $length;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
$packet = pack('Na*N3', strlen($job->handle), $job->handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
|
||||
$this->send($c, self::FXP_READ, $packet, self::READ, $offset, $length);
|
||||
}
|
||||
}
|
||||
if ($job->queue !== []) {
|
||||
return;
|
||||
}
|
||||
if ($job->handle === '') {
|
||||
$this->finish($c); // not opened: nothing to close
|
||||
return;
|
||||
}
|
||||
$this->send($c, self::FXP_CLOSE, pack('Na*', strlen($job->handle), $job->handle), self::CLOSE);
|
||||
}
|
||||
|
||||
private function send(int $c, int $type, string $packet, int $kind, int $offset = 0, int $length = 0): void {
|
||||
$this->requestId = $this->requestId >= 0x7fffffff ? 0x4e520000 : $this->requestId + 1;
|
||||
if (!$this->connections[$c]->_send_sftp_packet($type, $packet, $this->requestId)) {
|
||||
throw new BackendException('SFTP connection failed while sending a request');
|
||||
}
|
||||
$this->jobs[$c]->queue[] = [$this->requestId, $kind, $offset, $length];
|
||||
}
|
||||
|
||||
/** The range of connection $c is done: its stream for the caller, or null when it failed. */
|
||||
private function finish(int $c): void {
|
||||
$job = $this->jobs[$c];
|
||||
unset($this->jobs[$c]);
|
||||
if ($job->failed || $job->got !== $job->end - $job->start || !rewind($job->fh)) {
|
||||
self::closeFile($job->fh);
|
||||
$this->done[$job->i] = null;
|
||||
return;
|
||||
}
|
||||
$this->done[$job->i] = $job->fh;
|
||||
}
|
||||
|
||||
/** A connection in an unknown state: its range fails, and it is not used again. */
|
||||
private function drop(int $c): void {
|
||||
if (isset($this->jobs[$c])) {
|
||||
$job = $this->jobs[$c];
|
||||
unset($this->jobs[$c]);
|
||||
self::closeFile($job->fh);
|
||||
$this->done[$job->i] = null;
|
||||
}
|
||||
self::disconnect($this->connections[$c]);
|
||||
unset($this->connections[$c]);
|
||||
// A long restore outlives connections (a Storage Box drops one now and then): without a
|
||||
// new one, the rest would be read the slow way.
|
||||
if ($this->connect === null || $this->reconnects >= self::MAX_RECONNECTS) {
|
||||
return;
|
||||
}
|
||||
$this->reconnects++;
|
||||
try {
|
||||
$new = ($this->connect)();
|
||||
} catch (\Throwable) {
|
||||
$new = null;
|
||||
}
|
||||
if (function_exists('OCP\Log\logger')) {
|
||||
\OCP\Log\logger('ng_backup')->info($new === null
|
||||
? 'Reading ahead: an SFTP connection broke and no new one could be made'
|
||||
: 'Reading ahead: an SFTP connection broke; reading on with a new one');
|
||||
}
|
||||
if ($new !== null) {
|
||||
$key = max(array_merge(array_keys($this->chunks), [-1])) + 1;
|
||||
$this->connections[$key] = $new;
|
||||
$this->chunks[$key] = self::CHUNK;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return resource|null a new file in the spool, already unlinked: nothing is left behind if the process dies */
|
||||
private function spoolFile() {
|
||||
$path = $this->spool . '/' . bin2hex(random_bytes(8));
|
||||
$fh = @fopen($path, 'x+b');
|
||||
if ($fh === false) {
|
||||
return null;
|
||||
}
|
||||
@unlink($path);
|
||||
return $fh;
|
||||
}
|
||||
|
||||
private function end(): void {
|
||||
foreach ($this->jobs as $job) {
|
||||
self::closeFile($job->fh);
|
||||
}
|
||||
foreach ($this->done as $fh) {
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
$this->jobs = [];
|
||||
$this->done = [];
|
||||
foreach ($this->connections as $sftp) {
|
||||
self::disconnect($sftp);
|
||||
}
|
||||
$this->connections = [];
|
||||
}
|
||||
|
||||
private static function closeFile(mixed $fh): void {
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
private static function disconnect(object $sftp): void {
|
||||
try {
|
||||
if (is_callable([$sftp, 'disconnect'])) {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
/**
|
||||
* One range that SftpRangeFetcher reads on one connection.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SftpRangeJob {
|
||||
public string $handle = '';
|
||||
/** Offset of the next READ to send. */
|
||||
public int $next;
|
||||
/** @var list<array{int, int}> offset and length of parts to ask for again (after a short answer) */
|
||||
public array $redo = [];
|
||||
/** @var list<array{int, int, int, int}> request id, kind, offset and length, in the order sent */
|
||||
public array $queue = [];
|
||||
/** Bytes received. */
|
||||
public int $got = 0;
|
||||
public bool $failed = false;
|
||||
|
||||
/** @var resource the spool file that holds the range */
|
||||
public $fh;
|
||||
|
||||
/** @param resource $fh */
|
||||
public function __construct(
|
||||
public int $i,
|
||||
$fh,
|
||||
public int $start,
|
||||
public int $end,
|
||||
) {
|
||||
$this->fh = $fh;
|
||||
$this->next = $start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Backend;
|
||||
|
||||
/**
|
||||
* Read stream for files_external's SFTP storage that keeps several READ requests in flight.
|
||||
*
|
||||
* files_external's own read stream asks for the next 256 KiB only after the previous one has
|
||||
* arrived, so every request costs a full round trip: about 5 MB/s at 47 ms to a Hetzner Storage
|
||||
* Box, whatever the line speed. With 8 requests in flight the same connection reached 15 MB/s.
|
||||
* phpseclib's get() pipelines too, but only into memory or a local file, and only from offset 0.
|
||||
*
|
||||
* Talks to the storage's phpseclib connection through the same internal methods files_external's
|
||||
* read stream uses (_send_sftp_packet, _get_sftp_packet, _realpath); open() returns null when the
|
||||
* connection does not offer them (phpseclib 3 does not), and StorageBackend then falls back to the
|
||||
* storage's own stream. A restore must rather be slow than fail: when a read fails halfway, the
|
||||
* stream goes on from the same position with the storage's own stream. After MAX_FAILURES streams
|
||||
* in a row failed, open() returns null for the rest of the process; one that works resets the count.
|
||||
* Like that stream, it owns the connection while open: no other request may go over the same
|
||||
* connection until it is closed (PackReaderGuard takes care of that for the repository).
|
||||
*/
|
||||
final class SftpReadStream {
|
||||
private const PROTOCOL = 'ngbsftpread';
|
||||
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
|
||||
public const DEPTH = 8;
|
||||
/** Files read at once by readMany(): each holds a handle on the server while its batch runs. */
|
||||
public const BATCH = 32;
|
||||
/** Larger files are left to the usual read by readMany(), which holds every file in memory. */
|
||||
private const MANY_MAX_BYTES = 16 * 1048576;
|
||||
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
|
||||
private const CHUNK = 261120;
|
||||
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
|
||||
private const MIN_CHUNK = 32768;
|
||||
|
||||
private const FXP_OPEN = 3;
|
||||
private const FXP_CLOSE = 4;
|
||||
private const FXP_READ = 5;
|
||||
private const FXP_STATUS = 101;
|
||||
private const FXP_HANDLE = 102;
|
||||
private const FXP_DATA = 103;
|
||||
private const FXF_READ = 1;
|
||||
private const FX_OK = 0;
|
||||
private const FX_EOF = 1;
|
||||
|
||||
/**
|
||||
* READ size per connection, lowered when a server answers less than asked and then goes on, so
|
||||
* later streams over that connection ask for no more than it gives.
|
||||
*
|
||||
* @var \WeakMap<object, int>|null
|
||||
*/
|
||||
private static ?\WeakMap $chunks = null;
|
||||
private static int $requestId = 0x4e470000;
|
||||
/**
|
||||
* Connections whose server did not confirm closing a batch's files: readMany() is not used on
|
||||
* them again, so handles cannot pile up.
|
||||
*
|
||||
* @var \WeakMap<object, true>|null
|
||||
*/
|
||||
private static ?\WeakMap $noBatches = null;
|
||||
/** Streams in a row that failed; at MAX_FAILURES only the storage's own stream is used. */
|
||||
private static int $failures = 0;
|
||||
public const MAX_FAILURES = 3;
|
||||
/** Set by stream_open() when the connection failed rather than the file. */
|
||||
private static bool $openBroke = false;
|
||||
/** Why the last stream_open() failed on the connection. */
|
||||
private static string $openCause = '';
|
||||
|
||||
/** @var resource|null set by PHP for stream wrappers */
|
||||
public $context;
|
||||
|
||||
/**
|
||||
* The phpseclib connection. PHP creates stream wrappers without constructor arguments; it is
|
||||
* set in stream_open(), and no other method is called when that fails.
|
||||
*
|
||||
* @psalm-suppress PropertyNotSetInConstructor
|
||||
*/
|
||||
private object $sftp;
|
||||
/** The storage and the path on it, to fall back to the storage's own stream. */
|
||||
private ?object $storage = null;
|
||||
private string $file = '';
|
||||
/** @var resource|null the storage's own stream, once this one has failed */
|
||||
private $fallback = null;
|
||||
private string $handle = '';
|
||||
/** Position of the reader: the first byte of $buffer. */
|
||||
private int $pos = 0;
|
||||
/** Offset of the next READ to send. */
|
||||
private int $next = 0;
|
||||
private string $buffer = '';
|
||||
/** @var list<array{int, int, int}> request id, offset, length, in the order sent */
|
||||
private array $inFlight = [];
|
||||
private int $depth = 1;
|
||||
private bool $eof = false;
|
||||
/** This stream gave up on its own requests. */
|
||||
private bool $failed = false;
|
||||
/** The connection is in an unknown state: nothing more is sent over it or read from it. */
|
||||
private bool $broken = false;
|
||||
/** Length of the last short answer, until the next answer tells whether it was the end of the file. */
|
||||
private ?int $shortAnswer = null;
|
||||
|
||||
/**
|
||||
* @param object $storage files_external's SFTP storage (getConnection(), getRoot())
|
||||
* @return resource|false|null the stream, false when the file cannot be opened, null when the
|
||||
* connection does not offer what this stream needs
|
||||
* @throws BackendException when the connection failed after the OPEN request went out: the
|
||||
* storage's own stream must not be tried on it, as it would take a
|
||||
* late answer to that request for its own
|
||||
*/
|
||||
public static function open(object $storage, string $path) {
|
||||
if (self::$failures >= self::MAX_FAILURES) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$sftp = self::connection($storage);
|
||||
if ($sftp === null) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||
stream_wrapper_register(self::PROTOCOL, self::class);
|
||||
}
|
||||
$root = rtrim((string)$storage->getRoot(), '/');
|
||||
$context = stream_context_create([self::PROTOCOL => [
|
||||
'session' => $sftp,
|
||||
'path' => $root . '/' . ltrim($path, '/'),
|
||||
'storage' => $storage,
|
||||
'file' => $path,
|
||||
]]);
|
||||
self::$openBroke = false;
|
||||
$fh = @fopen(self::PROTOCOL . '://file', 'r', false, $context);
|
||||
} catch (\Throwable) {
|
||||
// nothing was sent yet
|
||||
self::$failures++;
|
||||
return null;
|
||||
}
|
||||
if ($fh === false && self::openBroke()) {
|
||||
throw new BackendException('SFTP connection failed while opening ' . $path . ': ' . self::$openCause);
|
||||
}
|
||||
return $fh;
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage's phpseclib connection, with its SFTP channel open, when it offers what these
|
||||
* streams (and SftpRangeFetcher) need; null otherwise.
|
||||
*/
|
||||
public static function connection(object $storage): ?object {
|
||||
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
|
||||
return null;
|
||||
}
|
||||
$sftp = $storage->getConnection();
|
||||
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||
// methods of these names neither public nor at all.
|
||||
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|
||||
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
return null;
|
||||
}
|
||||
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
|
||||
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
|
||||
if ($sftp->pwd() === false) {
|
||||
return null;
|
||||
}
|
||||
return $sftp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read many small files whole, BATCH at a time: the OPEN, READ and CLOSE requests of a batch
|
||||
* all go out before their answers are read, so a batch costs a few round trips instead of
|
||||
* four per file. Meant for files of up to a few hundred KiB (the repository's index files):
|
||||
* every file is held in memory.
|
||||
*
|
||||
* @param list<string> $paths below the storage's root
|
||||
* @return array<string, string|null>|null contents by path; null for a file this could not
|
||||
* read (missing, or a server error), so the caller
|
||||
* reads it the usual way; null for all when the
|
||||
* connection does not offer what this needs
|
||||
* @throws BackendException when the connection failed while requests were out: the storage's
|
||||
* own stream must not be tried on it
|
||||
*/
|
||||
public static function readMany(object $storage, array $paths): ?array {
|
||||
if (self::$failures >= self::MAX_FAILURES) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$sftp = self::connection($storage);
|
||||
$root = $sftp === null ? '' : rtrim((string)$storage->getRoot(), '/');
|
||||
} catch (\Throwable) {
|
||||
self::$failures++;
|
||||
return null;
|
||||
}
|
||||
if ($sftp === null || self::$noBatches?->offsetExists($sftp) === true) {
|
||||
return null;
|
||||
}
|
||||
$result = array_fill_keys($paths, null);
|
||||
foreach (array_chunk($paths, self::BATCH) as $batch) {
|
||||
try {
|
||||
[$read, $closed] = self::readBatch($sftp, $root, $batch);
|
||||
} catch (\Throwable $e) {
|
||||
self::$failures++;
|
||||
// Answers to requests already sent may still arrive, and files_external would take
|
||||
// them for its own: end the connection rather than leave it out of step.
|
||||
self::disconnect($sftp);
|
||||
throw $e instanceof BackendException ? $e : new BackendException('SFTP connection failed while reading ' . count($batch) . ' files: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
foreach ($read as $path => $contents) {
|
||||
$result[$path] = $contents;
|
||||
}
|
||||
if (!$closed) {
|
||||
// The server did not confirm closing these files: no more batches on this
|
||||
// connection, so handles cannot pile up. The rest is read the usual way.
|
||||
self::noMoreBatches($sftp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
self::$failures = 0;
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function noMoreBatches(object $sftp): void {
|
||||
self::$noBatches ??= new \WeakMap();
|
||||
self::$noBatches[$sftp] = true;
|
||||
}
|
||||
|
||||
private static function disconnect(object $sftp): void {
|
||||
try {
|
||||
if (is_callable([$sftp, 'disconnect'])) {
|
||||
$sftp->disconnect();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests of one kind for a whole batch, then their answers in order. @return list<array{int, string}> */
|
||||
private static function exchange(object $sftp, int $type, array $packets): array {
|
||||
$ids = [];
|
||||
foreach ($packets as $packet) {
|
||||
$id = self::nextId();
|
||||
if (!$sftp->_send_sftp_packet($type, $packet, $id)) {
|
||||
throw new BackendException('SFTP connection failed while sending a request');
|
||||
}
|
||||
$ids[] = $id;
|
||||
}
|
||||
$answers = [];
|
||||
foreach ($ids as $id) {
|
||||
$response = $sftp->_get_sftp_packet($id);
|
||||
if (!is_string($response)) {
|
||||
throw new BackendException('SFTP connection failed while waiting for an answer');
|
||||
}
|
||||
$answers[] = [$sftp->packet_type, $response];
|
||||
}
|
||||
return $answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $batch
|
||||
* @return array{0: array<string, string>, 1: bool} the files read whole, and whether the
|
||||
* server confirmed closing every handle
|
||||
*/
|
||||
private static function readBatch(object $sftp, string $root, array $batch): array {
|
||||
$opens = [];
|
||||
$openPaths = [];
|
||||
foreach ($batch as $path) {
|
||||
$remote = $sftp->_realpath($root . '/' . ltrim($path, '/'));
|
||||
if (is_string($remote)) {
|
||||
$opens[] = pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0);
|
||||
$openPaths[] = $path;
|
||||
}
|
||||
// a path that cannot be resolved is left to the caller's usual read
|
||||
}
|
||||
$handles = []; // path => handle
|
||||
foreach (self::exchange($sftp, self::FXP_OPEN, $opens) as $i => [$type, $response]) {
|
||||
if ($type === self::FXP_HANDLE) {
|
||||
$handles[$openPaths[$i]] = substr($response, 4);
|
||||
}
|
||||
// any other answer (missing, no permission): the caller reads it the usual way
|
||||
}
|
||||
$data = array_fill_keys(array_keys($handles), '');
|
||||
$reading = $handles;
|
||||
while ($reading !== []) {
|
||||
$length = self::chunk($sftp);
|
||||
$reads = [];
|
||||
foreach ($reading as $path => $handle) {
|
||||
$offset = strlen($data[$path]);
|
||||
$reads[] = pack('Na*N3', strlen($handle), $handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
|
||||
}
|
||||
$paths = array_keys($reading);
|
||||
foreach (self::exchange($sftp, self::FXP_READ, $reads) as $i => [$type, $response]) {
|
||||
$path = $paths[$i];
|
||||
$declared = strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||
if ($type === self::FXP_DATA && is_array($declared) && $declared[1] > 0 && $declared[1] === strlen($response) - 4
|
||||
&& $declared[1] <= $length && strlen($data[$path]) + $declared[1] <= self::MANY_MAX_BYTES) {
|
||||
$data[$path] .= substr($response, 4); // more may follow: read on from there
|
||||
continue;
|
||||
}
|
||||
unset($reading[$path]);
|
||||
$status = $type === self::FXP_STATUS && is_array($declared) ? $declared[1] : null;
|
||||
if ($status !== self::FX_EOF) {
|
||||
// a server error, a malformed answer or a file too large for this: the caller
|
||||
// reads it the usual way
|
||||
unset($data[$path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$closes = [];
|
||||
foreach ($handles as $handle) {
|
||||
$closes[] = pack('Na*', strlen($handle), $handle);
|
||||
}
|
||||
$closed = true;
|
||||
foreach (self::exchange($sftp, self::FXP_CLOSE, $closes) as [$type, $response]) {
|
||||
$status = $type === self::FXP_STATUS && strlen($response) >= 4 ? unpack('N', $response) : false;
|
||||
if (!is_array($status) || $status[1] !== self::FX_OK) {
|
||||
$closed = false;
|
||||
}
|
||||
}
|
||||
return [$data, $closed];
|
||||
}
|
||||
|
||||
/** Test hook: forget the learned READ sizes and earlier failures. */
|
||||
public static function reset(): void {
|
||||
self::$chunks = null;
|
||||
self::$noBatches = null;
|
||||
self::$failures = 0;
|
||||
}
|
||||
|
||||
public function stream_open(string $url, string $mode, int $options, ?string &$openedPath): bool {
|
||||
$options = stream_context_get_options($this->context ?? stream_context_get_default())[self::PROTOCOL] ?? [];
|
||||
if (!isset($options['session'], $options['path']) || !is_object($options['session'])) {
|
||||
return false;
|
||||
}
|
||||
$this->sftp = $options['session'];
|
||||
$storage = $options['storage'] ?? null;
|
||||
$this->storage = is_object($storage) ? $storage : null;
|
||||
$this->file = (string)($options['file'] ?? '');
|
||||
try {
|
||||
$remote = $this->sftp->_realpath((string)$options['path']);
|
||||
if (!is_string($remote)) {
|
||||
return false;
|
||||
}
|
||||
$id = self::nextId();
|
||||
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) {
|
||||
return $this->connectionFailed('the request could not be sent');
|
||||
}
|
||||
$response = $this->sftp->_get_sftp_packet($id);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->connectionFailed(get_class($e) . ': ' . $e->getMessage());
|
||||
}
|
||||
if (!is_string($response)) {
|
||||
return $this->connectionFailed('no answer');
|
||||
}
|
||||
if ($this->sftp->packet_type !== self::FXP_HANDLE) {
|
||||
return false; // an answer, but no handle: the file cannot be opened
|
||||
}
|
||||
$this->handle = substr($response, 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Not a missing file but a connection that does not behave as expected. */
|
||||
private function connectionFailed(string $cause): bool {
|
||||
self::$failures++;
|
||||
self::$openBroke = true;
|
||||
self::$openCause = $cause . self::connectionState($this->sftp);
|
||||
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
|
||||
self::disconnect($this->sftp);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** What phpseclib knows about the connection, for an error message. */
|
||||
private static function connectionState(object $sftp): string {
|
||||
$state = [];
|
||||
try {
|
||||
if (is_callable([$sftp, 'isConnected'])) {
|
||||
$state[] = 'connected: ' . ($sftp->isConnected() ? 'yes' : 'no');
|
||||
}
|
||||
if (is_callable([$sftp, 'getLastError'])) {
|
||||
$last = (string)$sftp->getLastError();
|
||||
if ($last !== '') {
|
||||
$state[] = 'ssh: ' . $last;
|
||||
}
|
||||
}
|
||||
if (is_callable([$sftp, 'getLastSFTPError'])) {
|
||||
$last = (string)$sftp->getLastSFTPError();
|
||||
if ($last !== '') {
|
||||
$state[] = 'sftp: ' . $last;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
return $state === [] ? '' : ' (' . implode('; ', $state) . ')';
|
||||
}
|
||||
|
||||
/** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
|
||||
private static function openBroke(): bool {
|
||||
return self::$openBroke;
|
||||
}
|
||||
|
||||
public function stream_read(int $count): string|false {
|
||||
if ($this->fallback !== null) {
|
||||
return fread($this->fallback, $count);
|
||||
}
|
||||
while (strlen($this->buffer) < $count && !$this->eof) {
|
||||
if (!$this->receive()) {
|
||||
return $this->useFallback() ? $this->stream_read($count) : false;
|
||||
}
|
||||
}
|
||||
$data = substr($this->buffer, 0, $count);
|
||||
$this->buffer = substr($this->buffer, strlen($data));
|
||||
$this->pos += strlen($data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function stream_eof(): bool {
|
||||
if ($this->fallback !== null) {
|
||||
return feof($this->fallback);
|
||||
}
|
||||
return $this->eof && $this->buffer === '';
|
||||
}
|
||||
|
||||
public function stream_tell(): int {
|
||||
if ($this->fallback !== null) {
|
||||
$pos = ftell($this->fallback);
|
||||
return $pos === false ? $this->pos : $pos;
|
||||
}
|
||||
return $this->pos;
|
||||
}
|
||||
|
||||
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
|
||||
if ($this->fallback !== null) {
|
||||
return fseek($this->fallback, $offset, $whence) === 0;
|
||||
}
|
||||
if ($whence === SEEK_CUR) {
|
||||
$offset += $this->pos;
|
||||
} elseif ($whence !== SEEK_SET) {
|
||||
return false; // the size is never asked for, to save a round trip
|
||||
}
|
||||
if ($offset < 0) {
|
||||
return false;
|
||||
}
|
||||
if ($this->failed) {
|
||||
$this->pos = $offset;
|
||||
return $this->useFallback();
|
||||
}
|
||||
if ($offset >= $this->pos && $offset - $this->pos <= strlen($this->buffer)) {
|
||||
$this->buffer = substr($this->buffer, $offset - $this->pos);
|
||||
} else {
|
||||
$this->drain();
|
||||
$this->buffer = '';
|
||||
$this->next = $offset;
|
||||
$this->eof = false;
|
||||
$this->depth = 1;
|
||||
$this->shortAnswer = null;
|
||||
}
|
||||
$this->pos = $offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<int|string, int> */
|
||||
public function stream_stat(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
public function stream_close(): void {
|
||||
if (!$this->failed) {
|
||||
self::$failures = 0;
|
||||
}
|
||||
$fallback = $this->fallback;
|
||||
$this->fallback = null;
|
||||
if ($fallback !== null) {
|
||||
fclose($fallback);
|
||||
}
|
||||
$this->closeHandle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go on with the storage's own stream from the current position, after a server error on this
|
||||
* one. The data still buffered is dropped and read again. False after a connection error, or
|
||||
* when that stream cannot be opened or positioned: then the read fails as it would have
|
||||
* without this class. Reopening by path is safe for a repository: packs and index files are
|
||||
* never rewritten, and every object is authenticated when it is decrypted.
|
||||
*/
|
||||
private function useFallback(): bool {
|
||||
$this->closeHandle();
|
||||
$this->buffer = '';
|
||||
// files_external's stream takes whatever answer comes next on the connection: after a
|
||||
// connection error, answers to this stream's requests may still be on their way.
|
||||
if ($this->broken || $this->storage === null || !is_callable([$this->storage, 'fopen'])) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$fh = $this->storage->fopen($this->file, 'r');
|
||||
if (!is_resource($fh)) {
|
||||
return false;
|
||||
}
|
||||
if ($this->pos > 0 && fseek($fh, $this->pos) !== 0) {
|
||||
fclose($fh);
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
$this->fallback = $fh;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function closeHandle(): void {
|
||||
$this->drain();
|
||||
if ($this->handle !== '' && !$this->broken) {
|
||||
try {
|
||||
$id = self::nextId();
|
||||
if (!$this->sftp->_send_sftp_packet(self::FXP_CLOSE, pack('Na*', strlen($this->handle), $this->handle), $id)
|
||||
|| !is_string($this->sftp->_get_sftp_packet($id))) {
|
||||
$this->broken = true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->broken = true;
|
||||
}
|
||||
if ($this->broken) {
|
||||
self::disconnect($this->sftp);
|
||||
}
|
||||
}
|
||||
$this->handle = '';
|
||||
}
|
||||
|
||||
/** Keep the pipeline full, then take the oldest answer. False on a connection or server error. */
|
||||
private function receive(): bool {
|
||||
if ($this->failed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return $this->receiveOne();
|
||||
} catch (\Throwable) {
|
||||
$this->broken = true;
|
||||
return $this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
private function receiveOne(): bool {
|
||||
while (count($this->inFlight) < $this->depth) {
|
||||
$id = self::nextId();
|
||||
$length = self::chunk($this->sftp);
|
||||
$packet = pack('Na*N3', strlen($this->handle), $this->handle, intdiv($this->next, 4294967296), $this->next % 4294967296, $length);
|
||||
if (!$this->sftp->_send_sftp_packet(self::FXP_READ, $packet, $id)) {
|
||||
$this->broken = true;
|
||||
return $this->fail();
|
||||
}
|
||||
$this->inFlight[] = [$id, $this->next, $length];
|
||||
$this->next += $length;
|
||||
}
|
||||
[$id, $offset, $length] = array_shift($this->inFlight);
|
||||
$response = $this->sftp->_get_sftp_packet($id);
|
||||
if (!is_string($response)) {
|
||||
$this->broken = true;
|
||||
return $this->fail();
|
||||
}
|
||||
if ($this->sftp->packet_type === self::FXP_DATA) {
|
||||
$data = substr($response, 4);
|
||||
if ($data === '') {
|
||||
return $this->fail();
|
||||
}
|
||||
$this->buffer .= $data;
|
||||
$this->depth = min(self::DEPTH, $this->depth * 2);
|
||||
if ($this->shortAnswer !== null) {
|
||||
// More data after a short answer: that one was the server's cap, not the end.
|
||||
// Never below what every server must support: a one-off small answer is no cap.
|
||||
self::$chunks ??= new \WeakMap();
|
||||
self::$chunks[$this->sftp] = max(self::MIN_CHUNK, min(self::chunk($this->sftp), $this->shortAnswer));
|
||||
$this->shortAnswer = null;
|
||||
}
|
||||
if (strlen($data) < $length) {
|
||||
// Fewer bytes than asked: the end of the file, or a server that caps a READ. The
|
||||
// requests behind this one assumed a full answer, so drop them and ask again from here.
|
||||
$this->drain();
|
||||
$this->next = $offset + strlen($data);
|
||||
$this->shortAnswer = strlen($data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
$status = $this->sftp->packet_type === self::FXP_STATUS ? unpack('N', $response) : false;
|
||||
if (is_array($status) && ($status[1] ?? null) === self::FX_EOF) {
|
||||
$this->eof = true;
|
||||
$this->shortAnswer = null;
|
||||
$this->drain();
|
||||
return true;
|
||||
}
|
||||
return $this->fail();
|
||||
}
|
||||
|
||||
private function fail(): bool {
|
||||
$this->drain();
|
||||
if (!$this->failed) {
|
||||
self::$failures++;
|
||||
}
|
||||
$this->failed = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Read and drop the answers still on their way, so the connection is free again. */
|
||||
private function drain(): void {
|
||||
$inFlight = $this->inFlight;
|
||||
$this->inFlight = [];
|
||||
try {
|
||||
foreach ($this->broken ? [] : $inFlight as [$id]) {
|
||||
if (!is_string($this->sftp->_get_sftp_packet($id))) {
|
||||
$this->broken = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->broken = true;
|
||||
}
|
||||
if ($this->broken) {
|
||||
// unknown state: closed, so that nothing else uses it (and the backend can build a new one)
|
||||
self::disconnect($this->sftp);
|
||||
}
|
||||
}
|
||||
|
||||
private static function chunk(object $sftp): int {
|
||||
$learned = self::$chunks?->offsetExists($sftp) === true ? self::$chunks->offsetGet($sftp) : null;
|
||||
return is_int($learned) ? $learned : self::CHUNK;
|
||||
}
|
||||
|
||||
private static function nextId(): int {
|
||||
self::$requestId = self::$requestId >= 0x7fffffff ? 0x4e470000 : self::$requestId + 1;
|
||||
return self::$requestId;
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,61 @@ use OCP\Files\Storage\IWriteStreamStorage;
|
||||
* IBackend on top of a Nextcloud storage object (from files_external or core), below a base
|
||||
* directory on that storage.
|
||||
*/
|
||||
final class StorageBackend implements IBackend {
|
||||
final class StorageBackend implements IBackend, IReadsAhead {
|
||||
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
|
||||
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;
|
||||
/**
|
||||
* Ranges readAhead() fetches at once over S3, each straight into a file in the spool (a range
|
||||
* is at most ReadAheadPlan::MAX_RANGE: the spool holds at most this many times that).
|
||||
*/
|
||||
public const S3_READ_AHEAD = 8;
|
||||
/** Most ranges, and bytes, getRanges() holds in memory at once. */
|
||||
private const RANGES_LOT = 64;
|
||||
private const RANGES_LOT_BYTES = 64 * 1048576;
|
||||
/** Seconds without a request after which an SFTP connection is checked before it is used. */
|
||||
public const IDLE_CHECK = 60;
|
||||
private float $lastUsed;
|
||||
/** Connections readAhead() opens (see SftpRangeFetcher::DEFAULT_CONNECTIONS). */
|
||||
public int $readAheadConnections = SftpRangeFetcher::DEFAULT_CONNECTIONS;
|
||||
/** Times the storage was built anew after its connection died (for tests and diagnostics). */
|
||||
public int $reconnects = 0;
|
||||
|
||||
/**
|
||||
* @param (\Closure():IStorage)|null $reconnect builds the same storage anew, with a new
|
||||
* connection
|
||||
*/
|
||||
public function __construct(
|
||||
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);
|
||||
if ($this->base !== '') {
|
||||
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
|
||||
$this->s3Reads = $storage instanceof \OCA\Files_External\Lib\Storage\AmazonS3;
|
||||
if ($createBase && $this->base !== '') {
|
||||
$this->mkdirs($this->base);
|
||||
}
|
||||
}
|
||||
|
||||
/** A temporary upload of put(): "<name>.part-<8 hex>". */
|
||||
public static function isTemporary(string $name): bool {
|
||||
return preg_match('/\.part-[0-9a-f]{8}$/', $name) === 1;
|
||||
}
|
||||
|
||||
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));
|
||||
$writeTo = $this->atomicPut ? $target : $target . '.part-' . bin2hex(random_bytes(4));
|
||||
@@ -54,6 +92,10 @@ final class StorageBackend implements IBackend {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
// Some adapters report success after a short write or a failed flush: check the size.
|
||||
if ($expected !== null && $this->storage->filesize($writeTo) !== $expected) {
|
||||
throw new BackendException('Incomplete write for ' . $path);
|
||||
}
|
||||
if ($writeTo !== $target && !$this->storage->rename($writeTo, $target)) {
|
||||
throw new BackendException('Cannot finalise ' . $path);
|
||||
}
|
||||
@@ -69,14 +111,316 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
|
||||
public function get(string $path) {
|
||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||
return $this->call(fn () => $this->open($path));
|
||||
}
|
||||
|
||||
/** @return resource */
|
||||
private function open(string $path) {
|
||||
if ($this->sftpReads) {
|
||||
$fh = SftpReadStream::open($this->storage, $this->abs($path));
|
||||
if (is_resource($fh)) {
|
||||
return $fh;
|
||||
}
|
||||
// null: not supported or failing for now; false: not opened, which need not mean
|
||||
// missing. Either way the storage's own stream decides.
|
||||
}
|
||||
try {
|
||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||
} catch (\Exception $e) {
|
||||
// Some storages throw for what is not a missing file (SMB: a connection that broke).
|
||||
// As a BackendException, the caller reads once more and --salvage can go on.
|
||||
throw new BackendException('Cannot read ' . $path . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
if ($fh === false) {
|
||||
throw new BackendException('Not found: ' . $path);
|
||||
}
|
||||
return $fh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Over SFTP and S3 several at a time (SftpReadStream::readMany(), s3Fetch()); elsewhere one
|
||||
* after the other, over SMB in fewer round trips (smbWhole()).
|
||||
*/
|
||||
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) {
|
||||
// over SMB in fewer round trips, one after the other (smbWhole())
|
||||
$data = $fast[$path] ?? $this->smbWhole($this->abs($path));
|
||||
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 {
|
||||
$s3 = $this->s3Client();
|
||||
if ($s3 === null) {
|
||||
return null;
|
||||
}
|
||||
[$client, $bucket, $sse] = $s3;
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* files_external's S3 client, bucket and server-side encryption parameters; null when the
|
||||
* storage is not as expected.
|
||||
*
|
||||
* @return array{0: \Aws\S3\S3Client, 1: string, 2: array}|null
|
||||
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
|
||||
*/
|
||||
private function s3Client(): ?array {
|
||||
/** @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);
|
||||
return $client instanceof \Aws\S3\S3Client && is_string($bucket) ? [$client, $bucket, is_array($sse) ? $sse : []] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* readAhead() over S3: S3_READ_AHEAD ranges at a time, fetched together straight into files in
|
||||
* the spool, then handed out in order. One request per range instead of one per blob (with a
|
||||
* new HTTPS connection each), and several at once: a restore is no longer bound by the round
|
||||
* trip. A range that could not be fetched is handed out as null (the caller reads it the
|
||||
* usual way, which reports why).
|
||||
*
|
||||
* @param list<array{0:string, 1:int, 2:int}> $ranges
|
||||
* @return \Generator<int, resource|null>|null
|
||||
* @psalm-suppress UndefinedClass the AWS SDK comes with Nextcloud, not with this app
|
||||
*/
|
||||
private function s3ReadAhead(array $ranges, string $spool): ?\Generator {
|
||||
// before anything is fetched: an invalid path throws
|
||||
$keys = array_map(fn (array $r): string => trim($this->abs($r[0]), '/'), $ranges);
|
||||
try {
|
||||
$s3 = $this->s3Client();
|
||||
} catch (\Throwable) {
|
||||
$s3 = null;
|
||||
}
|
||||
if ($s3 === null) {
|
||||
return null;
|
||||
}
|
||||
[$client, $bucket, $sse] = $s3;
|
||||
$this->idleCheck();
|
||||
return (static function () use ($client, $bucket, $sse, $ranges, $keys, $spool): \Generator {
|
||||
$prefix = rtrim($spool, '/') . '/s3-' . bin2hex(random_bytes(4)) . '-';
|
||||
foreach (array_chunk(array_keys($ranges), self::S3_READ_AHEAD) as $lot) {
|
||||
$files = [];
|
||||
$commands = [];
|
||||
foreach ($lot as $n => $i) {
|
||||
$files[$i] = $prefix . $i;
|
||||
$commands[$n] = $client->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $keys[$i],
|
||||
'Range' => 'bytes=' . $ranges[$i][1] . '-' . ($ranges[$i][1] + $ranges[$i][2] - 1), 'SaveAs' => $files[$i]] + $sse);
|
||||
}
|
||||
$fetched = [];
|
||||
try {
|
||||
(new \Aws\CommandPool($client, $commands, [
|
||||
'concurrency' => self::S3_READ_AHEAD,
|
||||
'fulfilled' => function (mixed $result, int $n) use (&$fetched, $lot): void {
|
||||
$fetched[$lot[$n]] = true;
|
||||
},
|
||||
'rejected' => static function (): void {
|
||||
},
|
||||
]))->promise()->wait();
|
||||
} catch (\Throwable) {
|
||||
// what was not fetched is read the usual way
|
||||
}
|
||||
try {
|
||||
foreach ($lot as $i) {
|
||||
clearstatcache(true, $files[$i]);
|
||||
$fh = isset($fetched[$i]) && @filesize($files[$i]) === $ranges[$i][2] ? @fopen($files[$i], 'rb') : false;
|
||||
@unlink($files[$i]); // the open stream keeps its data
|
||||
yield $i => is_resource($fh) ? $fh : null;
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// stopped halfway: the rest of this lot
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Over SFTP: SftpRangeFetcher, on readAheadConnections new connections of its own (built with the
|
||||
* reconnect function), so the storage's connection stays free for other requests. Over S3:
|
||||
* S3_READ_AHEAD ranges at a time through the S3 client (s3ReadAhead()). Elsewhere not (yet):
|
||||
* null.
|
||||
*/
|
||||
public function canReadAhead(): bool {
|
||||
return ($this->sftpReads && $this->reconnect !== null) || $this->s3Reads || $this->smbNative();
|
||||
}
|
||||
|
||||
private function smbNative(): bool {
|
||||
return $this->storage instanceof \OCA\Files_External\Lib\Storage\SMB && extension_loaded('smbclient');
|
||||
}
|
||||
|
||||
public function readAhead(array $ranges, string $spool): ?\Generator {
|
||||
if ($ranges !== [] && $this->s3Reads) {
|
||||
return $this->s3ReadAhead($ranges, $spool);
|
||||
}
|
||||
if ($ranges !== [] && $this->smbNative()) {
|
||||
return $this->smbReadAhead($ranges, $spool);
|
||||
}
|
||||
$reconnect = $this->reconnect;
|
||||
if (!$this->sftpReads || $reconnect === null || $ranges === []) {
|
||||
return null;
|
||||
}
|
||||
// before any connection is opened: an invalid path throws
|
||||
$abs = array_map(fn (array $r): array => [$this->abs($r[0]), $r[1], $r[2]], $ranges);
|
||||
$storages = [];
|
||||
$connections = [];
|
||||
$root = '';
|
||||
$wanted = max(1, min(SftpRangeFetcher::MAX_CONNECTIONS, $this->readAheadConnections));
|
||||
for ($n = 0; $n < $wanted; $n++) {
|
||||
try {
|
||||
$storage = $reconnect();
|
||||
$sftp = SftpReadStream::connection($storage);
|
||||
if ($sftp === null || !is_callable([$storage, 'getRoot'])) {
|
||||
break;
|
||||
}
|
||||
$root = (string)$storage->getRoot();
|
||||
} catch (\Throwable) {
|
||||
break; // the server may allow fewer connections: go on with those that work
|
||||
}
|
||||
$storages[] = $storage;
|
||||
$connections[] = $sftp;
|
||||
}
|
||||
if ($connections === []) {
|
||||
return null;
|
||||
}
|
||||
// in place of a connection that broke: a new storage, kept like the others
|
||||
$connect = static function () use ($reconnect, &$storages): ?object {
|
||||
$storage = $reconnect();
|
||||
$sftp = SftpReadStream::connection($storage);
|
||||
if ($sftp !== null) {
|
||||
$storages[] = $storage;
|
||||
}
|
||||
return $sftp;
|
||||
};
|
||||
return (static function () use (&$storages, $connections, $root, $abs, $spool, $connect): \Generator {
|
||||
// the storages hold their connections: kept until the fetcher is done
|
||||
try {
|
||||
yield from SftpRangeFetcher::fetch($connections, $root, $abs, $spool, $connect);
|
||||
} finally {
|
||||
unset($storages);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
public function getRange(string $path, int $offset, int $length): string {
|
||||
return $this->call(fn () => $this->readRange($path, $offset, $length));
|
||||
}
|
||||
|
||||
private function readRange(string $path, int $offset, int $length): string {
|
||||
try {
|
||||
$chunks = $this->smbChunks($this->abs($path), $offset, $length);
|
||||
if ($chunks !== null) {
|
||||
$data = implode('', iterator_to_array($chunks, false));
|
||||
if (strlen($data) === $length) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// read the usual way below, which reports what is wrong, over a new connection
|
||||
$this->smbReconnect();
|
||||
}
|
||||
$fh = $this->get($path);
|
||||
try {
|
||||
if (fseek($fh, $offset) !== 0) {
|
||||
@@ -99,23 +443,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)) {
|
||||
@@ -123,6 +484,386 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
$abs = $this->abs($dir);
|
||||
return $this->call(fn () => $this->filesIn($dir, $abs), fn (array $found) => $found === []);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function filesIn(string $dir, string $abs): array {
|
||||
if (!$this->storage->is_dir($abs)) {
|
||||
return []; // not created yet
|
||||
}
|
||||
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||
$names = $this->smbNames($abs);
|
||||
if ($names !== null) {
|
||||
$result = [];
|
||||
foreach ($names as $name) {
|
||||
if (!self::isTemporary($name)) {
|
||||
$result[] = substr($abs . '/' . $name, $cut);
|
||||
}
|
||||
}
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
$dh = $this->storage->opendir($abs);
|
||||
if ($dh === false) {
|
||||
// An existing folder read as empty would look like a new repository.
|
||||
throw new BackendException('Cannot list ' . $dir);
|
||||
}
|
||||
$result = [];
|
||||
while (($name = readdir($dh)) !== false) {
|
||||
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
|
||||
$result[] = substr($abs . '/' . $name, $cut);
|
||||
}
|
||||
}
|
||||
closedir($dh);
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The names in an SMB folder, read in one listing; null for other storages, with access
|
||||
* control lists on, or when the storage's internals are not as expected. The SMB storage's
|
||||
* opendir() asks the server for the attributes of each entry to leave out hidden ones: with
|
||||
* thousands of index files that took minutes. Hidden entries are still left out: names
|
||||
* starting with a dot, and, after asking, any name a repository does not use (letters,
|
||||
* digits, - and _), such as desktop.ini or Thumbs.db. The repository's own files have such
|
||||
* names: they are not checked one by one, which was the cost.
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
/**
|
||||
* Bytes per read over SMB (smbChunks()). libsmbclient keeps a large read in flight as many
|
||||
* requests at once; icewind's stream reads 1 MiB at a time. Over a line with a 130 ms round
|
||||
* trip: 4.5 MiB/s in 1 MiB reads, 18 MiB/s in 8 MiB reads (measured 2026-11-04, Samba in a
|
||||
* container behind netem). Without latency it makes no difference.
|
||||
*/
|
||||
public const SMB_READ = 8 * 1048576;
|
||||
|
||||
/**
|
||||
* $length bytes of $abs from $offset over SMB, SMB_READ at a time straight through the native
|
||||
* extension's state of files_external's SMB storage. Null when the storage is not SMB through
|
||||
* the extension, or its internals are not as expected (the usual way reads it then). The
|
||||
* generator throws when a read fails.
|
||||
*
|
||||
* @return \Generator<int, string>|null
|
||||
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||
*/
|
||||
private function smbChunks(string $abs, int $offset, int $length): ?\Generator {
|
||||
$handle = $this->smbHandle($abs);
|
||||
if ($handle === null) {
|
||||
return null;
|
||||
}
|
||||
[$state, $url] = $handle;
|
||||
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
|
||||
return (static function () use ($state, $url, $offset, $length): \Generator {
|
||||
$fh = $state->open($url, 'r');
|
||||
try {
|
||||
$state->lseek($fh, $offset, SEEK_SET, $url);
|
||||
for ($got = 0; $got < $length;) {
|
||||
$want = min(self::SMB_READ, $length - $got);
|
||||
$chunk = self::smbRead($state, $fh, $want, $url);
|
||||
if ($chunk === '') {
|
||||
throw new BackendException('Short read in ' . $url);
|
||||
}
|
||||
$got += strlen($chunk);
|
||||
yield $chunk;
|
||||
}
|
||||
} finally {
|
||||
$state->close($fh, $url);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole (small) file over SMB in four round trips: open, read, read up to the end, close.
|
||||
* files_external's fopen() first checks that the file exists (a stat with its attributes) and
|
||||
* icewind's stream reads 1 MiB at a time: about 7 round trips, 1 s per index file from Canada
|
||||
* to the Storage Box (130 ms), so ~8 h for the 27,000 index files of a 1 TB repository. Null
|
||||
* when the storage is not SMB through the extension or anything fails: the usual way then
|
||||
* reads the file and reports a missing one as such.
|
||||
*
|
||||
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||
*/
|
||||
private function smbWhole(string $abs): ?string {
|
||||
$handle = $this->smbHandle($abs);
|
||||
if ($handle === null) {
|
||||
return null;
|
||||
}
|
||||
[$state, $url] = $handle;
|
||||
try {
|
||||
/** @psalm-suppress MixedMethodCall the state is icewind's NativeState */
|
||||
$fh = $state->open($url, 'r');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
$data = '';
|
||||
try {
|
||||
do {
|
||||
$chunk = self::smbRead($state, $fh, self::SMB_READ, $url);
|
||||
$data .= $chunk;
|
||||
} while ($chunk !== '');
|
||||
} catch (\Throwable) {
|
||||
$data = null;
|
||||
}
|
||||
try {
|
||||
/** @psalm-suppress MixedMethodCall */
|
||||
$state->close($fh, $url);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
if ($data === null) {
|
||||
$this->smbReconnect(); // a read failed: the usual way reads it over a new connection
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* One read of at most $bytes through the native state, checked. When libsmbclient fails a read
|
||||
* (seen when the connection to the server dropped during a long restore), the smbclient
|
||||
* extension does not return false but a string whose length is -1, because it stores
|
||||
* smbc_read()'s -1 in an unsigned length; icewind passes it on. Counting that as data made
|
||||
* the read loop go backwards forever at 100% CPU (DR test 2026-11-05, after 392 GiB).
|
||||
*
|
||||
* @param resource $fh
|
||||
* @psalm-suppress MixedMethodCall, MixedArgument the state is icewind's NativeState
|
||||
*/
|
||||
private static function smbRead(object $state, $fh, int $bytes, string $url): string {
|
||||
$chunk = $state->read($fh, $bytes, $url);
|
||||
$n = is_string($chunk) ? strlen($chunk) : -1;
|
||||
if ($n < 0 || $n > $bytes) {
|
||||
throw new BackendException('Read failed in ' . $url);
|
||||
}
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a failed read: drop icewind's connection state, so the next read (the usual way, or
|
||||
* the next range) connects again instead of failing on a connection that is gone.
|
||||
*
|
||||
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||
*/
|
||||
private function smbReconnect(): void {
|
||||
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(function (): void {
|
||||
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||
$share = $this->share;
|
||||
if ($share instanceof \Icewind\SMB\Native\NativeShare) {
|
||||
(function (): void {
|
||||
/** @psalm-suppress UndefinedThisPropertyAssignment */
|
||||
$this->state = null;
|
||||
})->call($share);
|
||||
}
|
||||
})->call($this->storage);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* icewind's NativeState and the smb:// URL of $abs behind files_external's SMB storage, reached
|
||||
* through bound closures; null when the storage is not SMB through the native extension or its
|
||||
* internals are not as expected.
|
||||
*
|
||||
* @return array{0: object, 1: string}|null
|
||||
* @psalm-suppress UndefinedClass icewind/smb comes with files_external, not with this app
|
||||
*/
|
||||
private function smbHandle(string $abs): ?array {
|
||||
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB || !extension_loaded('smbclient')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
/** @var array{0: object, 1: string}|null $handle */
|
||||
$handle = (function (string $path): ?array {
|
||||
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||
$share = $this->share;
|
||||
if (!$share instanceof \Icewind\SMB\Native\NativeShare) {
|
||||
return null;
|
||||
}
|
||||
/** @psalm-suppress UndefinedMethod */
|
||||
$sharePath = ltrim($this->buildPath($path), '/');
|
||||
/** @psalm-suppress UndefinedMethod */
|
||||
return (fn (string $p): array => [$this->getState(), $this->buildUrl($p)])->call($share, $sharePath);
|
||||
})->call($this->storage, $abs);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* readAhead() over SMB: one range after the other, each in SMB_READ reads into a file in the
|
||||
* spool. Not several at once (PHP cannot wait on more than one libsmbclient call), but whole
|
||||
* ranges of up to ReadAheadPlan::MAX_RANGE in large reads instead of blob by blob in 1 MiB
|
||||
* reads. A range that cannot be read is handed out as null.
|
||||
*
|
||||
* @param list<array{0:string, 1:int, 2:int}> $ranges
|
||||
* @return \Generator<int, resource|null>
|
||||
*/
|
||||
private function smbReadAhead(array $ranges, string $spool): \Generator {
|
||||
// before anything is read: an invalid path throws
|
||||
$abs = array_map(fn (array $r): array => [$this->abs($r[0]), $r[1], $r[2]], $ranges);
|
||||
return (function () use ($abs, $spool): \Generator {
|
||||
$file = rtrim($spool, '/') . '/smb-' . bin2hex(random_bytes(4));
|
||||
try {
|
||||
foreach ($abs as $i => [$path, $offset, $length]) {
|
||||
$fh = null;
|
||||
$out = false;
|
||||
try {
|
||||
$chunks = $this->smbChunks($path, $offset, $length);
|
||||
$out = $chunks === null ? false : fopen($file, 'w+b');
|
||||
if ($chunks !== null && $out !== false) {
|
||||
foreach ($chunks as $chunk) {
|
||||
if (fwrite($out, $chunk) !== strlen($chunk)) {
|
||||
throw new BackendException('Cannot write to the spool');
|
||||
}
|
||||
}
|
||||
$fh = ftell($out) === $length && rewind($out) ? $out : null;
|
||||
if ($fh === null) {
|
||||
fclose($out);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
if (is_resource($out)) {
|
||||
fclose($out);
|
||||
}
|
||||
$fh = null; // read the usual way, which reports why, over a new connection
|
||||
$this->smbReconnect();
|
||||
} finally {
|
||||
@unlink($file); // an open stream keeps its data
|
||||
}
|
||||
yield $i => $fh;
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private function smbNames(string $abs): ?array {
|
||||
if (!$this->storage instanceof \OCA\Files_External\Lib\Storage\SMB) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
/** @var list<string>|null */
|
||||
return (function (string $path): ?array {
|
||||
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||
if ($this->checkAcl) {
|
||||
return null;
|
||||
}
|
||||
$names = [];
|
||||
/** @psalm-suppress UndefinedThisPropertyFetch, UndefinedMethod */
|
||||
foreach ($this->share->dir(ltrim($this->buildPath($path), '/')) as $info) {
|
||||
$name = (string)$info->getName();
|
||||
/** @psalm-suppress UndefinedThisPropertyFetch */
|
||||
if (!$this->showHidden && (str_starts_with($name, '.')
|
||||
|| (preg_match('/^[A-Za-z0-9_-]+$/', $name) !== 1 && $info->isHidden()))) {
|
||||
continue;
|
||||
}
|
||||
$names[] = $name;
|
||||
}
|
||||
return $names;
|
||||
})->call($this->storage, $abs);
|
||||
} catch (\Throwable) {
|
||||
return null; // another SMB storage version, or an error: the usual way reports it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run $op; when it throws, or $failed says its result may come from a dead connection, and
|
||||
* the SFTP connection turns out to be dead, run it once more on a new connection.
|
||||
*
|
||||
* @template T
|
||||
* @param \Closure():T $op
|
||||
* @param (\Closure(T):bool)|null $failed
|
||||
* @return T
|
||||
*/
|
||||
private function call(\Closure $op, ?\Closure $failed = null): mixed {
|
||||
$this->idleCheck();
|
||||
try {
|
||||
$result = $op();
|
||||
} catch (\Throwable $e) {
|
||||
if (!$this->reopenIfDead()) {
|
||||
throw $e;
|
||||
}
|
||||
return $op();
|
||||
}
|
||||
if ($failed !== null && $failed($result) && $this->reopenIfDead()) {
|
||||
return $op();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Hetzner Storage Box drops an SFTP connection that carried a lot of data and then stayed
|
||||
* quiet for a few minutes, while phpseclib still takes it for open; files_external never
|
||||
* builds a new one, and every request on it fails as if the file were missing. So after
|
||||
* such a pause the connection is checked (one round trip) before it is used again.
|
||||
*/
|
||||
private function idleCheck(): void {
|
||||
$now = microtime(true);
|
||||
if ($this->reconnect !== null && $this->sftpReads && $now - $this->lastUsed >= self::IDLE_CHECK
|
||||
&& $this->alive(true) === false) {
|
||||
$this->reopen();
|
||||
}
|
||||
$this->lastUsed = microtime(true);
|
||||
}
|
||||
|
||||
/** After a failed request: ask the server (one round trip), as phpseclib may not know it is gone. */
|
||||
private function reopenIfDead(): bool {
|
||||
if ($this->reconnect === null || !$this->sftpReads || $this->alive(true) !== false) {
|
||||
return false;
|
||||
}
|
||||
$this->reopen();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the storage's SFTP connection is alive; null when it cannot tell.
|
||||
* $ask: send a request instead of trusting what phpseclib saw last.
|
||||
*/
|
||||
private function alive(bool $ask): ?bool {
|
||||
try {
|
||||
if (!is_callable([$this->storage, 'getConnection'])) {
|
||||
return null;
|
||||
}
|
||||
$sftp = $this->storage->getConnection();
|
||||
if (!is_object($sftp) || !is_callable([$sftp, 'isConnected'])) {
|
||||
return null;
|
||||
}
|
||||
if (!$sftp->isConnected()) {
|
||||
return false;
|
||||
}
|
||||
if (!$ask || !is_callable([$sftp, 'stat'])) {
|
||||
return true;
|
||||
}
|
||||
if (is_callable([$sftp, 'clearStatCache'])) {
|
||||
$sftp->clearStatCache(); // or stat() answers from memory
|
||||
}
|
||||
// A Storage Box drops a quiet connection without phpseclib noticing: it still says
|
||||
// connected, and every request fails. So the answer counts, not what it says after.
|
||||
return $sftp->stat('.') !== false && (bool)$sftp->isConnected();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function reopen(): void {
|
||||
/** @var \Closure():IStorage $reconnect */
|
||||
$reconnect = $this->reconnect;
|
||||
$this->storage = $reconnect();
|
||||
$this->reconnects++;
|
||||
$this->lastUsed = microtime(true);
|
||||
if (function_exists('OCP\Log\logger')) {
|
||||
\OCP\Log\logger('ng_backup')->info('The SFTP connection to the backup location was lost; reconnected');
|
||||
}
|
||||
}
|
||||
|
||||
private function listInto(string $dir, array &$result): void {
|
||||
if (!$this->storage->is_dir($dir)) {
|
||||
return;
|
||||
@@ -132,19 +873,39 @@ final class StorageBackend implements IBackend {
|
||||
return;
|
||||
}
|
||||
while (($name = readdir($dh)) !== false) {
|
||||
if ($name === '.' || $name === '..' || str_contains($name, '.part-')) {
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
$child = $dir . '/' . $name;
|
||||
if ($this->storage->is_dir($child)) {
|
||||
$this->listInto($child, $result);
|
||||
} else {
|
||||
} elseif (!self::isTemporary($name)) {
|
||||
$result[] = $child;
|
||||
}
|
||||
}
|
||||
closedir($dh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes left to read in a local $stream (php://temp, php://memory or a plain file), or null:
|
||||
* stream wrappers of remote storages do not report a reliable size.
|
||||
*/
|
||||
public static function remaining(mixed $stream): ?int {
|
||||
if (!is_resource($stream)) {
|
||||
return null;
|
||||
}
|
||||
$meta = stream_get_meta_data($stream);
|
||||
if (!($meta['seekable'] ?? false) || !in_array($meta['wrapper_type'] ?? '', ['PHP', 'plainfile'], true)) {
|
||||
return null;
|
||||
}
|
||||
$stat = fstat($stream);
|
||||
$pos = ftell($stream);
|
||||
if ($stat === false || $pos === false) {
|
||||
return null;
|
||||
}
|
||||
return max(0, $stat['size'] - $pos);
|
||||
}
|
||||
|
||||
private function mkdirs(string $dir): void {
|
||||
if ($dir === '' || $dir === '.' || $this->storage->is_dir($dir)) {
|
||||
return;
|
||||
|
||||
@@ -32,9 +32,15 @@ final class BackupJob extends TimedJob {
|
||||
private RunMapper $runs,
|
||||
private IAppConfig $appConfig,
|
||||
private LoggerInterface $logger,
|
||||
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -42,15 +48,24 @@ final class BackupJob extends TimedJob {
|
||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||
$deadline = microtime(true) + $budget;
|
||||
$this->startScheduled();
|
||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
|
||||
// One after the other: runs that read the same disks at the same time are not faster together.
|
||||
foreach ($this->backups->runsInTurn() as $run) {
|
||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||
$next = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||
if ($next === $run) {
|
||||
break 2; // step() hands back the same object when another process holds the run's lease
|
||||
}
|
||||
$run = $next;
|
||||
}
|
||||
}
|
||||
$this->failStaleRestores();
|
||||
$this->weeklyPrune();
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$targets = $this->targets->list();
|
||||
foreach ($targets as $t) {
|
||||
$this->alerts->checkStale($t);
|
||||
$othersActive = array_filter($targets, static fn ($o) => $o->getId() !== $t->getId() && (string)$o->getSchedule() !== '') !== [];
|
||||
$this->alerts->checkOff($t, $othersActive);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +82,15 @@ final class BackupJob extends TimedJob {
|
||||
}
|
||||
}
|
||||
|
||||
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
||||
/**
|
||||
* Once a week, apply the retention policy to non-append-only locations: from Sunday on, at the
|
||||
* first moment no backup is running, so a long Sunday backup only delays it instead of skipping
|
||||
* the week.
|
||||
*/
|
||||
private function weeklyPrune(): void {
|
||||
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
||||
if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
||||
$due = date('N') === '7' ? date('o-W') : date('o-W', (int)strtotime('last sunday'));
|
||||
if (strcmp($lastWeek, $due) >= 0 || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->targets->list() as $t) {
|
||||
@@ -83,22 +103,21 @@ final class BackupJob extends TimedJob {
|
||||
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W'));
|
||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
|
||||
}
|
||||
|
||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
||||
/** Start the backups the schedules of the locations ask for; they run in turn (see LocationSchedule). */
|
||||
private function startScheduled(): void {
|
||||
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
|
||||
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
||||
return;
|
||||
}
|
||||
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
|
||||
if (time() < $todayAt) {
|
||||
return;
|
||||
}
|
||||
$now = time();
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||
$moment = \OCA\NgBackup\Service\LocationSchedule::lastMoment((string)$t->getSchedule(), $this->schedule->zoneOr($t->getScheduleZone()), $now);
|
||||
if ($moment === null || $this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== []) {
|
||||
continue;
|
||||
}
|
||||
$attempts = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $moment);
|
||||
if (!\OCA\NgBackup\Service\LocationSchedule::isDue($moment, (int)$t->getScheduleSince(),
|
||||
$this->dataStarted($this->runs->lastFinished($t->getId(), BackupService::KIND_FULL)),
|
||||
count($attempts), ($attempts[0] ?? null)?->getFinishedAt(), $now)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -108,4 +127,15 @@ final class BackupJob extends TimedJob {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When a backup began reading its data: a queued one can start well after it was asked for. */
|
||||
private function dataStarted(?\OCA\NgBackup\Db\Run $run): ?int {
|
||||
if ($run === null) {
|
||||
return null;
|
||||
}
|
||||
$state = json_decode($run->getState(), true);
|
||||
$started = is_array($state) ? ($state['files']['started'] ?? null) : null;
|
||||
$at = is_string($started) ? strtotime($started) : false;
|
||||
return $at !== false ? $at : $run->getStartedAt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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\TargetService;
|
||||
use OCA\NgBackup\Service\UserBackupService;
|
||||
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
|
||||
use OCP\IUserManager;
|
||||
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 BackupUser extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private UserBackupService $userBackup,
|
||||
private IUserManager $userManager,
|
||||
private UserMigrationInstaller $installer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:user:backup')
|
||||
->setDescription('Back up one user via user_migration (account, settings, files and everything else a migrator covers)')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('user', InputArgument::REQUIRED, 'User id')
|
||||
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
||||
->setHelp(<<<'HELP'
|
||||
Streams a user_migration export straight into the encrypted repository: no zip, no copy in the
|
||||
user's own storage. Shares data with the regular backup; this needs the user_migration app
|
||||
enabled. The manifest path this prints is what occ backup:user:restore expects.
|
||||
|
||||
Example:
|
||||
occ backup:user:backup offsite alice
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$uid = (string)$input->getArgument('user');
|
||||
$user = $this->userManager->get($uid);
|
||||
if ($user === null) {
|
||||
$output->writeln("<error>No such user: $uid</error>");
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$this->installer->ensureEnabled($input, $output);
|
||||
$manifest = $this->userBackup->backupUser($target, $user);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln("Backed up <info>$uid</info> to $manifest.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\RestoreService;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
@@ -19,6 +21,7 @@ final class Browse extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RestoreService $restore,
|
||||
private Schedule $schedule,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -43,9 +46,10 @@ final class Browse extends Command {
|
||||
$output->writeln('Nothing here.');
|
||||
return 0;
|
||||
}
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$rows = array_map(fn ($i) => [$i['type'] === 'dir' ? $i['name'] . '/' : $i['name'], $this->size($i['size']),
|
||||
$i['type'] === 'dir' ? $i['files'] . ' files' : date('Y-m-d H:i', (int)$i['mtime'])], $items);
|
||||
(new Table($output))->setHeaders(['name', 'size', 'files / modified'])->setRows($rows)->render();
|
||||
$i['type'] === 'dir' ? $i['files'] . ' files' : Format::time((int)$i['mtime'], $zone)], $items);
|
||||
(new Table($output))->setHeaders(['name', 'size', "files / modified ($zone)"])->setRows($rows)->render();
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?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\KeyService;
|
||||
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;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
final class KeyImport extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:key:import')
|
||||
->setDescription('Disaster recovery: adopt the backup key from a recovery kit on a fresh installation')
|
||||
->addArgument('kit-file', InputArgument::REQUIRED, 'The recovery kit file (occ backup:key:kit on the original installation)')
|
||||
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
|
||||
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'A new key was made here by mistake: replace it by the kit\'s key, if it was never used (no location, no backup)')
|
||||
->setHelp(<<<'HELP'
|
||||
For a fresh installation only (occ backup:key:init must not have been run here yet). Reads one
|
||||
of the kit's passphrase slots to recover the original master key, so occ backup:target:add opens
|
||||
the existing repository on the location instead of creating a new, empty one.
|
||||
|
||||
A new key was made here by mistake before the location was added? As long as it was never used
|
||||
(no location, no backup), --replace-unused-key replaces it by the kit's key. A key that was used
|
||||
is never replaced.
|
||||
|
||||
No kit? Skip this command: occ backup:target:add then takes the key from the location with one
|
||||
of the old server's passphrases. The kit still helps when the location's config file is damaged.
|
||||
|
||||
Example:
|
||||
occ backup:key:import /root/ng_backup-recovery-kit.json
|
||||
occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=...
|
||||
occ backup:restore:full offsite <snapshot>
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$replace = (bool)$input->getOption('replace-unused-key');
|
||||
if ($replace && !$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($this->keys->isInitialized()) {
|
||||
if (!$this->targets->keyIsUnused()) {
|
||||
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . ') and it has been used for a backup location: it is never replaced.</error>');
|
||||
$output->writeln('occ backup:key:import is for a server that is being restored.');
|
||||
return 1;
|
||||
}
|
||||
if (!$replace) {
|
||||
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '), but it was never used: no location, no backup.</error>');
|
||||
$output->writeln('Made by mistake on a server that is being restored? Run this command again with --replace-unused-key:');
|
||||
$output->writeln('the kit\'s key then takes its place, and the passphrases and recovery kit of the unused key stop working.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$kitFile = (string)$input->getArgument('kit-file');
|
||||
$raw = @file_get_contents($kitFile);
|
||||
if ($raw === false) {
|
||||
$output->writeln("<error>Cannot read $kitFile</error>");
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
$kit = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$output->writeln('<error>Not a valid recovery kit file: ' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$file = $input->getOption('passphrase-file');
|
||||
if ($file !== null) {
|
||||
$raw = @file_get_contents($file);
|
||||
if ($raw === false) {
|
||||
$output->writeln('<error>Cannot read the passphrase file</error>');
|
||||
return 1;
|
||||
}
|
||||
$pass = rtrim($raw, "\r\n");
|
||||
} else {
|
||||
$helper = $this->getHelper('question');
|
||||
$q = (new Question('Passphrase of one of the kit\'s slots: '))->setHidden(true)->setHiddenFallback(false);
|
||||
$pass = (string)$helper->ask($input, $output, $q);
|
||||
}
|
||||
try {
|
||||
if ($replace) {
|
||||
$this->targets->importKitReplacingUnusedKey($kit, $pass);
|
||||
} else {
|
||||
$this->keys->importRecoveryKit($kit, $pass);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
// Read from the kit file, which is not authenticated beyond the wrapped key: show it so a
|
||||
// wrong or altered value stands out.
|
||||
$output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
|
||||
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
|
||||
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) {
|
||||
|
||||
+15
-2
@@ -39,11 +39,24 @@ final class KeyKit extends Command {
|
||||
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
||||
return 0;
|
||||
}
|
||||
if (file_put_contents($file, $json) === false) {
|
||||
// Created owner-only from the start (no window where it is world-readable), never over an
|
||||
// existing file or symlink someone else may have placed at a predictable path.
|
||||
$old = umask(0077);
|
||||
try {
|
||||
$fh = @fopen($file, 'xb');
|
||||
} finally {
|
||||
umask($old);
|
||||
}
|
||||
if ($fh === false) {
|
||||
$output->writeln("<error>Cannot create $file (it must not exist yet)</error>");
|
||||
return 1;
|
||||
}
|
||||
$ok = fwrite($fh, $json) === strlen($json);
|
||||
if (!fclose($fh) || !$ok) {
|
||||
@unlink($file);
|
||||
$output->writeln("<error>Cannot write $file</error>");
|
||||
return 1;
|
||||
}
|
||||
@chmod($file, 0600);
|
||||
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ declare(strict_types=1);
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
@@ -19,6 +21,7 @@ final class ListSnapshots extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private SnapshotMapper $snapshots,
|
||||
private Schedule $schedule,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -32,18 +35,19 @@ final class ListSnapshots extends Command {
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$rows = [];
|
||||
foreach ($targets as $t) {
|
||||
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
|
||||
$rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(),
|
||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
|
||||
$rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(),
|
||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getPinned() ? 'yes' : '', $s->getLabel() ?? '', $s->getNote() ?? ''];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
$output->writeln('No snapshots yet (occ backup:run).');
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'pinned', 'label', 'note'])->setRows($rows)->render();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class ListUserExports extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:user:list')
|
||||
->setDescription('List the user exports (backup:user:backup) on a location')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)')
|
||||
->addArgument('user', InputArgument::OPTIONAL, 'Only the exports of this user id')
|
||||
->setHelp(<<<'HELP'
|
||||
The manifest column is what occ backup:user:restore expects. A safety export made by a restore
|
||||
with --mode=replace is listed here as well.
|
||||
|
||||
Example:
|
||||
occ backup:user:list offsite alice
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$uid = $input->getArgument('user');
|
||||
$rows = [];
|
||||
try {
|
||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||
foreach ($targets as $t) {
|
||||
foreach ($this->targets->repository($t)->listUserExports($uid) as $path) {
|
||||
$parts = explode('/', $path, 3);
|
||||
$user = $parts[1] ?? '';
|
||||
$exportId = $parts[2] ?? '';
|
||||
$time = \DateTimeImmutable::createFromFormat('Ymd\THis\Z', substr($exportId, 0, 16), new \DateTimeZone('UTC'));
|
||||
$rows[] = [$t->getName(), $user, $time !== false ? $time->format('Y-m-d H:i') : '', $path];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($rows === []) {
|
||||
$output->writeln('No user exports yet (occ backup:user:backup).');
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'user', 'date (UTC)', 'manifest'])->setRows($rows)->render();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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\PruneService;
|
||||
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\Output\OutputInterface;
|
||||
|
||||
final class Note extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:note')
|
||||
->setDescription('Set or remove the note on a snapshot')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||
->addArgument('note', InputArgument::OPTIONAL, 'The note, at most ' . PruneService::NOTE_MAX . ' characters; without it the note is removed', '');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$note = (string)$input->getArgument('note');
|
||||
try {
|
||||
$this->prune->setNote($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), $note);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(trim($note) === '' ? 'Note removed.' : 'Note saved.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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\HeavyJobPause;
|
||||
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 PauseJobs extends Command {
|
||||
public function __construct(
|
||||
private HeavyJobPause $pause,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:pause-jobs')
|
||||
->setDescription('Show or set whether heavy background jobs (Memories, Recognize, Preview Generator, ...) pause while a backup runs')
|
||||
->addArgument('setting', InputArgument::OPTIONAL, '"on" or "off"; omit to show the current one');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$setting = $input->getArgument('setting');
|
||||
if ($setting !== null) {
|
||||
if ($setting !== 'on' && $setting !== 'off') {
|
||||
$output->writeln('<error>Use "on" or "off".</error>');
|
||||
return 1;
|
||||
}
|
||||
$this->pause->setEnabled($setting === 'on');
|
||||
}
|
||||
$status = $this->pause->status();
|
||||
$output->writeln('Pausing heavy background jobs during a backup: ' . ($status['enabled'] ? 'on' : 'off'));
|
||||
if ($status['jobs'] !== []) {
|
||||
$output->writeln('Paused now: ' . implode(', ', $status['jobs']));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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\PruneService;
|
||||
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 Pin extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:pin')
|
||||
->setDescription('Keep a snapshot: the retention policy does not remove a pinned one')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||
->addOption('unpin', null, InputOption::VALUE_NONE, 'Leave the snapshot to the retention policy again');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$unpin = (bool)$input->getOption('unpin');
|
||||
try {
|
||||
$this->prune->setPinned($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), !$unpin);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln($unpin ? 'Snapshot unpinned; the retention policy applies to it again.' : 'Snapshot pinned; the retention policy leaves it alone.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,22 @@ final class Prune extends Command {
|
||||
$output->writeln('Policy: ' . json_encode($this->prune->policy()->toArray()) . ($dry ? ' (dry run)' : ''));
|
||||
$rc = 0;
|
||||
foreach ($targets as $t) {
|
||||
// User exports first: Repository::prune()'s blob garbage collection (run as part of
|
||||
// apply() below) scans the users/ manifests still on the location at that point, so
|
||||
// forgetting one here lets its now-unused blobs be freed in this same pass.
|
||||
try {
|
||||
$ru = $this->prune->applyUserExports($t, $dry);
|
||||
} catch (\Throwable $e) {
|
||||
// Snapshot retention is independent of this: report and carry on with it.
|
||||
$output->writeln('<error>' . $t->getName() . ' (user exports): ' . $e->getMessage() . '</error>');
|
||||
$rc = 1;
|
||||
$ru = ['forgotten' => []];
|
||||
}
|
||||
if ($ru['forgotten'] !== []) {
|
||||
$affected = count(array_unique(array_map(static fn (string $path): string => explode('/', $path)[1] ?? '', $ru['forgotten'])));
|
||||
$output->writeln(sprintf('<info>%s</info>: %s %d user export(s) for %d user(s)',
|
||||
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), $affected));
|
||||
}
|
||||
try {
|
||||
$r = $this->prune->apply($t, $dry);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
+34
-4
@@ -31,6 +31,7 @@ final class Restore extends Command {
|
||||
->addArgument('path', InputArgument::REQUIRED, 'Path in the snapshot (see backup:browse)')
|
||||
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'new-folder, merge or replace (user files)', RestoreService::MODE_NEW_FOLDER)
|
||||
->addOption('to-directory', 'd', InputOption::VALUE_REQUIRED, 'Write to this local directory instead (any path, raw files)')
|
||||
->addOption('salvage', null, InputOption::VALUE_NONE, 'Leave out a file that cannot be read or decrypted, list it at the end and go on, instead of stopping')
|
||||
->setHelp(<<<'HELP'
|
||||
User files (data/<user>/files/...) are restored through Nextcloud, so versions, trash and the
|
||||
file cache stay correct. Modes:
|
||||
@@ -42,30 +43,59 @@ Examples:
|
||||
occ backup:restore offsite 537bf7e9... data/alice/files/Documents
|
||||
occ backup:restore offsite 537bf7e9... data/alice/files/Documents/plan.odt --mode=merge
|
||||
occ backup:restore offsite 537bf7e9... config --to-directory=/root/restored-config
|
||||
With --salvage, a file whose data on the location is damaged or missing is left out and listed
|
||||
at the end; everything else is restored. The command then exits with code 2.
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$skipped = $input->getOption('salvage') ? [] : null;
|
||||
try {
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$snapshot = $input->getArgument('snapshot');
|
||||
$path = trim((string)$input->getArgument('path'), '/'); // CLI convenience; the service itself only accepts canonical paths
|
||||
if ($dir = $input->getOption('to-directory')) {
|
||||
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir);
|
||||
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir, false, null, $skipped);
|
||||
$output->writeln("Restored <info>$n</info> files to $dir.");
|
||||
return 0;
|
||||
return self::reportSkipped($skipped, $output);
|
||||
}
|
||||
$r = $this->restore->restoreUserFiles($target, $snapshot, $path, (string)$input->getOption('mode'),
|
||||
function (int $done, int $total, string $name) use ($output) {
|
||||
if ($output->isVerbose() || $done === $total || $done % 100 === 0) {
|
||||
$output->writeln(" $done/$total $name");
|
||||
}
|
||||
});
|
||||
}, $skipped);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
if ($skipped === null && self::backupLooksDamaged($e)) {
|
||||
$output->writeln('The data of a file on the location is damaged or missing. Run the same command with --salvage to restore everything else and list what could not be restored.');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(sprintf('Restored <info>%d</info> files to %s%s.', $r['restored'], $r['target'], $r['trashed'] ? ", moved {$r['trashed']} extra files to the trash" : ''));
|
||||
return 0;
|
||||
return self::reportSkipped($skipped, $output);
|
||||
}
|
||||
|
||||
/** Damaged data, or a file that is gone from the location: what --salvage is for. */
|
||||
public static function backupLooksDamaged(\Throwable $e): bool {
|
||||
return $e instanceof \OCA\NgBackup\Repository\RepositoryException
|
||||
|| $e instanceof \OCA\NgBackup\Crypto\CryptoException
|
||||
|| ($e instanceof \OCA\NgBackup\Backend\BackendException && str_starts_with($e->getMessage(), 'Not found'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The files --salvage left out; exit code 2 when there are any.
|
||||
*
|
||||
* @param array<string, string>|null $skipped
|
||||
*/
|
||||
public static function reportSkipped(?array $skipped, OutputInterface $output): int {
|
||||
if ($skipped === null || $skipped === []) {
|
||||
return 0;
|
||||
}
|
||||
$output->writeln(sprintf('<comment>%d file%s could not be restored (damaged or missing on the location):</comment>', count($skipped), count($skipped) === 1 ? '' : 's'));
|
||||
foreach ($skipped as $path => $reason) {
|
||||
$output->writeln(" $path: $reason");
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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\RestoreFullService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class RestoreFull extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RestoreFullService $restoreFull,
|
||||
private IUserManager $userManager,
|
||||
private \OCP\App\IAppManager $appManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:restore:full')
|
||||
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
|
||||
->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')
|
||||
->addOption('salvage', null, InputOption::VALUE_NONE, 'Restore what can be restored: leave out files that cannot be read, and when the database cannot be restored, put the files in a folder of their own')
|
||||
->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.
|
||||
|
||||
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
|
||||
connection and data directory settings, which stay as occ maintenance:install configured them
|
||||
here. Other files under config/ (if any) are not restored.
|
||||
|
||||
Afterwards: check trusted_domains and overwritehost/overwriteprotocol match this server, and
|
||||
occ maintenance:repair.
|
||||
When the backup is damaged: --salvage restores what can be restored. Files whose data cannot be
|
||||
read or decrypted are left out and listed. When the database cannot be restored (its dump is
|
||||
damaged, or it does not fit this installation), the database stays as occ maintenance:install
|
||||
made it, and the data directory and config of the backup go to ngb-salvage-<snapshot> in the
|
||||
data directory instead: from there, the files can be put back by hand. The command then exits
|
||||
with code 2.
|
||||
|
||||
Example:
|
||||
occ backup:restore:full offsite 537bf7e9c1a4...
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$force = (bool)$input->getOption('force');
|
||||
// A whole-server restore replaces the database and data directory of THIS instance. On
|
||||
// anything but a fresh install (only the admin from occ maintenance:install) that is
|
||||
// almost certainly a mistake, so it needs --force.
|
||||
$users = $this->userManager->countUsersTotal(2);
|
||||
if (!$force && ($users === false || $users > 1)) {
|
||||
$output->writeln('<error>This installation already has more than one user: backup:restore:full would replace its whole database and data directory. It is meant for a fresh installation. Use --force if this really is what you want.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$force && $input->isInteractive()) {
|
||||
$q = new ConfirmationQuestion('This replaces the database, the data directory and config.php of this installation. Continue? [y/N] ', false);
|
||||
if (!(new QuestionHelper())->ask($input, $output, $q)) {
|
||||
$output->writeln('Cancelled.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$snapshot = (string)$input->getArgument('snapshot');
|
||||
$skipMissingTables = (bool)$input->getOption('skip-missing-tables');
|
||||
$salvage = (bool)$input->getOption('salvage');
|
||||
if ($input->getOption('install-apps')) {
|
||||
$check = $this->restoreFull->checkCompatibility($target, $snapshot, $skipMissingTables, $salvage);
|
||||
foreach ($check['missingApps'] as $app) {
|
||||
$this->installApp($app, $output);
|
||||
}
|
||||
}
|
||||
$output->writeln('Nextcloud is in maintenance mode while the restore runs; it is switched off when the restore completes.');
|
||||
$result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables, $salvage);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
if (\OCP\Server::get(\OCP\IConfig::class)->getSystemValueBool('maintenance', false)) {
|
||||
$output->writeln('The restore had started: the instance may be partly restored, and maintenance mode stays on so that nobody uses it like that.');
|
||||
$output->writeln('Fix the cause, then switch maintenance mode off and run the same command again with --force, in one line:');
|
||||
$again = 'occ backup:restore:full ' . escapeshellarg((string)$input->getArgument('target')) . ' ' . escapeshellarg((string)$input->getArgument('snapshot'))
|
||||
. ($input->getOption('skip-missing-tables') ? ' --skip-missing-tables' : '') . ($input->getOption('salvage') ? ' --salvage' : '') . ' --force --no-interaction';
|
||||
$output->writeln(' occ maintenance:mode --off && ' . $again);
|
||||
$output->writeln('(Without --install-apps, so the instance is open only for the seconds until the restore starts again. Apps the first run could not install are listed at the end: install those afterwards.)');
|
||||
} else {
|
||||
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
|
||||
}
|
||||
$output->writeln('If occ then says that Nextcloud needs an upgrade (the database is already the old server\'s), run occ upgrade first.');
|
||||
if (!$input->getOption('salvage') && Restore::backupLooksDamaged($e)) {
|
||||
$output->writeln('The backup itself looks damaged here. --salvage restores everything that can be read and lists the rest.');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if ($result['dbError'] !== null) {
|
||||
$output->writeln('<error>Salvage: ' . $result['dbError'] . '</error>');
|
||||
$output->writeln(sprintf('The database stays as occ maintenance:install made it. The data directory and config of the backup (<info>%d</info> data files) are in:', $result['dataFiles']));
|
||||
$output->writeln(' ' . $result['salvageDir']);
|
||||
$output->writeln('Each user\'s files are in data/<user>/files there. Put back what is needed by hand, for example into the files of new users (then occ files:scan).');
|
||||
Restore::reportSkipped($result['skipped'], $output);
|
||||
return 2;
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
'Database restored (<info>%d</info> tables, <info>%d</info> unchanged), config merged (<info>%d</info> keys), <info>%d</info> data files restored.',
|
||||
count($result['dbReport']['restored']),
|
||||
count($result['dbReport']['skipped']),
|
||||
$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>');
|
||||
}
|
||||
if ($result['maintenanceLeftOn']) {
|
||||
$output->writeln('<comment>Maintenance mode was on before the restore and stays on: switch it off with occ maintenance:mode --off when the instance is ready.</comment>');
|
||||
}
|
||||
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
||||
$code = 0;
|
||||
if ($result['configError'] !== null) {
|
||||
$output->writeln('<comment>Salvage: config.php of the backup was not merged (' . $result['configError'] . '). Set at least \'instanceid\', \'passwordsalt\' and \'secret\' to the old server\'s values if you have them, or data encrypted with them (server-side encryption, stored passwords) cannot be read.</comment>');
|
||||
$code = 2;
|
||||
}
|
||||
return max($code, Restore::reportSkipped($result['skipped'], $output));
|
||||
}
|
||||
|
||||
/** 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>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?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\TargetService;
|
||||
use OCA\NgBackup\Service\UserRestoreService;
|
||||
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
|
||||
use OCP\IUserManager;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
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 RestoreUser extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private UserRestoreService $userRestore,
|
||||
private UserMigrationInstaller $installer,
|
||||
private IUserManager $userManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:user:restore')
|
||||
->setDescription('Restore a user from a user_migration export (see backup:user:backup)')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>), or a user id with exactly one export')
|
||||
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
|
||||
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
||||
->setHelp(<<<'HELP'
|
||||
Never imports over a live account. Modes:
|
||||
replace (default) safety-exports the current account first, then deletes and recreates it
|
||||
fresh from the backup, under the same user id
|
||||
as-backup restores into a new account "<uid>-bak" (or "-bak-2", ...) instead, so an admin can
|
||||
compare and transfer by hand; Nextcloud cannot rename a user id, so the backup gets
|
||||
the new name, never the existing account
|
||||
|
||||
occ backup:user:list shows the exports on a location. A user id alone works when that user
|
||||
has exactly one export there; with more, the command lists them and you pass one.
|
||||
|
||||
Example:
|
||||
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34
|
||||
occ backup:user:restore offsite alice
|
||||
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34 --mode=as-backup
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$mode = (string)$input->getOption('mode');
|
||||
try {
|
||||
$target = $this->targets->get($input->getArgument('target'));
|
||||
$ref = (string)$input->getArgument('manifest');
|
||||
$manifest = $this->targets->repository($target)->resolveUserExport($ref);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($manifest !== $ref) {
|
||||
$output->writeln("Using $manifest.");
|
||||
}
|
||||
$uid = explode('/', $manifest)[1];
|
||||
if ($mode === UserRestoreService::MODE_REPLACE && $this->userManager->userExists($uid) && !$input->getOption('force')) {
|
||||
// Replace deletes the live account (after a safety export). Shares to others and data of
|
||||
// apps without a user_migration migrator do not come back, so never do it unasked.
|
||||
if (!$input->isInteractive()) {
|
||||
$output->writeln("<error>$uid exists: --mode=replace deletes and recreates it. Add --force, or use --mode=as-backup.</error>");
|
||||
return 1;
|
||||
}
|
||||
$q = new ConfirmationQuestion("$uid exists and will be deleted and recreated from the backup (a safety export is made first; its shares to others and data of apps without a migrator are not restored). Continue? [y/N] ", false);
|
||||
if (!(new QuestionHelper())->ask($input, $output, $q)) {
|
||||
$output->writeln('Cancelled.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
|
||||
$this->installer->ensureEnabled($input, $output);
|
||||
$user = $this->userRestore->restoreUser($target, $manifest, $mode);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Restored <info>' . $user->getUID() . '</info>.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,15 @@ final class Retention extends Command {
|
||||
->addOption('daily', null, InputOption::VALUE_REQUIRED, 'Keep one per day for the last N days with a backup')
|
||||
->addOption('weekly', null, InputOption::VALUE_REQUIRED, 'Keep one per week for N weeks')
|
||||
->addOption('monthly', null, InputOption::VALUE_REQUIRED, 'Keep one per month for N months')
|
||||
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12");
|
||||
->addOption('user-last', null, InputOption::VALUE_REQUIRED, 'Keep the newest N of each user\'s own exports (occ backup:user:backup)')
|
||||
->setHelp("Example: occ backup:retention --last=3 --daily=7 --weekly=4 --monthly=12 --user-last=3");
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if ($input->getOption('user-last') !== null && !ctype_digit((string)$input->getOption('user-last'))) {
|
||||
$output->writeln('<error>--user-last must be a whole number</error>');
|
||||
return 1;
|
||||
}
|
||||
$p = $this->prune->policy()->toArray();
|
||||
$changed = false;
|
||||
foreach (['last', 'daily', 'weekly', 'monthly'] as $k) {
|
||||
@@ -48,7 +53,16 @@ final class Retention extends Command {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if ($input->getOption('user-last') !== null) {
|
||||
try {
|
||||
$this->prune->setUserExportRetention((int)$input->getOption('user-last'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('Keep the last %d, plus one per day for %d days, per week for %d weeks, per month for %d months.', $p['last'], $p['daily'], $p['weekly'], $p['monthly']));
|
||||
$output->writeln(sprintf('Keep the newest %d of each user\'s own exports.', $this->prune->userExportRetention()));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,16 @@ HELP);
|
||||
});
|
||||
if ($run->getStatus() === Run::DONE) {
|
||||
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
||||
$f = json_decode($run->getStats() ?? '{}', true)['files'] ?? [];
|
||||
if (($f['unreadable'] ?? 0) > 0) {
|
||||
$output->writeln(sprintf('<comment> warning: %d unreadable files or folders are missing from this backup (see the server log)</comment>', $f['unreadable']));
|
||||
}
|
||||
if (($f['badNames'] ?? 0) > 0) {
|
||||
$output->writeln(sprintf('<comment> warning: %d files or folders are missing from this backup because their name is not supported; rename them and the next backup includes them (see the server log)</comment>', $f['badNames']));
|
||||
}
|
||||
if (($f['changing'] ?? 0) > 0) {
|
||||
$output->writeln(sprintf('<comment> warning: %d files kept changing and were saved as read (see the server log)</comment>', $f['changing']));
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
||||
$failed++;
|
||||
|
||||
+71
-7
@@ -8,33 +8,51 @@ declare(strict_types=1);
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\CronHealth;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
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,
|
||||
private Schedule $schedule,
|
||||
private CronHealth $cron,
|
||||
private \OCA\NgBackup\Service\BackupService $backups,
|
||||
) {
|
||||
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 {
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$cronProblem = CronHealth::describe($this->cron->check());
|
||||
if ($cronProblem !== null) {
|
||||
$output->writeln('<error>' . $cronProblem . '</error>');
|
||||
}
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
||||
} else {
|
||||
$c = $this->keys->recoveryKitConfirmation();
|
||||
$output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit '
|
||||
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : '<error>not confirmed</error> (occ backup:key:confirm)'));
|
||||
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . Format::time($c['time'], $zone) . " ($zone)" : '<error>not confirmed</error> (occ backup:key:confirm)'));
|
||||
}
|
||||
$names = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
@@ -42,15 +60,61 @@ 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);
|
||||
$queued = array_map(static fn (\OCA\NgBackup\Db\Run $r): int => $r->getId(), array_slice($this->backups->runsInTurn(), 1));
|
||||
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(),
|
||||
in_array($r->getId(), $queued, true) ? 'queued' : ($r->getStatus() === 'running' ? self::runningStatus($r) : $r->getStatus()),
|
||||
Format::time($r->getStartedAt(), $zone), $r->getFinishedAt() ? 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 ($zone)", '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);
|
||||
}
|
||||
|
||||
/** " 47/183 tables" in the database phase, " 68%" or " ~68%" in the files phase; empty without a figure */
|
||||
private static function percent(\OCA\NgBackup\Db\Run $r): string {
|
||||
$p = \OCA\NgBackup\Job\BackupRun::progressOf($r);
|
||||
if ($p === null) {
|
||||
return '';
|
||||
}
|
||||
if (isset($p['tables'], $p['totalTables'])) {
|
||||
return sprintf(': %d/%d tables', $p['tables'], $p['totalTables']);
|
||||
}
|
||||
return sprintf(' %s%d%%', $p['approximate'] ? '~' : '', (int)floor($p['fraction'] * 100.0));
|
||||
}
|
||||
|
||||
/** "running (db: 47/183 tables)", or "waiting for background jobs (last step 12 min ago)" */
|
||||
private static function runningStatus(\OCA\NgBackup\Db\Run $r): string {
|
||||
$waiting = $r->getKind() === \OCA\NgBackup\Service\BackupService::KIND_FULL ? CronHealth::waitingFor($r) : null;
|
||||
if ($waiting !== null) {
|
||||
return 'waiting for background jobs (' . $r->getPhase() . self::percent($r) . ', last step ' . CronHealth::ago($waiting) . ')';
|
||||
}
|
||||
return 'running (' . $r->getPhase() . self::percent($r) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
+172
-6
@@ -13,7 +13,9 @@ 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\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
final class TargetAdd extends Command {
|
||||
public function __construct(
|
||||
@@ -31,10 +33,25 @@ final class TargetAdd extends Command {
|
||||
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
|
||||
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
|
||||
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
|
||||
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Disaster recovery without the recovery kit: read the passphrase from this file instead of asking')
|
||||
->addOption('repair-config', null, InputOption::VALUE_NONE, 'The location\'s config file is gone: write it again with the key of this server or of the imported recovery kit (locations backed up by 1.1.0 or newer)')
|
||||
->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'Disaster recovery after a new key was made here by mistake: replace that key, if it was never used, by the key of the backups at this location (asks for a passphrase of the old server)')
|
||||
->setHelp(<<<'HELP'
|
||||
The location is reached with files_external's own connection code, but it is never added as an
|
||||
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
|
||||
|
||||
Disaster recovery without the recovery kit: on a server without a backup key, a location that
|
||||
holds backups gives its key with one of the old server's passphrases. Run this command first
|
||||
(no occ backup:key:import), then occ backup:list and occ backup:restore:full.
|
||||
|
||||
A new key was made here by mistake (occ backup:key:init or the web page) before the location
|
||||
was added? As long as that key was never used (no location, no backup), --replace-unused-key
|
||||
replaces it by the key of the backups. A key that was used is never replaced.
|
||||
|
||||
With the recovery kit: occ backup:key:import first; this command then asks for nothing more.
|
||||
If the location's config file is gone, only the kit has the key: import it, then add the
|
||||
location with --repair-config.
|
||||
|
||||
Examples:
|
||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
|
||||
|
||||
@@ -43,10 +60,6 @@ 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>');
|
||||
return 1;
|
||||
}
|
||||
$options = [];
|
||||
foreach ($input->getOption('option') as $pair) {
|
||||
if (!str_contains($pair, '=')) {
|
||||
@@ -60,14 +73,167 @@ HELP);
|
||||
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
||||
return 1;
|
||||
}
|
||||
$name = (string)$input->getArgument('name');
|
||||
$backend = (string)$input->getArgument('backend');
|
||||
$auth = (string)$input->getOption('auth');
|
||||
$path = (string)$input->getOption('path');
|
||||
$passphrase = null;
|
||||
$repair = (bool)$input->getOption('repair-config');
|
||||
$replace = (bool)$input->getOption('replace-unused-key');
|
||||
if ($replace) {
|
||||
if ($repair) {
|
||||
$output->writeln('<error>--replace-unused-key needs the config at the location; with the config gone only the kit helps: occ backup:key:import <kit file> --replace-unused-key, then --repair-config.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$this->targets->keyIsUnused()) {
|
||||
$output->writeln('<error>The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced.</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') was never used. It is replaced by the key of the backups at');
|
||||
$output->writeln('this location, once a passphrase of the old server opens it. Its own passphrases and recovery kit then stop working.');
|
||||
$passphrase = $this->passphrase($input, $output);
|
||||
if ($passphrase === null) {
|
||||
return 1;
|
||||
}
|
||||
} elseif (!$this->keys->isInitialized()) {
|
||||
try {
|
||||
$state = $this->targets->locationState($backend, $auth, $options, $path);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($state === TargetService::LOCATION_EMPTY) {
|
||||
$output->writeln('<error>No backup key yet, and no backups at this location.</error>');
|
||||
$output->writeln('Restoring a server (disaster recovery)? Check the connection details and --path: they must be the same as on the old server.');
|
||||
$output->writeln('Only for a new location: create a new key first with occ backup:key:init. A new key cannot read existing backups.');
|
||||
return 1;
|
||||
}
|
||||
if ($state === TargetService::LOCATION_DATA_WITHOUT_CONFIG) {
|
||||
$output->writeln('<error>This location holds backups, but its config file is gone, and the key was stored in it.</error>');
|
||||
$output->writeln('Now only the recovery kit has the key. Import it, then run this command again with --repair-config:');
|
||||
$output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json');
|
||||
$output->writeln('Do not run occ backup:key:init: a new key cannot read these backups.');
|
||||
return 1;
|
||||
}
|
||||
if ($repair) {
|
||||
$output->writeln('<error>The config at this location is there: leave out --repair-config.</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('No backup key on this server yet, and this location holds backups. Their key is stored there too,');
|
||||
$output->writeln('locked with the passphrases of the old server: one of them unlocks it (the recovery kit is not needed).');
|
||||
$passphrase = $this->passphrase($input, $output);
|
||||
if ($passphrase === null) {
|
||||
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'));
|
||||
} catch (\Throwable $e) {
|
||||
$r = $passphrase === null
|
||||
? $this->targets->add($name, $backend, $auth, $options, $path, self::progress($output), $repair)
|
||||
: $this->targets->addWithPassphrase($name, $backend, $auth, $options, $path, $passphrase, self::progress($output), $replace);
|
||||
} catch (\OCA\NgBackup\Crypto\CryptoException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
if (!$this->keys->isInitialized() || $replace) {
|
||||
$output->writeln('Nothing was changed. Run the command again to try another passphrase, or use the recovery kit (occ backup:key:import).');
|
||||
}
|
||||
return 1;
|
||||
} catch (\Throwable $e) {
|
||||
if ($output->isDecorated()) {
|
||||
$output->write("\r\033[K"); // clear an unfinished progress line
|
||||
}
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
if (str_contains($e->getMessage(), 'different key') && !$replace) {
|
||||
$output->writeln('The backup key here (fingerprint ' . $this->keys->fingerprint() . ') is not the one these backups were made with.');
|
||||
$output->writeln('Restoring a server? The recovery kit must be the old server\'s: its fingerprint is in the kit file. Nothing was changed.');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if ($repair) {
|
||||
$output->writeln('The config of the location was written again from the recovery kit.');
|
||||
}
|
||||
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
|
||||
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||
if ($passphrase !== null) {
|
||||
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$days = 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days';
|
||||
$output->writeln(match (true) {
|
||||
($r['deleteDelayDays'] ?? null) !== null => $days . ' (from the location).',
|
||||
$r['settingsUnreadable'] ?? false => '<comment>' . $days . ' (the longest: the settings on the location could not be read or hold no valid delay, so nothing leaves the Bin sooner than on the old server; removed restore points take space for that long).</comment>',
|
||||
default => $days . ' (the default: the location has none stored, its backups were made before NG Backup 1.1.0).',
|
||||
});
|
||||
$output->writeln('Next: <info>occ backup:list</info> and <info>occ backup:restore:full</info>. Before backups run again, download a new recovery kit and confirm it (<info>occ backup:key:kit</info>, <info>occ backup:key:confirm</info>).');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function passphrase(InputInterface $input, OutputInterface $output): ?string {
|
||||
$file = $input->getOption('passphrase-file');
|
||||
if ($file !== null) {
|
||||
$raw = @file_get_contents((string)$file);
|
||||
if ($raw === false) {
|
||||
$output->writeln('<error>Cannot read the passphrase file</error>');
|
||||
return null;
|
||||
}
|
||||
return rtrim($raw, "\r\n");
|
||||
}
|
||||
if (!$input->isInteractive()) {
|
||||
$output->writeln('<error>Give the passphrase with --passphrase-file, or run the command without --no-interaction to type it.</error>');
|
||||
return null;
|
||||
}
|
||||
$q = (new Question('Passphrase of the old server (one of its slots): '))->setHidden(true)->setHiddenFallback(false);
|
||||
/** @var QuestionHelper $helper */
|
||||
$helper = $this->getHelper('question');
|
||||
return (string)$helper->ask($input, $output, $q);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
$shown = 0.0;
|
||||
$first = null; // [time, files done] when the first files were in
|
||||
$inPlace = $output->isDecorated();
|
||||
return static function (int $done, int $total) use ($output, &$shown, &$first, $inPlace): void {
|
||||
$now = microtime(true);
|
||||
if ($first === null && $done > 0) {
|
||||
$first = [$now, $done];
|
||||
}
|
||||
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)));
|
||||
$left = $first === null ? null : self::minutesLeft($now - $first[0], $done - $first[1], $total - $done, $total);
|
||||
if ($left !== null) {
|
||||
$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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes left for reading the index, from the rate since the first files were in: the first
|
||||
* file also carries the time to connect, which made an S3 location with 4,568 index files
|
||||
* say "about 642 min left" for something done within a minute. No estimate until 5% of the
|
||||
* files, or 50 files, came in after that, and at least 5 seconds passed.
|
||||
*/
|
||||
public static function minutesLeft(float $elapsed, int $done, int $left, int $total): ?int {
|
||||
if ($left <= 0 || $done <= 0 || $elapsed < 5.0 || ($done < 50 && $done * 20 < $total)) {
|
||||
return null;
|
||||
}
|
||||
return (int)ceil($elapsed / (float)$done * (float)$left / 60.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ final class TargetList extends Base {
|
||||
$rows = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
|
||||
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt()),
|
||||
'schedule' => (string)$t->getSchedule() !== '' ? $t->getSchedule() : 'off'];
|
||||
if ($input->getOption('test')) {
|
||||
try {
|
||||
$repo = $this->targets->repository($t);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?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\Schedule;
|
||||
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 TargetSchedule extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private Schedule $schedule,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:target:schedule')
|
||||
->setDescription('Show or set when a location is backed up')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('schedule', InputArgument::OPTIONAL, '"off", HH:MM for once a day, or HH:MM/N for every N hours from HH:MM; omit to show the current one')
|
||||
->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Time zone of the time, e.g. Europe/Amsterdam');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$t = $this->targets->get($input->getArgument('target'));
|
||||
$schedule = $input->getArgument('schedule');
|
||||
if ($schedule === null && $input->getOption('timezone') !== null) {
|
||||
$output->writeln('<error>--timezone goes with a schedule, e.g. backup:target:schedule ' . $t->getName() . ' 02:30 --timezone=Europe/Amsterdam</error>');
|
||||
return 1;
|
||||
}
|
||||
$zone = $input->getOption('timezone') ?? $this->schedule->zoneOr($t->getScheduleZone());
|
||||
if ($schedule !== null) {
|
||||
try {
|
||||
$t = $this->targets->setSchedule($t, $schedule, (string)$zone);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
$current = (string)$t->getSchedule();
|
||||
$output->writeln($t->getName() . ': ' . ($current === '' ? 'off' : $current . ' (' . $zone . ')'));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Format;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
@@ -19,6 +21,7 @@ final class Trash extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
private Schedule $schedule,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -32,11 +35,14 @@ final class Trash extends Command {
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
||||
$zone = $this->schedule->get()['timezone'];
|
||||
$rows = [];
|
||||
foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) {
|
||||
foreach ($this->targets->repository($t)->trash() as $id => $info) {
|
||||
$rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'],
|
||||
date('Y-m-d H:i', $info['forgottenAt'] + $delay)];
|
||||
$time = $info['time'] ?? null;
|
||||
$made = is_string($time) ? strtotime($time) : false;
|
||||
$rows[] = [$t->getName(), $id, $made !== false ? Format::time($made, $zone) : '', $info['label'], Format::time($info['forgottenAt'], $zone), $info['by'],
|
||||
Format::time($info['forgottenAt'] + $delay, $zone)];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
@@ -44,6 +50,7 @@ final class Trash extends Command {
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render();
|
||||
$output->writeln("Times in $zone. The retention policy keeps the newest snapshots and the last of each recent day, week and month; it moves the others here, and they are deleted permanently after " . $this->keys->deleteDelayDays() . ' days.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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\TargetService;
|
||||
use OCA\NgBackup\Service\VerifyService;
|
||||
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 Verify extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private VerifyService $verify,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:verify')
|
||||
->setDescription('Checksum audit of a snapshot: every blob it needs must be in the (freshly re-checked) index and its pack present')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||
->addOption('deep', null, InputOption::VALUE_NONE, 'Also download and decrypt every blob (verifies its authentication tag and content hash, not just that it is listed); much slower')
|
||||
->setHelp(<<<'HELP'
|
||||
Without --deep this re-downloads and re-authenticates the index (never trusting the local cache)
|
||||
and confirms every blob the snapshot needs is listed and its pack is present -- fast, no blob
|
||||
content is read, so it cannot by itself catch a truncated or bit-rotted pack. With --deep every
|
||||
blob is also downloaded and decrypted, which fails loudly on exactly that even if the index still
|
||||
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.
|
||||
|
||||
Examples:
|
||||
occ backup:verify offsite 537bf7e9c1a4...
|
||||
occ backup:verify offsite 537bf7e9c1a4... --deep
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
try {
|
||||
$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, $onProgress)
|
||||
: $this->verify->verify($target, $ref, $deep, $onProgress);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(sprintf('%d files, %d blobs checked%s.', $result['filesChecked'], $result['blobsChecked'],
|
||||
$deep ? sprintf(' (%.1f MiB decrypted)', $result['bytesChecked'] / 1048576) : ''));
|
||||
if ($result['missing'] !== []) {
|
||||
$output->writeln('<error>' . count($result['missing']) . ' blob(s) missing from the index:</error> ' . implode(', ', $result['missing']));
|
||||
}
|
||||
if ($result['failed'] !== []) {
|
||||
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $result['failed']));
|
||||
}
|
||||
if ($result['unreadable'] !== []) {
|
||||
$output->writeln('<error>' . count($result['unreadable']) . ' blob(s) could not be read from the location</error> (often a temporary storage '
|
||||
. 'or network error: run the check again; if the same blobs stay unreadable, their pack may be damaged or truncated): '
|
||||
. implode(', ', $result['unreadable']));
|
||||
$output->writeln('Last read error: ' . $result['readError']);
|
||||
}
|
||||
if ($result['missing'] === [] && $result['failed'] === [] && $result['unreadable'] === []) {
|
||||
$output->writeln('<info>OK</info>');
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,25 @@ final class AdminApiController extends Controller {
|
||||
private \OCA\NgBackup\Service\AlertService $alerts,
|
||||
private \OCA\NgBackup\Service\KeySlotService $slots,
|
||||
private \OCA\NgBackup\Service\PruneService $prune,
|
||||
private \OCA\NgBackup\Service\Schedule $schedule,
|
||||
private \OCA\NgBackup\Service\HeavyJobPause $heavyJobs,
|
||||
private \OCA\NgBackup\Service\CronHealth $cron,
|
||||
) {
|
||||
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)];
|
||||
'maxBytes' => $t->getMaxBytes(), 'storedBytes' => $this->alerts->lastStoredBytes($t),
|
||||
'schedule' => (string)$t->getSchedule(), 'scheduleZone' => $this->schedule->zoneOr($t->getScheduleZone())];
|
||||
}
|
||||
$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);
|
||||
$queued = array_flip(array_map(static fn (Run $r): int => $r->getId(), array_slice($this->backups->runsInTurn(), 1)));
|
||||
$runs = array_map(fn (Run $r) => $this->runJson($r) + ['queued' => isset($queued[$r->getId()])], $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,10 +75,50 @@ final class AdminApiController extends Controller {
|
||||
'confirmationPhrase' => KeyConfirm::CONFIRMATION,
|
||||
'targets' => $targets,
|
||||
'runs' => $runs,
|
||||
'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''),
|
||||
'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown),
|
||||
'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS,
|
||||
'cron' => $this->cron->check(),
|
||||
'heavyJobs' => $this->heavyJobs->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function setPauseHeavyJobs(bool $enabled): JSONResponse {
|
||||
$this->heavyJobs->setEnabled($enabled);
|
||||
return new JSONResponse(['enabled' => $enabled]);
|
||||
}
|
||||
|
||||
/** Seconds of backup work one tick() does: well within a normal web request. */
|
||||
public const TICK_SECONDS = 10.0;
|
||||
/** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */
|
||||
private const TRASH_PAGE_SECONDS = 5.0;
|
||||
|
||||
/**
|
||||
* Move running backups forward from the open admin page, when background jobs do not run on a
|
||||
* working system cron (#13). The page calls this while a backup runs, so "Back up now" works
|
||||
* without cron as long as the page stays open; scheduled backups still need cron. With a
|
||||
* working system cron this does nothing: the background job does the work.
|
||||
*/
|
||||
public function tick(): JSONResponse {
|
||||
if ($this->cron->check()['ok']) {
|
||||
return new JSONResponse(['stepped' => 0]);
|
||||
}
|
||||
$deadline = microtime(true) + self::TICK_SECONDS;
|
||||
$stepped = 0;
|
||||
$this->heavyJobs->sync($this->backups->runsInTurn() !== []);
|
||||
foreach ($this->backups->runsInTurn() as $run) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
// step() returns at once when the background job (or another tab) holds the run.
|
||||
$next = $this->backups->step($run, $deadline);
|
||||
$stepped++;
|
||||
if ($next->getStatus() === Run::RUNNING) {
|
||||
break; // not finished: the next one waits for its turn
|
||||
}
|
||||
}
|
||||
return new JSONResponse(['stepped' => $stepped]);
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse {
|
||||
try {
|
||||
@@ -140,15 +187,18 @@ final class AdminApiController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
public function trash(int $targetId): JSONResponse {
|
||||
public function trash(int $targetId, string $after = ''): JSONResponse {
|
||||
try {
|
||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
||||
$repo = $this->targets->repository($this->targets->get((string)$targetId));
|
||||
// In parts, so that the page can show how far it is (a read per removed restore point).
|
||||
$page = $repo->trashPage($after, microtime(true) + self::TRASH_PAGE_SECONDS);
|
||||
$out = [];
|
||||
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
|
||||
foreach ($page['items'] as $id => $info) {
|
||||
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
|
||||
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
return new JSONResponse(['total' => $page['total'], 'items' => $out]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
@@ -168,6 +218,7 @@ final class AdminApiController extends Controller {
|
||||
return new JSONResponse($this->targets->availableBackends());
|
||||
}
|
||||
|
||||
#[PasswordConfirmationRequired]
|
||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
|
||||
try {
|
||||
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
||||
@@ -180,6 +231,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 {
|
||||
@@ -213,23 +286,43 @@ final class AdminApiController extends Controller {
|
||||
return new JSONResponse($this->runJson($run));
|
||||
}
|
||||
|
||||
public function setSchedule(string $time): JSONResponse {
|
||||
if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
||||
return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST);
|
||||
/** @param string $timezone the administrator's zone (the browser's); empty: the default one */
|
||||
public function setTargetSchedule(int $id, string $schedule, string $timezone = ''): JSONResponse {
|
||||
try {
|
||||
$t = $this->targets->setSchedule($this->targets->get((string)$id), $schedule, $timezone !== '' ? $timezone : $this->schedule->get()['timezone']);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time);
|
||||
return new JSONResponse(['schedule' => $time]);
|
||||
return new JSONResponse(['schedule' => $t->getSchedule(), 'scheduleZone' => $t->getScheduleZone()]);
|
||||
}
|
||||
|
||||
public function snapshots(int $targetId): JSONResponse {
|
||||
$out = [];
|
||||
foreach ($this->snapshots->findForTarget($targetId) as $s) {
|
||||
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
|
||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
|
||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
}
|
||||
|
||||
public function setSnapshotNote(int $targetId, string $snapshotId, string $note = ''): JSONResponse {
|
||||
try {
|
||||
$this->prune->setNote($this->targets->get((string)$targetId), $snapshotId, $note);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
public function setSnapshotPinned(int $targetId, string $snapshotId, bool $pinned = true): JSONResponse {
|
||||
try {
|
||||
$this->prune->setPinned($this->targets->get((string)$targetId), $snapshotId, $pinned);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
|
||||
try {
|
||||
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
|
||||
@@ -274,7 +367,7 @@ final class AdminApiController extends Controller {
|
||||
if ($r->getSnapshotId() !== null) {
|
||||
try {
|
||||
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
|
||||
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
|
||||
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
|
||||
} catch (\Throwable) {
|
||||
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
|
||||
}
|
||||
@@ -282,6 +375,7 @@ 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),
|
||||
'waitingFor' => $r->getKind() === BackupService::KIND_FULL ? \OCA\NgBackup\Service\CronHealth::waitingFor($r) : null];
|
||||
}
|
||||
}
|
||||
|
||||
+30
-6
@@ -87,19 +87,43 @@ final class KeyRing {
|
||||
foreach ($field['slots'] as $slot) {
|
||||
try {
|
||||
return self::unwrap($slot['key'], $passphrase);
|
||||
} catch (CryptoException) {
|
||||
} catch (CryptoException|\SodiumException|\TypeError|\ValueError) {
|
||||
// a wrong passphrase for this slot, or a damaged slot: the next one may still open
|
||||
}
|
||||
}
|
||||
throw new CryptoException('Wrong passphrase or damaged key');
|
||||
}
|
||||
|
||||
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
||||
/** @param array<string, mixed> $wrapped from a kit file or a location's config: checked here, not trusted */
|
||||
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
||||
if (!isset($wrapped['salt'], $wrapped['nonce'], $wrapped['key'], $wrapped['ops'], $wrapped['mem'])
|
||||
|| !is_string($wrapped['salt']) || !is_string($wrapped['nonce']) || !is_string($wrapped['key'])
|
||||
|| !is_int($wrapped['ops']) || !is_int($wrapped['mem'])) {
|
||||
throw new CryptoException('Damaged key file: a part of the key is missing');
|
||||
}
|
||||
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
|
||||
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
|
||||
sodium_memzero($kek);
|
||||
// ops/mem come from unauthenticated JSON (kit file, repository config): bound them, so a
|
||||
// damaged or crafted file cannot make the KDF use unbounded memory or time.
|
||||
if ($wrapped['ops'] < SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE || $wrapped['ops'] > SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE
|
||||
|| $wrapped['mem'] < SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE || $wrapped['mem'] > SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE) {
|
||||
throw new CryptoException('Unsupported key derivation parameters (damaged or foreign key file)');
|
||||
}
|
||||
$kek = null;
|
||||
try {
|
||||
$salt = $b64($wrapped['salt']);
|
||||
$sealed = $b64($wrapped['key']);
|
||||
$nonce = $b64($wrapped['nonce']);
|
||||
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $salt,
|
||||
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
$master = sodium_crypto_secretbox_open($sealed, $nonce, $kek);
|
||||
} catch (\SodiumException|\ValueError $e) {
|
||||
// invalid base64 or a salt/nonce of the wrong length: damaged, so the caller shows its recovery options
|
||||
throw new CryptoException('Damaged key file: ' . $e->getMessage(), 0, $e);
|
||||
} finally {
|
||||
if ($kek !== null) {
|
||||
sodium_memzero($kek);
|
||||
}
|
||||
}
|
||||
if ($master === false) {
|
||||
throw new CryptoException('Wrong passphrase or damaged key');
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ final class StreamCipher {
|
||||
/** fread() may return short reads on network streams; keep reading until $length or EOF. */
|
||||
private static function readFull($in, int $length): string {
|
||||
$buffer = '';
|
||||
$empty = 0;
|
||||
while (strlen($buffer) < $length && !feof($in)) {
|
||||
$chunk = fread($in, $length - strlen($buffer));
|
||||
if ($chunk === false) {
|
||||
@@ -132,8 +133,15 @@ final class StreamCipher {
|
||||
if (feof($in)) {
|
||||
break;
|
||||
}
|
||||
// A stalled network stream returns '' without EOF forever: back off, then give up
|
||||
// (about a minute) instead of spinning at full CPU until the lease expires.
|
||||
if (++$empty > 600) {
|
||||
throw new CryptoException('Read stalled');
|
||||
}
|
||||
usleep(100000);
|
||||
continue;
|
||||
}
|
||||
$empty = 0;
|
||||
$buffer .= $chunk;
|
||||
}
|
||||
return $buffer;
|
||||
|
||||
+367
-50
@@ -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.
|
||||
@@ -72,50 +76,57 @@ final class DbDumper {
|
||||
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
||||
$state['steps']++;
|
||||
$sm = $this->db->createSchemaManager();
|
||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||
$this->db->beginTransaction();
|
||||
// A pack that fills up during the dump is uploaded right away; its index cache row must
|
||||
// wait until the read-only transaction is over.
|
||||
$repo->holdIndexCache();
|
||||
try {
|
||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||
}
|
||||
while ($state['i'] < count($state['tables'])) {
|
||||
$table = $state['tables'][$state['i']];
|
||||
$schema = $sm->introspectTable($table);
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||
if ($state['after'] === null && $state['segments'] === []) {
|
||||
$writer->write($this->header($schema) . "\n");
|
||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||
}
|
||||
$complete = true;
|
||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
||||
$writer->write($line . "\n");
|
||||
hash_update($hash, $line . "\n");
|
||||
$state['rows']++;
|
||||
$state['after'] = $key;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$complete = false;
|
||||
while ($state['i'] < count($state['tables'])) {
|
||||
$table = $state['tables'][$state['i']];
|
||||
$schema = $sm->introspectTable($table);
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||
if ($state['after'] === null && $state['segments'] === []) {
|
||||
$writer->write($this->header($schema) . "\n");
|
||||
}
|
||||
$complete = true;
|
||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
||||
$writer->write($line . "\n");
|
||||
hash_update($hash, $line . "\n");
|
||||
$state['rows']++;
|
||||
$state['after'] = $key;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state['segments'][] = $writer->finish();
|
||||
$state['bytes'] += $writer->bytes();
|
||||
if (!$complete) {
|
||||
$state['hash'] = base64_encode(serialize($hash));
|
||||
break;
|
||||
}
|
||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash), 'schema' => self::structure($schema)];
|
||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state['segments'][] = $writer->finish();
|
||||
$state['bytes'] += $writer->bytes();
|
||||
if (!$complete) {
|
||||
$state['hash'] = base64_encode(serialize($hash));
|
||||
break;
|
||||
if ($state['i'] >= count($state['tables'])) {
|
||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||
$state['done'] = true;
|
||||
}
|
||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($state['i'] >= count($state['tables'])) {
|
||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||
$state['done'] = true;
|
||||
} finally {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
} finally {
|
||||
$this->db->rollBack();
|
||||
$repo->releaseIndexCache();
|
||||
}
|
||||
$repo->flushPacks();
|
||||
return $state;
|
||||
@@ -130,18 +141,48 @@ 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): int {
|
||||
$sm = $this->db->createSchemaManager();
|
||||
$existing = array_flip($sm->listTableNames());
|
||||
$total = 0;
|
||||
$report = ['skipped' => [], 'restored' => []];
|
||||
$this->db->beginTransaction();
|
||||
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.
|
||||
try {
|
||||
foreach ($missing as $table) {
|
||||
if (isset($manifest['tables'][$table]['schema'])) {
|
||||
$report['created'][] = $table; // first: creating it can stop halfway (its indexes)
|
||||
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
|
||||
} else {
|
||||
$report['missing'][] = $table;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->dropCreatedTables($report);
|
||||
throw $e;
|
||||
}
|
||||
$total = 0;
|
||||
$foreignKeys = [];
|
||||
try {
|
||||
$this->db->beginTransaction();
|
||||
} catch (\Throwable $e) {
|
||||
$this->dropCreatedTables($report);
|
||||
throw $e;
|
||||
}
|
||||
try {
|
||||
$foreignKeys = $this->suspendForeignKeys(); // first: on SQLite it also takes the write lock
|
||||
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);
|
||||
@@ -175,6 +216,9 @@ final class DbDumper {
|
||||
if (count($batch) >= self::INSERT_ROWS) {
|
||||
$total += $this->insertBatch($table, $columns, $batch, $binary);
|
||||
$batch = [];
|
||||
if ($heartbeat !== null) {
|
||||
$heartbeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($batch !== []) {
|
||||
@@ -182,15 +226,253 @@ final class DbDumper {
|
||||
}
|
||||
$report['restored'][] = $table;
|
||||
}
|
||||
$this->restoreSequences($manifest['sequences'] ?? []);
|
||||
// unchanged tables too: a restore that stopped earlier may have left their counters at 1
|
||||
$this->restoreSequences($manifest['sequences'] ?? [], $report['missing'], array_merge($report['restored'], $report['skipped']));
|
||||
$this->resumeForeignKeys($foreignKeys);
|
||||
$this->db->commit();
|
||||
} 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');
|
||||
}
|
||||
$this->dropCreatedTables($report);
|
||||
throw $e;
|
||||
}
|
||||
// Committed. 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, and the created tables stay.
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore did not go through: a rollback does not undo CREATE TABLE everywhere. Left behind,
|
||||
* the empty tables of an app would stop that app's installation later ("table already exists").
|
||||
*
|
||||
* @param array{created: list<string>, ...} $report
|
||||
*/
|
||||
private function dropCreatedTables(array &$report): void {
|
||||
foreach ($report['created'] as $table) {
|
||||
try {
|
||||
$this->db->createSchemaManager()->dropTable($table);
|
||||
} catch (\Throwable) {
|
||||
// the error that brought us here is the one to report; a run again uses the table
|
||||
}
|
||||
}
|
||||
$report['created'] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The table prefix of the instance the dump was made on (from its appconfig table), or null
|
||||
* when the dump has none.
|
||||
*
|
||||
*/
|
||||
public static function dumpPrefix(array $manifest): ?string {
|
||||
/** @var list<string> $tables table names */
|
||||
$tables = array_keys($manifest['tables'] ?? []);
|
||||
foreach ($tables as $name) {
|
||||
if (str_ends_with($name, 'appconfig') && !str_ends_with($name, '_ex_appconfig')) {
|
||||
return substr($name, 0, -strlen('appconfig'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables in the dump that do not exist on this instance.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function missingTables(array $manifest): array {
|
||||
$existing = array_flip($this->db->createSchemaManager()->listTableNames());
|
||||
return array_values(array_filter(array_keys($manifest['tables']), fn (string $t) => !isset($existing[$t])));
|
||||
}
|
||||
|
||||
/** One message for all missing tables of an older backup, grouped by the app they probably belong to. */
|
||||
public static function describeMissing(array $tables, string $prefix): string {
|
||||
$groups = [];
|
||||
foreach ($tables as $t) {
|
||||
$name = substr($t, strlen($prefix));
|
||||
$groups[explode('_', $name, 2)[0]][] = $t;
|
||||
}
|
||||
$lines = [];
|
||||
foreach ($groups as $app => $list) {
|
||||
$lines[] = '- ' . $app . ': ' . implode(', ', $list);
|
||||
}
|
||||
return "These tables from the backup do not exist here, and this backup does not record their structure (made before 1.0.0-rc.2):\n"
|
||||
. implode("\n", $lines)
|
||||
. "\nInstall the apps they belong to (often apps that were removed or switched off long ago) and run the restore again, or leave these tables out with --skip-missing-tables.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable description of a table: enough to create it again, also on another database type.
|
||||
*
|
||||
* @return array{columns: list<array<string, mixed>>, pk: list<string>, indexes: list<array{name: string, columns: list<string>, unique: bool}>}
|
||||
*/
|
||||
private static function structure(\Doctrine\DBAL\Schema\Table $table): array {
|
||||
$columns = [];
|
||||
foreach ($table->getColumns() as $c) {
|
||||
$columns[] = ['name' => $c->getName(), 'type' => \Doctrine\DBAL\Types\Type::lookupName($c->getType()),
|
||||
'length' => $c->getLength(), 'precision' => $c->getPrecision(), 'scale' => $c->getScale(),
|
||||
'unsigned' => $c->getUnsigned(), 'fixed' => $c->getFixed(), 'notnull' => $c->getNotnull(),
|
||||
'default' => $c->getDefault(), 'autoincrement' => $c->getAutoincrement()];
|
||||
}
|
||||
$indexes = [];
|
||||
foreach ($table->getIndexes() as $i) {
|
||||
if (!$i->isPrimary()) {
|
||||
// options holds the prefix lengths MySQL needs for indexes on long text columns
|
||||
$indexes[] = ['name' => $i->getName(), 'columns' => array_values($i->getColumns()), 'unique' => $i->isUnique(),
|
||||
'flags' => array_values($i->getFlags()), 'options' => $i->getOptions()];
|
||||
}
|
||||
}
|
||||
$foreignKeys = [];
|
||||
foreach ($table->getForeignKeys() as $fk) {
|
||||
$foreignKeys[] = ['name' => $fk->getName(), 'columns' => array_values($fk->getLocalColumns()),
|
||||
'table' => $fk->getForeignTableName(), 'foreign' => array_values($fk->getForeignColumns()), 'options' => $fk->getOptions()];
|
||||
}
|
||||
return ['columns' => $columns, 'pk' => array_values($table->getPrimaryKey()?->getColumns() ?? []), 'indexes' => $indexes,
|
||||
'foreignKeys' => $foreignKeys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Table options for a table the restore creates: those of this installation's own tables.
|
||||
* On MySQL/MariaDB that is the character set and collation, as Nextcloud's tables have them
|
||||
* (utf8mb4, so emoji and other 4-byte characters fit); DBAL alone would use 3-byte utf8.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @psalm-suppress UndefinedClass the DBAL classes come with Nextcloud, not with this app
|
||||
*/
|
||||
private function tableOptions(): array {
|
||||
$options = $this->db->createSchemaManager()->createSchemaConfig()->getDefaultTableOptions();
|
||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||
$collation = $this->db->fetchOne('SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?',
|
||||
[$this->prefix . 'appconfig']);
|
||||
if (is_string($collation) && $collation !== '') {
|
||||
$options['collation'] = $collation;
|
||||
$options['charset'] = explode('_', $collation, 2)[0];
|
||||
unset($options['collate']);
|
||||
}
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $options table options (see tableOptions()) */
|
||||
private static function tableFromStructure(string $name, array $structure, array $options = []): \Doctrine\DBAL\Schema\Table {
|
||||
$table = new \Doctrine\DBAL\Schema\Table($name, [], [], [], [], $options);
|
||||
foreach ($structure['columns'] as $c) {
|
||||
$table->addColumn($c['name'], $c['type'], array_filter([
|
||||
'length' => $c['length'], 'precision' => $c['precision'], 'scale' => $c['scale'],
|
||||
'unsigned' => $c['unsigned'], 'fixed' => $c['fixed'], 'notnull' => $c['notnull'],
|
||||
'default' => $c['default'], 'autoincrement' => $c['autoincrement'],
|
||||
], fn ($v) => $v !== null));
|
||||
}
|
||||
if ($structure['pk'] !== []) {
|
||||
$table->setPrimaryKey($structure['pk']);
|
||||
}
|
||||
foreach ($structure['indexes'] as $i) {
|
||||
$i['unique']
|
||||
? $table->addUniqueIndex($i['columns'], $i['name'], $i['options'] ?? [])
|
||||
: $table->addIndex($i['columns'], $i['name'], $i['flags'] ?? [], $i['options'] ?? []);
|
||||
}
|
||||
return $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop checking foreign keys while rows are loaded in name order. PostgreSQL has no session
|
||||
* switch for this without superuser rights, so its foreign keys of Nextcloud tables are dropped
|
||||
* here and added back (and so checked) by resumeForeignKeys(), inside the same transaction.
|
||||
*
|
||||
* @return list<string> statements that restore the foreign keys
|
||||
*/
|
||||
private function suspendForeignKeys(): array {
|
||||
$platform = $this->db->getDatabasePlatform();
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT conrelid::regclass::text AS tbl, conname, pg_get_constraintdef(oid) AS def
|
||||
FROM pg_constraint WHERE contype = 'f' AND connamespace = current_schema()::regnamespace");
|
||||
$restore = [];
|
||||
foreach ($rows as $r) {
|
||||
if (!str_starts_with(trim($r['tbl'], '"'), $this->prefix)) {
|
||||
continue;
|
||||
}
|
||||
$this->db->executeStatement(sprintf('ALTER TABLE %s DROP CONSTRAINT %s', $r['tbl'], $this->db->quoteIdentifier($r['conname'])));
|
||||
$restore[] = sprintf('ALTER TABLE %s ADD CONSTRAINT %s %s', $r['tbl'], $this->db->quoteIdentifier($r['conname']), $r['def']);
|
||||
}
|
||||
return $restore;
|
||||
}
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
|
||||
// MySQL does not check rows again when FOREIGN_KEY_CHECKS goes back to 1, and its DDL
|
||||
// would commit the transaction halfway. So every foreign key is checked explicitly,
|
||||
// with a query that finds rows pointing at nothing (resumeForeignKeys()).
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
'SELECT TABLE_NAME AS tbl, CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS rtbl, REFERENCED_COLUMN_NAME AS rcol
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION');
|
||||
$keys = [];
|
||||
foreach ($rows as $r) {
|
||||
if (str_starts_with((string)$r['tbl'], $this->prefix)) {
|
||||
$keys[$r['tbl'] . "\0" . $r['name']][] = $r;
|
||||
}
|
||||
}
|
||||
$q = fn (string $i): string => $this->db->quoteIdentifier($i);
|
||||
$checks = ['SET FOREIGN_KEY_CHECKS = 1'];
|
||||
foreach ($keys as $parts) {
|
||||
$join = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' = p.' . $q($r['rcol']), $parts));
|
||||
$set = implode(' AND ', array_map(fn (array $r): string => 'c.' . $q($r['col']) . ' IS NOT NULL', $parts));
|
||||
$checks[] = sprintf('SELECT COUNT(*) FROM %s c LEFT JOIN %s p ON %s WHERE %s AND p.%s IS NULL /* %s */',
|
||||
$q($parts[0]['tbl']), $q($parts[0]['rtbl']), $join, $set, $q($parts[0]['rcol']), $parts[0]['name']);
|
||||
}
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
return $checks;
|
||||
}
|
||||
if ($platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform) {
|
||||
// SQLite (WAL) starts a transaction as a reader. When another process (a cron job, the
|
||||
// installer still finishing) writes between our first read and our first write, that
|
||||
// write fails at once with "database is locked": no waiting helps a reader whose view
|
||||
// is out of date. Take the write lock first, so we wait for the other writer instead
|
||||
// and it waits for us after that.
|
||||
$this->db->executeStatement('DELETE FROM ' . $this->db->quoteIdentifier($this->prefix . 'appconfig') . ' WHERE 0 = 1');
|
||||
$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');
|
||||
@@ -253,7 +535,11 @@ final class DbDumper {
|
||||
} elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) {
|
||||
// Keep invalid UTF-8 byte-exact instead of substituting characters.
|
||||
$row[$i] = ['$b' => base64_encode($row[$i])];
|
||||
} elseif (is_int($row[$i]) || is_float($row[$i]) || is_bool($row[$i])) {
|
||||
} elseif (is_float($row[$i])) {
|
||||
// var_export uses serialize_precision (shortest exact form), not the lossy
|
||||
// 14-digit 'precision' of a (string) cast.
|
||||
$row[$i] = var_export($row[$i], true);
|
||||
} elseif (is_int($row[$i]) || is_bool($row[$i])) {
|
||||
// Normalise: drivers differ in returning numbers as int or string.
|
||||
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
|
||||
}
|
||||
@@ -345,11 +631,21 @@ final class DbDumper {
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function restoreSequences(array $sequences): void {
|
||||
/**
|
||||
* @param list<string> $leftOut tables not restored (--skip-missing-tables): they do not exist here
|
||||
* @param list<string> $restored tables restored or unchanged: their other counters go after their data
|
||||
*/
|
||||
private function restoreSequences(array $sequences, array $leftOut = [], array $restored = []): void {
|
||||
if (!$this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
return;
|
||||
}
|
||||
$leftOut = array_flip($leftOut);
|
||||
$set = [];
|
||||
foreach ($sequences as $info) {
|
||||
if (isset($leftOut[$info['table']])) {
|
||||
continue;
|
||||
}
|
||||
$set[$info['table'] . '.' . $info['column']] = true;
|
||||
$seq = $this->db->fetchOne('SELECT pg_get_serial_sequence(quote_ident(?), ?)', [$info['table'], $info['column']]);
|
||||
if ($seq === false || $seq === null) {
|
||||
continue;
|
||||
@@ -364,5 +660,26 @@ final class DbDumper {
|
||||
[\Doctrine\DBAL\ParameterType::STRING, \Doctrine\DBAL\ParameterType::INTEGER, \Doctrine\DBAL\ParameterType::BOOLEAN]);
|
||||
}
|
||||
}
|
||||
// A backup from MySQL/MariaDB or SQLite records no counters (those derive them from the
|
||||
// data): every other counter of a restored table goes after its data, or new rows (a new
|
||||
// file, an activity) would collide with restored ones.
|
||||
$restored = array_flip($restored);
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT c.table_name, c.column_name, pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) AS seq
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = current_schema() AND pg_get_serial_sequence(quote_ident(c.table_name), c.column_name) IS NOT NULL");
|
||||
foreach ($rows as $r) {
|
||||
if (!isset($restored[$r['table_name']]) || isset($set[$r['table_name'] . '.' . $r['column_name']])) {
|
||||
continue;
|
||||
}
|
||||
$max = (int)$this->db->fetchOne(sprintf('SELECT COALESCE(MAX(%s), 0) FROM %s',
|
||||
$this->db->quoteIdentifier($r['column_name']), $this->db->quoteIdentifier($r['table_name'])));
|
||||
// never backwards: a counter already past the data stays where it is
|
||||
$state = $this->db->fetchAssociative('SELECT last_value, is_called FROM ' . self::sequenceIdentifier($r['seq']));
|
||||
$next = $state === false ? 1 : (int)$state['last_value'] + ($state['is_called'] ? 1 : 0);
|
||||
if ($next <= $max) {
|
||||
$this->db->executeQuery('SELECT setval(CAST(? AS regclass), ?, false)', [$r['seq'], $max + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-7
@@ -30,19 +30,91 @@ final class DbIndexCache implements IIndexCache {
|
||||
}
|
||||
|
||||
public function put(string $repositoryId, string $packId, array $entries): void {
|
||||
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,51 @@ 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();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
|
||||
->andWhere($qb->expr()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('id', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function lastFinished(int $targetId, string $kind): ?Run {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
|
||||
@@ -24,6 +24,10 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setFiles(?int $v)
|
||||
* @method int|null getBytes()
|
||||
* @method void setBytes(?int $v)
|
||||
* @method string|null getNote()
|
||||
* @method void setNote(?string $v)
|
||||
* @method bool|null getPinned()
|
||||
* @method void setPinned(?bool $v)
|
||||
*/
|
||||
final class Snapshot extends Entity {
|
||||
protected int $targetId = 0;
|
||||
@@ -33,8 +37,11 @@ final class Snapshot extends Entity {
|
||||
protected int $createdAt = 0;
|
||||
protected ?int $files = null;
|
||||
protected ?int $bytes = null;
|
||||
protected ?string $note = null;
|
||||
protected ?bool $pinned = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('pinned', 'boolean');
|
||||
$this->addType('targetId', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('files', 'integer');
|
||||
|
||||
@@ -28,6 +28,12 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setMaxBytes(?int $v)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $v)
|
||||
* @method string|null getSchedule()
|
||||
* @method void setSchedule(?string $v)
|
||||
* @method string|null getScheduleZone()
|
||||
* @method void setScheduleZone(?string $v)
|
||||
* @method int|null getScheduleSince()
|
||||
* @method void setScheduleSince(?int $v)
|
||||
*/
|
||||
final class Target extends Entity {
|
||||
protected string $name = '';
|
||||
@@ -39,8 +45,12 @@ final class Target extends Entity {
|
||||
protected int $createdAt = 0;
|
||||
protected ?bool $appendOnly = null;
|
||||
protected ?int $maxBytes = null;
|
||||
protected ?string $schedule = null;
|
||||
protected ?string $scheduleZone = null;
|
||||
protected ?int $scheduleSince = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('scheduleSince', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('appendOnly', 'boolean');
|
||||
$this->addType('maxBytes', 'integer');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* "2026-10-29 04:30" in $zone. Nextcloud runs PHP in UTC; occ shows times in the zone of the
|
||||
* schedule, the administrator's, as the web interface does.
|
||||
*
|
||||
* @param non-empty-string $zone
|
||||
*/
|
||||
public static function time(int $timestamp, string $zone): string {
|
||||
return (new \DateTimeImmutable('@' . $timestamp))->setTimezone(new \DateTimeZone($zone))->format('Y-m-d H:i');
|
||||
}
|
||||
}
|
||||
+199
-22
@@ -49,13 +49,54 @@ final class BackupRun {
|
||||
public static function step(Repository $repo, array $state, float $deadline): array {
|
||||
$state['steps']++;
|
||||
if ($state['phase'] === 'files') {
|
||||
$walker = new TreeWalker($state['roots'], $state['exclude']);
|
||||
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
||||
$stats = $state['stats'];
|
||||
$walker = new TreeWalker($state['roots'], $state['exclude'], function (string $logical) use (&$state, &$stats): void {
|
||||
self::recordUnreadable($state, $stats, $logical);
|
||||
}, true);
|
||||
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
||||
$lines = [];
|
||||
$emptyDirs = [];
|
||||
$folders = [];
|
||||
$finishedWalk = true;
|
||||
|
||||
foreach ($walker->walk($state['after']) as $logical => $path) {
|
||||
$safe = mb_check_encoding($logical, 'UTF-8') && Repository::isSafePath($logical);
|
||||
if ($walker->atFolderWithContent()) {
|
||||
// Only its time: what is in it is reported by itself, and a bad name in the path of
|
||||
// each of those is what gets reported. The cursor stays for such a name, see below.
|
||||
$mtime = $safe ? @filemtime($path) : false;
|
||||
if ($safe && $mtime !== false) {
|
||||
$folders[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$state['after'] = $logical;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$finishedWalk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!$safe) {
|
||||
// A name a snapshot cannot hold (a backslash, a control character, not UTF-8). One
|
||||
// such path must not make a backup fail, or the whole snapshot unreadable later.
|
||||
// The cursor stays where it is: the state is JSON, which cannot hold such a name. A
|
||||
// next step meets it again, and it is reported once.
|
||||
self::recordBadName($state, $stats, $logical);
|
||||
continue;
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$mtime = @filemtime($path);
|
||||
if ($mtime === false) {
|
||||
continue; // removed while walking
|
||||
}
|
||||
$emptyDirs[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$stats['emptyDirs'] = ($stats['emptyDirs'] ?? 0) + 1;
|
||||
$state['after'] = $logical;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$finishedWalk = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$version = Repository::fileVersion($path);
|
||||
if ($version === 'missing') {
|
||||
continue; // deleted while walking
|
||||
@@ -68,15 +109,20 @@ final class BackupRun {
|
||||
$stats['reused']++;
|
||||
} else {
|
||||
$cur = $state['cur'];
|
||||
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
|
||||
if ($cur !== null && $cur['path'] === $logical) {
|
||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
|
||||
if ($cur !== null && $cur['path'] === $logical && $cur['version'] !== $version) {
|
||||
// Changed since the previous step: start over, unless it keeps changing.
|
||||
if (self::countRetry($state, $stats, $logical)) {
|
||||
$cur['version'] = $version;
|
||||
} else {
|
||||
$cur = null;
|
||||
}
|
||||
}
|
||||
if ($cur === null || $cur['path'] !== $logical) {
|
||||
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
||||
}
|
||||
$fh = fopen($path, 'rb');
|
||||
$fh = Repository::openForReading($path);
|
||||
if ($fh === false) {
|
||||
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
||||
self::recordUnreadable($state, $stats, $logical);
|
||||
$state['cur'] = null;
|
||||
continue;
|
||||
}
|
||||
@@ -91,12 +137,17 @@ final class BackupRun {
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
if (Repository::fileVersion($path) !== $cur['version']) {
|
||||
// Changed while reading: read it again from the start (now or next step).
|
||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||
$state['cur'] = null;
|
||||
$finishedWalk = false;
|
||||
break;
|
||||
$now = Repository::fileVersion($path);
|
||||
if ($now !== $cur['version']) {
|
||||
if (!self::countRetry($state, $stats, $logical)) {
|
||||
// Changed while reading: read it again from the start (now or next step).
|
||||
$state['cur'] = null;
|
||||
$finishedWalk = false;
|
||||
break;
|
||||
}
|
||||
// Keeps changing (a live database, VM image or log): keep what was read,
|
||||
// like any copy of a file in use, and report it instead of retrying forever.
|
||||
$cur['version'] = $now;
|
||||
}
|
||||
if (!$complete) {
|
||||
$state['cur'] = $cur;
|
||||
@@ -104,6 +155,7 @@ final class BackupRun {
|
||||
break;
|
||||
}
|
||||
$blobs = $cur['blobs'];
|
||||
$size = $cur['offset'];
|
||||
$state['cur'] = null;
|
||||
$stats['read']++;
|
||||
}
|
||||
@@ -123,29 +175,154 @@ final class BackupRun {
|
||||
$writer->write(implode("\n", $lines) . "\n");
|
||||
$state['segments'][] = $writer->finish();
|
||||
}
|
||||
if ($emptyDirs !== []) {
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$writer->write(implode("\n", $emptyDirs) . "\n");
|
||||
$state['dirSegments'][] = $writer->finish();
|
||||
}
|
||||
if ($folders !== []) {
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$writer->write(implode("\n", $folders) . "\n");
|
||||
$state['folderSegments'][] = $writer->finish();
|
||||
}
|
||||
$repo->flushPacks();
|
||||
$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'];
|
||||
$repo->writeSnapshot($state['snapshot'],
|
||||
// Every segment ends with a newline, so they read as one stream: fetched together,
|
||||
// several at a time where the location allows it.
|
||||
// Empty folders go in the meta record, not between the files: a version that does not
|
||||
// know them reads the snapshot as before, and only restores no empty folders.
|
||||
$emptyDirs = [];
|
||||
foreach (isset($state['dirSegments']) ? $repo->readLines(array_values(array_merge(...$state['dirSegments']))) : [] as $line) {
|
||||
if ($line !== '') {
|
||||
$emptyDirs[] = json_decode($line, true, 8, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
// The times of the other folders too, packed: every look at a snapshot's meta record reads
|
||||
// this, and a string of a few hundred KiB costs that far less than tens of thousands of arrays.
|
||||
$folders = isset($state['folderSegments']) ? implode("\n", iterator_to_array($repo->readLines(array_values(array_merge(...$state['folderSegments']))), false)) : '';
|
||||
$repo->putSnapshot($state['snapshot'],
|
||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
|
||||
(function () use ($repo, $segments) {
|
||||
foreach ($segments as $blobs) {
|
||||
yield from $repo->readLines($blobs);
|
||||
}
|
||||
})());
|
||||
$state['phase'] = 'done';
|
||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
||||
'warnings' => self::warnings($state)] + ($emptyDirs === [] ? [] : ['emptyDirs' => $emptyDirs])
|
||||
+ ($folders === '' ? [] : ['folderTimes' => base64_encode((string)gzdeflate($folders))]) + $state['meta'],
|
||||
$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 in its files phase; in its database phase, the tables dumped so far.
|
||||
*
|
||||
* @return array{fraction: float, approximate: bool, tables?: int, totalTables?: int}|null
|
||||
*/
|
||||
public static function progressOf(\OCA\NgBackup\Db\Run $run): ?array {
|
||||
if ($run->getStatus() !== \OCA\NgBackup\Db\Run::RUNNING || !in_array($run->getPhase(), ['db', 'files'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return null;
|
||||
}
|
||||
if ($run->getPhase() === 'db') {
|
||||
// Tables dumped so far, as of the last step (none known before the first step ends)
|
||||
$tables = count($state['dump']['tables'] ?? []);
|
||||
return $tables > 0
|
||||
? ['fraction' => min(1.0, ($state['dump']['i'] ?? 0) / $tables), 'approximate' => false,
|
||||
'tables' => (int)($state['dump']['i'] ?? 0), 'totalTables' => $tables]
|
||||
: 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.
|
||||
*/
|
||||
private static function recordUnreadable(array &$state, array &$stats, string $logical): void {
|
||||
if (isset($state['unreadable'][$logical])) {
|
||||
return;
|
||||
}
|
||||
if (count($state['unreadable'] ?? []) < 1000) {
|
||||
$state['unreadable'][$logical] = true;
|
||||
}
|
||||
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file or directory left out because of its name. Remembered like an unreadable one, in a form
|
||||
* that fits the JSON state and is safe to show: control characters and invalid bytes replaced.
|
||||
*/
|
||||
private static function recordBadName(array &$state, array &$stats, string $logical): void {
|
||||
$shown = (string)preg_replace('/[\x00-\x1f\x7f]/', '?', mb_scrub($logical, 'UTF-8'));
|
||||
if (isset($state['badNames'][$shown])) {
|
||||
return;
|
||||
}
|
||||
if (count($state['badNames'] ?? []) < 1000) {
|
||||
$state['badNames'][$shown] = true;
|
||||
}
|
||||
$stats['badNames'] = ($stats['badNames'] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** @return array{unreadable: list<string>, badNames: list<string>, changing: list<string>} examples for the report (first 50 each) */
|
||||
public static function warnings(array $state): array {
|
||||
return [
|
||||
'unreadable' => array_slice(array_map('strval', array_keys($state['unreadable'] ?? [])), 0, 50),
|
||||
'badNames' => array_slice(array_map('strval', array_keys($state['badNames'] ?? [])), 0, 50),
|
||||
'changing' => array_slice(array_map('strval', array_values($state['changing'] ?? [])), 0, 50),
|
||||
];
|
||||
}
|
||||
|
||||
/** Retries of one file that changes while it is read, before its last read is kept anyway. */
|
||||
public const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Count a retry of $logical. Returns true once it has changed MAX_RETRIES times: the caller then
|
||||
* keeps the data it read, and the file is listed in $state['changing'] (first 50) for the report.
|
||||
*/
|
||||
private static function countRetry(array &$state, array &$stats, string $logical): bool {
|
||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||
$n = (($state['retryPath'] ?? null) === $logical ? (int)($state['retryCount'] ?? 0) : 0) + 1;
|
||||
$state['retryPath'] = $logical;
|
||||
$state['retryCount'] = $n;
|
||||
if ($n < self::MAX_RETRIES) {
|
||||
return false;
|
||||
}
|
||||
if ($n === self::MAX_RETRIES) {
|
||||
$stats['changing'] = ($stats['changing'] ?? 0) + 1;
|
||||
if (count($state['changing'] ?? []) < 50) {
|
||||
$state['changing'][] = $logical;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function readFull($fh, int $length): string {
|
||||
$buffer = '';
|
||||
while (strlen($buffer) < $length && !feof($fh)) {
|
||||
|
||||
+33
-3
@@ -9,12 +9,16 @@ namespace OCA\NgBackup\Job;
|
||||
|
||||
/**
|
||||
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
|
||||
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
|
||||
* by name, children right after their directory), yielding "<root>/<relative path>" for files
|
||||
* and for directories with nothing in them to walk (the caller tells them apart with is_dir()).
|
||||
* A root itself without a name is never yielded.
|
||||
*
|
||||
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
|
||||
* and be merge-joined with the previous snapshot without holding any list in memory.
|
||||
*/
|
||||
final class TreeWalker {
|
||||
private bool $atFolder = false;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $roots name => absolute directory, walked in this order
|
||||
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
|
||||
@@ -23,9 +27,18 @@ final class TreeWalker {
|
||||
public function __construct(
|
||||
private array $roots,
|
||||
private array $exclude = [],
|
||||
/** Called with the logical path of a directory that cannot be listed. */
|
||||
private ?\Closure $onUnreadable = null,
|
||||
/** Also yield directories with something in them, right before the first thing in them. */
|
||||
private bool $withFolders = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Whether what was just yielded is a directory with something in it (see $withFolders). */
|
||||
public function atFolderWithContent(): bool {
|
||||
return $this->atFolder;
|
||||
}
|
||||
|
||||
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
|
||||
public function walk(?string $after = null): \Generator {
|
||||
foreach ($this->roots as $name => $dir) {
|
||||
@@ -57,31 +70,48 @@ final class TreeWalker {
|
||||
}
|
||||
$names = @scandir($abs);
|
||||
if ($names === false) {
|
||||
if ($this->onUnreadable !== null) {
|
||||
($this->onUnreadable)($logical === '' ? '/' : $logical);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$names = array_values(array_diff($names, ['.', '..']));
|
||||
sort($names, SORT_STRING);
|
||||
$empty = true; // no file or directory below it that is walked, whatever the cursor skips
|
||||
foreach ($names as $name) {
|
||||
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
|
||||
$childAbs = $abs . '/' . $name;
|
||||
if (is_link($childAbs)) {
|
||||
continue; // never follow symlinks out of the data directory
|
||||
}
|
||||
if (is_dir($childAbs)) {
|
||||
$childIsDir = is_dir($childAbs);
|
||||
if (($childIsDir || is_file($childAbs)) && $empty && !$this->isExcluded($childLogical, $childIsDir)
|
||||
&& $this->withFolders && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
|
||||
$this->atFolder = true;
|
||||
yield $logical => $abs;
|
||||
$this->atFolder = false;
|
||||
}
|
||||
if ($childIsDir) {
|
||||
$empty = $empty && $this->isExcluded($childLogical, true);
|
||||
// Skip a directory entirely if everything in it sorts before the cursor.
|
||||
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
|
||||
continue;
|
||||
}
|
||||
yield from $this->walkDir($childAbs, $childLogical, $after);
|
||||
} elseif (is_file($childAbs)) {
|
||||
$excluded = $this->isExcluded($childLogical, false);
|
||||
$empty = $empty && $excluded;
|
||||
if ($after !== null && self::compare($childLogical, $after) <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isExcluded($childLogical, false)) {
|
||||
if (!$excluded) {
|
||||
yield $childLogical => $childAbs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($empty && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
|
||||
yield $logical => $abs;
|
||||
}
|
||||
}
|
||||
|
||||
private function isExcluded(string $logical, bool $isDir): bool {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** Local copy of what the catalog on the location says about a snapshot: its note and pin. */
|
||||
final class Version000007Date20261107000000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
$t = $schema->getTable('ngb_snapshots');
|
||||
$changed = false;
|
||||
if (!$t->hasColumn('note')) {
|
||||
$t->addColumn('note', Types::STRING, ['notnull' => false, 'length' => 1000]);
|
||||
$changed = true;
|
||||
}
|
||||
if (!$t->hasColumn('pinned')) {
|
||||
$t->addColumn('pinned', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
|
||||
$changed = true;
|
||||
}
|
||||
return $changed ? $schema : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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 OCA\NgBackup\Service\LocationSchedule;
|
||||
use OCA\NgBackup\Service\Schedule;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** A schedule per location. Each existing location gets the daily time that held for all of them. */
|
||||
final class Version000008Date20261107010000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
private Schedule $schedule,
|
||||
) {
|
||||
}
|
||||
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
$t = $schema->getTable('ngb_targets');
|
||||
if ($t->hasColumn('schedule')) {
|
||||
return null;
|
||||
}
|
||||
$t->addColumn('schedule', Types::STRING, ['notnull' => false, 'length' => 16]);
|
||||
$t->addColumn('schedule_zone', Types::STRING, ['notnull' => false, 'length' => 64]);
|
||||
$t->addColumn('schedule_since', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
['time' => $time, 'timezone' => $zone] = $this->schedule->get();
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('ngb_targets')
|
||||
->set('schedule', $qb->createNamedParameter(Schedule::validTime($time) ? LocationSchedule::normalize($time) : ''))
|
||||
->set('schedule_zone', $qb->createNamedParameter($zone))
|
||||
->where($qb->expr()->isNull('schedule'));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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 OCA\NgBackup\Service\KeyService;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* A key from before 1.1.0 counts as used: those versions did not keep track, and it may protect
|
||||
* backups on a location that was removed from this server later (user exports included). Only a
|
||||
* key made with 1.1.0 or newer can be replaced as an unused one.
|
||||
*/
|
||||
final class Version000009Date20261108000000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
) {
|
||||
}
|
||||
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
if ($this->keys->isInitialized()) {
|
||||
$this->keys->markUsed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,26 +42,48 @@ final class Notifier implements INotifier {
|
||||
: $l->t('Backup to %s failed', [$p['target']]));
|
||||
$notification->setParsedMessage((string)($p['error'] ?? ''));
|
||||
break;
|
||||
case 'run_warnings':
|
||||
$notification->setParsedSubject($l->t('Backup to %1$s finished with warnings: %2$s unreadable, %3$s changed while being read', [$p['target'], $p['unreadable'], $p['changing']]));
|
||||
$notification->setParsedMessage($l->t('Unreadable files and folders are missing from this backup (check their permissions). Files that kept changing were saved as they were while being read. Examples: %s', [$p['examples'] ?? '']));
|
||||
break;
|
||||
case 'run_bad_names':
|
||||
$notification->setParsedSubject($l->n('Backup to %s finished, but %n item is missing because of its name', 'Backup to %s finished, but %n items are missing because of their names', (int)$p['count'], [$p['target']]));
|
||||
$notification->setParsedMessage($l->t('Names with a backslash or a control character, or that are not valid UTF-8, are not supported, so these files and folders are not in this backup. Rename them; the next backup includes them. Examples: %s', [$p['examples'] ?? '']));
|
||||
break;
|
||||
case 'quota_warning':
|
||||
$notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']]));
|
||||
$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 'schedule_off':
|
||||
$notification->setParsedSubject($l->t('Backup location %s has no schedule', [$p['target']]));
|
||||
$notification->setParsedMessage(($p['others'] ?? '') === '1'
|
||||
? $l->t('No backups run to this location until it gets a schedule or someone starts one. If that is what you want, you can ignore this reminder.')
|
||||
: $l->t('No location has a schedule, so no backups run at all unless someone starts one. Set a schedule under Backups.'));
|
||||
break;
|
||||
case 'security_event':
|
||||
$who = (string)($p['actor'] ?? '');
|
||||
$notification->setParsedSubject(match ($p['event'] ?? '') {
|
||||
'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]),
|
||||
'key_replaced' => $l->t('%1$s replaced the unused backup key %2$s by the key of existing backups, %3$s; download and confirm the new recovery kit', [$who, $p['old'] ?? '', $p['new'] ?? '']),
|
||||
'target_removed' => $l->t('%1$s removed 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'] ?? '']),
|
||||
'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']),
|
||||
'schedule_off' => $l->t('%1$s turned off the schedule 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'] ?? '']),
|
||||
'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']),
|
||||
'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']),
|
||||
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||
'user_export_retention_changed' => $l->t('%1$s changed how many exports per user are kept to %2$s', [$who, $p['keep'] ?? '']),
|
||||
'user_exports_forgotten' => $l->t('%1$s removed %2$s per-user exports from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']),
|
||||
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
|
||||
default => $l->t('NG Backup security event'),
|
||||
});
|
||||
|
||||
+183
-26
@@ -7,6 +7,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\BackendException;
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Crypto\StreamCipher;
|
||||
|
||||
@@ -25,9 +26,15 @@ final class BlobIndex {
|
||||
private array $pending = [];
|
||||
|
||||
private ?IIndexCache $cache = null;
|
||||
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
|
||||
private ?array $heldCacheWrites = null;
|
||||
private string $repositoryId = '';
|
||||
/** 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;
|
||||
/** @var array<string, true> packs whose index file is loaded (refresh() fetches only others) */
|
||||
private array $loaded = [];
|
||||
|
||||
public function __construct(
|
||||
private IBackend $backend,
|
||||
@@ -40,52 +47,124 @@ final class BlobIndex {
|
||||
$this->repositoryId = $repositoryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold back cache writes until releaseCache(). The database dump reads inside a read-only
|
||||
* transaction on the same connection that the cache writes to: a write there fails on
|
||||
* PostgreSQL and would be rolled back with the dump transaction elsewhere.
|
||||
*/
|
||||
public function holdCache(): void {
|
||||
$this->heldCacheWrites ??= [];
|
||||
}
|
||||
|
||||
/** Write the cache changes held back since holdCache(). */
|
||||
public function releaseCache(): void {
|
||||
$writes = $this->heldCacheWrites ?? [];
|
||||
$this->heldCacheWrites = null;
|
||||
foreach ($writes as $write) {
|
||||
$write();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||
private function cachePut(string $packId, array $entries): void {
|
||||
if ($this->heldCacheWrites !== null) {
|
||||
$this->heldCacheWrites[] = function () use ($packId, $entries): void {
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
};
|
||||
return;
|
||||
}
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
}
|
||||
|
||||
/** @param list<string> $packIds */
|
||||
private function cacheRemove(array $packIds): void {
|
||||
if ($this->heldCacheWrites !== null) {
|
||||
$this->heldCacheWrites[] = function () use ($packIds): void {
|
||||
$this->cache?->remove($this->repositoryId, $packIds);
|
||||
};
|
||||
return;
|
||||
}
|
||||
$this->cache?->remove($this->repositoryId, $packIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the index: one listing of index/, entries from the local cache where available,
|
||||
* 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 {
|
||||
$this->blobs = [];
|
||||
public function load(bool $verify = false, ?callable $progress = null): void {
|
||||
$hadIndex = $this->blobs !== [];
|
||||
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||
$present = [];
|
||||
$this->downloaded = 0;
|
||||
foreach ($this->backend->list('index') as $path) {
|
||||
$order = []; // pack ids in listing order: where a blob is in two packs, the last listed wins
|
||||
$fetch = [];
|
||||
$known = array_keys($this->loaded + $cached + ($verify ? ($this->cache?->all($this->repositoryId) ?? []) : []));
|
||||
foreach ($this->listIndex($known) as $path) {
|
||||
$packId = basename($path);
|
||||
$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];
|
||||
}
|
||||
// only now: an empty listing that listIndex() refused leaves the index as it was
|
||||
$this->blobs = [];
|
||||
$this->loaded = $present;
|
||||
// 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]);
|
||||
}
|
||||
@@ -112,13 +191,81 @@ final class BlobIndex {
|
||||
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
||||
rewind($stream);
|
||||
$this->backend->put('index/' . $packId, $stream);
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
$this->cachePut($packId, $entries);
|
||||
$this->loaded[$packId] = true;
|
||||
foreach ($entries as $e) {
|
||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||
unset($this->pending[$e['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the index files that appeared on the location since load(), such as those of packs
|
||||
* another server's cleanup wrote while a restore runs. Only adds and updates: a blob is
|
||||
* never dropped, as a listing that misses files (a connection that failed) must not make a
|
||||
* restore lose blobs it knows. A blob in a new pack takes that pack's place.
|
||||
*/
|
||||
public function refresh(): void {
|
||||
$fetch = [];
|
||||
$listed = 0;
|
||||
foreach ($this->listIndex(array_keys($this->loaded)) as $path) {
|
||||
$listed++;
|
||||
if (!isset($this->loaded[basename($path)])) {
|
||||
$fetch[] = $path;
|
||||
}
|
||||
}
|
||||
$toCache = [];
|
||||
foreach ($this->backend->getMany($fetch) as $path => $data) {
|
||||
$packId = basename($path);
|
||||
/** @var list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries authenticated: written by NG Backup */
|
||||
$entries = json_decode($this->cipher->decryptString($data, 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->downloaded++;
|
||||
$this->add($packId, $entries);
|
||||
$this->loaded[$packId] = true;
|
||||
$toCache[$packId] = $entries;
|
||||
}
|
||||
if ($toCache !== []) {
|
||||
$this->cache?->putMany($this->repositoryId, $toCache);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The index files on the location. An empty listing while index files are known ($known,
|
||||
* pack ids) is usually a connection that failed quietly; taken as is, it would empty the
|
||||
* index and the cache, make a check report every blob missing, or make a cleanup wipe the
|
||||
* cache. So the connection is checked (the repository's config must be there) and the folder
|
||||
* listed once more. Empty again, three of the known index files are looked up one by one:
|
||||
* only when they are gone too is the repository taken as empty (another server removed its
|
||||
* last snapshot and cleaned up). Even when that is wrong, nothing on the location is lost:
|
||||
* only this index and the cache are empty until the next load.
|
||||
*
|
||||
* @param list<string> $known
|
||||
* @return list<string>
|
||||
* @throws BackendException when the location does not answer as it should
|
||||
*/
|
||||
private function listIndex(array $known): array {
|
||||
$paths = $this->backend->listFiles('index');
|
||||
if ($paths !== [] || $known === []) {
|
||||
return $paths;
|
||||
}
|
||||
if (!$this->backend->exists('config')) {
|
||||
throw new BackendException('The location listed no index files and has no repository config: not trusting that listing');
|
||||
}
|
||||
$paths = $this->backend->listFiles('index');
|
||||
if ($paths !== []) {
|
||||
return $paths;
|
||||
}
|
||||
// spread over the list: first, middle and last (another server's cleanup removes packs
|
||||
// here and there, not in this order)
|
||||
$n = count($known);
|
||||
foreach (array_unique([$known[0], $known[intdiv($n, 2)], $known[$n - 1]]) as $packId) {
|
||||
if ($this->backend->exists('index/' . $packId)) {
|
||||
throw new BackendException('The location listed no index files, while index/' . $packId . ' is there: not trusting that listing');
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{id:string, offset:int, length:int}>> packId => blobs currently located in that pack */
|
||||
public function packs(): array {
|
||||
$packs = [];
|
||||
@@ -131,7 +278,8 @@ final class BlobIndex {
|
||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
||||
public function removePack(string $packId): void {
|
||||
$this->backend->delete('index/' . $packId);
|
||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
||||
$this->cacheRemove([$packId]);
|
||||
unset($this->loaded[$packId]);
|
||||
foreach ($this->blobs as $id => $loc) {
|
||||
if ($loc[0] === $packId) {
|
||||
unset($this->blobs[$id]);
|
||||
@@ -139,6 +287,15 @@ final class BlobIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of packs that hold blobs of the index. */
|
||||
public function packCount(): int {
|
||||
$packs = [];
|
||||
foreach ($this->blobs as [$pack]) {
|
||||
$packs[$pack] = true;
|
||||
}
|
||||
return count($packs);
|
||||
}
|
||||
|
||||
/** Bytes of all blobs in the index (encrypted size, i.e. what is stored in packs). */
|
||||
public function storedBytes(): int {
|
||||
$total = 0;
|
||||
|
||||
@@ -28,6 +28,12 @@ final class Catalog {
|
||||
public array $snapshots = [];
|
||||
/** @var array<string, true> */
|
||||
public array $trash = [];
|
||||
/** @var array<string, true> manifest path => true, for a per-user export (users/<uid>/<exportId>) */
|
||||
public array $userExports = [];
|
||||
/** @var array<string, true> snapshots the retention policy leaves alone */
|
||||
public array $pinned = [];
|
||||
/** @var array<string, string> snapshot id => note, also while the snapshot is in the trash */
|
||||
public array $notes = [];
|
||||
public string $hash = '';
|
||||
|
||||
public function __construct(
|
||||
@@ -39,7 +45,7 @@ final class Catalog {
|
||||
}
|
||||
|
||||
/** Load and verify the newest catalog; create the first one for repositories that have none. */
|
||||
public function load(callable $listSnapshots, callable $listTrash): void {
|
||||
public function load(callable $listSnapshots, callable $listTrash, callable $listUserExports): void {
|
||||
$gens = $this->generations();
|
||||
if ($gens === []) {
|
||||
// First use (new or pre-catalog repository): adopt the current state as generation 1.
|
||||
@@ -49,6 +55,7 @@ final class Catalog {
|
||||
}
|
||||
$this->snapshots = array_fill_keys($listSnapshots(), true);
|
||||
$this->trash = array_fill_keys($listTrash(), true);
|
||||
$this->userExports = array_fill_keys($listUserExports(), true);
|
||||
$this->write();
|
||||
return;
|
||||
}
|
||||
@@ -76,14 +83,28 @@ final class Catalog {
|
||||
$this->hash = $hash;
|
||||
$this->snapshots = array_fill_keys($data['snapshots'], true);
|
||||
$this->trash = array_fill_keys($data['trash'], true);
|
||||
// Catalogs written before user exports existed have no such key: default to empty rather
|
||||
// than adopting the location's current users/ listing (that would skip verification).
|
||||
$this->userExports = array_fill_keys($data['userExports'] ?? [], true);
|
||||
$this->pinned = array_fill_keys($data['pinned'] ?? [], true);
|
||||
$this->notes = $data['notes'] ?? [];
|
||||
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
|
||||
}
|
||||
|
||||
/** Write the next generation after the in-memory state was changed. */
|
||||
public function write(): void {
|
||||
$next = $this->gen + 1;
|
||||
// Two writers starting from the same generation would otherwise overwrite each other
|
||||
// silently and break the chain for whichever server anchored the lost write.
|
||||
if ($this->backend->exists($this->path($next))) {
|
||||
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
|
||||
}
|
||||
// A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good.
|
||||
$this->pinned = array_intersect_key($this->pinned, $this->snapshots);
|
||||
$this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash);
|
||||
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
|
||||
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash)], JSON_THROW_ON_ERROR);
|
||||
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
|
||||
'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR);
|
||||
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
|
||||
$stream = fopen('php://memory', 'w+b');
|
||||
fwrite($stream, $encrypted);
|
||||
@@ -116,7 +137,7 @@ final class Catalog {
|
||||
|
||||
/** @return list<int> */
|
||||
private function generations(): array {
|
||||
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
|
||||
return array_map(fn ($p) => (int)basename($p), $this->backend->listFiles('catalog'));
|
||||
}
|
||||
|
||||
private function path(int $gen): string {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
|
||||
/**
|
||||
* Reads blobs from packs. Restores and deep verifies read the blobs of a snapshot in the order
|
||||
* they were written, so the stream of the pack read last is kept open and read on from where it
|
||||
* is: one request per pack instead of one per blob (a round trip each, and on most remote
|
||||
* storages a new connection or file handle too). Anything else opens the pack at the offset,
|
||||
* as a single read always did.
|
||||
*
|
||||
* The open stream must not overlap other requests on the location (SFTP cannot read and write
|
||||
* on one connection at once): Repository reaches the location through a PackReaderGuard, which
|
||||
* closes it first.
|
||||
*/
|
||||
final class PackReader {
|
||||
/** A gap up to this size in the open stream is read past instead of opening the pack again. */
|
||||
private const SKIP_MAX = 8 * 1048576;
|
||||
private const CHUNK = 1048576;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
private string $pack = '';
|
||||
private int $pos = 0;
|
||||
|
||||
public function __construct(
|
||||
private IBackend $backend,
|
||||
) {
|
||||
}
|
||||
|
||||
public function read(string $pack, int $offset, int $length): string {
|
||||
if ($this->stream !== null && $this->pack === $pack && $offset >= $this->pos && $offset - $this->pos <= self::SKIP_MAX) {
|
||||
try {
|
||||
$this->skipTo($offset);
|
||||
return $this->readExactly($length);
|
||||
} catch (\Exception) {
|
||||
// The open stream broke (e.g. a timeout); open the pack again below.
|
||||
}
|
||||
}
|
||||
$this->close();
|
||||
$fh = $this->backend->get('packs/' . substr($pack, 0, 2) . '/' . $pack);
|
||||
$this->stream = $fh;
|
||||
$this->pack = $pack;
|
||||
$this->pos = 0;
|
||||
try {
|
||||
if ($offset > 0 && (stream_get_meta_data($fh)['seekable'] ?? false) && fseek($fh, $offset) === 0) {
|
||||
$this->pos = $offset;
|
||||
}
|
||||
$this->skipTo($offset); // a stream that cannot seek is read up to the offset
|
||||
return $this->readExactly($length);
|
||||
} catch (\Exception $e) {
|
||||
$this->close();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function close(): void {
|
||||
$fh = $this->stream;
|
||||
$this->stream = null;
|
||||
if ($fh !== null) {
|
||||
@fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
$this->close();
|
||||
}
|
||||
|
||||
private function skipTo(int $offset): void {
|
||||
while ($this->pos < $offset) {
|
||||
$this->readExactly(min($offset - $this->pos, self::CHUNK));
|
||||
}
|
||||
}
|
||||
|
||||
/** Read $length bytes from the open stream; a short read closes it. */
|
||||
private function readExactly(int $length): string {
|
||||
$fh = $this->stream ?? throw new RepositoryException('No open pack stream');
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$chunk = fread($fh, min($length - strlen($data), self::CHUNK));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
$this->close();
|
||||
throw new RepositoryException('Short read in pack ' . $this->pack);
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
$this->pos += $length;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Backend\IReadsAhead;
|
||||
|
||||
/** The location as Repository sees it: closes the PackReader's open stream before every other request. */
|
||||
final class PackReaderGuard implements IBackend, IReadsAhead {
|
||||
public function __construct(
|
||||
private IBackend $inner,
|
||||
private PackReader $reader,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param resource $stream */
|
||||
public function put(string $path, $stream): void {
|
||||
$this->reader->close();
|
||||
$this->inner->put($path, $stream);
|
||||
}
|
||||
|
||||
public function get(string $path) {
|
||||
$this->reader->close();
|
||||
return $this->inner->get($path);
|
||||
}
|
||||
|
||||
public function getMany(array $paths): \Generator {
|
||||
$this->reader->close();
|
||||
return $this->inner->getMany($paths);
|
||||
}
|
||||
|
||||
public function getRange(string $path, int $offset, int $length): string {
|
||||
$this->reader->close();
|
||||
return $this->inner->getRange($path, $offset, $length);
|
||||
}
|
||||
|
||||
public function getRanges(array $ranges): \Generator {
|
||||
$this->reader->close();
|
||||
return $this->inner->getRanges($ranges);
|
||||
}
|
||||
|
||||
public function canReadAhead(): bool {
|
||||
return $this->inner instanceof IReadsAhead && $this->inner->canReadAhead();
|
||||
}
|
||||
|
||||
/** Over connections of the location's own: the open stream may stay open. */
|
||||
public function readAhead(array $ranges, string $spool): ?\Generator {
|
||||
return $this->inner instanceof IReadsAhead ? $this->inner->readAhead($ranges, $spool) : null;
|
||||
}
|
||||
|
||||
public function exists(string $path): bool {
|
||||
$this->reader->close();
|
||||
return $this->inner->exists($path);
|
||||
}
|
||||
|
||||
public function move(string $from, string $to): void {
|
||||
$this->reader->close();
|
||||
$this->inner->move($from, $to);
|
||||
}
|
||||
|
||||
public function list(string $prefix): array {
|
||||
$this->reader->close();
|
||||
return $this->inner->list($prefix);
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
$this->reader->close();
|
||||
return $this->inner->listFiles($dir);
|
||||
}
|
||||
|
||||
public function delete(string $path): void {
|
||||
$this->reader->close();
|
||||
$this->inner->delete($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
/**
|
||||
* The ranges of packs a restore reads, in order, for IReadsAhead::readAhead(), and the reading of
|
||||
* blobs from them as the restore goes.
|
||||
*
|
||||
* add() takes the blobs in the order the restore needs them. A blob in the same pack as the range
|
||||
* before it, not before that range and at most GAP after its end, extends that range; anything
|
||||
* else starts a new one. read() takes the same blobs in the same order: a blob is in the current
|
||||
* range or in the next one, which then becomes current; a blob not in the plan is read elsewhere.
|
||||
*/
|
||||
final class ReadAheadPlan {
|
||||
/** Unused bytes between two blobs of a pack that are read along rather than asked for apart. */
|
||||
public const GAP = 4 * 1048576;
|
||||
/** Longest range: packs are 32 MiB, repacked ones too. */
|
||||
public const MAX_RANGE = 64 * 1048576;
|
||||
/** Ranges after the current one that read() looks in for a blob. */
|
||||
private const SEARCH = 16;
|
||||
|
||||
/** @var list<array{0:string, 1:int, 2:int}> [path, offset, length] */
|
||||
private array $ranges = [];
|
||||
/** @var \Generator<int, resource|null>|null */
|
||||
private ?\Generator $streams = null;
|
||||
private int $current = -1;
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
|
||||
/** @param string $spool local folder for the ranges read ahead (see IReadsAhead::readAhead()) */
|
||||
public function __construct(
|
||||
public readonly string $spool,
|
||||
) {
|
||||
}
|
||||
|
||||
public function add(string $path, int $offset, int $length): void {
|
||||
$last = array_pop($this->ranges);
|
||||
if ($last !== null) {
|
||||
[$p, $start, $len] = $last;
|
||||
if ($p === $path && $offset >= $start && $offset <= $start + $len + self::GAP
|
||||
&& $offset + $length - $start <= self::MAX_RANGE) {
|
||||
$this->ranges[] = [$p, $start, max($len, $offset + $length - $start)];
|
||||
return;
|
||||
}
|
||||
$this->ranges[] = $last;
|
||||
}
|
||||
$this->ranges[] = [$path, $offset, $length];
|
||||
}
|
||||
|
||||
/** @return list<array{0:string, 1:int, 2:int}> */
|
||||
public function ranges(): array {
|
||||
return $this->ranges;
|
||||
}
|
||||
|
||||
/** @param \Generator<int, resource|null> $streams what IReadsAhead::readAhead() gave for ranges() */
|
||||
public function readFrom(\Generator $streams): void {
|
||||
$this->streams = $streams;
|
||||
}
|
||||
|
||||
/** The bytes of a blob, or null when its range could not be read ahead (read it the usual way). */
|
||||
public function read(string $path, int $offset, int $length): ?string {
|
||||
if ($this->streams === null) {
|
||||
return null;
|
||||
}
|
||||
if (!$this->contains($this->current, $path, $offset, $length)) {
|
||||
// Normally the next range. Further on when blobs before it were read elsewhere: the
|
||||
// index was reloaded after another server's cleanup moved them to new packs.
|
||||
$to = null;
|
||||
for ($i = $this->current + 1; $i <= $this->current + self::SEARCH && $to === null; $i++) {
|
||||
if ($this->contains($i, $path, $offset, $length)) {
|
||||
$to = $i;
|
||||
}
|
||||
}
|
||||
if ($to === null) {
|
||||
return null; // not where the plan has it
|
||||
}
|
||||
if ($this->current < 0) {
|
||||
$this->streams->current();
|
||||
$this->current = 0;
|
||||
}
|
||||
while ($this->current < $to) {
|
||||
$this->streams->next();
|
||||
$this->current++;
|
||||
}
|
||||
$stream = $this->streams->valid() ? $this->streams->current() : null;
|
||||
$this->stream = is_resource($stream) ? $stream : null;
|
||||
}
|
||||
$fh = $this->stream;
|
||||
if ($fh === null || fseek($fh, $offset - $this->ranges[$this->current][1]) !== 0) {
|
||||
return null;
|
||||
}
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$chunk = fread($fh, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
return null;
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Ends the reads ahead, also halfway: closes their streams and connections. */
|
||||
public function close(): void {
|
||||
$this->stream = null;
|
||||
$streams = $this->streams;
|
||||
$this->streams = null;
|
||||
unset($streams); // the generator's finally blocks run when it is destroyed
|
||||
}
|
||||
|
||||
private function contains(int $i, string $path, int $offset, int $length): bool {
|
||||
if ($i < 0 || !isset($this->ranges[$i])) {
|
||||
return false;
|
||||
}
|
||||
[$p, $start, $len] = $this->ranges[$i];
|
||||
return $p === $path && $offset >= $start && $offset + $length <= $start + $len;
|
||||
}
|
||||
}
|
||||
+1013
-52
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
/**
|
||||
* A restore cannot write to where it restores to (a full disk, permissions, something in the way).
|
||||
* The backup is fine, so this never counts as damaged data: the restore stops, also with --salvage.
|
||||
*/
|
||||
final class RestoreTargetException extends \RuntimeException {
|
||||
}
|
||||
@@ -54,6 +54,23 @@ final class AlertService {
|
||||
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
|
||||
}
|
||||
|
||||
/** A backup that finished but is missing unreadable files, or holds files that changed while read. */
|
||||
public function runWarnings(Run $run, string $targetName, int $unreadable, int $changing, array $examples): void {
|
||||
$this->notifyAdmins('run_warnings', 'run-' . $run->getId(), ['target' => $targetName,
|
||||
'unreadable' => (string)$unreadable, 'changing' => (string)$changing,
|
||||
'examples' => mb_strimwidth(implode(', ', array_merge($examples['unreadable'], $examples['changing'])), 0, 300, '…')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A backup that finished but left out files or folders because of their name.
|
||||
*
|
||||
* @param list<string> $examples
|
||||
*/
|
||||
public function runBadNames(Run $run, string $targetName, int $count, array $examples): void {
|
||||
$this->notifyAdmins('run_bad_names', 'run-' . $run->getId(), ['target' => $targetName, 'count' => (string)$count,
|
||||
'examples' => mb_strimwidth(implode(', ', $examples), 0, 300, '…')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember how much is stored on a location (for the settings page) and warn administrators
|
||||
* once a day when it is at 80% of its limit or more.
|
||||
@@ -84,9 +101,36 @@ final class AlertService {
|
||||
return $v === '' ? null : (int)$v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether it is time to remind administrators of a location without a schedule: once a week
|
||||
* while another location is still backed up, every day when none is. An hour of margin, so
|
||||
* that the moment of the cron run does not push a reminder to the next day.
|
||||
*/
|
||||
public static function offReminderDue(int $last, int $now, bool $othersActive): bool {
|
||||
return $now - $last >= ($othersActive ? 7 * 86400 : 86400) - 3600;
|
||||
}
|
||||
|
||||
public function checkOff(Target $target, bool $othersActive): void {
|
||||
if ((string)$target->getSchedule() !== '') {
|
||||
return;
|
||||
}
|
||||
$key = 'off_notified_' . $target->getId();
|
||||
$now = time();
|
||||
$last = max((int)$target->getScheduleSince(), $this->appConfig->getValueInt(Application::APP_ID, $key, 0));
|
||||
if (!self::offReminderDue($last, $now, $othersActive)) {
|
||||
return;
|
||||
}
|
||||
$this->appConfig->setValueInt(Application::APP_ID, $key, $now);
|
||||
$this->notifyAdmins('schedule_off', 'off-' . $target->getId() . '-' . date('Y-m-d', $now),
|
||||
['target' => $target->getName(), 'others' => $othersActive ? '1' : '0']);
|
||||
}
|
||||
|
||||
/** At most one notification per location per day. */
|
||||
public function checkStale(Target $target): void {
|
||||
$last = $this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
|
||||
if ((string)$target->getSchedule() === '') {
|
||||
return; // no schedule is a choice, as for a location that is only connected now and then
|
||||
}
|
||||
$last =$this->runs->lastFinished($target->getId(), BackupService::KIND_FULL);
|
||||
$lastTime = $last?->getFinishedAt() ?? $target->getCreatedAt();
|
||||
if (time() - $lastTime < self::STALE_AFTER || $this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
|
||||
return;
|
||||
@@ -97,7 +141,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 {
|
||||
|
||||
+250
-17
@@ -14,8 +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;
|
||||
@@ -30,6 +34,11 @@ use Psr\Log\LoggerInterface;
|
||||
*/
|
||||
final class BackupService {
|
||||
public const KIND_FULL = 'full';
|
||||
/** Characters of a snapshot label (the column holds 255) */
|
||||
public const LABEL_MAX = 100;
|
||||
|
||||
/** @var \WeakMap<Repository, string> the location settings each kept repository was opened with */
|
||||
private \WeakMap $openedWith;
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
@@ -44,9 +53,28 @@ final class BackupService {
|
||||
private AlertService $alerts,
|
||||
private LeaseService $leases,
|
||||
) {
|
||||
/** @var \WeakMap<Repository, string> $opened */
|
||||
$opened = new \WeakMap();
|
||||
$this->openedWith = $opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* The running backups in the order they get their turn: first come, first served. The first one
|
||||
* runs, the others are queued; only one at a time reads the disks.
|
||||
*
|
||||
* @return list<Run>
|
||||
*/
|
||||
public function runsInTurn(): array {
|
||||
$running = $this->runs->findRunning(null, self::KIND_FULL);
|
||||
usort($running, static fn (Run $a, Run $b): int => [$a->getStartedAt(), $a->getId()] <=> [$b->getStartedAt(), $b->getId()]);
|
||||
return $running;
|
||||
}
|
||||
|
||||
public function start(Target $target, string $label = ''): Run {
|
||||
$label = trim($label);
|
||||
if (mb_strlen($label) > self::LABEL_MAX) {
|
||||
throw new \InvalidArgumentException('A label has at most ' . self::LABEL_MAX . ' characters');
|
||||
}
|
||||
if (!$this->keys->everConfirmed()) {
|
||||
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
||||
}
|
||||
@@ -63,7 +91,47 @@ final class BackupService {
|
||||
try {
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::EXCLUSIVE, 60, fn () => $this->createRun($target, $label));
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
|
||||
throw new \RuntimeException('A cleanup, a restore or another start for ' . $target->getName() . ' is running, or one was stopped by force (that clears by itself within two hours); try again later');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +169,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) {
|
||||
@@ -117,16 +197,38 @@ 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') {
|
||||
if (!isset($state['dump'])) {
|
||||
// It may have been queued for hours: the restore point has the time of its data.
|
||||
$state['files']['started'] = gmdate('c');
|
||||
// Locations from before 1.1.0 get their sealed settings at their next backup.
|
||||
$this->targets->recordSettings($repo);
|
||||
$this->targets->catchUpWrappedKey($target, $repo);
|
||||
}
|
||||
$dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_'));
|
||||
$dbStats = $state['dbStats'] ?? [];
|
||||
// With system cron the whole dump usually fits in one step (one transaction, fully
|
||||
@@ -146,10 +248,45 @@ 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.
|
||||
try {
|
||||
$this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
|
||||
fn () => $this->targets->repository($target)->recordSnapshot((string)$state['files']['snapshot']));
|
||||
$state['files']['phase'] = 'done';
|
||||
} catch (LockedException|RepositoryException) {
|
||||
// Another catalog write is in progress (or just raced us to the next
|
||||
// generation): the next step retries this commit on the fresh catalog.
|
||||
}
|
||||
}
|
||||
if ($state['files']['phase'] === 'done') {
|
||||
$this->recordSnapshot($run, $state['files']);
|
||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||
// The snapshot is committed: what follows only reports on it and must not turn
|
||||
// the run into a failure.
|
||||
try {
|
||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||
$stats = $state['files']['stats'];
|
||||
$warnings = BackupRun::warnings($state['files']);
|
||||
if (($stats['badNames'] ?? 0) > 0) {
|
||||
$this->logger->warning(sprintf('NG Backup run %d to %s: %d left out because their name is not supported (rename them): %s',
|
||||
$run->getId(), $target->getName(), $stats['badNames'], implode(', ', $warnings['badNames'])));
|
||||
$this->alerts->runBadNames($run, $target->getName(), (int)$stats['badNames'], $warnings['badNames']);
|
||||
}
|
||||
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
||||
$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');
|
||||
$run->setFinishedAt(time());
|
||||
@@ -157,15 +294,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) {
|
||||
@@ -179,14 +334,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);
|
||||
@@ -195,14 +352,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']);
|
||||
@@ -211,7 +432,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> */
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCP\Exceptions\AppConfigTypeConflictException;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IURLGenerator;
|
||||
|
||||
/**
|
||||
* Whether Nextcloud's background jobs run on a system cron, which backups need: a backup moves
|
||||
* forward only when a background job run takes its next step (#13). Without system cron, AJAX
|
||||
* cron runs a job only now and then while someone has Nextcloud open, and webcron only as often as
|
||||
* the outside service calls it.
|
||||
*/
|
||||
final class CronHealth {
|
||||
/** A system cron that has not run for this long has stopped (it runs every 5 minutes). */
|
||||
public const STOPPED_AFTER = 3600;
|
||||
|
||||
/**
|
||||
* A running backup that took no step for this long is waiting for background jobs. A step with
|
||||
* system cron takes at most a few minutes (BackupJob) and the job runs every minute.
|
||||
*/
|
||||
public const WAITING_AFTER = 900;
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private IURLGenerator $urls,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* mode: ajax, webcron or cron; lastRun: the last background job run (0: never); ok: a system
|
||||
* cron that ran recently; stopped: system cron is set but has not run for STOPPED_AFTER.
|
||||
*
|
||||
* @return array{mode: string, lastRun: int, ok: bool, stopped: bool, docs: string}
|
||||
*/
|
||||
public function check(?int $now = null): array {
|
||||
$now ??= time();
|
||||
$mode = $this->coreString('backgroundjobs_mode', 'ajax');
|
||||
$lastRun = (int)$this->coreString('lastcron', '0');
|
||||
$stopped = $mode === 'cron' && $now - $lastRun > self::STOPPED_AFTER;
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'lastRun' => $lastRun,
|
||||
'ok' => $mode === 'cron' && !$stopped,
|
||||
'stopped' => $stopped,
|
||||
'docs' => $this->urls->linkToDocs('admin-background-jobs'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds since a running backup took its last step, when that is long enough to say it waits
|
||||
* for background jobs; null while it moves (or when it is not running).
|
||||
*/
|
||||
public static function waitingFor(Run $run, ?int $now = null): ?int {
|
||||
$idle = ($now ?? time()) - $run->getUpdatedAt();
|
||||
return $run->getStatus() === Run::RUNNING && $idle >= self::WAITING_AFTER ? $idle : null;
|
||||
}
|
||||
|
||||
/** One line for occ and the log; null when system cron runs. */
|
||||
public static function describe(array $health, ?int $now = null): ?string {
|
||||
if ($health['ok']) {
|
||||
return null;
|
||||
}
|
||||
$last = $health['lastRun'] > 0 ? 'last ran ' . self::ago(($now ?? time()) - $health['lastRun']) : 'never ran';
|
||||
$what = $health['stopped']
|
||||
? 'Background jobs are set to system cron, but cron has not run for over an hour (' . $last . ').'
|
||||
: 'Background jobs do not run on a system cron: they are set to "' . ($health['mode'] === 'webcron' ? 'Webcron' : 'AJAX') . '" and ' . $last . '.';
|
||||
return $what . ' Backups, also the scheduled ones, need a system cron that runs every 5 minutes: ' . $health['docs'];
|
||||
}
|
||||
|
||||
/** "12 min ago" style, for durations from a minute to days */
|
||||
public static function ago(int $seconds): string {
|
||||
if ($seconds < 3600) {
|
||||
return max(1, intdiv($seconds, 60)) . ' min ago';
|
||||
}
|
||||
if ($seconds < 172800) {
|
||||
return intdiv($seconds, 3600) . ' h ago';
|
||||
}
|
||||
return intdiv($seconds, 86400) . ' days ago';
|
||||
}
|
||||
|
||||
/** Core stores these with different value types across versions: read whatever is there. */
|
||||
private function coreString(string $key, string $default): string {
|
||||
try {
|
||||
return $this->appConfig->getValueString('core', $key, $default);
|
||||
} catch (AppConfigTypeConflictException) {
|
||||
return (string)$this->appConfig->getValueInt('core', $key, (int)$default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/** A step after the database was committed failed: the database is no longer the fresh one. */
|
||||
final class DatabaseAlreadyRestoredException extends \RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Keeps heavy background jobs of other apps from running while a backup reads the same disks, by
|
||||
* reserving them in Nextcloud's job list: no app is turned off, and a job that is running goes on.
|
||||
* Nextcloud itself gives up on a reservation after 12 hours, so a pause that is never ended, because
|
||||
* cron or this app is gone, ends by itself. It lasts at most MAX_SECONDS, then the jobs get REST_SECONDS.
|
||||
*/
|
||||
final class HeavyJobPause {
|
||||
public const ENABLED_KEY = 'pause_heavy_jobs';
|
||||
private const SINCE_KEY = 'heavy_jobs_paused_since';
|
||||
private const REST_KEY = 'heavy_jobs_rest_until';
|
||||
public const MAX_SECONDS = 6 * 3600;
|
||||
public const REST_SECONDS = 3600;
|
||||
/** Nextcloud runs a job again this long after it was reserved. */
|
||||
private const RESERVATION_SECONDS = 12 * 3600;
|
||||
|
||||
/** Class, or the start of a class name ending in a backslash => what to call it */
|
||||
private const JOBS = [
|
||||
'OCA\\Memories\\' => 'Memories',
|
||||
'OCA\\Files_Antivirus\\BackgroundJob\\BackgroundScanner' => 'Antivirus background scan',
|
||||
'OCA\\Recognize\\' => 'Recognize',
|
||||
'OCA\\PreviewGenerator\\' => 'Preview Generator',
|
||||
'OCA\\FullTextSearch\\' => 'Full text search',
|
||||
];
|
||||
|
||||
public const NOTHING = 'nothing';
|
||||
public const PAUSE = 'pause';
|
||||
public const RELEASE = 'release';
|
||||
public const REST = 'rest';
|
||||
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
private IAppConfig $appConfig,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return self::NOTHING|self::PAUSE|self::RELEASE|self::REST */
|
||||
public static function decide(bool $enabled, bool $backupRunning, int $since, int $restUntil, int $now): string {
|
||||
if ($since > 0 && $now - $since >= self::MAX_SECONDS) {
|
||||
return self::REST;
|
||||
}
|
||||
if ($enabled && $backupRunning && $now >= $restUntil) {
|
||||
return self::PAUSE;
|
||||
}
|
||||
return $since > 0 ? self::RELEASE : self::NOTHING;
|
||||
}
|
||||
|
||||
/** Call regularly: pauses or ends the pause as needed. A problem here never stops a backup. */
|
||||
public function sync(bool $backupRunning): void {
|
||||
try {
|
||||
$this->apply($backupRunning, time());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('NG Backup: pausing heavy background jobs failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
private function apply(bool $backupRunning, int $now): void {
|
||||
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
|
||||
$restUntil = $this->appConfig->getValueInt(Application::APP_ID, self::REST_KEY, 0);
|
||||
switch (self::decide($this->enabled(), $backupRunning, $since, $restUntil, $now)) {
|
||||
case self::PAUSE:
|
||||
if ($since === 0) {
|
||||
$since = $now;
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::SINCE_KEY, $since);
|
||||
$this->logger->info('NG Backup: heavy background jobs paused while a backup runs');
|
||||
}
|
||||
$this->reserve($since, $now); // also the jobs that were added or running until now
|
||||
break;
|
||||
case self::REST:
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::REST_KEY, $now + self::REST_SECONDS);
|
||||
$this->release($since);
|
||||
$this->logger->info('NG Backup: heavy background jobs get their turn for an hour during a long backup');
|
||||
break;
|
||||
case self::RELEASE:
|
||||
$this->release($since);
|
||||
$this->logger->info('NG Backup: heavy background jobs run again');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function reserve(int $stamp, int $now): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('jobs')
|
||||
->set('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->lte('reserved_at', $qb->createNamedParameter($now - self::RESERVATION_SECONDS, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($this->isHeavy($qb));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
/** Only our own reservations: a job that Nextcloud reserved at another moment is running. */
|
||||
private function release(int $stamp): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('jobs')
|
||||
->set('reserved_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
|
||||
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($stamp, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($this->isHeavy($qb));
|
||||
$qb->executeStatement();
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::SINCE_KEY);
|
||||
}
|
||||
|
||||
/** @return \OCP\DB\QueryBuilder\ICompositeExpression */
|
||||
private function isHeavy(IQueryBuilder $qb) {
|
||||
$conditions = [];
|
||||
foreach (array_keys(self::JOBS) as $class) {
|
||||
$conditions[] = str_ends_with($class, '\\')
|
||||
? $qb->expr()->like('class', $qb->createNamedParameter($this->db->escapeLikeParameter($class) . '%'))
|
||||
: $qb->expr()->eq('class', $qb->createNamedParameter($class));
|
||||
}
|
||||
return $qb->expr()->orX(...$conditions);
|
||||
}
|
||||
|
||||
public function enabled(): bool {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::ENABLED_KEY, '1') !== '0';
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::ENABLED_KEY, $enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
/** @return array{enabled: bool, since: int, jobs: list<string>} what is paused now */
|
||||
public function status(): array {
|
||||
$since = $this->appConfig->getValueInt(Application::APP_ID, self::SINCE_KEY, 0);
|
||||
$jobs = [];
|
||||
if ($since > 0) {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->selectDistinct('class')->from('jobs')
|
||||
->where($qb->expr()->eq('reserved_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($this->isHeavy($qb));
|
||||
$result = $qb->executeQuery();
|
||||
while (($class = $result->fetchOne()) !== false) {
|
||||
foreach (self::JOBS as $start => $name) {
|
||||
if (str_starts_with((string)$class, $start)) {
|
||||
$jobs[$name] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
}
|
||||
return ['enabled' => $this->enabled(), 'since' => $since, 'jobs' => array_map('strval', array_keys($jobs))];
|
||||
}
|
||||
}
|
||||
+165
-6
@@ -30,7 +30,15 @@ final class KeyService {
|
||||
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||
private const KEY_KIT_CODE = 'recovery_kit_code';
|
||||
/** Set once a location was created or adopted with this key: from then on it is never replaced. */
|
||||
private const KEY_USED = 'master_key_used';
|
||||
private const KEY_WRAPPED_PENDING = 'master_key_wrapped_pending';
|
||||
public const DEFAULT_DELETE_DELAY = 7;
|
||||
/**
|
||||
* The location holds the passphrase-wrapped key too, so whoever can read it can try
|
||||
* passphrases offline: the passphrase is what really protects the backups.
|
||||
*/
|
||||
public const MIN_PASSPHRASE = 16;
|
||||
|
||||
private ?KeyRing $cached = null;
|
||||
|
||||
@@ -56,9 +64,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);
|
||||
@@ -71,6 +77,158 @@ final class KeyService {
|
||||
$this->cached = $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disaster recovery on a fresh installation: adopt the master key from a recovery kit
|
||||
* (occ backup:key:kit on the original installation) instead of generating a new one.
|
||||
* Afterwards occ backup:target:add opens the existing repository on the location with this
|
||||
* key, instead of creating a new, empty one.
|
||||
*
|
||||
* @param array<string, mixed> $kit as produced by recoveryKit()
|
||||
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase, or a damaged/foreign kit
|
||||
*/
|
||||
public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase, bool $replaceUnused = false): void {
|
||||
if ($this->isInitialized() && !$replaceUnused) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
if (!isset($kit['wrapped_key'])) {
|
||||
throw new \InvalidArgumentException('Not a NG Backup recovery kit file');
|
||||
}
|
||||
$keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase);
|
||||
$this->discardUnused($keys, $replaceUnused);
|
||||
// Kits from before this field existed fall back to the default.
|
||||
$this->store($keys, $kit['wrapped_key'], (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY), (int)($kit['kit_version'] ?? 1));
|
||||
// Having and successfully using a real kit file is stronger proof than the normal
|
||||
// download-and-confirm flow it replaces.
|
||||
$this->confirmRecoveryKit('cli-import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disaster recovery without the recovery kit: adopt the master key that a passphrase opened
|
||||
* from the key slots in a location's config (TargetService::addWithPassphrase). There is no
|
||||
* kit, so none is confirmed: backups wait until a new kit is downloaded and confirmed.
|
||||
*
|
||||
* @param ?int $deleteDelayDays from the location's sealed settings, null when it has none
|
||||
*/
|
||||
public function adoptKey(KeyRing $keys, array $wrappedKey, ?int $deleteDelayDays, bool $replaceUnused = false): void {
|
||||
if ($this->isInitialized() && !$replaceUnused) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
$this->discardUnused($keys, $replaceUnused);
|
||||
$this->store($keys, $wrappedKey, $deleteDelayDays ?? self::DEFAULT_DELETE_DELAY, 1);
|
||||
}
|
||||
|
||||
/** Whether a location was ever created or adopted with this key (TargetService::keyIsUnused() also looks at the database). */
|
||||
public function wasUsed(): bool {
|
||||
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_USED, false, true);
|
||||
}
|
||||
|
||||
public function markUsed(): void {
|
||||
if (!$this->wasUsed()) {
|
||||
$this->appConfig->setValueBool(Application::APP_ID, self::KEY_USED, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locations that missed the last passphrase change (offline at the time): they still hold
|
||||
* the previous slots until the change reaches them, which is tried again at each backup.
|
||||
*/
|
||||
public function wrappedKeyPending(int $targetId): bool {
|
||||
return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_WRAPPED_PENDING . '_' . $targetId);
|
||||
}
|
||||
|
||||
/** One flag per location, so that two processes never overwrite each other's. */
|
||||
public function setWrappedKeyPending(int $targetId, bool $pending): void {
|
||||
$key = self::KEY_WRAPPED_PENDING . '_' . $targetId;
|
||||
if ($pending) {
|
||||
$this->appConfig->setValueBool(Application::APP_ID, $key, true);
|
||||
} elseif ($this->wrappedKeyPending($targetId)) {
|
||||
$this->appConfig->deleteKey(Application::APP_ID, $key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key made by mistake on a server that is being restored: remove it, so the key of the
|
||||
* backups can take its place. Only after the new key was opened with its passphrase, and
|
||||
* only when the caller checked that the old key never protected anything
|
||||
* (TargetService::keyIsUnused()). Its passphrases and recovery kit become worthless.
|
||||
*/
|
||||
private function discardUnused(KeyRing $replacement, bool $replaceUnused): void {
|
||||
if (!$replaceUnused || !$this->isInitialized()) {
|
||||
return;
|
||||
}
|
||||
if ($this->wasUsed()) {
|
||||
throw new \RuntimeException('The backup key of this server has been used for a backup location: it is never replaced');
|
||||
}
|
||||
if ($this->fingerprintOf($replacement) === $this->fingerprint()) {
|
||||
throw new \RuntimeException('This server already has this backup key: nothing to replace');
|
||||
}
|
||||
foreach ([self::KEY_MASTER, self::KEY_WRAPPED, self::KEY_CONFIRMED, self::KEY_FINGERPRINT, self::KEY_DELETE_DELAY,
|
||||
self::KEY_KIT_VERSION, self::KEY_KIT_CODE, self::KEY_USED] as $key) {
|
||||
$this->appConfig->deleteKey(Application::APP_ID, $key);
|
||||
}
|
||||
$this->cached = null;
|
||||
}
|
||||
|
||||
private function store(KeyRing $keys, array $wrappedKey, int $deleteDelayDays, int $kitVersion): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||
$this->cached = $keys;
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($wrappedKey, JSON_THROW_ON_ERROR), true, true);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY,
|
||||
($deleteDelayDays >= 1 && $deleteDelayDays <= 365) ? $deleteDelayDays : self::DEFAULT_DELETE_DELAY, true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, max(1, $kitVersion), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The master key and the recovery kit code are stored encrypted with Nextcloud's own
|
||||
* instance secret (ICrypto), not with themselves; the wrapped key is not (it is sealed with
|
||||
* the admin's passphrase instead), but Nextcloud's own AppConfig additionally encrypts any
|
||||
* "sensitive" value with the instance secret regardless, underneath whatever we store. So
|
||||
* changing that secret (occ backup:restore:full merging config.php) would otherwise leave
|
||||
* all three permanently undecryptable. Call $applySecretChange (which must be the only thing
|
||||
* that changes the secret) through here instead: all three are decrypted first, then
|
||||
* re-encrypted under whatever secret is active once $applySecretChange returns.
|
||||
*
|
||||
* Not crash-safe: a process kill between the delete and the re-save of one of these keys
|
||||
* would leave it missing rather than merely stale. Disaster recovery is run once, by hand,
|
||||
* right after occ maintenance:install; a dedicated two-phase write is not justified yet for
|
||||
* that narrow a window.
|
||||
*/
|
||||
public function reencryptForSecretRotation(callable $applySecretChange): mixed {
|
||||
$master = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
|
||||
$wrapped = $this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '', true);
|
||||
$kitCodeRaw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '', true);
|
||||
$kitCode = $kitCodeRaw !== '' ? json_decode($kitCodeRaw, true, 512, JSON_THROW_ON_ERROR) : null;
|
||||
$masterPlain = $master !== '' ? $this->crypto->decrypt($master) : null;
|
||||
$kitCodePlain = $kitCode !== null && isset($kitCode['code']) ? $this->crypto->decrypt($kitCode['code']) : null;
|
||||
|
||||
$result = $applySecretChange();
|
||||
$this->cached = null; // the master key is about to be re-saved; do not keep the pre-rotation copy
|
||||
|
||||
// Nextcloud's own AppConfig additionally encrypts a "sensitive" value itself (tied to
|
||||
// the same secret, underneath our own encrypt() above); setValueString() on an existing
|
||||
// key reads the old value first to compare, which would try to decrypt it with the new
|
||||
// secret and fail the same way. Delete first so there is nothing to compare against.
|
||||
if ($masterPlain !== null) {
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_MASTER);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, $this->crypto->encrypt($masterPlain), true, true);
|
||||
}
|
||||
if ($wrapped !== '') {
|
||||
// Already plaintext JSON here: getValueString() above transparently undid
|
||||
// AppConfig's own sensitive-value encryption; there is no encrypt() of our own to
|
||||
// redo (wrapping is sealed with the passphrase, not the instance secret).
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_WRAPPED);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, $wrapped, true, true);
|
||||
}
|
||||
if ($kitCodePlain !== null) {
|
||||
$kitCode['code'] = $this->crypto->encrypt($kitCodePlain);
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_KIT_CODE);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_KIT_CODE, json_encode($kitCode, JSON_THROW_ON_ERROR), true, true);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function keyRing(): KeyRing {
|
||||
if ($this->cached === null) {
|
||||
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
|
||||
@@ -154,8 +312,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,13 +340,14 @@ final class KeyService {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
|
||||
}
|
||||
|
||||
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
|
||||
/** @return array{version:int, app:string, fingerprint:string, kit_version:int, delete_delay_days:int, confirmation_code:string, created:string, wrapped_key:array, slots:list<array{slot:int, label:string, created:int}>, instructions:string} */
|
||||
public function recoveryKit(): array {
|
||||
return [
|
||||
'version' => 1,
|
||||
'app' => 'NG Backup',
|
||||
'fingerprint' => $this->fingerprint(),
|
||||
'kit_version' => $this->kitVersion(),
|
||||
'delete_delay_days' => $this->deleteDelayDays(),
|
||||
'confirmation_code' => $this->kitCode(),
|
||||
'created' => gmdate('c'),
|
||||
'wrapped_key' => $this->wrappedKey(),
|
||||
|
||||
@@ -43,8 +43,10 @@ final class KeySlotService {
|
||||
foreach ($this->targets->list() as $t) {
|
||||
try {
|
||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
||||
$this->keys->setWrappedKeyPending($t->getId(), false);
|
||||
} catch (\Throwable $e) {
|
||||
$failed[] = $t->getName();
|
||||
$this->keys->setWrappedKeyPending($t->getId(), true);
|
||||
}
|
||||
}
|
||||
$label = '';
|
||||
@@ -55,7 +57,7 @@ final class KeySlotService {
|
||||
}
|
||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
||||
if ($failed !== []) {
|
||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases; the change is tried again at their next backup): ' . implode(', ', $failed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,26 @@ final class LeaseService {
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
/** Remove the shared leases on $name, for a caller that knows their holders were stopped. */
|
||||
public function dropShared(string $name): void {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('ngb_leases')
|
||||
->where($qb->expr()->eq('name', $qb->createNamedParameter($name)))
|
||||
->andWhere($qb->expr()->eq('mode', $qb->createNamedParameter(self::SHARED)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
|
||||
/** Wraps $refresh so calling it often (per file, per row batch) only touches the database every $seconds. */
|
||||
public static function throttled(callable $refresh, int $seconds = 60): \Closure {
|
||||
$last = time();
|
||||
return static function () use ($refresh, $seconds, &$last): void {
|
||||
if (time() - $last >= $seconds) {
|
||||
$refresh();
|
||||
$last = time();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Run $fn under a lease; $refresh() can be called during long work. */
|
||||
public function with(string $name, string $mode, int $ttl, callable $fn): mixed {
|
||||
$id = $this->acquire($name, $mode, $ttl);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/**
|
||||
* When a location is backed up: "" (off), "HH:MM" (daily) or "HH:MM/N" (from HH:MM every N hours,
|
||||
* starting over at HH:MM the next day), in the location's time zone.
|
||||
*/
|
||||
final class LocationSchedule {
|
||||
/** Attempts per moment after a failure, an hour apart, instead of one every few minutes. */
|
||||
public const MAX_ATTEMPTS = 3;
|
||||
public const RETRY_AFTER = 3600;
|
||||
|
||||
/** @throws \InvalidArgumentException */
|
||||
public static function normalize(string $schedule): string {
|
||||
$schedule = trim($schedule);
|
||||
if ($schedule === '' || strtolower($schedule) === 'off') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^(([01]\d|2[0-3]):[0-5]\d)(?:\/(\d{1,2}))?$/', $schedule, $m) !== 1) {
|
||||
throw new \InvalidArgumentException('Use "off", HH:MM for once a day, or HH:MM/N for every N hours from HH:MM');
|
||||
}
|
||||
$hours = (int)($m[3] ?? 24);
|
||||
if ($hours < 1 || $hours > 24) {
|
||||
throw new \InvalidArgumentException('Every 1 to 24 hours');
|
||||
}
|
||||
return $hours === 24 ? $m[1] : $m[1] . '/' . $hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent scheduled moment at or before $now; null without a schedule.
|
||||
*
|
||||
* @param non-empty-string $zone
|
||||
*/
|
||||
public static function lastMoment(string $schedule, string $zone, int $now): ?int {
|
||||
if ($schedule === '') {
|
||||
return null;
|
||||
}
|
||||
$tz = new \DateTimeZone($zone);
|
||||
$today = (new \DateTimeImmutable('@' . $now))->setTimezone($tz);
|
||||
$moments = array_merge(self::moments($schedule, $today->modify('-1 day'), $tz), self::moments($schedule, $today, $tz));
|
||||
$last = null;
|
||||
foreach ($moments as $moment) {
|
||||
if ($moment <= $now && ($last === null || $moment > $last)) {
|
||||
$last = $moment;
|
||||
}
|
||||
}
|
||||
return $last;
|
||||
}
|
||||
|
||||
/** @return list<int> the moments of one day */
|
||||
private static function moments(string $schedule, \DateTimeImmutable $day, \DateTimeZone $tz): array {
|
||||
[$time, $every] = array_pad(explode('/', $schedule), 2, '24');
|
||||
[$hour, $minute] = array_map('intval', explode(':', $time));
|
||||
$moments = [];
|
||||
for ($h = $hour; $h < 24; $h += (int)$every) {
|
||||
$moments[] = (new \DateTimeImmutable($day->format('Y-m-d') . sprintf(' %02d:%02d', $h, $minute), $tz))->getTimestamp();
|
||||
}
|
||||
return $moments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a backup has to start for $moment. One that was made since then is enough, also when
|
||||
* several moments went by during a long backup. A moment from before the schedule was set does
|
||||
* not count: choosing a time must not start a backup right away.
|
||||
*/
|
||||
public static function isDue(int $moment, int $since, ?int $lastDoneStarted, int $attempts, ?int $lastAttemptFinished, int $now): bool {
|
||||
if ($moment < $since || ($lastDoneStarted !== null && $lastDoneStarted >= $moment) || $attempts >= self::MAX_ATTEMPTS) {
|
||||
return false;
|
||||
}
|
||||
return $attempts === 0 || $now - (int)$lastAttemptFinished >= self::RETRY_AFTER;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
@@ -33,6 +34,54 @@ final class PruneService {
|
||||
) {
|
||||
}
|
||||
|
||||
/** Characters of a note on a snapshot (the column holds 1000) */
|
||||
public const NOTE_MAX = 500;
|
||||
|
||||
/** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */
|
||||
public function setPinned(Target $target, string $snapshotId, bool $pinned): void {
|
||||
$changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool {
|
||||
$was = in_array($snapshotId, $repo->pinnedSnapshots(), true);
|
||||
$repo->setPinned($snapshotId, $pinned);
|
||||
return $was !== $pinned;
|
||||
});
|
||||
$this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned);
|
||||
if ($changed) {
|
||||
// Unpinning opens the way to removing it, so every administrator hears about that.
|
||||
$this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned);
|
||||
}
|
||||
}
|
||||
|
||||
public function setNote(Target $target, string $snapshotId, string $note): void {
|
||||
$note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note));
|
||||
if (!mb_check_encoding($note, 'UTF-8')) {
|
||||
throw new \InvalidArgumentException('A note must be valid UTF-8');
|
||||
}
|
||||
if (mb_strlen($note) > self::NOTE_MAX) {
|
||||
throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters');
|
||||
}
|
||||
$this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool {
|
||||
$repo->setSnapshotNote($snapshotId, $note);
|
||||
return true;
|
||||
});
|
||||
$this->targets->syncSnapshot($target, $snapshotId, note: $note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the catalog next to a running backup or restore, which only takes it for a moment to
|
||||
* commit. Not during a cleanup, which holds a repository opened earlier and writes from that.
|
||||
*
|
||||
* @param callable(Repository): bool $change
|
||||
*/
|
||||
private function changeCatalog(Target $target, callable $change): bool {
|
||||
try {
|
||||
return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300,
|
||||
fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
|
||||
fn (): bool => $change($this->targets->repository($target))));
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later');
|
||||
}
|
||||
}
|
||||
|
||||
public function untrash(Target $target, string $snapshotId): void {
|
||||
$lease = $this->lockExclusive($target, 300);
|
||||
try {
|
||||
@@ -87,6 +136,7 @@ final class PruneService {
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
$s->setNote($repo->snapshotNotes()[$snapshotId] ?? null);
|
||||
try {
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable) {
|
||||
@@ -98,6 +148,83 @@ final class PruneService {
|
||||
return 'ng_backup/target/' . $target->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* A separate, short-lived exclusive lock serializing writes to the catalog itself. The
|
||||
* per-target lock above only keeps readers/writers out while a cleanup runs; it does not
|
||||
* stop two SHARED holders (e.g. two concurrent per-user backups, or one alongside a full
|
||||
* backup) from both reading the same catalog generation and racing to write the next one,
|
||||
* silently dropping whichever's change loses. The full-backup path avoids this today only
|
||||
* because a Run row makes concurrent runs for the same target mutually exclusive; per-user
|
||||
* backup/restore has no such row, so it takes this lock around the catalog write itself.
|
||||
*/
|
||||
public static function catalogLockKey(Target $target): string {
|
||||
return self::lockKey($target) . '/catalog';
|
||||
}
|
||||
|
||||
/** How many of each user's own exports (occ backup:user:backup) to keep, newest first. */
|
||||
public function userExportRetention(): int {
|
||||
return $this->appConfig->getValueInt(Application::APP_ID, 'user_export_retention', 3);
|
||||
}
|
||||
|
||||
public function setUserExportRetention(int $keep): void {
|
||||
if ($keep < 1) {
|
||||
throw new \InvalidArgumentException('Keep at least the last export');
|
||||
}
|
||||
$old = $this->userExportRetention();
|
||||
$this->appConfig->setValueInt(Application::APP_ID, 'user_export_retention', $keep);
|
||||
if ($old !== $keep) {
|
||||
$this->alerts->securityEvent('user_export_retention_changed', ['keep' => (string)$keep]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a user's older exports once more than userExportRetention() exist for them,
|
||||
* newest first; no trash, unlike a full snapshot (occ backup:user:backup is on-demand, not
|
||||
* yet the primary safety net the deletion delay protects).
|
||||
*
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>}
|
||||
*/
|
||||
public function applyUserExports(Target $target, bool $dryRun = false): array {
|
||||
if ($target->getAppendOnly()) {
|
||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
||||
}
|
||||
$lease = $this->lockExclusive($target, 1800);
|
||||
try {
|
||||
$repo = $this->targets->repository($target);
|
||||
$keep = $this->userExportRetention();
|
||||
$byUser = [];
|
||||
foreach ($repo->listUserExports() as $path) {
|
||||
$uid = explode('/', $path)[1] ?? '';
|
||||
$manifest = json_decode($repo->getObject($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
$byUser[$uid][$path] = (int)strtotime((string)($manifest['time'] ?? '@0'));
|
||||
}
|
||||
$kept = [];
|
||||
$forget = [];
|
||||
foreach ($byUser as $uid => $times) {
|
||||
arsort($times);
|
||||
$i = 0;
|
||||
foreach (array_keys($times) as $path) {
|
||||
if ($i++ < $keep) {
|
||||
$kept[$uid][] = $path;
|
||||
} else {
|
||||
$forget[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$dryRun) {
|
||||
foreach ($forget as $path) {
|
||||
$repo->forgetUserExport($path);
|
||||
}
|
||||
if ($forget !== []) {
|
||||
$this->alerts->securityEvent('user_exports_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], false);
|
||||
}
|
||||
}
|
||||
return ['kept' => $kept, 'forgotten' => $forget];
|
||||
} finally {
|
||||
$this->leases->release($lease);
|
||||
}
|
||||
}
|
||||
|
||||
public function policy(): RetentionPolicy {
|
||||
return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []);
|
||||
}
|
||||
@@ -131,7 +258,7 @@ final class PruneService {
|
||||
$meta = $repo->snapshotMeta($id);
|
||||
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
|
||||
}
|
||||
$keep = $this->policy()->keep($times);
|
||||
$keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots());
|
||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
||||
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
|
||||
$purged = [];
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\DbDumper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\LockedException;
|
||||
|
||||
/**
|
||||
* Disaster recovery on a fresh Nextcloud installation (same major version): replace the
|
||||
* database, restore the data directory and merge the backed-up config.php, see PLAN.md 3.7.
|
||||
*
|
||||
* Deliberately NOT handled yet (phase 3): restoring in place over a live, already-used
|
||||
* installation. The admin is expected to run this once, right after occ maintenance:install and
|
||||
* occ backup:key:import/backup:target:add, before anyone else touches the instance.
|
||||
*/
|
||||
final class RestoreFullService {
|
||||
/**
|
||||
* Config keys that must keep pointing at THIS installation's actual, already-working
|
||||
* database and data directory (set by occ maintenance:install), never at the old server's.
|
||||
*/
|
||||
/** App setting: when a full restore started that has not completed (see restoreFull()). */
|
||||
private const INTERRUPTED_KEY = 'restore_full_started';
|
||||
|
||||
private const CONNECTION_KEYS = [
|
||||
'dbtype', 'dbhost', 'dbport', 'dbname', 'dbuser', 'dbpassword', 'dbpersistent',
|
||||
'dbtableprefix', 'dbdriveroptions', 'datadirectory',
|
||||
// set by restoreFull() itself for the length of the restore
|
||||
'maintenance',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private RestoreService $restore,
|
||||
private LeaseService $leases,
|
||||
private IConfig $config,
|
||||
private IDBConnection $db,
|
||||
private IAppManager $appManager,
|
||||
private KeyService $keys,
|
||||
) {
|
||||
}
|
||||
|
||||
/** 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, 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).
|
||||
*
|
||||
* With $salvage, a database dump that cannot be read does not block: dbUnreadable says why, and
|
||||
* the restore then salvages the files instead (see restoreFull()).
|
||||
*
|
||||
* @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list<string>, missingTables:list<string>, dbUnreadable?:string}
|
||||
*/
|
||||
public function checkCompatibility(Target $target, string $snapshotId, bool $skipMissingTables = false, bool $salvage = 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), self::ENVIRONMENT_APPS));
|
||||
$missingTables = [];
|
||||
$prefixMismatch = null;
|
||||
$dbUnreadable = null;
|
||||
$manifest = null;
|
||||
if (isset($meta['db'])) {
|
||||
try {
|
||||
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable $e) {
|
||||
if (!$salvage) {
|
||||
throw $e;
|
||||
}
|
||||
$dbUnreadable = 'the database dump cannot be read: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (is_array($manifest)) {
|
||||
$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 && $prefixMismatch === null && ($missingTables === [] || $skipMissingTables),
|
||||
'currentMajor' => $currentMajor,
|
||||
'snapshotMajor' => $snapshotMajor,
|
||||
'missingApps' => $missingApps,
|
||||
'missingTables' => $prefixMismatch === null ? $missingTables : [],
|
||||
'prefixMismatch' => $prefixMismatch,
|
||||
] + ($dbUnreadable !== null ? ['dbUnreadable' => $dbUnreadable] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* With $salvage, restore whatever can be restored instead of stopping:
|
||||
* - a file that cannot be read or decrypted is left out and listed (skipped);
|
||||
* - when the database cannot be restored here (its dump is unreadable or damaged, or it does
|
||||
* not fit this installation), the fresh database stays as it is, and the data directory and
|
||||
* config of the backup go to a folder of their own (salvageDir) instead: the files are what
|
||||
* matters most, and putting Nextcloud back together from there is work by hand;
|
||||
* - a config.php that cannot be read is not merged (configError).
|
||||
*
|
||||
* @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>, maintenanceLeftOn:bool, skipped:array<string, string>, dbError:?string, salvageDir:?string, configError:?string}
|
||||
*/
|
||||
public function restoreFull(Target $target, string $snapshotId, bool $skipMissingTables = false, bool $salvage = false): array {
|
||||
// Maintenance mode for the whole restore: background jobs (cron.php stops in maintenance
|
||||
// mode), page visits (AJAX cron) and users must not write into tables or the data
|
||||
// directory while they are being replaced; a row written in between would collide with a
|
||||
// restored one or be silently lost. Switched off after a restore that completed. After an
|
||||
// error it stays on: the instance may be partly restored, and must not be used like that.
|
||||
// occ only offers app commands outside maintenance mode, so the admin switches it off
|
||||
// right before running the restore again (see RestoreFull). Already on before: left on.
|
||||
// On before the checks below, so that a run again after an error leaves the partly
|
||||
// restored instance open for seconds at most; off again when they refuse (nothing changed).
|
||||
// A marker in ng_backup's own app settings (which the restore keeps) says a restore started
|
||||
// and did not complete: then the instance may be partly restored, and a check that refuses
|
||||
// a run again must not open it.
|
||||
$appConfig = \OCP\Server::get(\OCP\IAppConfig::class);
|
||||
$interrupted = $appConfig->getValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, 0) > 0;
|
||||
$wasInMaintenance = $this->config->getSystemValueBool('maintenance', false);
|
||||
$this->config->setSystemValue('maintenance', true);
|
||||
$reopen = function () use ($wasInMaintenance, $interrupted): void {
|
||||
if (!$wasInMaintenance && !$interrupted) {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
$check = $this->checkCompatibility($target, $snapshotId, $skipMissingTables, $salvage);
|
||||
} catch (\Throwable $e) {
|
||||
$reopen();
|
||||
throw $e;
|
||||
}
|
||||
if (!$check['ok']) {
|
||||
if (!$salvage) {
|
||||
$reopen();
|
||||
throw new \RuntimeException($this->describeBlockers($check));
|
||||
}
|
||||
// The database cannot go into this installation; the files can.
|
||||
$check['dbUnreadable'] = 'the database cannot be restored here: ' . $this->describeBlockers($check);
|
||||
}
|
||||
if ($interrupted) {
|
||||
// A restore that was killed leaves its leases, for up to two hours: they would keep
|
||||
// the first backup of the recovered server waiting after this restore completes.
|
||||
// In maintenance mode, on a partly restored instance, nothing else reads the location.
|
||||
$this->leases->dropShared(PruneService::lockKey($target));
|
||||
}
|
||||
$appConfig->setValueInt(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY, time());
|
||||
$result = $this->restoreInMaintenance($target, $snapshotId, $skipMissingTables, $check, $salvage);
|
||||
$appConfig->deleteKey(\OCA\NgBackup\AppInfo\Application::APP_ID, self::INTERRUPTED_KEY);
|
||||
if (!$wasInMaintenance) {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
}
|
||||
return $result + ['maintenanceLeftOn' => $wasInMaintenance];
|
||||
}
|
||||
|
||||
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list<string>, skipped:array<string, string>, dbError:?string, salvageDir:?string, configError:?string} */
|
||||
private function restoreInMaintenance(Target $target, string $snapshotId, bool $skipMissingTables, array $check, bool $salvage): array {
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $skipMissingTables, $check, $salvage) {
|
||||
$beat = LeaseService::throttled($refresh);
|
||||
$repo = $this->targets->repository($target);
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
|
||||
$dbReport = null;
|
||||
$manifest = [];
|
||||
$dbError = $check['dbUnreadable'] ?? null;
|
||||
$skipped = $salvage ? [] : null;
|
||||
$dataDir = rtrim($this->config->getSystemValueString('datadirectory'), '/');
|
||||
if ($dbError === null) {
|
||||
try {
|
||||
$this->restoreDatabase($repo, $meta, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||
} catch (DatabaseAlreadyRestoredException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
if (!$salvage) {
|
||||
throw $e;
|
||||
}
|
||||
// DbDumper::restore() rolled its transaction back: the database is the fresh one.
|
||||
$dbError = 'the database could not be restored: ' . $e->getMessage();
|
||||
$dbReport = null;
|
||||
}
|
||||
}
|
||||
if ($dbError !== null) {
|
||||
$salvageDir = $dataDir . '/ngb-salvage-' . substr($snapshotId, 0, 12);
|
||||
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $salvageDir . '/data', stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
|
||||
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $salvageDir . '/config', stripPrefix: true, heartbeat: $beat, skipped: $skipped);
|
||||
return ['dbReport' => ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => 0,
|
||||
'dbConsistent' => true, 'missingApps' => $check['missingApps'], 'skipped' => $skipped ?? [], 'dbError' => $dbError,
|
||||
'salvageDir' => $salvageDir, 'configError' => null];
|
||||
}
|
||||
|
||||
// Read (but do not yet apply) the backed-up config.php before touching the data
|
||||
// directory: once applied, 'secret' may differ from what ng_backup's own Target
|
||||
// options were encrypted with (see restoreFull()'s docblock), and every further
|
||||
// repository access through $this->targets would fail to decrypt them.
|
||||
$configError = null;
|
||||
try {
|
||||
/** @var array<string, mixed> $backupConfig */
|
||||
$backupConfig = $this->fetchBackupConfig($target, $snapshotId, $salvage);
|
||||
} catch (\OCA\NgBackup\Repository\RestoreTargetException $e) {
|
||||
throw $e; // nowhere to put it: not a config.php that cannot be read
|
||||
} catch (\Throwable $e) {
|
||||
if (!$salvage) {
|
||||
throw $e;
|
||||
}
|
||||
$configError = $e->getMessage();
|
||||
$backupConfig = [];
|
||||
}
|
||||
|
||||
$dataFiles = $this->restore->restoreToDirectory($target, $snapshotId, 'data', $dataDir, stripPrefix: true, heartbeat: $beat, skipped: $skipped, keepUnchanged: true);
|
||||
|
||||
// Last step: nothing after this may need $this->targets->repository($target) again.
|
||||
if ($backupConfig !== []) {
|
||||
$this->targets->reencryptOptionsAround(
|
||||
fn (): mixed => $this->keys->reencryptForSecretRotation(fn (): mixed => $this->config->setSystemValues($backupConfig)),
|
||||
);
|
||||
}
|
||||
|
||||
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
||||
'dbConsistent' => (bool)($manifest['consistent'] ?? true), 'missingApps' => $check['missingApps'],
|
||||
'skipped' => $skipped ?? [], 'dbError' => null, 'salvageDir' => null, 'configError' => $configError];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the database with the snapshot's dump (one transaction, see DbDumper::restore()).
|
||||
*
|
||||
* @param array<string, mixed> $meta the snapshot's meta record
|
||||
* @param array $manifest set to the dump's manifest
|
||||
*/
|
||||
private function restoreDatabase(\OCA\NgBackup\Repository\Repository $repo, array $meta, array &$manifest, ?array &$dbReport, callable $beat, bool $skipMissingTables): void {
|
||||
if (!isset($meta['db'])) {
|
||||
return;
|
||||
}
|
||||
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
|
||||
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
||||
// ng_backup's own tables (and its own rows in the shared appconfig table) must
|
||||
// survive this restore untouched: they describe the backup/key/target that is
|
||||
// actively performing it right now, on THIS installation, not the one that was
|
||||
// backed up. Overwriting them mid-restore would pull the rug from under it.
|
||||
foreach (array_keys($manifest['tables']) as $table) {
|
||||
/** @var string $table */
|
||||
if (str_starts_with($table, $prefix . 'ngb_')) {
|
||||
unset($manifest['tables'][$table]);
|
||||
}
|
||||
}
|
||||
// Sequences are keyed separately from tables; excluding a table does not exclude
|
||||
// its sequence, which would otherwise rewind a retained ngb_* table's sequence
|
||||
// below its current ids (PostgreSQL only -- see DbDumper::restoreSequences()).
|
||||
foreach (array_keys($manifest['sequences'] ?? []) as $seq) {
|
||||
/** @var string $seq */
|
||||
if (str_starts_with((string)($manifest['sequences'][$seq]['table'] ?? ''), $prefix . 'ngb_')) {
|
||||
unset($manifest['sequences'][$seq]);
|
||||
}
|
||||
}
|
||||
$ownAppConfig = $this->preserveOwnAppConfig();
|
||||
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
||||
try {
|
||||
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$this->restoreOwnAppConfig($ownAppConfig);
|
||||
} catch (\Throwable) {
|
||||
// rolled back, so they are still there: the error that brought us here is the one to report
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
try {
|
||||
$this->restoreOwnAppConfig($ownAppConfig);
|
||||
} catch (\Throwable $e) {
|
||||
// The old server's database is in place by now: not a case for "the database stays as
|
||||
// the fresh installation made it" under --salvage. A run again restores these rows.
|
||||
throw new DatabaseAlreadyRestoredException('The database was restored, but NG Backup could not put back its own settings: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> config.php's $CONFIG from the backup, minus the connection keys
|
||||
* @throws \RuntimeException with $salvage, when config.php itself cannot be read
|
||||
*/
|
||||
private function fetchBackupConfig(Target $target, string $snapshotId, bool $salvage = false): array {
|
||||
$tmp = sys_get_temp_dir() . '/ngb-restore-config-' . bin2hex(random_bytes(6));
|
||||
try {
|
||||
$skipped = $salvage ? [] : null;
|
||||
$this->restore->restoreToDirectory($target, $snapshotId, 'config', $tmp, stripPrefix: true, skipped: $skipped);
|
||||
if (isset($skipped['config/config.php'])) {
|
||||
throw new \RuntimeException('config.php cannot be read: ' . $skipped['config/config.php']);
|
||||
}
|
||||
$path = $tmp . '/config.php';
|
||||
if (!is_file($path)) {
|
||||
return []; // nothing to merge; the live config.php stays as occ maintenance:install made it
|
||||
}
|
||||
$backupConfig = (static function () use ($path): array {
|
||||
$CONFIG = [];
|
||||
include $path;
|
||||
return $CONFIG;
|
||||
})();
|
||||
foreach (self::CONNECTION_KEYS as $key) {
|
||||
unset($backupConfig[$key]);
|
||||
}
|
||||
return $backupConfig;
|
||||
} finally {
|
||||
$this->rmrf($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> raw appconfig rows (appid, configkey, configvalue, type, lazy) */
|
||||
private function preserveOwnAppConfig(): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from('appconfig')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter(\OCA\NgBackup\AppInfo\Application::APP_ID)));
|
||||
/** @var list<array<string, mixed>> */
|
||||
return $qb->executeQuery()->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows raw appconfig rows, as returned by preserveOwnAppConfig()
|
||||
*
|
||||
* Runs in its own transaction (DbDumper::restore() already committed its own by the time
|
||||
* this is called): not a full guarantee against a crash spanning both, but means this step
|
||||
* itself cannot leave ng_backup's appconfig rows half deleted, half reinserted.
|
||||
*/
|
||||
private function restoreOwnAppConfig(array $rows): void {
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->delete('appconfig')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter(\OCA\NgBackup\AppInfo\Application::APP_ID)));
|
||||
$qb->executeStatement();
|
||||
foreach ($rows as $row) {
|
||||
$ins = $this->db->getQueryBuilder();
|
||||
$ins->insert('appconfig')->values([
|
||||
'appid' => $ins->createNamedParameter($row['appid']),
|
||||
'configkey' => $ins->createNamedParameter($row['configkey']),
|
||||
'configvalue' => $ins->createNamedParameter($row['configvalue']),
|
||||
'type' => $ins->createNamedParameter($row['type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'lazy' => $ins->createNamedParameter($row['lazy'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
]);
|
||||
$ins->executeStatement();
|
||||
}
|
||||
$this->db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function rmrf(string $dir): void {
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
|
||||
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function describeBlockers(array $check): string {
|
||||
$parts = [];
|
||||
if ($check['currentMajor'] !== $check['snapshotMajor']) {
|
||||
$parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)";
|
||||
}
|
||||
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";
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
// Refreshed during the database and data restore (per row batch, per blob), so a
|
||||
// cleanup cannot take over the location while a long restore is still reading it.
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 7200, $fn);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-14
@@ -33,6 +33,9 @@ final class RestoreService {
|
||||
public const MODE_MERGE = 'merge';
|
||||
public const MODE_REPLACE = 'replace';
|
||||
|
||||
/** @var array<string, true> folders of the current restore already cleared of stale temporaries */
|
||||
private array $cleaned = [];
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private IRootFolder $root,
|
||||
@@ -58,7 +61,8 @@ final class RestoreService {
|
||||
$prefix = trim($path, '/');
|
||||
$prefix = $prefix === '' ? '' : $prefix . '/';
|
||||
$children = [];
|
||||
foreach ($this->targets->repository($target)->entries($snapshotId) as $e) {
|
||||
$repo = $this->targets->repository($target);
|
||||
foreach ($repo->entries($snapshotId) as $e) {
|
||||
if ($prefix !== '' && !str_starts_with($e['p'], $prefix)) {
|
||||
continue;
|
||||
}
|
||||
@@ -72,6 +76,12 @@ final class RestoreService {
|
||||
}
|
||||
$children[$name] = $c;
|
||||
}
|
||||
foreach ($repo->emptyDirs($snapshotId) as [$dir]) { // read along with the entries
|
||||
if ($prefix === '' || str_starts_with($dir, $prefix)) {
|
||||
$name = explode('/', substr($dir, strlen($prefix)), 2)[0];
|
||||
$children[$name] ??= ['name' => $name, 'type' => 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
|
||||
}
|
||||
}
|
||||
ksort($children, SORT_STRING);
|
||||
return array_values($children);
|
||||
}
|
||||
@@ -81,8 +91,8 @@ final class RestoreService {
|
||||
*
|
||||
* @return array{restored:int, trashed:int, target:string}
|
||||
*/
|
||||
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null): array {
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $logicalPath, $mode, $progress) {
|
||||
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null, ?array &$skipped = null): array {
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $logicalPath, $mode, $progress, &$skipped) {
|
||||
$last = time();
|
||||
// Keep the lease alive while files are being written.
|
||||
$beat = function (int $done, int $total, string $name) use ($refresh, $progress, &$last): void {
|
||||
@@ -94,11 +104,12 @@ final class RestoreService {
|
||||
$progress($done, $total, $name);
|
||||
}
|
||||
};
|
||||
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat);
|
||||
return $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $beat, $skipped);
|
||||
});
|
||||
}
|
||||
|
||||
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
|
||||
/** @param array<string, string>|null $skipped see Repository::restore() */
|
||||
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress, ?array &$skipped): array {
|
||||
if (!Repository::isSafePath($logicalPath) || !preg_match('#^data/([^/]+)/files(?:/(.*))?$#', $logicalPath, $m)) {
|
||||
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
|
||||
}
|
||||
@@ -119,7 +130,17 @@ final class RestoreService {
|
||||
$entries[substr($e['p'], strlen($source) + 1)] = $e;
|
||||
}
|
||||
}
|
||||
if ($entries === []) {
|
||||
// Folders without anything in them, relative to $source ('' when $source itself is one).
|
||||
/** @var array<array-key, int> $emptyDirs modification times; a folder called "2026" is an integer key */
|
||||
$emptyDirs = [];
|
||||
foreach ($single ? [] : $repo->emptyDirs($snapshotId) as [$dir, $mtime]) {
|
||||
if ($dir === $source) {
|
||||
$emptyDirs[''] = $mtime;
|
||||
} elseif (str_starts_with($dir, $source . '/')) {
|
||||
$emptyDirs[substr($dir, strlen($source) + 1)] = $mtime;
|
||||
}
|
||||
}
|
||||
if ($entries === [] && $emptyDirs === []) {
|
||||
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
|
||||
}
|
||||
|
||||
@@ -130,39 +151,102 @@ final class RestoreService {
|
||||
} elseif ($mode !== self::MODE_MERGE && $mode !== self::MODE_REPLACE) {
|
||||
throw new \InvalidArgumentException("Unknown mode $mode");
|
||||
}
|
||||
$destExisted = $parentRel === '' || $userFolder->nodeExists($parentRel);
|
||||
$dest = $this->ensureFolder($userFolder, $parentRel);
|
||||
$names = $single ? [basename($rel) => $entries['']] : $entries;
|
||||
|
||||
// The folders with something in them that this restore creates get their time back; one that
|
||||
// was already there keeps its own, as its content changes now. Noted before anything is written.
|
||||
/** @var array<array-key, int> $newFolders a folder called "2026" is an integer key */
|
||||
$newFolders = [];
|
||||
foreach ($single ? [] : $repo->folderTimes($snapshotId) as [$dir, $mtime]) {
|
||||
$relative = $dir === $source ? '' : (str_starts_with($dir, $source . '/') ? substr($dir, strlen($source) + 1) : null);
|
||||
if ($relative !== null && ($relative === '' ? !$destExisted : !$dest->nodeExists($relative))) {
|
||||
$newFolders[$relative] = $mtime;
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleaned = [];
|
||||
$restored = 0;
|
||||
foreach ($names as $relPath => $entry) {
|
||||
$this->writeFile($repo, $dest, (string)$relPath, $entry);
|
||||
try {
|
||||
$this->writeFile($repo, $dest, (string)$relPath, $entry);
|
||||
} catch (\OCA\NgBackup\Repository\RepositoryException|\OCA\NgBackup\Backend\BackendException|\OCA\NgBackup\Crypto\CryptoException $e) {
|
||||
// Damaged or missing on the location (writeFile removed its temporary)
|
||||
if ($skipped === null) {
|
||||
throw $e;
|
||||
}
|
||||
$skipped[$entry['p']] = $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
$restored++;
|
||||
if ($progress !== null) {
|
||||
$progress($restored, count($names), (string)$relPath);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($emptyDirs as $dir => $mtime) {
|
||||
$dir = (string)$dir;
|
||||
$existed = $dir === '' ? $destExisted : $dest->nodeExists($dir); // one that is there keeps its own time
|
||||
$folder = $this->ensureFolder($dest, $dir);
|
||||
if (!$existed) {
|
||||
$folder->touch($mtime);
|
||||
}
|
||||
if ($progress !== null) {
|
||||
$progress($restored, count($names), $dir); // keeps the lease alive too
|
||||
}
|
||||
}
|
||||
|
||||
$trashed = 0;
|
||||
if ($mode === self::MODE_REPLACE && !$single) {
|
||||
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
|
||||
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)) + $emptyDirs);
|
||||
}
|
||||
// Last and deepest first: a change in a folder also touches the folders above it.
|
||||
foreach (array_reverse($newFolders, true) as $dir => $mtime) {
|
||||
$this->ensureFolder($dest, (string)$dir)->touch($mtime);
|
||||
if ($progress !== null) {
|
||||
$progress($restored, count($names), (string)$dir); // keeps the lease alive too
|
||||
}
|
||||
}
|
||||
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
|
||||
}
|
||||
|
||||
/** Raw restore of any snapshot path to a local directory (administrators, disaster recovery). */
|
||||
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory): int {
|
||||
/**
|
||||
* Raw restore of any snapshot path to a local directory (administrators, disaster recovery).
|
||||
*
|
||||
* @param bool $stripPrefix write into $directory itself instead of $directory/$prefix (see
|
||||
* Repository::restore()); used to restore "data/" or "config/" straight into the
|
||||
* real data/config directory for occ backup:restore:full.
|
||||
* @param bool $keepUnchanged leave files already in place with the snapshot's size and
|
||||
* modification time (see Repository::restore()): a full restore that runs again
|
||||
* goes on where it stopped
|
||||
*/
|
||||
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory, bool $stripPrefix = false, ?callable $heartbeat = null, ?array &$skipped = null, bool $keepUnchanged = false): int {
|
||||
if ($prefix !== '' && !Repository::isSafePath($prefix)) {
|
||||
throw new \InvalidArgumentException('Invalid path: ' . $prefix);
|
||||
}
|
||||
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException("Cannot create $directory");
|
||||
throw new \OCA\NgBackup\Repository\RestoreTargetException("Cannot create $directory (disk full, or no permission?)");
|
||||
}
|
||||
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'), 3600);
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $prefix, $directory, $stripPrefix, $heartbeat, &$skipped, $keepUnchanged): int {
|
||||
$own = LeaseService::throttled($refresh);
|
||||
$beat = static function () use ($own, $heartbeat): void {
|
||||
$own();
|
||||
if ($heartbeat !== null) {
|
||||
$heartbeat();
|
||||
}
|
||||
};
|
||||
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/', $stripPrefix, $beat, $skipped, $keepUnchanged);
|
||||
}, 3600);
|
||||
}
|
||||
|
||||
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
|
||||
$dir = dirname($relPath);
|
||||
$folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir);
|
||||
if (!isset($this->cleaned[$folder->getPath()])) {
|
||||
$this->removeStaleTemporaries($folder);
|
||||
$this->cleaned[$folder->getPath()] = true;
|
||||
}
|
||||
$name = basename($relPath);
|
||||
// Stage under a temporary name; only rename into place after every blob was verified.
|
||||
$tmpName = '.' . $name . '.ngb-restore-' . bin2hex(random_bytes(3));
|
||||
@@ -180,7 +264,7 @@ final class RestoreService {
|
||||
throw new \RuntimeException('Write failed for ' . $relPath);
|
||||
}
|
||||
if ($written !== $entry['s']) {
|
||||
throw new \RuntimeException("Size mismatch restoring $relPath");
|
||||
throw new \OCA\NgBackup\Repository\RepositoryException("Size mismatch restoring $relPath");
|
||||
}
|
||||
if ($folder->nodeExists($name)) {
|
||||
$existing = $folder->get($name);
|
||||
@@ -208,12 +292,25 @@ final class RestoreService {
|
||||
}
|
||||
}
|
||||
|
||||
/** See Repository::removeStaleTemporaries(): what a restore that was stopped left in this folder. */
|
||||
private function removeStaleTemporaries(Folder $folder): void {
|
||||
foreach ($folder->getDirectoryListing() as $node) {
|
||||
if ($node instanceof File && Repository::isRestoreTemporary($node->getName())) {
|
||||
try {
|
||||
$node->delete();
|
||||
} catch (\Throwable) {
|
||||
// in the way of nothing: the new temporary gets a name of its own
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function trashExtras(Folder $folder, string $prefix, array $keep): int {
|
||||
$count = 0;
|
||||
foreach ($folder->getDirectoryListing() as $node) {
|
||||
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
|
||||
if ($node instanceof Folder) {
|
||||
$hasKept = false;
|
||||
$hasKept = isset($keep[$rel]); // a folder that was empty in the snapshot
|
||||
foreach (array_keys($keep) as $k) {
|
||||
if (str_starts_with((string)$k, $rel . '/')) {
|
||||
$hasKept = true;
|
||||
|
||||
@@ -28,6 +28,19 @@ final class RetentionPolicy {
|
||||
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
|
||||
}
|
||||
|
||||
/**
|
||||
* keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not
|
||||
* take the place of a daily, weekly or monthly one.
|
||||
*
|
||||
* @param array<string, int> $snapshots id => unix time
|
||||
* @param list<string> $pinned
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function keepWithPinned(array $snapshots, array $pinned): array {
|
||||
$pins = array_intersect_key($snapshots, array_flip($pinned));
|
||||
return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $snapshots id => unix time
|
||||
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroupManager;
|
||||
|
||||
/**
|
||||
* The time zone times are shown in, and the daily time from before each location had its own
|
||||
* schedule (see LocationSchedule). Nextcloud runs PHP in UTC, so a time without a zone meant UTC
|
||||
* (issue #19: 03:00 ran at 05:00 in Berlin). A time saved before there was a zone is converted once
|
||||
* to the zone of the server or of an administrator, at today's offset: it then shows as it
|
||||
* effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer).
|
||||
*/
|
||||
final class Schedule {
|
||||
public const TIME_KEY = 'schedule_time';
|
||||
public const ZONE_KEY = 'schedule_timezone';
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private IConfig $config,
|
||||
private IGroupManager $groups,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{time: string, timezone: non-empty-string} time "" = no schedule */
|
||||
public function get(): array {
|
||||
$time = $this->appConfig->getValueString(Application::APP_ID, self::TIME_KEY, '');
|
||||
$zone = $this->appConfig->getValueString(Application::APP_ID, self::ZONE_KEY, '');
|
||||
if ($zone === '' || !self::validZone($zone)) {
|
||||
$zone = $this->guessZone();
|
||||
if ($time !== '' && self::validTime($time)) {
|
||||
$time = self::convert($time, 'UTC', $zone);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time);
|
||||
}
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone);
|
||||
}
|
||||
return ['time' => $time, 'timezone' => $zone];
|
||||
}
|
||||
|
||||
/** @return non-empty-string the zone of a location's schedule, or else this one */
|
||||
public function zoneOr(?string $zone): string {
|
||||
return $zone !== null && self::validZone($zone) ? $zone : $this->get()['timezone'];
|
||||
}
|
||||
|
||||
/**
|
||||
* "HH:MM" in zone $from as "HH:MM" in zone $to, at today's offsets.
|
||||
*
|
||||
* @param non-empty-string $from
|
||||
* @param non-empty-string $to
|
||||
*/
|
||||
public static function convert(string $time, string $from, string $to, ?int $now = null): string {
|
||||
$day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone(new \DateTimeZone($from))->format('Y-m-d');
|
||||
return (new \DateTimeImmutable($day . ' ' . $time, new \DateTimeZone($from)))->setTimezone(new \DateTimeZone($to))->format('H:i');
|
||||
}
|
||||
|
||||
public static function validTime(string $time): bool {
|
||||
return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
|
||||
}
|
||||
|
||||
/** @psalm-assert-if-true non-empty-string $zone */
|
||||
public static function validZone(string $zone): bool {
|
||||
return in_array($zone, \DateTimeZone::listIdentifiers(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's default_timezone when set, else the first administrator's, else UTC. A server
|
||||
* zone of UTC is passed over on purpose: it is mostly what an image or installer left there,
|
||||
* while the administrator who set the time read it as their own (#19).
|
||||
*
|
||||
* @return non-empty-string
|
||||
*/
|
||||
private function guessZone(): string {
|
||||
$zone = $this->config->getSystemValueString('default_timezone', '');
|
||||
if ($zone !== '' && $zone !== 'UTC' && self::validZone($zone)) {
|
||||
return $zone;
|
||||
}
|
||||
foreach ($this->groups->get('admin')?->getUsers() ?? [] as $user) {
|
||||
$zone = $this->config->getUserValue($user->getUID(), 'core', 'timezone', '');
|
||||
if ($zone !== '' && self::validZone($zone)) {
|
||||
return $zone;
|
||||
}
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
+395
-21
@@ -10,10 +10,14 @@ namespace OCA\NgBackup\Service;
|
||||
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Backend\StorageBackend;
|
||||
use OCA\NgBackup\Db\Snapshot;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
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;
|
||||
|
||||
@@ -23,6 +27,9 @@ use OCP\Server;
|
||||
* classes but its enabled/disabled state is left as the admin set it.
|
||||
*/
|
||||
final class TargetService {
|
||||
/** Deletion delay taken when a location's own cannot be read: nothing leaves the Bin sooner than on the old server. */
|
||||
public const LONGEST_DELETE_DELAY = 365;
|
||||
|
||||
public function __construct(
|
||||
private TargetMapper $mapper,
|
||||
private KeyService $keys,
|
||||
@@ -30,10 +37,13 @@ final class TargetService {
|
||||
private IAppManager $appManager,
|
||||
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
|
||||
private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor,
|
||||
private SnapshotMapper $snapshots,
|
||||
private AlertService $alerts,
|
||||
private \OCA\NgBackup\Db\RunMapper $runs,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @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();
|
||||
}
|
||||
@@ -43,26 +53,34 @@ 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)');
|
||||
}
|
||||
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, bool $repairConfig = false): 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);
|
||||
$created = false;
|
||||
} else {
|
||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
||||
$created = true;
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, !$repairConfig);
|
||||
try {
|
||||
if ($repairConfig) {
|
||||
Repository::repairConfig($storageBackend, $keys, $this->keys->wrappedKey());
|
||||
}
|
||||
if ($storageBackend->exists('config')) {
|
||||
$repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress);
|
||||
$created = false;
|
||||
} else {
|
||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor);
|
||||
$created = true;
|
||||
}
|
||||
} catch (\OCA\NgBackup\Repository\RepositoryException $e) {
|
||||
$otherKey = str_contains($e->getMessage(), 'different key') || str_contains($e->getMessage(), 'do not open with this key');
|
||||
if ($otherKey && $this->keyIsUnused()) {
|
||||
throw new \OCA\NgBackup\Repository\RepositoryException(rtrim($e->getMessage(), '.') . '. ' . self::UNUSED_KEY_HINT, 0, $e);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
$this->keys->markUsed();
|
||||
$this->recordSettings($repo);
|
||||
|
||||
foreach ($this->mapper->findAll() as $existing) {
|
||||
if ($existing->getRepositoryId() === $repo->id()) {
|
||||
@@ -78,7 +96,307 @@ final class TargetService {
|
||||
$target->setBasePath(trim($basePath, '/'));
|
||||
$target->setRepositoryId($repo->id());
|
||||
$target->setCreatedAt(time());
|
||||
return ['target' => $this->mapper->insert($target), 'created' => $created];
|
||||
// Like the location before it: a backup scheme is usually one scheme.
|
||||
$first = $this->mapper->findAll()[0] ?? null;
|
||||
$target->setSchedule($first?->getSchedule() ?? '');
|
||||
$target->setScheduleZone($first?->getScheduleZone());
|
||||
$target->setScheduleSince(time());
|
||||
$target = $this->mapper->insert($target);
|
||||
if (!$created) {
|
||||
// Disaster recovery: occ backup:list reads this local cache, not the repository
|
||||
// directly, so a snapshot made by another installation must be backfilled here too.
|
||||
$this->adoptSnapshots($target, $repo);
|
||||
}
|
||||
return ['target' => $target, 'created' => $created];
|
||||
}
|
||||
|
||||
public const UNUSED_KEY_HINT = 'The backup key of this server was never used (no location, no backup), so it can make way for the key of these backups: '
|
||||
. 'with the old server\'s recovery kit, occ backup:key:import <kit file> --replace-unused-key; without the kit, '
|
||||
. 'occ backup:target:add with the same details and --replace-unused-key (asks for a passphrase of the old server)';
|
||||
|
||||
/**
|
||||
* A key that never protected anything: no location was created or adopted with it, and the
|
||||
* database has no location and no run (installations from before the flag existed). Only such
|
||||
* a key may make way for the key of existing backups, e.g. after a new key was made by
|
||||
* mistake on a server that is being restored.
|
||||
*/
|
||||
public function keyIsUnused(): bool {
|
||||
return $this->keys->isInitialized() && !$this->keys->wasUsed()
|
||||
&& $this->mapper->findAll() === [] && $this->runs->countAll() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* occ backup:key:import on a server whose own key was never used: the kit's key takes its place.
|
||||
*
|
||||
* @param array<string, mixed> $kit
|
||||
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase: nothing changed
|
||||
*/
|
||||
public function importKitReplacingUnusedKey(array $kit, #[\SensitiveParameter] string $passphrase): void {
|
||||
$old = $this->requireUnusedKey();
|
||||
$this->keys->importRecoveryKit($kit, $passphrase, true);
|
||||
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
|
||||
}
|
||||
|
||||
/** @return string the fingerprint of the unused key */
|
||||
private function requireUnusedKey(): string {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
throw new \RuntimeException('There is no backup key here to replace');
|
||||
}
|
||||
if (!$this->keyIsUnused()) {
|
||||
throw new \RuntimeException('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced');
|
||||
}
|
||||
return $this->keys->fingerprint();
|
||||
}
|
||||
|
||||
public const LOCATION_REPOSITORY = 'repository';
|
||||
public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config';
|
||||
public const LOCATION_EMPTY = 'empty';
|
||||
|
||||
/**
|
||||
* What these settings lead to: a repository, backup data whose config is gone, or nothing
|
||||
* of NG Backup. Creates nothing.
|
||||
*
|
||||
* @return self::LOCATION_*
|
||||
*/
|
||||
public function locationState(string $backend, string $auth, array $options, string $basePath): string {
|
||||
$this->requireUsableBackend($backend);
|
||||
$storage = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
||||
if ($storage->exists('config')) {
|
||||
return self::LOCATION_REPOSITORY;
|
||||
}
|
||||
return Repository::holdsData($storage) ? self::LOCATION_DATA_WITHOUT_CONFIG : self::LOCATION_EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disaster recovery without the recovery kit, on an installation without a backup key: take
|
||||
* the key from the location's config with one of its passphrases, then add the location as
|
||||
* usual. The key is only kept when it really is the one the repository was made with.
|
||||
* With $replaceUnusedKey, a key this server has but never used makes way (keyIsUnused()).
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
* @param ?callable(int, int): void $progress
|
||||
* @return array{target: Target, created: bool, deleteDelayDays: ?int, settingsUnreadable: bool} the deletion delay the location had stored, if any, and whether its settings could not be read
|
||||
* @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase
|
||||
*/
|
||||
public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath,
|
||||
#[\SensitiveParameter] string $passphrase, ?callable $progress = null, bool $replaceUnusedKey = false): array {
|
||||
$old = null;
|
||||
if ($replaceUnusedKey) {
|
||||
$old = $this->requireUnusedKey();
|
||||
} elseif ($this->keys->isInitialized()) {
|
||||
throw new \RuntimeException('A backup key already exists here');
|
||||
}
|
||||
$this->validateName($name);
|
||||
$this->requireUsableBackend($backend);
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false);
|
||||
if (!$storageBackend->exists('config')) {
|
||||
throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'");
|
||||
}
|
||||
$wrapped = Repository::wrappedKeyAt($storageBackend);
|
||||
$keys = \OCA\NgBackup\Crypto\KeyRing::unwrapAny($wrapped, $passphrase);
|
||||
if (!Repository::madeWithKey($storageBackend, $keys)) {
|
||||
throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import <kit file>, then this command again.');
|
||||
}
|
||||
$settings = Repository::settingsAt($storageBackend, $keys);
|
||||
$stored = $settings['delete_delay_days'] ?? null;
|
||||
$delay = is_int($stored) && $stored >= 1 && $stored <= 365 ? $stored : null;
|
||||
// Settings that are there but cannot be read, or hold no valid delay (also none at all): the old server's delay
|
||||
// is unknown, so take the longest one; a shorter guess could empty the Bin too soon.
|
||||
$unknown = ($settings['unreadable'] ?? false) === true || ($settings !== [] && $delay === null);
|
||||
$this->keys->adoptKey($keys, $wrapped, $delay ?? ($unknown ? self::LONGEST_DELETE_DELAY : null), $replaceUnusedKey);
|
||||
if ($old !== null) {
|
||||
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
|
||||
}
|
||||
return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay, 'settingsUnreadable' => $unknown];
|
||||
}
|
||||
|
||||
/** A passphrase change that missed this location (offline at the time) reaches it now. A failure is tried again at the next backup. */
|
||||
public function catchUpWrappedKey(Target $target, Repository $repo): void {
|
||||
if (!$this->keys->wrappedKeyPending($target->getId())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$repo->updateWrappedKey($this->keys->wrappedKey());
|
||||
$this->keys->setWrappedKeyPending($target->getId(), false);
|
||||
} catch (\Throwable $e) {
|
||||
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: the last passphrase change still does not reach ' . $target->getName()
|
||||
. ' (it keeps the previous passphrases); tried again at its next backup: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
|
||||
public function recordSettings(Repository $repo): void {
|
||||
try {
|
||||
$repo->recordSettings(['delete_delay_days' => $this->keys->deleteDelayDays()]);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* would otherwise leave every target permanently undecryptable. Call $applySecretChange
|
||||
* (which must be the only thing that changes the secret) through here instead: every
|
||||
* target's options are decrypted first, then re-encrypted under whatever secret is active
|
||||
* once $applySecretChange returns.
|
||||
*/
|
||||
public function reencryptOptionsAround(callable $applySecretChange): mixed {
|
||||
$plain = [];
|
||||
foreach ($this->mapper->findAll() as $target) {
|
||||
$plain[$target->getId()] = $this->options($target);
|
||||
}
|
||||
$result = $applySecretChange();
|
||||
foreach ($this->mapper->findAll() as $target) {
|
||||
if (!isset($plain[$target->getId()])) {
|
||||
continue; // added by $applySecretChange itself; nothing to re-encrypt
|
||||
}
|
||||
$target->setOptions($this->crypto->encrypt(json_encode($plain[$target->getId()], JSON_THROW_ON_ERROR)));
|
||||
$this->mapper->update($target);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
|
||||
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
|
||||
try {
|
||||
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
|
||||
} catch (DoesNotExistException) {
|
||||
return;
|
||||
}
|
||||
if ($note !== null) {
|
||||
$s->setNote($note !== '' ? $note : null);
|
||||
}
|
||||
if ($pinned !== null) {
|
||||
$s->setPinned($pinned);
|
||||
}
|
||||
$this->snapshots->update($s);
|
||||
}
|
||||
|
||||
private function adoptSnapshots(Target $target, Repository $repo): void {
|
||||
$notes = $repo->snapshotNotes();
|
||||
$pinned = array_flip($repo->pinnedSnapshots());
|
||||
foreach ($repo->listSnapshots() as $snapshotId) {
|
||||
try {
|
||||
$this->snapshots->findOne($target->getId(), $snapshotId);
|
||||
continue; // already known locally
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
// One unreadable/corrupt snapshot must not stop the rest of the location's
|
||||
// snapshots from being backfilled, nor abort adding the target itself.
|
||||
try {
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
$s = new Snapshot();
|
||||
$s->setTargetId($target->getId());
|
||||
$s->setSnapshotId($snapshotId);
|
||||
$s->setKind((string)($meta['kind'] ?? 'full'));
|
||||
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
$s->setNote($notes[$snapshotId] ?? null);
|
||||
$s->setPinned(isset($pinned[$snapshotId]));
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable $e) {
|
||||
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<Target> */
|
||||
@@ -87,10 +405,38 @@ final class TargetService {
|
||||
}
|
||||
|
||||
public function get(string $nameOrId): Target {
|
||||
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
||||
try {
|
||||
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new \InvalidArgumentException("No such location: $nameOrId");
|
||||
}
|
||||
}
|
||||
|
||||
/** Size limit for the data stored on this location (null = none). */
|
||||
/**
|
||||
* @param string $schedule see LocationSchedule
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function setSchedule(Target $target, string $schedule, string $zone): Target {
|
||||
if (!Schedule::validZone($zone)) {
|
||||
throw new \InvalidArgumentException('Unknown time zone: ' . $zone);
|
||||
}
|
||||
$new = LocationSchedule::normalize($schedule);
|
||||
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
|
||||
if ($new === (string)$target->getSchedule() && $zone === $target->getScheduleZone()) {
|
||||
// Saved again unchanged: a moment that already passed still gets its backup
|
||||
return $target;
|
||||
}
|
||||
$target->setSchedule($new);
|
||||
$target->setScheduleZone($zone);
|
||||
$target->setScheduleSince(time());
|
||||
$target = $this->mapper->update($target);
|
||||
if ($turnedOff) {
|
||||
$this->alerts->securityEvent('schedule_off', ['target' => $target->getName()]);
|
||||
}
|
||||
return $target;
|
||||
}
|
||||
|
||||
public function setMaxBytes(Target $target, ?int $maxBytes): Target {
|
||||
$target->setMaxBytes($maxBytes);
|
||||
return $this->mapper->update($target);
|
||||
@@ -104,6 +450,30 @@ 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 location is gone now: tidying up must not fail the removal, or the caller would skip
|
||||
// the removal event and a retry would no longer find the location
|
||||
$this->tidyUp($target, fn () => $this->keys->setWrappedKeyPending($target->getId(), false));
|
||||
// The cached index of its repository is only of use while a location points at it
|
||||
$this->tidyUp($target, function () use ($target): void {
|
||||
$repositoryId = $target->getRepositoryId();
|
||||
if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) {
|
||||
$this->indexCache->forget($repositoryId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One tidy-up step after a removal: a failure is logged (if even that works) and never thrown. */
|
||||
private function tidyUp(Target $target, callable $step): void {
|
||||
try {
|
||||
$step();
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: location ' . $target->getName()
|
||||
. ' removed, tidying up after it failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (\Throwable) {
|
||||
// nothing left to tell it to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function backend(Target $target): IBackend {
|
||||
@@ -124,8 +494,12 @@ 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);
|
||||
$storage = new StorageBackend($create(), $basePath, $createBase, $create);
|
||||
$storage->readAheadConnections = Server::get(\OCP\IAppConfig::class)
|
||||
->getValueInt('ng_backup', 'restore_sftp_connections', \OCA\NgBackup\Backend\SftpRangeFetcher::DEFAULT_CONNECTIONS);
|
||||
return $storage;
|
||||
}
|
||||
|
||||
private ?ExternalStorageFactory $factory = null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\UserMigration\RepositoryExportDestination;
|
||||
use OCA\NgBackup\UserMigration\UserMigrationServiceLocator;
|
||||
use OCP\IUser;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\UserMigration\UserMigrationException;
|
||||
|
||||
/**
|
||||
* Per-user backup via user_migration: every registered migrator's export streams straight into
|
||||
* the encrypted repository (no zip, no copy in the user's own storage, see
|
||||
* RepositoryExportDestination). Shares blobs with the full backup; prune() keeps a user export's
|
||||
* blobs referenced through its manifest under users/<uid>/.
|
||||
*
|
||||
* user_migration is an optional dependency (unlike files_external): without it, only this
|
||||
* per-user backup/restore is unavailable, the full backup still works.
|
||||
*/
|
||||
final class UserBackupService {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private LeaseService $leases,
|
||||
private UserMigrationServiceLocator $locator,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws UserMigrationException when user_migration is not enabled, or the export itself fails */
|
||||
public function backupUser(Target $target, IUser $user): string {
|
||||
$service = $this->locator->get();
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $user, $service) {
|
||||
$repo = $this->targets->repository($target);
|
||||
$dest = new RepositoryExportDestination($repo, $user->getUID(), LeaseService::throttled($refresh));
|
||||
$service->export($dest, $user);
|
||||
// A short exclusive lock around the catalog write only: the shared lock above
|
||||
// allows another concurrent backup (full or per-user) to be writing the catalog at
|
||||
// the same time. The repository is reopened inside the lock, so the write builds on
|
||||
// the latest catalog generation instead of the one loaded before the lock was taken.
|
||||
$this->withCatalogLock($target, fn () => $this->targets->repository($target)->recordUserExport($dest->manifestPath()));
|
||||
return $dest->manifestPath();
|
||||
});
|
||||
}
|
||||
|
||||
private function withSharedLock(Target $target, callable $fn): mixed {
|
||||
try {
|
||||
// Refreshed by RepositoryExportDestination as it writes data, so a long export keeps it.
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
||||
}
|
||||
}
|
||||
|
||||
private function withCatalogLock(Target $target, callable $fn): mixed {
|
||||
try {
|
||||
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\UserMigration\RepositoryExportDestination;
|
||||
use OCA\NgBackup\UserMigration\RepositoryImportSource;
|
||||
use OCA\NgBackup\UserMigration\UserMigrationServiceLocator;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\Files\Events\Node\FilesystemTornDownEvent;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ISecureRandom;
|
||||
use OCP\UserMigration\UserMigrationException;
|
||||
|
||||
/**
|
||||
* Restoring a user from a user_migration export (see UserBackupService), never over an existing
|
||||
* account:
|
||||
* - replace: a safety export of the current state goes into the repository first, then the
|
||||
* account is deleted and recreated fresh from the backup, under the same uid;
|
||||
* - as-backup: the backup is restored into a new account "<uid>-bak" (or "-bak-2", ...) instead,
|
||||
* so an admin can compare and transfer by hand. Nextcloud cannot rename a uid, so the *backup*
|
||||
* gets the new name, never the existing account.
|
||||
*/
|
||||
final class UserRestoreService {
|
||||
public const MODE_REPLACE = 'replace';
|
||||
public const MODE_AS_BACKUP = 'as-backup';
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private LeaseService $leases,
|
||||
private UserMigrationServiceLocator $locator,
|
||||
private IUserManager $userManager,
|
||||
private ISecureRandom $random,
|
||||
private IEventDispatcher $events,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws UserMigrationException when user_migration is not enabled, or the restore itself fails */
|
||||
public function restoreUser(Target $target, string $manifestPath, string $mode = self::MODE_REPLACE): IUser {
|
||||
$service = $this->locator->get();
|
||||
return $this->withSharedLock($target, function (callable $refresh) use ($target, $manifestPath, $mode, $service): IUser {
|
||||
$repo = $this->targets->repository($target);
|
||||
// Only ever read a manifest the verified catalog actually recorded: a path alone
|
||||
// (even one this app generated before) proves nothing about it still being current
|
||||
// or genuine once the catalog's rollback protection is supposed to vouch for it.
|
||||
if (!$repo->isRecordedUserExport($manifestPath)) {
|
||||
throw new \InvalidArgumentException("$manifestPath is not a recorded user export for this location");
|
||||
}
|
||||
$beat = LeaseService::throttled($refresh);
|
||||
$source = new RepositoryImportSource($repo, $manifestPath, $beat);
|
||||
$originalUid = $source->getOriginalUid();
|
||||
|
||||
if ($mode === self::MODE_AS_BACKUP) {
|
||||
$backupUid = $this->freeBackupUid($originalUid);
|
||||
$user = $this->userManager->createUser($backupUid, $this->random->generate(32));
|
||||
if ($user === false) {
|
||||
throw new \RuntimeException("Could not create the account $backupUid");
|
||||
}
|
||||
try {
|
||||
return $service->import($source, $user);
|
||||
} catch (\Throwable $e) {
|
||||
// A partially-imported, never-used account left behind is strictly worse
|
||||
// than no account at all; the original source account was never touched.
|
||||
$user->delete();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($mode !== self::MODE_REPLACE) {
|
||||
throw new \InvalidArgumentException("Unknown restore mode: $mode");
|
||||
}
|
||||
$existing = $this->userManager->get($originalUid);
|
||||
$safetyManifestPath = null;
|
||||
if ($existing !== null) {
|
||||
// Never import over a live account: export its current state first, so nothing
|
||||
// is lost if the restore turns out to be the wrong choice.
|
||||
$safety = new RepositoryExportDestination($repo, $originalUid, $beat);
|
||||
$service->export($safety, $existing);
|
||||
$safetyManifestPath = $safety->manifestPath();
|
||||
// Same race as UserBackupService::backupUser(): reopen the repository inside the
|
||||
// catalog lock so the write builds on the latest generation.
|
||||
$this->withCatalogLock($target, fn () => $this->targets->repository($target)->recordUserExport($safetyManifestPath));
|
||||
if (!$existing->delete()) {
|
||||
throw new \RuntimeException("Could not delete the existing account $originalUid before restoring; its safety export is at $safetyManifestPath");
|
||||
}
|
||||
// IRootFolder caches a Folder per uid for the lifetime of this process; without
|
||||
// this it would still hand the just-deleted account's (now storage-less) folder
|
||||
// to the import below, and every write would fail.
|
||||
$this->events->dispatchTyped(new FilesystemTornDownEvent());
|
||||
}
|
||||
try {
|
||||
return $service->import($source, null);
|
||||
} catch (\Throwable $e) {
|
||||
// The original account is already gone at this point (or never existed); a
|
||||
// failure here can only be reported, not undone in place. Its safety export
|
||||
// (if any) is the recovery path, already recorded above.
|
||||
throw new UserMigrationException(
|
||||
"Could not restore $originalUid" . ($safetyManifestPath !== null ? "; its safety export is at $safetyManifestPath" : '') . ': ' . $e->getMessage(),
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function freeBackupUid(string $uid): string {
|
||||
$candidate = $uid . '-bak';
|
||||
for ($n = 2; $this->userManager->userExists($candidate); $n++) {
|
||||
$candidate = $uid . '-bak-' . $n;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function withSharedLock(Target $target, callable $fn): mixed {
|
||||
try {
|
||||
// Refreshed while the safety export writes and the import reads (see UserBackupService::withSharedLock()).
|
||||
return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');
|
||||
}
|
||||
}
|
||||
|
||||
private function withCatalogLock(Target $target, callable $fn): mixed {
|
||||
try {
|
||||
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Lock\LockedException;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
$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();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ final class RepositoryExportDestination implements IExportDestination {
|
||||
public function __construct(
|
||||
private Repository $repo,
|
||||
private string $uid,
|
||||
/** Called while data is written (per chunk), to keep the location lease alive. */
|
||||
private ?\Closure $heartbeat = null,
|
||||
) {
|
||||
$this->exportId = gmdate('Ymd\THis\Z') . '-' . bin2hex(random_bytes(4));
|
||||
}
|
||||
@@ -36,6 +38,7 @@ final class RepositoryExportDestination implements IExportDestination {
|
||||
$writer = $this->repo->blobWriter($this->stats);
|
||||
$writer->write($content);
|
||||
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => strlen($content)];
|
||||
$this->beat();
|
||||
}
|
||||
|
||||
public function addFileAsStream(string $path, $stream): void {
|
||||
@@ -46,6 +49,7 @@ final class RepositoryExportDestination implements IExportDestination {
|
||||
throw new UserMigrationException('Read error while exporting ' . $path);
|
||||
}
|
||||
$writer->write($chunk);
|
||||
$this->beat();
|
||||
}
|
||||
$this->entries[self::norm($path)] = ['t' => 'f', 'b' => $writer->finish(), 's' => $writer->bytes()];
|
||||
}
|
||||
@@ -65,8 +69,11 @@ final class RepositoryExportDestination implements IExportDestination {
|
||||
if ($stream === false) {
|
||||
throw new \RuntimeException('Cannot read ' . $node->getPath());
|
||||
}
|
||||
$this->addFileAsStream($target, $stream);
|
||||
fclose($stream);
|
||||
try {
|
||||
$this->addFileAsStream($target, $stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +96,12 @@ final class RepositoryExportDestination implements IExportDestination {
|
||||
return $this->stats;
|
||||
}
|
||||
|
||||
private function beat(): void {
|
||||
if ($this->heartbeat !== null) {
|
||||
($this->heartbeat)();
|
||||
}
|
||||
}
|
||||
|
||||
private static function norm(string $path): string {
|
||||
return trim($path, '/');
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ final class RepositoryImportSource implements IImportSource {
|
||||
public function __construct(
|
||||
private Repository $repo,
|
||||
string $manifestPath,
|
||||
/** Called while blobs are read, to keep the location lease alive. */
|
||||
private ?\Closure $heartbeat = null,
|
||||
) {
|
||||
$this->manifest = json_decode($repo->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -40,6 +42,9 @@ final class RepositoryImportSource implements IImportSource {
|
||||
throw new UserMigrationException('Cannot buffer ' . $path);
|
||||
}
|
||||
foreach ($this->repo->readBlobs($entry['b']) as $data) {
|
||||
if ($this->heartbeat !== null) {
|
||||
($this->heartbeat)();
|
||||
}
|
||||
for ($done = 0, $len = strlen($data); $done < $len; $done += $n) {
|
||||
$n = fwrite($stream, $done === 0 ? $data : substr($data, $done));
|
||||
if ($n === false || $n === 0) {
|
||||
@@ -109,7 +114,14 @@ final class RepositoryImportSource implements IImportSource {
|
||||
$destination->newFile($name, $stream);
|
||||
}
|
||||
} finally {
|
||||
fclose($stream);
|
||||
// putContent()/newFile() already close the stream once they've consumed it:
|
||||
// despite the @return resource docblock above, $stream is no longer a
|
||||
// resource by the time we get here (verified against a real storage; a
|
||||
// double fclose() throws a TypeError on PHP 8).
|
||||
/** @psalm-suppress RedundantConditionGivenDocblockType */
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\UserMigration;
|
||||
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\App\IAppManager;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
|
||||
/**
|
||||
* Installs or enables the optional user_migration app, but only when the administrator asked for it
|
||||
* (occ prompt or --install-user-migration). Runs occ in a child process, as the admin would.
|
||||
*/
|
||||
final class UserMigrationInstaller {
|
||||
private const APP = 'user_migration';
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
return $this->appManager->isEnabledForAnyone(self::APP);
|
||||
}
|
||||
|
||||
private function codeIsPresent(): bool {
|
||||
try {
|
||||
$this->appManager->getAppPath(self::APP);
|
||||
return true;
|
||||
} catch (AppPathNotFoundException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs (from the app store, if missing) and enables user_migration; throws on failure. */
|
||||
public function install(): void {
|
||||
$args = $this->codeIsPresent() ? ['app:enable', self::APP] : ['app:install', self::APP];
|
||||
/** @psalm-suppress UndefinedClass */
|
||||
$occ = \OC::$SERVERROOT . '/occ';
|
||||
$command = implode(' ', array_map('escapeshellarg', [PHP_BINARY, $occ, ...$args])) . ' 2>&1';
|
||||
exec($command, $lines, $code);
|
||||
if ($code !== 0) {
|
||||
throw new \RuntimeException("occ " . implode(' ', $args) . " failed:\n" . implode("\n", $lines));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns when user_migration is enabled. Otherwise asks the administrator (interactive shell) or
|
||||
* requires --install-user-migration, and throws when neither gives consent.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function ensureEnabled(InputInterface $input, OutputInterface $output): void {
|
||||
if ($this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
$consent = $input->getOption('install-user-migration') === true;
|
||||
if (!$consent && $input->isInteractive()) {
|
||||
$consent = (new QuestionHelper())->ask($input, $output, new ConfirmationQuestion(
|
||||
'The user_migration app is not enabled. Install and enable it now? [y/N] ', false)) === true;
|
||||
}
|
||||
if (!$consent) {
|
||||
throw new \RuntimeException('The user_migration app is not enabled; per-user backup/restore needs it. Run "occ '
|
||||
. 'app:install ' . self::APP . '" and "occ app:enable ' . self::APP . '", or repeat with --install-user-migration');
|
||||
}
|
||||
$output->writeln('Installing and enabling user_migration (approved by the administrator)...');
|
||||
$this->install();
|
||||
throw new \RuntimeException('user_migration is installed and enabled; run this command again');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\UserMigration;
|
||||
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\Server;
|
||||
use OCP\UserMigration\UserMigrationException;
|
||||
|
||||
/**
|
||||
* Resolves user_migration's own (internal, non-public) UserMigrationService on demand.
|
||||
*
|
||||
* Unlike files_external, user_migration is an optional dependency: without it, only per-user
|
||||
* backup/restore is unavailable, the full backup still works. The class is never autoloaded by
|
||||
* composer; everything that touches it is behind this one class.
|
||||
*/
|
||||
final class UserMigrationServiceLocator {
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @throws UserMigrationException when user_migration is not enabled anywhere */
|
||||
public function get(): object {
|
||||
if (!$this->appManager->isEnabledForAnyone('user_migration')) {
|
||||
throw new UserMigrationException('The user_migration app is not enabled; per-user backup/restore is unavailable');
|
||||
}
|
||||
$this->appManager->loadApp('user_migration');
|
||||
return Server::get(\OCA\UserMigration\Service\UserMigrationService::class);
|
||||
}
|
||||
}
|
||||
+46
-45
@@ -49,6 +49,11 @@
|
||||
<code><![CDATA[ask]]></code>
|
||||
</UndefinedInterfaceMethod>
|
||||
</file>
|
||||
<file src="lib/Command/KeyImport.php">
|
||||
<UndefinedInterfaceMethod>
|
||||
<code><![CDATA[ask]]></code>
|
||||
</UndefinedInterfaceMethod>
|
||||
</file>
|
||||
<file src="lib/Command/KeyInit.php">
|
||||
<UndefinedInterfaceMethod>
|
||||
<code><![CDATA[ask]]></code>
|
||||
@@ -105,11 +110,6 @@
|
||||
<code><![CDATA[ask]]></code>
|
||||
</UndefinedInterfaceMethod>
|
||||
</file>
|
||||
<file src="lib/Command/Trash.php">
|
||||
<RedundantCastGivenDocblockType>
|
||||
<code><![CDATA[(string)($info['time'] ?? '')]]></code>
|
||||
</RedundantCastGivenDocblockType>
|
||||
</file>
|
||||
<file src="lib/Controller/AdminApiController.php">
|
||||
<InvalidOperand>
|
||||
<code><![CDATA[$maxGb * 1073741824]]></code>
|
||||
@@ -148,10 +148,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>
|
||||
@@ -184,6 +180,13 @@
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[$this->db]]></code>
|
||||
<code><![CDATA[TransactionIsolationLevel]]></code>
|
||||
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
||||
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
|
||||
@@ -191,6 +194,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>
|
||||
@@ -213,21 +221,11 @@
|
||||
</PropertyNotSetInConstructor>
|
||||
</file>
|
||||
<file src="lib/Job/BackupRun.php">
|
||||
<ArgumentTypeCoercion>
|
||||
<code><![CDATA[$blobs]]></code>
|
||||
</ArgumentTypeCoercion>
|
||||
<MissingParamType>
|
||||
<code><![CDATA[$fh]]></code>
|
||||
</MissingParamType>
|
||||
<PossiblyNullIterator>
|
||||
<code><![CDATA[$segments]]></code>
|
||||
</PossiblyNullIterator>
|
||||
<PossiblyUndefinedArrayOffset>
|
||||
<code><![CDATA[$mtime]]></code>
|
||||
<code><![CDATA[$state['cur']]]></code>
|
||||
<code><![CDATA[$state['phase']]]></code>
|
||||
<code><![CDATA[$state['segments']]]></code>
|
||||
<code><![CDATA[$state['stats']]]></code>
|
||||
</PossiblyUndefinedArrayOffset>
|
||||
</file>
|
||||
<file src="lib/Job/ParentCursor.php">
|
||||
@@ -302,6 +300,22 @@
|
||||
<code><![CDATA[$t]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
</file>
|
||||
<file src="lib/Migration/Version000008Date20261107010000.php">
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
</file>
|
||||
<file src="lib/Migration/Version000007Date20261107000000.php">
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
</file>
|
||||
<file src="lib/Migration/Version000005Date20261002040000.php">
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[$t]]></code>
|
||||
@@ -353,7 +367,6 @@
|
||||
<code><![CDATA[$encrypted]]></code>
|
||||
<code><![CDATA[$encrypted]]></code>
|
||||
<code><![CDATA[$encrypted]]></code>
|
||||
<code><![CDATA[$fh]]></code>
|
||||
<code><![CDATA[$plain]]></code>
|
||||
<code><![CDATA[$plain]]></code>
|
||||
<code><![CDATA[$plain]]></code>
|
||||
@@ -395,7 +408,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>
|
||||
@@ -409,24 +422,6 @@
|
||||
<ArgumentTypeCoercion>
|
||||
<code><![CDATA[array_diff(range(1, self::MAX_SLOTS), $used)]]></code>
|
||||
</ArgumentTypeCoercion>
|
||||
<InvalidReturnStatement>
|
||||
<code><![CDATA[[
|
||||
'version' => 1,
|
||||
'app' => 'NG Backup',
|
||||
'fingerprint' => $this->fingerprint(),
|
||||
'kit_version' => $this->kitVersion(),
|
||||
'confirmation_code' => $this->kitCode(),
|
||||
'created' => gmdate('c'),
|
||||
'wrapped_key' => $this->wrappedKey(),
|
||||
'slots' => $this->slots(),
|
||||
'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. '
|
||||
. 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. '
|
||||
. 'Without the kit (or the location) and a passphrase, the backups cannot be decrypted.',
|
||||
]]]></code>
|
||||
</InvalidReturnStatement>
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string}]]></code>
|
||||
</InvalidReturnType>
|
||||
<LessSpecificReturnStatement>
|
||||
<code><![CDATA[array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots'])]]></code>
|
||||
</LessSpecificReturnStatement>
|
||||
@@ -455,6 +450,18 @@
|
||||
<code><![CDATA[array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}]]></code>
|
||||
</MoreSpecificReturnType>
|
||||
</file>
|
||||
<file src="lib/Service/RestoreFullService.php">
|
||||
<EmptyArrayAccess>
|
||||
<code><![CDATA[$backupConfig[$key]]]></code>
|
||||
</EmptyArrayAccess>
|
||||
<UndefinedInterfaceMethod>
|
||||
<code><![CDATA[getInner]]></code>
|
||||
<code><![CDATA[getInner]]></code>
|
||||
</UndefinedInterfaceMethod>
|
||||
<UnresolvableInclude>
|
||||
<code><![CDATA[include $path]]></code>
|
||||
</UnresolvableInclude>
|
||||
</file>
|
||||
<file src="lib/Service/RestoreService.php">
|
||||
<LessSpecificReturnStatement>
|
||||
<code><![CDATA[array_values($children)]]></code>
|
||||
@@ -481,12 +488,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>
|
||||
|
||||
@@ -31,8 +31,17 @@
|
||||
<errorLevel type="suppress">
|
||||
<!-- Provided by files_external at runtime; not a composer dependency (PLAN.md 3.5). -->
|
||||
<referencedClass name="OCA\Files_External\**" />
|
||||
<!-- Provided by user_migration at runtime, an optional dependency (PLAN.md 3.8). -->
|
||||
<referencedClass name="OCA\UserMigration\**" />
|
||||
</errorLevel>
|
||||
</UndefinedClass>
|
||||
<UndefinedDocblockClass>
|
||||
<errorLevel type="suppress">
|
||||
<!-- Server::get()'s templated return type resolving the users_migration class-string
|
||||
constant below; same reason as the UndefinedClass suppression above. -->
|
||||
<referencedClass name="OCA\UserMigration\**" />
|
||||
</errorLevel>
|
||||
</UndefinedDocblockClass>
|
||||
<!-- The #[\Override] attribute convention (PHP 8.3+) adds no behaviour; this app supports
|
||||
PHP 8.1+, and applying it to every interface-implementing method project-wide is pure
|
||||
ceremony with no bug-catching value, so it's suppressed rather than papered over. -->
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
+98
-8
@@ -6,23 +6,48 @@
|
||||
<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', 'We would like to hear how NG Backup 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>
|
||||
<NcNoteCard v-if="status && !status.cron.ok" type="warning">
|
||||
<p>
|
||||
{{ status.cron.stopped
|
||||
? t('ng_backup', 'Background jobs are set to system cron, but cron has not run since {time}.', { time: lastCron })
|
||||
: t('ng_backup', 'Background jobs do not run on a system cron (they are set to "{mode}").', { mode: status.cron.mode === 'webcron' ? 'Webcron' : 'AJAX' }) }}
|
||||
{{ t('ng_backup', 'Backups need a system cron that runs every 5 minutes, also the scheduled ones.') }}
|
||||
<a :href="status.cron.docs" target="_blank" rel="noopener noreferrer">{{ t('ng_backup', 'How to set up cron ↗') }}</a>
|
||||
</p>
|
||||
<p>{{ t('ng_backup', 'Until then, a backup started with "Back up now" only moves on while this page is open.') }}</p>
|
||||
</NcNoteCard>
|
||||
</NcSettingsSection>
|
||||
|
||||
<template v-if="status">
|
||||
<div class="ngb-tabs">
|
||||
<NcButton :variant="tab === 'backup' ? 'primary' : 'secondary'" @click="tab = 'backup'">{{ t('ng_backup', 'Backup and restore') }}</NcButton>
|
||||
<NcButton v-if="status.key.everConfirmed && status.targets.length" :variant="tab === 'bin' ? 'primary' : 'secondary'" @click="tab = 'bin'">{{ t('ng_backup', 'Bin') }}</NcButton>
|
||||
<NcButton :variant="tab === 'settings' ? 'primary' : 'secondary'" @click="tab = 'settings'">{{ t('ng_backup', 'Settings') }}</NcButton>
|
||||
<NcButton :variant="tab === 'docs' ? 'primary' : 'secondary'" @click="tab = 'docs'">{{ t('ng_backup', 'Docs') }}</NcButton>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'settings'">
|
||||
<NcSettingsSection v-if="tab === 'docs'" :name="t('ng_backup', 'Documentation')">
|
||||
<ul class="ngb-docs">
|
||||
<li v-for="doc in docs" :key="doc.url">
|
||||
<a :href="doc.url" target="_blank" rel="noopener noreferrer">{{ doc.name }} ↗</a>
|
||||
<span>{{ doc.description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</NcSettingsSection>
|
||||
<Bin v-else-if="tab === 'bin' && status.key.everConfirmed && status.targets.length" :status="status" @changed="refresh" />
|
||||
<template v-else-if="tab === 'settings'">
|
||||
<KeySetup :status="status" @changed="refresh" />
|
||||
<Locations v-if="status.key.everConfirmed" :status="status" @changed="refresh" />
|
||||
</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>
|
||||
@@ -33,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
@@ -41,19 +66,48 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import api from './api.js'
|
||||
import Backups from './components/Backups.vue'
|
||||
import Bin from './components/Bin.vue'
|
||||
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 repoUrl = 'https://github.com/dillardblom/ng_backup'
|
||||
const docs = [
|
||||
{
|
||||
name: t('ng_backup', 'How-to'),
|
||||
description: t('ng_backup', 'Step by step: from first install to a tested restore.'),
|
||||
url: repoUrl + '/blob/main/docs/HOWTO.md',
|
||||
},
|
||||
{
|
||||
name: t('ng_backup', 'Disaster recovery'),
|
||||
description: t('ng_backup', 'Your server is gone: get Nextcloud back on a new server from a backup location.'),
|
||||
url: repoUrl + '/blob/main/docs/DISASTER-RECOVERY.md',
|
||||
},
|
||||
{
|
||||
name: t('ng_backup', 'Roadmap'),
|
||||
description: t('ng_backup', 'What is planned for the next versions.'),
|
||||
url: repoUrl + '#roadmap',
|
||||
},
|
||||
]
|
||||
|
||||
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.
|
||||
@@ -64,24 +118,60 @@ async function refresh() {
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
// Poll while something runs, so progress shows up without reloading.
|
||||
// Poll while something runs, so progress shows up without reloading. Without a working
|
||||
// system cron, each poll also lets a running backup take a short step (see tick()).
|
||||
clearTimeout(timer)
|
||||
if (status.value?.runs.some(r => r.status === 'running')) {
|
||||
timer = setTimeout(refresh, 4000)
|
||||
timer = setTimeout(tickAndRefresh, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
async function tickAndRefresh() {
|
||||
if (status.value && !status.value.cron.ok && status.value.runs.some(r => r.status === 'running' && r.kind === 'full')) {
|
||||
try {
|
||||
await api.tick()
|
||||
} catch (e) {
|
||||
// The next poll tries again; the error of a failed step shows on its run.
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const lastCron = computed(() => status.value?.cron.lastRun ? new Date(status.value.cron.lastRun * 1000).toLocaleString() : '')
|
||||
|
||||
onMounted(refresh)
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* NcSettingsSection sets its own width (at most 900px): the runs table needs more */
|
||||
.ng-backup :deep(.settings-section) {
|
||||
max-width: 900px;
|
||||
width: min(1400px, 100% - var(--default-grid-baseline) * 7 * 2);
|
||||
max-width: none;
|
||||
}
|
||||
.ngb-feedback {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ngb-feedback a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ngb-docs li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ngb-docs a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: underline;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.ngb-docs span {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
.ngb-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
/* Same inline margin as NcSettingsSection, so the tabs line up with the section content */
|
||||
margin: 0 calc(var(--default-grid-baseline) * 7) 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+9
-3
@@ -16,23 +16,29 @@ 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 }),
|
||||
removeSlot: (slot) => call('delete', `/key/slots/${slot}`),
|
||||
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
|
||||
trash: (targetId, after = '') => call('get', `/targets/${targetId}/trash`, { after }),
|
||||
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
|
||||
kitUrl: () => url('/key/kit'),
|
||||
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 }),
|
||||
startBackup: (targetId, label) => call('post', '/backups', { targetId, label }),
|
||||
setSchedule: (time) => call('put', '/schedule', { time }),
|
||||
tick: () => call('post', '/tick'),
|
||||
setPauseHeavyJobs: (enabled) => call('put', '/settings/pause-heavy-jobs', { enabled }),
|
||||
setTargetSchedule: (id, schedule, timezone) => call('put', `/targets/${id}/schedule`, { schedule, timezone }),
|
||||
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
||||
setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }),
|
||||
setSnapshotPinned: (targetId, snapshotId, pinned) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/pin`, { pinned }),
|
||||
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
||||
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
||||
}
|
||||
|
||||
+87
-80
@@ -5,117 +5,122 @@
|
||||
<template>
|
||||
<NcSettingsSection :name="t('ng_backup', 'Backups')"
|
||||
:description="t('ng_backup', 'Backups run in the background through Nextcloud\'s cron. Unchanged files are not read again; only new data is uploaded.')">
|
||||
<LocationScheduleRow v-for="target in status.targets" :key="target.id" :target="target" @changed="emit('changed')" @error="error = $event" />
|
||||
<NcCheckboxRadioSwitch :model-value="status.heavyJobs.enabled" @update:model-value="savePause">
|
||||
{{ t('ng_backup', 'Pause heavy background jobs (Memories, Recognize, Preview Generator, full text search, antivirus scan) while a backup runs, at most 6 hours in a row') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p v-if="status.heavyJobs.jobs.length" class="ngb-muted">{{ t('ng_backup', 'Paused now: {jobs}', { jobs: status.heavyJobs.jobs.join(', ') }) }}</p>
|
||||
<div class="ngb-row">
|
||||
<NcTextField v-model="schedule" class="ngb-time" :label="t('ng_backup', 'Daily at (HH:MM, empty = off)')" />
|
||||
<NcButton :disabled="schedule === status.schedule" @click="saveSchedule">{{ t('ng_backup', 'Save') }}</NcButton>
|
||||
</div>
|
||||
<div class="ngb-row">
|
||||
<NcTextField v-model="label" class="ngb-label" :maxlength="100" :label="t('ng_backup', 'Label (optional)')"
|
||||
:placeholder="t('ng_backup', 'e.g. before the upgrade to Nextcloud 35')" />
|
||||
<NcButton v-for="target in status.targets" :key="target.id" :disabled="isRunning(target.id)" @click="start(target)">
|
||||
{{ isRunning(target.id) ? t('ng_backup', 'Backup to {name} running …', { name: target.name }) : t('ng_backup', 'Back up now to {name}', { name: target.name }) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
|
||||
|
||||
<template v-for="target in status.targets" :key="'trash' + target.id">
|
||||
<div v-if="trash[target.id]?.length" class="ngb-trash">
|
||||
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
|
||||
<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>
|
||||
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-if="status.runs.length" class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||
<th>{{ t('ng_backup', 'Files') }}</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>{{ 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.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="status.runs.length" class="ngb-table-wrap">
|
||||
<table class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||
<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>{{ formatDateTime(run.startedAt) }}</td>
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td class="ngb-point">{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td class="ngb-num">{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td class="ngb-num">{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
|
||||
<td class="ngb-num">{{ 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 { ref } from 'vue'
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import LocationScheduleRow from './LocationScheduleRow.vue'
|
||||
import api from '../api.js'
|
||||
import { formatSize } from '../format.js'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import { reactive } from 'vue'
|
||||
import { formatDateTime, formatSize } from '../format.js'
|
||||
|
||||
const props = defineProps({ status: { type: Object, required: true } })
|
||||
const emit = defineEmits(['changed'])
|
||||
const schedule = ref(props.status.schedule)
|
||||
const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['changed', 'show-all-runs'])
|
||||
const error = ref('')
|
||||
watch(() => props.status.schedule, (s) => { schedule.value = s })
|
||||
|
||||
const trash = reactive({})
|
||||
async function loadTrash() {
|
||||
for (const target of props.status.targets) {
|
||||
try {
|
||||
trash[target.id] = await api.trash(target.id)
|
||||
} catch (e) {
|
||||
trash[target.id] = []
|
||||
}
|
||||
const label = ref('')
|
||||
|
||||
// ": 47/183 tables" in the database phase; " 68%", or " ~68%" when the total is estimated from
|
||||
// Nextcloud's file cache (first backup)
|
||||
const percent = (run) => {
|
||||
if (!run.progress) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
|
||||
|
||||
async function untrash(target, item) {
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.untrash(target.id, item.id)
|
||||
await loadTrash()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e?.message || ''
|
||||
if (run.progress.totalTables) {
|
||||
return ': ' + t('ng_backup', '{done}/{total} tables', { done: run.progress.tables, total: run.progress.totalTables })
|
||||
}
|
||||
return ' ' + (run.progress.approximate ? '~' : '') + Math.floor(run.progress.fraction * 100) + '%'
|
||||
}
|
||||
// "running (db: 47/183 tables)", or "waiting for background jobs (…, last step 12 min ago)"
|
||||
const runStatus = (run) => {
|
||||
if (run.status !== 'running') {
|
||||
return run.status
|
||||
}
|
||||
if (run.queued) {
|
||||
return t('ng_backup', 'queued, after the backup that is running now')
|
||||
}
|
||||
const detail = run.phase + percent(run)
|
||||
if (run.waitingFor) {
|
||||
return t('ng_backup', 'waiting for background jobs ({detail}, last step {minutes} min ago)', { detail, minutes: Math.floor(run.waitingFor / 60) })
|
||||
}
|
||||
return run.status + ' (' + detail + ')'
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
async function start(target) {
|
||||
async function savePause(enabled) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.startBackup(target.id, '')
|
||||
await api.setPauseHeavyJobs(enabled)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
async function start(target) {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.setSchedule(schedule.value.trim())
|
||||
await api.startBackup(target.id, label.value.trim())
|
||||
label.value = ''
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
@@ -125,12 +130,14 @@ async function saveSchedule() {
|
||||
|
||||
<style scoped>
|
||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-time { max-width: 260px; }
|
||||
.ngb-label { max-width: 360px; }
|
||||
.ngb-table-wrap { max-width: 90%; overflow-x: auto; } /* scrolls only on a really narrow screen */
|
||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); white-space: normal; overflow-wrap: break-word; }
|
||||
/* Text wraps; numbers stay on one line, the restore point keeps room for its label and note */
|
||||
.ngb-table td.ngb-num { white-space: nowrap; }
|
||||
.ngb-table td.ngb-point { min-width: 9em; }
|
||||
.ngb-failed { color: var(--color-error-text); }
|
||||
.ngb-muted { color: var(--color-text-maxcontrast); }
|
||||
.ngb-trash { margin: 12px 0; }
|
||||
.ngb-trash h3 { font-weight: bold; }
|
||||
.ngb-done { color: var(--color-success-text); }
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user