mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 02:03:01 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e77ff2b7 | ||
|
|
3517d9d4f3 | ||
|
|
d3c7279dad | ||
|
|
a99c48c115 | ||
|
|
94cedd4cf8 | ||
|
|
a4baf4623b | ||
|
|
77df425bd1 | ||
|
|
69476a4fab | ||
|
|
be6b865a81 | ||
|
|
b58a52e03f | ||
|
|
9b85c5bc61 | ||
|
|
b8041f5c39 | ||
|
|
d9d6d3f7f2 | ||
|
|
0844cd0d4f | ||
|
|
d4705602fa | ||
|
|
5174a78109 | ||
|
|
3db79b4352 | ||
|
|
d6732d9abb | ||
|
|
267af5b372 | ||
|
|
d53ea09adb | ||
|
|
8696cbfa22 | ||
|
|
48dca28c74 | ||
|
|
36bcbd0592 | ||
|
|
ebb3bca4b3 | ||
|
|
b1e343f15c | ||
|
|
cb7f98192c | ||
|
|
3ceb4f554f | ||
|
|
4b18c0bc81 | ||
|
|
2ce09dbf82 | ||
|
|
8a4f3b8f1a | ||
|
|
81cd03cbbf | ||
|
|
f2455527fc | ||
|
|
62d67cde0a | ||
|
|
ae8a9db27d | ||
|
|
8979f8918d | ||
|
|
eb97708092 | ||
|
|
f2d93b85b4 | ||
|
|
b999d2dc4d | ||
|
|
7f2e38d061 | ||
|
|
140fc248b6 | ||
|
|
ec9e1a8223 | ||
|
|
03bbe77dd9 | ||
|
|
f1c5f11422 | ||
|
|
f8df06fb92 | ||
|
|
d95707ff9b | ||
|
|
51a7f50e3a | ||
|
|
49b8b693af | ||
|
|
d0e92493f6 | ||
|
|
9afdaca985 | ||
|
|
cc11ed78e0 | ||
|
|
87f3746881 | ||
|
|
347a4c3dd5 | ||
|
|
399bb6ef68 | ||
|
|
9b9ecad299 | ||
|
|
8c4b899a13 | ||
|
|
9b77de3d66 | ||
|
|
bfeea5d394 | ||
|
|
8a6225b7c2 | ||
|
|
9aaa3c925f | ||
|
|
88fd1ae454 | ||
|
|
27305ec2bf | ||
|
|
4453c2d49c | ||
|
|
6367a00013 | ||
|
|
cd654cbb57 | ||
|
|
1e8f73779f | ||
|
|
27d167b071 | ||
|
|
cfff6c6855 | ||
|
|
37efaeae88 | ||
|
|
0978c669ad | ||
|
|
1366269586 | ||
|
|
a9a0910817 | ||
|
|
5bcc7b60c8 | ||
|
|
84a0552277 | ||
|
|
d4a02f73b5 | ||
|
|
3f901c0a52 | ||
|
|
b5b5c1fafa | ||
|
|
86e5085acc | ||
|
|
08a5e8717b | ||
|
|
6b2f2b2ac4 | ||
|
|
a07cf9e699 | ||
|
|
bf40b01077 | ||
|
|
a5c6a2fe1c | ||
|
|
82141fe981 | ||
|
|
228a83978d | ||
|
|
638db3770b | ||
|
|
98df5c3af2 | ||
|
|
b0e906c0e7 | ||
|
|
e8dccbf1c1 | ||
|
|
4a997bc234 | ||
|
|
3197178b3d | ||
|
|
5e618154d0 | ||
|
|
84f611ae4f | ||
|
|
5dc8450c8e | ||
|
|
689643e5fa | ||
|
|
0a3d87eaea | ||
|
|
b45b62cd38 | ||
|
|
8de7094691 | ||
|
|
8c7e68305e | ||
|
|
65a323433c | ||
|
|
b5a3589471 | ||
|
|
f4a736bdfe | ||
|
|
eab0ec15ef | ||
|
|
c65aa24001 | ||
|
|
5a24bf2037 | ||
|
|
324dbc3a79 | ||
|
|
9fe7db320a | ||
|
|
4d19596616 | ||
|
|
5cec2bf3d9 | ||
|
|
06e0f98fd8 | ||
|
|
87f36caf8d | ||
|
|
ab7acceff6 | ||
|
|
1b2b0c3020 | ||
|
|
289d178581 | ||
|
|
1e7f6d9f41 | ||
|
|
d0c90389fb | ||
|
|
f9e920dce9 | ||
|
|
0ed52bbc4a | ||
|
|
da8278b566 | ||
|
|
2af3522902 | ||
|
|
5e4784991a | ||
|
|
ab43ef00ce | ||
|
|
47a8a95b29 | ||
|
|
7c90c04ce0 | ||
|
|
97305cc3ce | ||
|
|
4985b805b4 | ||
|
|
d09b4c72a9 | ||
|
|
9807549f88 | ||
|
|
30c821120e | ||
|
|
13884bd448 | ||
|
|
6bce4c4a0d | ||
|
|
25572c98d7 | ||
|
|
dab0dfcb32 | ||
|
|
851c454ef0 | ||
|
|
c7a0cebaf7 | ||
|
|
76cfeda290 | ||
|
|
afdf831c59 | ||
|
|
9ac3087304 | ||
|
|
7cca83b698 | ||
|
|
4b5df7117a | ||
|
|
57decfa4db | ||
|
|
b80f60a731 | ||
|
|
8f5ea95348 | ||
|
|
b0cad58d6c | ||
|
|
073d6bddf6 | ||
|
|
810b65589f | ||
|
|
295bfb0c57 | ||
|
|
5f3d4f9b03 | ||
|
|
5321301708 | ||
|
|
a939a66fb4 | ||
|
|
c0721a8cad | ||
|
|
ea47704d86 | ||
|
|
61e4eeff6c | ||
|
|
3ab4b45041 | ||
|
|
4e1f256b48 | ||
|
|
96bb402837 | ||
|
|
97949266b3 | ||
|
|
e69d2385fc | ||
|
|
6d9340ebb2 | ||
|
|
0441e79b41 | ||
|
|
b1af304125 | ||
|
|
eb8f7e0329 | ||
|
|
bf978f2db4 | ||
|
|
22776b123d | ||
|
|
ef66349674 | ||
|
|
51b885e7db | ||
|
|
1781787305 | ||
|
|
46ebb0cebb | ||
|
|
3e0fa57860 | ||
|
|
59f8722e05 | ||
|
|
4ba42e8905 | ||
|
|
3b79482b24 | ||
|
|
7eb19cb0a7 | ||
|
|
a4fabb8521 | ||
|
|
85ea8f4f45 | ||
|
|
290559116d | ||
|
|
72b27b0858 | ||
|
|
0fdee067c7 | ||
|
|
0dca5eeafc | ||
|
|
02ce3ba190 | ||
|
|
dc78bf4d6b | ||
|
|
4b7fbce291 | ||
|
|
1817b9a9ea | ||
|
|
009055c61a | ||
|
|
54884da8fa | ||
|
|
1177385e08 | ||
|
|
a45ba8553c | ||
|
|
d7d6e30178 | ||
|
|
56304fdcad | ||
|
|
3f75e9931f | ||
|
|
227f475e17 | ||
|
|
467ddd0e93 | ||
|
|
be08e889f0 | ||
|
|
94c8a56373 | ||
|
|
cecf04aa69 | ||
|
|
814cdb4b87 | ||
|
|
13878be254 | ||
|
|
94db527500 | ||
|
|
2849f54932 | ||
|
|
129f3e753c | ||
|
|
c85bf46ad9 | ||
|
|
fa6a4734d4 | ||
|
|
e52e29444f | ||
|
|
f713f1df7e | ||
|
|
87d824553d | ||
|
|
52fbf8cb24 | ||
|
|
f758374772 | ||
|
|
60c5949c23 | ||
|
|
d11c517f67 | ||
|
|
237999cc81 | ||
|
|
d060f8d77a | ||
|
|
385ef2d012 | ||
|
|
02a2e77315 | ||
|
|
9b623a8a8e | ||
|
|
e1f60e4b09 | ||
|
|
269c00b240 | ||
|
|
48f008d720 | ||
|
|
53d9ffd1d3 | ||
|
|
d7323e08ac | ||
|
|
fc7a2852e0 | ||
|
|
f8c9d985e8 | ||
|
|
c7ca9bf844 | ||
|
|
7a117d5cc9 | ||
|
|
bd9751586c | ||
|
|
fe0fe27c36 | ||
|
|
1426859e1c | ||
|
|
ffb431e3ab | ||
|
|
11fee81486 | ||
|
|
83bc737185 | ||
|
|
cda83310c8 | ||
|
|
90ccbecf07 | ||
|
|
ccbf668bea | ||
|
|
f18219a768 | ||
|
|
9ba8ca24eb | ||
|
|
b42793a2dc | ||
|
|
2877c1ad0d | ||
|
|
1b9f95ca47 | ||
|
|
7aff22536d | ||
|
|
d6e1cc3e12 | ||
|
|
c1a08edca2 | ||
|
|
02a219fac2 | ||
|
|
78f81c7b73 | ||
|
|
8edb40a8e9 | ||
|
|
f2d7687ca3 | ||
|
|
b5fb0c8247 | ||
|
|
9b2ac961d7 | ||
|
|
38ce98771b | ||
|
|
f616284ffb | ||
|
|
5da898003f | ||
|
|
5a464b3186 | ||
|
|
42fb8c38e0 | ||
|
|
80839566d6 | ||
|
|
702e55e6f7 | ||
|
|
1a8e8835c1 | ||
|
|
9abc5c60d4 | ||
|
|
fc87b74ab0 | ||
|
|
ad21eb41ae | ||
|
|
601e393ec7 | ||
|
|
e391fd196d | ||
|
|
5f387b3991 | ||
|
|
ed957a940a | ||
|
|
bd4c672382 | ||
|
|
c71da46963 | ||
|
|
3da7471fe6 | ||
|
|
b874e7e66f | ||
|
|
b029d1cb67 | ||
|
|
33b1101ce1 | ||
|
|
d96c5f79fb | ||
|
|
9e29ce788f | ||
|
|
4658c5d1cb | ||
|
|
5280de86ff | ||
|
|
29d5b36a78 | ||
|
|
29f214a269 | ||
|
|
6fdce2a4a6 | ||
|
|
a4b65cf710 | ||
|
|
79725d2ff7 | ||
|
|
f7e8a2c1d1 | ||
|
|
f54d566edc | ||
|
|
b7efa0d3f0 | ||
|
|
88b945dcb9 | ||
|
|
34305d686c | ||
|
|
bf1e8bc44e | ||
|
|
fd4f69f6c3 | ||
|
|
1fe6ae83a8 | ||
|
|
1197d6d0f6 | ||
|
|
288a4bf243 | ||
|
|
1fb943e5f1 | ||
|
|
e0298685a1 | ||
|
|
c5633227bf | ||
|
|
6087c12e09 | ||
|
|
9d83f02e24 | ||
|
|
b6ccde6757 | ||
|
|
548aceb3d5 | ||
|
|
2bd63bbdd2 | ||
|
|
9b4fed64a6 | ||
|
|
ff1001a5f5 | ||
|
|
e7f1c3eda5 | ||
|
|
21e343a948 | ||
|
|
4c4fd92013 | ||
|
|
37735e464c | ||
|
|
424cc6b66c | ||
|
|
e128a3e0a9 | ||
|
|
27e7ece2f5 | ||
|
|
d44dc93509 | ||
|
|
31778fd3bf | ||
|
|
4313f90dd8 | ||
|
|
1f82ea2798 | ||
|
|
148536d867 | ||
|
|
197f0a521d | ||
|
|
0d8e352033 | ||
|
|
20a3995977 | ||
|
|
66aa953371 | ||
|
|
ba053de8f7 | ||
|
|
2f844d65d5 | ||
|
|
2dca5e1834 | ||
|
|
36197ce027 | ||
|
|
e9a0226ee0 | ||
|
|
fc3b4a653e | ||
|
|
3673abb01e | ||
|
|
ac4277d36c | ||
|
|
21cbc99d9e | ||
|
|
d080bf2ae9 | ||
|
|
2a1c790655 | ||
|
|
410204a70d | ||
|
|
4a0c167c1c | ||
|
|
593c956d33 | ||
|
|
d18cb89493 | ||
|
|
067c79c606 | ||
|
|
ebde88ccaa | ||
|
|
cc402487d9 | ||
|
|
d108e6102b | ||
|
|
3e60043632 | ||
|
|
a8d691169a | ||
|
|
939c2f6718 | ||
|
|
0837059e21 | ||
|
|
0ee166fdf0 | ||
|
|
b50996b864 | ||
|
|
8f423c7293 | ||
|
|
14ce88e04b | ||
|
|
f97968b72d | ||
|
|
612f867ea8 | ||
|
|
303d6609e4 | ||
|
|
bf7b163ccd | ||
|
|
4bd798f0ad | ||
|
|
52aa7c5d21 | ||
|
|
f5a1dd31c8 | ||
|
|
c41000a4b1 | ||
|
|
c3f8b05a68 | ||
|
|
f4fcf92bd6 | ||
|
|
a2c139245d | ||
|
|
a509cdedd5 | ||
|
|
dcbc30b164 | ||
|
|
5ab99b4cc0 | ||
|
|
27c90b7cf1 | ||
|
|
6eb76454bb | ||
|
|
83bcea98dc | ||
|
|
4db09a73b3 | ||
|
|
45a9e3bfc3 | ||
|
|
bd40015e1c | ||
|
|
7894600408 | ||
|
|
df4668754d | ||
|
|
08d6f83b2e | ||
|
|
c58f510054 | ||
|
|
c2879d054a | ||
|
|
f821d2c909 | ||
|
|
1ef2218919 | ||
|
|
177c958572 | ||
|
|
b5ab1ff0cd | ||
|
|
70a978b83d | ||
|
|
2037810c6b | ||
|
|
de304f83de | ||
|
|
5752373009 | ||
|
|
fecae39fcd | ||
|
|
38bc4fbfe2 | ||
|
|
92ed7573d4 | ||
|
|
80f0e92462 | ||
|
|
5f10b1b2ca | ||
|
|
4f83b1e6b3 | ||
|
|
15d5a687fb | ||
|
|
eb1fce3787 | ||
|
|
7f735cbe59 | ||
|
|
a690ea4016 | ||
|
|
7a110c7acd | ||
|
|
407bb33359 | ||
|
|
4b7f7bba04 | ||
|
|
cfdc0a1f2a | ||
|
|
f926055e67 | ||
|
|
058af95d70 | ||
|
|
54facdc391 | ||
|
|
2e4c0cc7e7 | ||
|
|
cb2fd7c5e8 | ||
|
|
94133cc8b1 | ||
|
|
dcec89be90 | ||
|
|
fefd5d1d0e |
@@ -26,3 +26,5 @@ jobs:
|
||||
PASSWORD: "${{ secrets.REGISTRY_PASSWORD }}"
|
||||
- run: podman build --tag "$IMAGE_NAME" --file docs/Dockerfile --storage-opt "overlay.mount_program=/usr/bin/fuse-overlayfs" .
|
||||
- run: podman push "$IMAGE_NAME"
|
||||
- run: scripts/export-docs-from-image
|
||||
- run: curl --user "${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}" --upload-file borgmatic-docs.tar.gz https://projects.torsion.org/api/packages/borgmatic-collective/generic/borgmatic-docs/$(head --lines=1 NEWS)/borgmatic-docs.tar.gz
|
||||
|
||||
@@ -1,3 +1,166 @@
|
||||
1.9.5
|
||||
* #418: Backup and restore databases that have the same name but with different ports, hostnames,
|
||||
or hooks.
|
||||
* #947: To avoid a hang in the database hooks, error and exit when the borgmatic runtime
|
||||
directory overlaps with the configured excludes.
|
||||
* #954: Fix a findmnt command error in the Btrfs hook by switching to parsing JSON output.
|
||||
* #956: Fix the printing of a color reset code even when color is disabled.
|
||||
* #958: Drop colorama as a library dependency.
|
||||
* When the ZFS, Btrfs, or LVM hooks aren't configured, don't try to cleanup snapshots for them.
|
||||
|
||||
1.9.4
|
||||
* #80 (beta): Add an LVM hook for snapshotting and backing up LVM logical volumes. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* #251 (beta): Add a Btrfs hook for snapshotting and backing up Btrfs subvolumes. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* #926: Fix a library error when running within a PyInstaller bundle.
|
||||
* #950: Fix a snapshot unmount error in the ZFS hook when using nested datasets.
|
||||
* Update the ZFS hook to discover and snapshot ZFS datasets even if they are parent/grandparent
|
||||
directories of your source directories.
|
||||
* Reorganize data source and monitoring hooks to make developing new hooks easier.
|
||||
|
||||
1.9.3
|
||||
* #261 (beta): Add a ZFS hook for snapshotting and backing up ZFS datasets. See the documentation
|
||||
for more information: https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* Remove any temporary copies of the manifest file created in support of the "bootstrap" action.
|
||||
* Deprecate the "store_config_files" option at the global scope and move it under the "bootstrap"
|
||||
hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
|
||||
* Require the runtime directory to be an absolute path.
|
||||
* Add a "--deleted" flag to the "repo-list" action for listing deleted archives that haven't
|
||||
yet been compacted (Borg 2 only).
|
||||
* Promote the "spot" check from a beta feature to stable.
|
||||
|
||||
1.9.2
|
||||
* #441: Apply the "umask" option to all relevant actions, not just some of them.
|
||||
* #722: Remove the restriction that the "extract" and "mount" actions must match a single
|
||||
repository. Now they work more like other actions, where each repository is applied in turn.
|
||||
* #932: Fix the missing build backend setting in pyproject.toml to allow Fedora builds.
|
||||
* #934: Update the logic that probes for the borgmatic streaming database dump, bootstrap
|
||||
metadata, and check state directories to support more platforms and use cases. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory
|
||||
* #934: Add the "RuntimeDirectory" and "StateDirectory" options to the sample systemd service
|
||||
file to support the new runtime and state directory logic.
|
||||
* #939: Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and
|
||||
"BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables.
|
||||
* Add a Pushover monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook
|
||||
|
||||
1.9.1
|
||||
* #928: Fix the user runtime directory location on macOS (and possibly Cygwin).
|
||||
* #930: Fix an error with the sample systemd service when no credentials are configured.
|
||||
* #931: Fix an error when implicitly upgrading the check state directory from ~/.borgmatic to
|
||||
~/.local/state/borgmatic across filesystems.
|
||||
|
||||
1.9.0
|
||||
* #609: Fix the glob expansion of "source_directories" values to respect the "working_directory"
|
||||
option.
|
||||
* #609: BREAKING: Apply the "working_directory" option to all actions, not just "create". This
|
||||
includes repository paths, destination paths, mount points, etc.
|
||||
* #562: Deprecate the "borgmatic_source_directory" option in favor of "user_runtime_directory"
|
||||
and "user_state_directory".
|
||||
* #562: BREAKING: Move the default borgmatic streaming database dump and bootstrap metadata
|
||||
directory from ~/.borgmatic to /run/user/$UID/borgmatic, which is more XDG-compliant. You can
|
||||
override this location with the new "user_runtime_directory" option. Existing archives with
|
||||
database dumps at the old location are still restorable.
|
||||
* #562, #638: Move the default check state directory from ~/.borgmatic to
|
||||
~/.local/state/borgmatic. This is more XDG-compliant and also prevents these state files from
|
||||
getting backed up (unless you explicitly include them). You can override this location with the
|
||||
new "user_state_directory" option. After the first time you run the "check" action with borgmatic
|
||||
1.9.0, you can safely delete the ~/.borgmatic directory.
|
||||
* #838: BREAKING: With Borg 1.4+, store database dumps and bootstrap metadata in a "/borgmatic"
|
||||
directory within a backup archive, so the path doesn't depend on the current user. This means
|
||||
that you can now backup as one user and restore or bootstrap as another user, among other use
|
||||
cases.
|
||||
* #902: Add loading of encrypted systemd credentials. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/#using-systemd-service-credentials
|
||||
* #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key.
|
||||
* #914: Fix a confusing apparent hang when when the repository location changes, and instead
|
||||
show a helpful error message.
|
||||
* #915: BREAKING: Rename repository actions like "rcreate" to more explicit names like
|
||||
"repo-create" for compatibility with recent changes in Borg 2.0.0b10.
|
||||
* #918: BREAKING: When databases are configured, don't auto-enable the "one_file_system" option,
|
||||
as existing auto-excludes of special files should be sufficient to prevent Borg from hanging on
|
||||
them. But if this change causes problems for you, you can always enable "one_file_system"
|
||||
explicitly.
|
||||
* #919: Clarify the command-line help for the "--config" flag.
|
||||
* #919: Document a policy for versioning and breaking changes:
|
||||
https://torsion.org/borgmatic/docs/how-to/upgrade/#versioning-and-breaking-changes
|
||||
* #921: BREAKING: Change soft failure command hooks to skip only the current repository rather than
|
||||
all repositories in the configuration file.
|
||||
* #922: Replace setup.py (Python packaging metadata) with the more modern pyproject.toml.
|
||||
* When using Borg 2, default the "archive_name_format" option to just "{hostname}", as Borg 2 does
|
||||
not require unique archive names; identical archive names form a common "series" that can be
|
||||
targeted together. See the Borg 2 documentation for more information:
|
||||
https://borgbackup.readthedocs.io/en/2.0.0b13/changes.html#borg-1-2-x-1-4-x-to-borg-2-0
|
||||
* Add support for Borg 2's "rclone:" repository URLs, so you can backup to 70+ cloud storage
|
||||
services whether or not they support Borg explicitly.
|
||||
* Add support for Borg 2's "sftp://" repository URLs.
|
||||
* Update the "--match-archives" and "--archive" flags to support Borg 2 series names or archive
|
||||
hashes.
|
||||
* Add a "--match-archives" flag to the "prune" action.
|
||||
* Add "--local-path" and "--remote-path" flags to the "config bootstrap" action for setting the
|
||||
Borg executable paths used for bootstrapping.
|
||||
* Add a "--user-runtime-directory" flag to the "config bootstrap" action for helping borgmatic
|
||||
locate the bootstrap metadata stored in an archive.
|
||||
* Add a Zabbix monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook
|
||||
* Add a tarball of borgmatic's HTML documentation to the packages on the project page.
|
||||
|
||||
1.8.14
|
||||
* #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4.
|
||||
* #898: Add glob ("*") support to the "--repository" flag. Just quote any values containing
|
||||
globs so your shell doesn't interpret them.
|
||||
* #899: Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern.
|
||||
* #900: Fix for a potential traceback (TypeError) during the handling of another error.
|
||||
* #904: Clarify the configuration reference about the "spot" check options:
|
||||
https://torsion.org/borgmatic/docs/reference/configuration/
|
||||
* #905: Fix the "source_directories_must_exist" option to work with relative "source_directories"
|
||||
paths when a "working_directory" is set.
|
||||
* #906: Add documentation details for how to run custom database dump commands using binaries from
|
||||
running containers:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
|
||||
* Fix a regression in which the "color" option had no effect.
|
||||
* Add a recent contributors section to the documentation, because credit where credit's due! See:
|
||||
https://torsion.org/borgmatic/#recent-contributors
|
||||
|
||||
1.8.13
|
||||
* #298: Add "delete" and "rdelete" actions to delete archives or entire repositories.
|
||||
* #785: Add an "only_run_on" option to consistency checks so you can limit a check to running on
|
||||
particular days of the week. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-days
|
||||
* #885: Add an Uptime Kuma monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook
|
||||
* #886: Fix a PagerDuty hook traceback with Python < 3.10.
|
||||
* #889: Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes.
|
||||
|
||||
1.8.12
|
||||
* #817: Add a "--max-duration" flag to the "check" action and a "max_duration" option to the
|
||||
repository check configuration. This tells Borg to interrupt a repository check after a certain
|
||||
duration.
|
||||
* #860: Fix interaction between environment variable interpolation in constants and shell escaping.
|
||||
* #863: When color output is disabled (explicitly or implicitly), don't prefix each log line with
|
||||
the log level.
|
||||
* #865: Add an "upload_buffer_size" option to set the size of the upload buffer used in "create"
|
||||
action.
|
||||
* #866: Fix "Argument list too long" error in the "spot" check when checking hundreds of thousands
|
||||
of files at once.
|
||||
* #874: Add the configured repository label as "repository_label" to the interpolated variables
|
||||
passed to before/after command hooks.
|
||||
* #881: Fix "Unrecognized argument" error when the same value is used with different command-line
|
||||
flags.
|
||||
* In the "spot" check, don't try to hash symlinked directories.
|
||||
|
||||
1.8.11
|
||||
* #815: Add optional Healthchecks auto-provisioning via "create_slug" option.
|
||||
* #851: Fix lack of file extraction when using "extract --strip-components all" on a path with a
|
||||
leading slash.
|
||||
* #854: Fix a traceback when the "data" consistency check is used.
|
||||
* #857: Fix a traceback with "check --only spot" when the "spot" check is unconfigured.
|
||||
|
||||
1.8.10
|
||||
* #656 (beta): Add a "spot" consistency check that compares file counts and contents between your
|
||||
source files and the latest archive, ensuring they fall within configured tolerances. This can
|
||||
|
||||
@@ -61,13 +61,20 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.pushover.net/"><img src="docs/static/pushover.png" alt="Pushover" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://grafana.com/oss/loki/"><img src="docs/static/loki.png" alt="Loki" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
|
||||
|
||||
@@ -154,3 +161,7 @@ general, contributions are very welcome. We don't bite!
|
||||
Also, please check out the [borgmatic development
|
||||
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
|
||||
info on cloning source code, running tests, etc.
|
||||
|
||||
### Recent contributors
|
||||
|
||||
{% include borgmatic/contributors.html %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.borg
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,7 +25,7 @@ def run_borg(
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Running arbitrary Borg command'
|
||||
)
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
borg_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.change_passphrase
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_change_passphrase(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "key change-passprhase" action for the given repository.
|
||||
'''
|
||||
if (
|
||||
change_passphrase_arguments.repository is None
|
||||
or borgmatic.config.validate.repositories_match(
|
||||
repository, change_passphrase_arguments.repository
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Changing repository passphrase'
|
||||
)
|
||||
borgmatic.borg.change_passphrase.change_passphrase(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
+197
-89
@@ -1,3 +1,4 @@
|
||||
import calendar
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
@@ -5,14 +6,17 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import shutil
|
||||
|
||||
import borgmatic.actions.create
|
||||
import borgmatic.borg.check
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.command
|
||||
@@ -99,12 +103,17 @@ def parse_frequency(frequency):
|
||||
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
|
||||
|
||||
|
||||
WEEKDAY_DAYS = calendar.day_name[0:5]
|
||||
WEEKEND_DAYS = calendar.day_name[5:7]
|
||||
|
||||
|
||||
def filter_checks_on_frequency(
|
||||
config,
|
||||
borg_repository_id,
|
||||
checks,
|
||||
force,
|
||||
archives_check_id=None,
|
||||
datetime_now=datetime.datetime.now,
|
||||
):
|
||||
'''
|
||||
Given a configuration dict with a "checks" sequence of dicts, a Borg repository ID, a sequence
|
||||
@@ -143,6 +152,29 @@ def filter_checks_on_frequency(
|
||||
if checks and check not in checks:
|
||||
continue
|
||||
|
||||
only_run_on = check_config.get('only_run_on')
|
||||
if only_run_on:
|
||||
# Use a dict instead of a set to preserve ordering.
|
||||
days = dict.fromkeys(only_run_on)
|
||||
|
||||
if 'weekday' in days:
|
||||
days = {
|
||||
**dict.fromkeys(day for day in days if day != 'weekday'),
|
||||
**dict.fromkeys(WEEKDAY_DAYS),
|
||||
}
|
||||
if 'weekend' in days:
|
||||
days = {
|
||||
**dict.fromkeys(day for day in days if day != 'weekend'),
|
||||
**dict.fromkeys(WEEKEND_DAYS),
|
||||
}
|
||||
|
||||
if calendar.day_name[datetime_now().weekday()] not in days:
|
||||
logger.info(
|
||||
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(days)} (use --force to check anyway)"
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
continue
|
||||
|
||||
frequency_delta = parse_frequency(check_config.get('frequency'))
|
||||
if not frequency_delta:
|
||||
continue
|
||||
@@ -153,8 +185,8 @@ def filter_checks_on_frequency(
|
||||
|
||||
# If we've not yet reached the time when the frequency dictates we're ready for another
|
||||
# check, skip this check.
|
||||
if datetime.datetime.now() < check_time + frequency_delta:
|
||||
remaining = check_time + frequency_delta - datetime.datetime.now()
|
||||
if datetime_now() < check_time + frequency_delta:
|
||||
remaining = check_time + frequency_delta - datetime_now()
|
||||
logger.info(
|
||||
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
|
||||
)
|
||||
@@ -180,15 +212,11 @@ def make_check_time_path(config, borg_repository_id, check_type, archives_check_
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return a path for recording
|
||||
that check's time (the time of that check last occurring).
|
||||
'''
|
||||
borgmatic_source_directory = os.path.expanduser(
|
||||
config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
)
|
||||
)
|
||||
borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config)
|
||||
|
||||
if check_type in ('archives', 'data'):
|
||||
return os.path.join(
|
||||
borgmatic_source_directory,
|
||||
borgmatic_state_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
@@ -196,7 +224,7 @@ def make_check_time_path(config, borg_repository_id, check_type, archives_check_
|
||||
)
|
||||
|
||||
return os.path.join(
|
||||
borgmatic_source_directory,
|
||||
borgmatic_state_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
@@ -229,7 +257,7 @@ def read_check_time(path):
|
||||
def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
|
||||
'''
|
||||
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return a the corresponding
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return the corresponding
|
||||
check time or None if such a check time does not exist.
|
||||
|
||||
When the check type is "archives" or "data", this function probes two different paths to find
|
||||
@@ -267,14 +295,37 @@ def upgrade_check_times(config, borg_repository_id):
|
||||
Given a configuration dict and a Borg repository ID, upgrade any corresponding check times on
|
||||
disk from old-style paths to new-style paths.
|
||||
|
||||
Currently, the only upgrade performed is renaming an archive or data check path that looks like:
|
||||
One upgrade performed is moving the checks directory from:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives
|
||||
{borgmatic_source_directory}/checks (e.g., ~/.borgmatic/checks)
|
||||
|
||||
to:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives/all
|
||||
{borgmatic_state_directory}/checks (e.g. ~/.local/state/borgmatic)
|
||||
|
||||
Another upgrade is renaming an archive or data check path that looks like:
|
||||
|
||||
{borgmatic_state_directory}/checks/1234567890/archives
|
||||
|
||||
to:
|
||||
|
||||
{borgmatic_state_directory}/checks/1234567890/archives/all
|
||||
'''
|
||||
borgmatic_source_checks_path = os.path.join(
|
||||
borgmatic.config.paths.get_borgmatic_source_directory(config), 'checks'
|
||||
)
|
||||
borgmatic_state_path = borgmatic.config.paths.get_borgmatic_state_directory(config)
|
||||
borgmatic_state_checks_path = os.path.join(borgmatic_state_path, 'checks')
|
||||
|
||||
if os.path.exists(borgmatic_source_checks_path) and not os.path.exists(
|
||||
borgmatic_state_checks_path
|
||||
):
|
||||
logger.debug(
|
||||
f'Upgrading archives check times directory from {borgmatic_source_checks_path} to {borgmatic_state_checks_path}'
|
||||
)
|
||||
os.makedirs(borgmatic_state_path, mode=0o700, exist_ok=True)
|
||||
shutil.move(borgmatic_source_checks_path, borgmatic_state_checks_path)
|
||||
|
||||
for check_type in ('archives', 'data'):
|
||||
new_path = make_check_time_path(config, borg_repository_id, check_type, 'all')
|
||||
old_path = os.path.dirname(new_path)
|
||||
@@ -283,32 +334,37 @@ def upgrade_check_times(config, borg_repository_id):
|
||||
if not os.path.isfile(old_path) and not os.path.isfile(temporary_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Upgrading archives check time from {old_path} to {new_path}')
|
||||
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
|
||||
|
||||
try:
|
||||
os.rename(old_path, temporary_path)
|
||||
shutil.move(old_path, temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.mkdir(old_path)
|
||||
os.rename(temporary_path, new_path)
|
||||
shutil.move(temporary_path, new_path)
|
||||
|
||||
|
||||
def collect_spot_check_source_paths(
|
||||
repository, config, local_borg_version, global_arguments, local_path, remote_path
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, a configuration dict, the local Borg version, global
|
||||
arguments as an argparse.Namespace instance, the local Borg path, and the remote Borg path,
|
||||
collect the source paths that Borg would use in an actual create (but only include files and
|
||||
symlinks).
|
||||
collect the source paths that Borg would use in an actual create (but only include files).
|
||||
'''
|
||||
stream_processes = any(
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
'use_streaming',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
).values()
|
||||
)
|
||||
|
||||
@@ -317,10 +373,12 @@ def collect_spot_check_source_paths(
|
||||
dry_run=True,
|
||||
repository_path=repository['path'],
|
||||
config=config,
|
||||
config_paths=(),
|
||||
source_directories=borgmatic.actions.create.process_source_directories(
|
||||
config,
|
||||
),
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
borgmatic_source_directories=(),
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
list_files=True,
|
||||
@@ -328,17 +386,13 @@ def collect_spot_check_source_paths(
|
||||
)
|
||||
)
|
||||
borg_environment = borgmatic.borg.environment.make_environment(config)
|
||||
|
||||
try:
|
||||
working_directory = os.path.expanduser(config.get('working_directory'))
|
||||
except TypeError:
|
||||
working_directory = None
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
paths_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
create_flags + create_positional_arguments,
|
||||
capture_stderr=True,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -349,26 +403,31 @@ def collect_spot_check_source_paths(
|
||||
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
|
||||
)
|
||||
|
||||
return tuple(path for path in paths if os.path.isfile(path) or os.path.islink(path))
|
||||
return tuple(
|
||||
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
|
||||
)
|
||||
|
||||
|
||||
BORG_DIRECTORY_FILE_TYPE = 'd'
|
||||
|
||||
|
||||
def collect_spot_check_archive_paths(
|
||||
repository, archive, config, local_borg_version, global_arguments, local_path, remote_path
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
|
||||
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, and
|
||||
the remote Borg path, collect the paths from the given archive (but only include files and
|
||||
symlinks).
|
||||
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
|
||||
remote Borg path, and the borgmatic runtime directory, collect the paths from the given archive
|
||||
(but only include files and symlinks and exclude borgmatic runtime directories).
|
||||
'''
|
||||
borgmatic_source_directory = os.path.expanduser(
|
||||
config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
)
|
||||
)
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return tuple(
|
||||
path
|
||||
@@ -384,10 +443,15 @@ def collect_spot_check_archive_paths(
|
||||
)
|
||||
for (file_type, path) in (line.split(' ', 1),)
|
||||
if file_type != BORG_DIRECTORY_FILE_TYPE
|
||||
if pathlib.Path('/borgmatic') not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_source_directory) not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_runtime_directory) not in pathlib.Path(path).parents
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_PATHS_SUBSET_COUNT = 10000
|
||||
|
||||
|
||||
def compare_spot_check_hashes(
|
||||
repository,
|
||||
archive,
|
||||
@@ -396,7 +460,7 @@ def compare_spot_check_hashes(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
log_label,
|
||||
log_prefix,
|
||||
source_paths,
|
||||
):
|
||||
'''
|
||||
@@ -413,39 +477,68 @@ def compare_spot_check_hashes(
|
||||
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)), 1
|
||||
)
|
||||
source_sample_paths = tuple(random.sample(source_paths, sample_count))
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
existing_source_sample_paths = {
|
||||
source_path for source_path in source_sample_paths if os.path.exists(source_path)
|
||||
source_path
|
||||
for source_path in source_sample_paths
|
||||
if os.path.exists(os.path.join(working_directory or '', source_path))
|
||||
}
|
||||
logger.debug(
|
||||
f'{log_label}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
f'{log_prefix}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
)
|
||||
|
||||
# Hash each file in the sample paths (if it exists).
|
||||
hash_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
|
||||
+ tuple(path for path in source_sample_paths if path in existing_source_sample_paths)
|
||||
)
|
||||
source_sample_paths_iterator = iter(source_sample_paths)
|
||||
source_hashes = {}
|
||||
archive_hashes = {}
|
||||
|
||||
source_hashes = dict(
|
||||
(reversed(line.split(' ', 1)) for line in hash_output.splitlines()),
|
||||
**{path: '' for path in source_sample_paths if path not in existing_source_sample_paths},
|
||||
)
|
||||
# Only hash a few thousand files at a time (a subset of the total paths) to avoid an "Argument
|
||||
# list too long" shell error.
|
||||
while True:
|
||||
# Hash each file in the sample paths (if it exists).
|
||||
source_sample_paths_subset = tuple(
|
||||
itertools.islice(source_sample_paths_iterator, SAMPLE_PATHS_SUBSET_COUNT)
|
||||
)
|
||||
if not source_sample_paths_subset:
|
||||
break
|
||||
|
||||
archive_hashes = dict(
|
||||
reversed(line.split(' ', 1))
|
||||
for line in borgmatic.borg.list.capture_archive_listing(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=source_sample_paths,
|
||||
path_format='{xxh64} /{path}{NL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
hash_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
|
||||
+ tuple(
|
||||
path for path in source_sample_paths_subset if path in existing_source_sample_paths
|
||||
),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
source_hashes.update(
|
||||
**dict(
|
||||
(reversed(line.split(' ', 1)) for line in hash_output.splitlines()),
|
||||
# Represent non-existent files as having empty hashes so the comparison below still works.
|
||||
**{
|
||||
path: ''
|
||||
for path in source_sample_paths_subset
|
||||
if path not in existing_source_sample_paths
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Get the hash for each file in the archive.
|
||||
archive_hashes.update(
|
||||
**dict(
|
||||
reversed(line.split(' ', 1))
|
||||
for line in borgmatic.borg.list.capture_archive_listing(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=source_sample_paths_subset,
|
||||
path_format='{xxh64} /{path}{NL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if line
|
||||
)
|
||||
)
|
||||
if line
|
||||
)
|
||||
|
||||
# Compare the source hashes with the archive hashes to see how many match.
|
||||
failing_paths = []
|
||||
@@ -468,19 +561,26 @@ def spot_check(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository dict, a loaded configuration dict, the local Borg version, global arguments
|
||||
as an argparse.Namespace instance, the local Borg path, and the remote Borg path, perform a spot
|
||||
check for the latest archive in the given repository.
|
||||
as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the borgmatic
|
||||
runtime directory, perform a spot check for the latest archive in the given repository.
|
||||
|
||||
A spot check compares file counts and also the hashes for a random sampling of source files on
|
||||
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
|
||||
then the check fails.
|
||||
'''
|
||||
log_label = f'{repository.get("label", repository["path"])}'
|
||||
logger.debug(f'{log_label}: Running spot check')
|
||||
spot_check_config = next(check for check in config['checks'] if check['name'] == 'spot')
|
||||
log_prefix = f'{repository.get("label", repository["path"])}'
|
||||
logger.debug(f'{log_prefix}: Running spot check')
|
||||
|
||||
try:
|
||||
spot_check_config = next(
|
||||
check for check in config.get('checks', ()) if check.get('name') == 'spot'
|
||||
)
|
||||
except StopIteration:
|
||||
raise ValueError('Cannot run spot check because it is unconfigured')
|
||||
|
||||
if spot_check_config['data_tolerance_percentage'] > spot_check_config['data_sample_percentage']:
|
||||
raise ValueError(
|
||||
@@ -494,10 +594,11 @@ def spot_check(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{log_label}: {len(source_paths)} total source paths for spot check')
|
||||
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
|
||||
|
||||
archive = borgmatic.borg.rlist.resolve_archive_name(
|
||||
archive = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
'latest',
|
||||
config,
|
||||
@@ -506,7 +607,7 @@ def spot_check(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
logger.debug(f'{log_label}: Using archive {archive} for spot check')
|
||||
logger.debug(f'{log_prefix}: Using archive {archive} for spot check')
|
||||
|
||||
archive_paths = collect_spot_check_archive_paths(
|
||||
repository,
|
||||
@@ -516,8 +617,9 @@ def spot_check(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{log_label}: {len(archive_paths)} total archive paths for spot check')
|
||||
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
|
||||
|
||||
# Calculate the percentage delta between the source paths count and the archive paths count, and
|
||||
# compare that delta to the configured count tolerance percentage.
|
||||
@@ -525,10 +627,10 @@ def spot_check(
|
||||
|
||||
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
|
||||
logger.debug(
|
||||
f'{log_label}: Paths in source paths but not latest archive: {", ".join(set(source_paths) - set(archive_paths)) or "none"}'
|
||||
f'{log_prefix}: Paths in source paths but not latest archive: {", ".join(set(source_paths) - set(archive_paths)) or "none"}'
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_label}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - set(source_paths)) or "none"}'
|
||||
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - set(source_paths)) or "none"}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
|
||||
@@ -542,25 +644,25 @@ def spot_check(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
log_label,
|
||||
log_prefix,
|
||||
source_paths,
|
||||
)
|
||||
|
||||
# Error if the percentage of failing hashes exceeds the configured tolerance percentage.
|
||||
logger.debug(f'{log_label}: {len(failing_paths)} non-matching spot check hashes')
|
||||
logger.debug(f'{log_prefix}: {len(failing_paths)} non-matching spot check hashes')
|
||||
data_tolerance_percentage = spot_check_config['data_tolerance_percentage']
|
||||
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
|
||||
|
||||
if failing_percentage > data_tolerance_percentage:
|
||||
logger.debug(
|
||||
f'{log_label}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
|
||||
f'{log_prefix}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'{log_label}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
f'{log_prefix}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
)
|
||||
|
||||
|
||||
@@ -594,7 +696,9 @@ def run_check(
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Running consistency checks')
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Running consistency checks')
|
||||
|
||||
repository_id = borgmatic.borg.check.get_repository_id(
|
||||
repository['path'],
|
||||
config,
|
||||
@@ -646,14 +750,18 @@ def run_check(
|
||||
write_check_time(make_check_time_path(config, repository_id, 'extract'))
|
||||
|
||||
if 'spot' in checks:
|
||||
spot_check(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
config, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
spot_check(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, repository_id, 'spot'))
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
|
||||
@@ -3,55 +3,79 @@ import logging
|
||||
import os
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_config_paths(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
def make_bootstrap_config(bootstrap_arguments):
|
||||
'''
|
||||
Given the bootstrap arguments as an argparse.Namespace (containing the repository and archive
|
||||
name, borgmatic source directory, destination directory, and whether to strip components), the
|
||||
global arguments as an argparse.Namespace (containing the dry run flag and the local borg
|
||||
version), return the config paths from the manifest.json file in the borgmatic source directory
|
||||
after extracting it from the repository.
|
||||
Given the bootstrap arguments as an argparse.Namespace, return a corresponding config dict.
|
||||
'''
|
||||
return {
|
||||
'ssh_command': bootstrap_arguments.ssh_command,
|
||||
# In case the repo has been moved or is accessed from a different path at the point of
|
||||
# bootstrapping.
|
||||
'relocated_repo_access_is_ok': True,
|
||||
}
|
||||
|
||||
|
||||
def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_borg_version):
|
||||
'''
|
||||
Given an archive name, the bootstrap arguments as an argparse.Namespace (containing the
|
||||
repository and archive name, Borg local path, Borg remote path, borgmatic runtime directory,
|
||||
borgmatic source directory, destination directory, and whether to strip components), the global
|
||||
arguments as an argparse.Namespace (containing the dry run flag and the local borg version),
|
||||
return the config paths from the manifest.json file in the borgmatic source directory or runtime
|
||||
directory after extracting it from the repository archive.
|
||||
|
||||
Raise ValueError if the manifest JSON is missing, can't be decoded, or doesn't contain the
|
||||
expected configuration path data.
|
||||
'''
|
||||
borgmatic_source_directory = (
|
||||
bootstrap_arguments.borgmatic_source_directory or DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(
|
||||
{'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory}
|
||||
)
|
||||
borgmatic_manifest_path = os.path.expanduser(
|
||||
os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json')
|
||||
)
|
||||
config = {'ssh_command': bootstrap_arguments.ssh_command}
|
||||
config = make_bootstrap_config(bootstrap_arguments)
|
||||
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
# Probe for the manifest file in multiple locations, as the default location has moved to the
|
||||
# borgmatic runtime directory (which get stored as just "/borgmatic" with Borg 1.4+). But we
|
||||
# still want to support reading the manifest from previously created archives as well.
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory},
|
||||
bootstrap_arguments.repository,
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
),
|
||||
[borgmatic_manifest_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
extract_to_stdout=True,
|
||||
)
|
||||
manifest_json = extract_process.stdout.read()
|
||||
) as borgmatic_runtime_directory:
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
|
||||
borgmatic_source_directory,
|
||||
):
|
||||
borgmatic_manifest_path = 'sh:' + os.path.join(
|
||||
base_directory, 'bootstrap', 'manifest.json'
|
||||
)
|
||||
|
||||
if not manifest_json:
|
||||
raise ValueError(
|
||||
'Cannot read configuration paths from archive due to missing bootstrap manifest'
|
||||
)
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
bootstrap_arguments.repository,
|
||||
archive_name,
|
||||
[borgmatic_manifest_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
extract_to_stdout=True,
|
||||
)
|
||||
manifest_json = extract_process.stdout.read()
|
||||
|
||||
if manifest_json:
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
'Cannot read configuration paths from archive due to missing bootstrap manifest'
|
||||
)
|
||||
|
||||
try:
|
||||
manifest_data = json.loads(manifest_json)
|
||||
@@ -75,27 +99,32 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
Raise ValueError if the bootstrap configuration could not be loaded.
|
||||
Raise CalledProcessError or OSError if Borg could not be run.
|
||||
'''
|
||||
config = make_bootstrap_config(bootstrap_arguments)
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
)
|
||||
manifest_config_paths = get_config_paths(
|
||||
bootstrap_arguments, global_arguments, local_borg_version
|
||||
archive_name, bootstrap_arguments, global_arguments, local_borg_version
|
||||
)
|
||||
config = {'ssh_command': bootstrap_arguments.ssh_command}
|
||||
|
||||
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
|
||||
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
bootstrap_arguments.repository,
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
),
|
||||
archive_name,
|
||||
[config_path.lstrip(os.path.sep) for config_path in manifest_config_paths],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
extract_to_stdout=False,
|
||||
destination_path=bootstrap_arguments.destination,
|
||||
strip_components=bootstrap_arguments.strip_components,
|
||||
|
||||
+180
-66
@@ -1,46 +1,146 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
import borgmatic.hooks.dispatch
|
||||
import borgmatic.hooks.dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_borgmatic_manifest(config, config_paths, dry_run):
|
||||
def expand_directory(directory, working_directory):
|
||||
'''
|
||||
Create a borgmatic manifest file to store the paths to the configuration files used to create
|
||||
the archive.
|
||||
Given a directory path, expand any tilde (representing a user's home directory) and any globs
|
||||
therein. Return a list of one or more resulting paths.
|
||||
'''
|
||||
if dry_run:
|
||||
return
|
||||
expanded_directory = os.path.join(working_directory or '', os.path.expanduser(directory))
|
||||
|
||||
borgmatic_source_directory = config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
)
|
||||
return glob.glob(expanded_directory) or [expanded_directory]
|
||||
|
||||
borgmatic_manifest_path = os.path.expanduser(
|
||||
os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json')
|
||||
)
|
||||
|
||||
if not os.path.exists(borgmatic_manifest_path):
|
||||
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
|
||||
def expand_directories(directories, working_directory=None):
|
||||
'''
|
||||
Given a sequence of directory paths and an optional working directory, expand tildes and globs
|
||||
in each one. Return all the resulting directories as a single flattened tuple.
|
||||
'''
|
||||
if directories is None:
|
||||
return ()
|
||||
|
||||
with open(borgmatic_manifest_path, 'w') as config_list_file:
|
||||
json.dump(
|
||||
{
|
||||
'borgmatic_version': importlib.metadata.version('borgmatic'),
|
||||
'config_paths': config_paths,
|
||||
},
|
||||
config_list_file,
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(
|
||||
expand_directory(directory, working_directory) for directory in directories
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def map_directories_to_devices(directories, working_directory=None):
|
||||
'''
|
||||
Given a sequence of directories and an optional working directory, return a map from directory
|
||||
to an identifier for the device on which that directory resides or None if the path doesn't
|
||||
exist.
|
||||
|
||||
This is handy for determining whether two different directories are on the same filesystem (have
|
||||
the same device identifier).
|
||||
'''
|
||||
return {
|
||||
directory: os.stat(full_directory).st_dev if os.path.exists(full_directory) else None
|
||||
for directory in directories
|
||||
for full_directory in (os.path.join(working_directory or '', directory),)
|
||||
}
|
||||
|
||||
|
||||
def deduplicate_directories(directory_devices, additional_directory_devices):
|
||||
'''
|
||||
Given a map from directory to the identifier for the device on which that directory resides,
|
||||
return the directories as a sorted sequence with all duplicate child directories removed. For
|
||||
instance, if paths is ['/foo', '/foo/bar'], return just: ['/foo']
|
||||
|
||||
The one exception to this rule is if two paths are on different filesystems (devices). In that
|
||||
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
|
||||
location.one_file_system option is true).
|
||||
|
||||
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
|
||||
child directories, because it will naturally spider the contents of the parent directory. And
|
||||
there are cases where Borg coming across the same file twice will result in duplicate reads and
|
||||
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
|
||||
Borg.
|
||||
|
||||
If any additional directory devices are given, also deduplicate against them, but don't include
|
||||
them in the returned directories.
|
||||
'''
|
||||
deduplicated = set()
|
||||
directories = sorted(directory_devices.keys())
|
||||
additional_directories = sorted(additional_directory_devices.keys())
|
||||
all_devices = {**directory_devices, **additional_directory_devices}
|
||||
|
||||
for directory in directories:
|
||||
deduplicated.add(directory)
|
||||
parents = pathlib.PurePath(directory).parents
|
||||
|
||||
# If another directory in the given list (or the additional list) is a parent of current
|
||||
# directory (even n levels up) and both are on the same filesystem, then the current
|
||||
# directory is a duplicate.
|
||||
for other_directory in directories + additional_directories:
|
||||
for parent in parents:
|
||||
if (
|
||||
pathlib.PurePath(other_directory) == parent
|
||||
and all_devices[directory] is not None
|
||||
and all_devices[other_directory] == all_devices[directory]
|
||||
):
|
||||
if directory in deduplicated:
|
||||
deduplicated.remove(directory)
|
||||
break
|
||||
|
||||
return sorted(deduplicated)
|
||||
|
||||
|
||||
ROOT_PATTERN_PREFIX = 'R '
|
||||
|
||||
|
||||
def pattern_root_directories(patterns=None):
|
||||
'''
|
||||
Given a sequence of patterns, parse out and return just the root directories.
|
||||
'''
|
||||
if not patterns:
|
||||
return []
|
||||
|
||||
return [
|
||||
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
|
||||
for pattern in patterns
|
||||
if pattern.startswith(ROOT_PATTERN_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def process_source_directories(config, source_directories=None):
|
||||
'''
|
||||
Given a sequence of source directories (either in the source_directories argument or, lacking
|
||||
that, from config), expand and deduplicate the source directories, returning the result.
|
||||
'''
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if source_directories is None:
|
||||
source_directories = tuple(config.get('source_directories', ()))
|
||||
|
||||
return deduplicate_directories(
|
||||
map_directories_to_devices(
|
||||
expand_directories(
|
||||
tuple(source_directories),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
),
|
||||
additional_directory_devices=map_directories_to_devices(
|
||||
expand_directories(
|
||||
pattern_root_directories(config.get('patterns')),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run_create(
|
||||
@@ -74,54 +174,68 @@ def run_create(
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating archive{dry_run_label}')
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
active_dumps = borgmatic.hooks.dispatch.call_hooks(
|
||||
'dump_data_sources',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if config.get('store_config_files', True):
|
||||
create_borgmatic_manifest(
|
||||
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Creating archive{dry_run_label}')
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
config, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
source_directories = process_source_directories(config)
|
||||
active_dumps = borgmatic.hooks.dispatch.call_hooks(
|
||||
'dump_data_sources',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
config_paths,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=create_arguments.progress,
|
||||
stats=create_arguments.stats,
|
||||
json=create_arguments.json,
|
||||
list_files=create_arguments.list_files,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
# Process source directories again in case any data source hooks updated them. Without this
|
||||
# step, we could end up with duplicate paths that cause Borg to hang when it tries to read
|
||||
# from the same named pipe twice.
|
||||
source_directories = process_source_directories(config, source_directories)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
source_directories,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=create_arguments.progress,
|
||||
stats=create_arguments.stats,
|
||||
json=create_arguments.json,
|
||||
list_files=create_arguments.list_files,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
config_filename,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
config_filename,
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_backup'),
|
||||
config.get('umask'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.arguments
|
||||
import borgmatic.borg.delete
|
||||
import borgmatic.borg.repo_delete
|
||||
import borgmatic.borg.repo_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "delete" action for the given repository and archive(s).
|
||||
'''
|
||||
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, delete_arguments.repository
|
||||
):
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Deleting archives')
|
||||
|
||||
archive_name = (
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
delete_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
if delete_arguments.archive
|
||||
else None
|
||||
)
|
||||
|
||||
borgmatic.borg.delete.delete_archives(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
borgmatic.actions.arguments.update_arguments(delete_arguments, archive=archive_name),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.export_tar
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,7 +28,7 @@ def run_export_tar(
|
||||
borgmatic.borg.export_tar.export_tar_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
export_tar_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
|
||||
@@ -39,7 +39,7 @@ def run_extract(
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
extract_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import borgmatic.actions.arguments
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.info
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +30,7 @@ def run_info(
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying archive summary information'
|
||||
)
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
info_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -31,7 +31,7 @@ def run_list(
|
||||
elif not list_arguments.archive: # pragma: no cover
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
|
||||
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
list_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.mount
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +31,7 @@ def run_mount(
|
||||
|
||||
borgmatic.borg.mount.mount_archive(
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
mount_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.rcreate
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rcreate(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rcreate_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rcreate" action for the given repository.
|
||||
'''
|
||||
if rcreate_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, rcreate_arguments.repository
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
|
||||
borgmatic.borg.rcreate.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
rcreate_arguments.encryption_mode,
|
||||
rcreate_arguments.source_repository,
|
||||
rcreate_arguments.copy_crypt_key,
|
||||
rcreate_arguments.append_only,
|
||||
rcreate_arguments.storage_quota,
|
||||
rcreate_arguments.make_parent_dirs,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.repo_create
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_repo_create(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_create_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "repo-create" action for the given repository.
|
||||
'''
|
||||
if repo_create_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, repo_create_arguments.repository
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
|
||||
borgmatic.borg.repo_create.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
repo_create_arguments.encryption_mode,
|
||||
repo_create_arguments.source_repository,
|
||||
repo_create_arguments.copy_crypt_key,
|
||||
repo_create_arguments.append_only,
|
||||
repo_create_arguments.storage_quota,
|
||||
repo_create_arguments.make_parent_dirs,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.repo_delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_repo_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "repo-delete" action for the given repository.
|
||||
'''
|
||||
if repo_delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_delete_arguments.repository
|
||||
):
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Deleting repository'
|
||||
+ (' cache' if repo_delete_arguments.cache_only else '')
|
||||
)
|
||||
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
@@ -1,39 +1,39 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.rinfo
|
||||
import borgmatic.borg.repo_info
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rinfo(
|
||||
def run_repo_info(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments,
|
||||
repo_info_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rinfo" action for the given repository.
|
||||
Run the "repo-info" action for the given repository.
|
||||
|
||||
If rinfo_arguments.json is True, yield the JSON output from the info for the repository.
|
||||
If repo_info_arguments.json is True, yield the JSON output from the info for the repository.
|
||||
'''
|
||||
if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, rinfo_arguments.repository
|
||||
if repo_info_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_info_arguments.repository
|
||||
):
|
||||
if not rinfo_arguments.json:
|
||||
if not repo_info_arguments.json:
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying repository summary information'
|
||||
)
|
||||
|
||||
json_output = borgmatic.borg.rinfo.display_repository_info(
|
||||
json_output = borgmatic.borg.repo_info.display_repository_info(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments=rinfo_arguments,
|
||||
repo_info_arguments=repo_info_arguments,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
@@ -1,37 +1,37 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rlist(
|
||||
def run_repo_list(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rlist" action for the given repository.
|
||||
Run the "repo-list" action for the given repository.
|
||||
|
||||
If rlist_arguments.json is True, yield the JSON output from listing the repository.
|
||||
If repo_list_arguments.json is True, yield the JSON output from listing the repository.
|
||||
'''
|
||||
if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, rlist_arguments.repository
|
||||
if repo_list_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_list_arguments.repository
|
||||
):
|
||||
if not rlist_arguments.json:
|
||||
if not repo_list_arguments.json:
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
|
||||
|
||||
json_output = borgmatic.borg.rlist.list_repository(
|
||||
json_output = borgmatic.borg.repo_list.list_repository(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments=rlist_arguments,
|
||||
repo_list_arguments=repo_list_arguments,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
+374
-232
@@ -1,67 +1,141 @@
|
||||
import copy
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.mount
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.data_source.dump
|
||||
import borgmatic.hooks.dispatch
|
||||
import borgmatic.hooks.dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
UNSPECIFIED_HOOK = object()
|
||||
UNSPECIFIED = object()
|
||||
|
||||
|
||||
def get_configured_data_source(
|
||||
config,
|
||||
archive_data_source_names,
|
||||
hook_name,
|
||||
data_source_name,
|
||||
configuration_data_source_name=None,
|
||||
):
|
||||
Dump = collections.namedtuple(
|
||||
'Dump',
|
||||
('hook_name', 'data_source_name', 'hostname', 'port'),
|
||||
defaults=('localhost', None),
|
||||
)
|
||||
|
||||
|
||||
def dumps_match(first, second):
|
||||
'''
|
||||
Find the first data source with the given hook name and data source name in the configuration
|
||||
dict and the given archive data source names dict (from hook name to data source names contained
|
||||
in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data
|
||||
source hooks for the named data source. If a configuration data source name is given, use that
|
||||
instead of the data source name to lookup the data source in the given hooks configuration.
|
||||
|
||||
Return the found data source as a tuple of (found hook name, data source configuration dict) or
|
||||
(None, None) if not found.
|
||||
Compare two Dump instances for equality while supporting a field value of UNSPECIFIED, which
|
||||
indicates that the field should match any value.
|
||||
'''
|
||||
if not configuration_data_source_name:
|
||||
configuration_data_source_name = data_source_name
|
||||
for field_name in first._fields:
|
||||
first_value = getattr(first, field_name)
|
||||
second_value = getattr(second, field_name)
|
||||
|
||||
if hook_name == UNSPECIFIED_HOOK:
|
||||
hooks_to_search = {
|
||||
hook_name: value
|
||||
for (hook_name, value) in config.items()
|
||||
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
|
||||
}
|
||||
if first_value == UNSPECIFIED or second_value == UNSPECIFIED:
|
||||
continue
|
||||
|
||||
if first_value != second_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def render_dump_metadata(dump):
|
||||
'''
|
||||
Given a Dump instance, make a display string describing it for use in log messages.
|
||||
'''
|
||||
name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name
|
||||
hostname = dump.hostname or 'localhost'
|
||||
port = None if dump.port is UNSPECIFIED else dump.port
|
||||
|
||||
if port:
|
||||
metadata = f'{name}@:{port}' if hostname is UNSPECIFIED else f'{name}@{hostname}:{port}'
|
||||
else:
|
||||
try:
|
||||
hooks_to_search = {hook_name: config[hook_name]}
|
||||
except KeyError:
|
||||
return (None, None)
|
||||
metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}'
|
||||
|
||||
return next(
|
||||
(
|
||||
(name, hook_data_source)
|
||||
for (name, hook) in hooks_to_search.items()
|
||||
for hook_data_source in hook
|
||||
if hook_data_source['name'] == configuration_data_source_name
|
||||
and data_source_name in archive_data_source_names.get(name, [])
|
||||
),
|
||||
(None, None),
|
||||
if dump.hook_name not in (None, UNSPECIFIED):
|
||||
return f'{metadata} ({dump.hook_name})'
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_configured_data_source(config, restore_dump):
|
||||
'''
|
||||
Search in the given configuration dict for dumps corresponding to the given dump to restore. If
|
||||
there are multiple matches, error.
|
||||
|
||||
Return the found data source as a data source configuration dict or None if not found.
|
||||
'''
|
||||
try:
|
||||
hooks_to_search = {restore_dump.hook_name: config[restore_dump.hook_name]}
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
matching_dumps = tuple(
|
||||
hook_data_source
|
||||
for (hook_name, hook) in hooks_to_search.items()
|
||||
for hook_data_source in hook
|
||||
if dumps_match(
|
||||
Dump(
|
||||
hook_name,
|
||||
hook_data_source.get('name'),
|
||||
hook_data_source.get('hostname', 'localhost'),
|
||||
hook_data_source.get('port'),
|
||||
),
|
||||
restore_dump,
|
||||
)
|
||||
)
|
||||
|
||||
if not matching_dumps:
|
||||
return None
|
||||
|
||||
def restore_single_data_source(
|
||||
if len(matching_dumps) > 1:
|
||||
raise ValueError(
|
||||
f'Cannot restore data source {render_dump_metadata(restore_dump)} because there are multiple matching data sources configured'
|
||||
)
|
||||
|
||||
return matching_dumps[0]
|
||||
|
||||
|
||||
def strip_path_prefix_from_extracted_dump_destination(
|
||||
destination_path, borgmatic_runtime_directory
|
||||
):
|
||||
'''
|
||||
Directory-format dump files get extracted into a temporary directory containing a path prefix
|
||||
that depends how the files were stored in the archive. So, given the destination path where the
|
||||
dump was extracted and the borgmatic runtime directory, move the dump files such that the
|
||||
restore doesn't have to deal with that varying path prefix.
|
||||
|
||||
For instance, if the dump was extracted to:
|
||||
|
||||
/run/user/0/borgmatic/tmp1234/borgmatic/postgresql_databases/test/...
|
||||
|
||||
or:
|
||||
|
||||
/run/user/0/borgmatic/tmp1234/root/.borgmatic/postgresql_databases/test/...
|
||||
|
||||
then this function moves it to:
|
||||
|
||||
/run/user/0/borgmatic/postgresql_databases/test/...
|
||||
'''
|
||||
for subdirectory_path, _, _ in os.walk(destination_path):
|
||||
databases_directory = os.path.basename(subdirectory_path)
|
||||
|
||||
if not databases_directory.endswith('_databases'):
|
||||
continue
|
||||
|
||||
shutil.move(
|
||||
subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory)
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
def restore_single_dump(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -72,55 +146,82 @@ def restore_single_data_source(
|
||||
hook_name,
|
||||
data_source,
|
||||
connection_params,
|
||||
): # pragma: no cover
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given (among other things) an archive name, a data source hook name, the hostname, port,
|
||||
username/password as connection params, and a configured data source configuration dict, restore
|
||||
that data source from the archive.
|
||||
'''
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}'
|
||||
dump_metadata = render_dump_metadata(
|
||||
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
|
||||
)
|
||||
|
||||
dump_pattern = borgmatic.hooks.dispatch.call_hooks(
|
||||
'make_data_source_dump_pattern',
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data source {dump_metadata}'
|
||||
)
|
||||
|
||||
dump_patterns = borgmatic.hooks.dispatch.call_hooks(
|
||||
'make_data_source_dump_patterns',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
data_source['name'],
|
||||
)[hook_name]
|
||||
)[hook_name.split('_databases', 1)[0]]
|
||||
|
||||
# Kick off a single data source extract to stdout.
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
dry_run=global_arguments.dry_run,
|
||||
repository=repository['path'],
|
||||
archive=archive_name,
|
||||
paths=borgmatic.hooks.dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
|
||||
config=config,
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path='/',
|
||||
# A directory format dump isn't a single file, and therefore can't extract
|
||||
# to stdout. In this case, the extract_process return value is None.
|
||||
extract_to_stdout=bool(data_source.get('format') != 'directory'),
|
||||
destination_path = (
|
||||
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
|
||||
if data_source.get('format') == 'directory'
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
# Kick off a single data source extract. If using a directory format, extract to a temporary
|
||||
# directory. Otherwise extract the single dump file to stdout.
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
dry_run=global_arguments.dry_run,
|
||||
repository=repository['path'],
|
||||
archive=archive_name,
|
||||
paths=[
|
||||
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
|
||||
dump_patterns
|
||||
)
|
||||
],
|
||||
config=config,
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path=destination_path,
|
||||
# A directory format dump isn't a single file, and therefore can't extract
|
||||
# to stdout. In this case, the extract_process return value is None.
|
||||
extract_to_stdout=bool(data_source.get('format') != 'directory'),
|
||||
)
|
||||
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
strip_path_prefix_from_extracted_dump_destination(
|
||||
destination_path, borgmatic_runtime_directory
|
||||
)
|
||||
finally:
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
shutil.rmtree(destination_path, ignore_errors=True)
|
||||
|
||||
# Run a single data source restore, consuming the extract stdout (if any).
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='restore_data_source_dump',
|
||||
config=config,
|
||||
log_prefix=repository['path'],
|
||||
hook_names=[hook_name],
|
||||
hook_name=hook_name,
|
||||
data_source=data_source,
|
||||
dry_run=global_arguments.dry_run,
|
||||
extract_process=extract_process,
|
||||
connection_params=connection_params,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
|
||||
def collect_archive_data_source_names(
|
||||
def collect_dumps_from_archive(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
@@ -128,18 +229,21 @@ def collect_archive_data_source_names(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a resolved archive name, a configuration dict, the
|
||||
local Borg version, global_arguments an argparse.Namespace, and local and remote Borg paths,
|
||||
query the archive for the names of data sources it contains as dumps and return them as a dict
|
||||
from hook name to a sequence of data source names.
|
||||
local Borg version, global arguments an argparse.Namespace, local and remote Borg paths, and the
|
||||
borgmatic runtime directory, query the archive for the names of data sources dumps it contains
|
||||
and return them as a set of Dump instances.
|
||||
'''
|
||||
borgmatic_source_directory = os.path.expanduser(
|
||||
config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
)
|
||||
).lstrip('/')
|
||||
borgmatic_source_directory = str(
|
||||
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
|
||||
)
|
||||
|
||||
# Probe for the data source dumps in multiple locations, as the default location has moved to
|
||||
# the borgmatic runtime directory (which gets stored as just "/borgmatic" with Borg 1.4+). But
|
||||
# we still want to support reading dumps from previously created archives as well.
|
||||
dump_paths = borgmatic.borg.list.capture_archive_listing(
|
||||
repository,
|
||||
archive,
|
||||
@@ -147,108 +251,159 @@ def collect_archive_data_source_names(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=[
|
||||
os.path.expanduser(
|
||||
borgmatic.hooks.dump.make_data_source_dump_path(borgmatic_source_directory, pattern)
|
||||
'sh:'
|
||||
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
|
||||
base_directory, '*_databases/*/*'
|
||||
)
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
|
||||
borgmatic_source_directory.lstrip('/'),
|
||||
)
|
||||
for pattern in ('*_databases/*/*',)
|
||||
],
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
# Determine the data source names corresponding to the dumps found in the archive and
|
||||
# add them to restore_names.
|
||||
archive_data_source_names = {}
|
||||
# Parse the paths of dumps found in the archive to get their respective dump metadata.
|
||||
dumps_from_archive = set()
|
||||
|
||||
for dump_path in dump_paths:
|
||||
try:
|
||||
(hook_name, _, data_source_name) = dump_path.split(
|
||||
borgmatic_source_directory + os.path.sep, 1
|
||||
)[1].split(os.path.sep)[0:3]
|
||||
except (ValueError, IndexError):
|
||||
if not dump_path:
|
||||
continue
|
||||
|
||||
# Probe to find the base directory that's at the start of the dump path.
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic_runtime_directory,
|
||||
borgmatic_source_directory,
|
||||
):
|
||||
try:
|
||||
(hook_name, host_and_port, data_source_name) = dump_path.split(
|
||||
base_directory + os.path.sep, 1
|
||||
)[1].split(os.path.sep)[0:3]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
parts = host_and_port.split(':', 1)
|
||||
|
||||
if len(parts) == 1:
|
||||
parts += (None,)
|
||||
|
||||
(hostname, port) = parts
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except (ValueError, TypeError):
|
||||
port = None
|
||||
|
||||
dumps_from_archive.add(Dump(hook_name, data_source_name, hostname, port))
|
||||
|
||||
# We've successfully parsed the dump path, so need to probe any further.
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
)
|
||||
else:
|
||||
if data_source_name not in archive_data_source_names.get(hook_name, []):
|
||||
archive_data_source_names.setdefault(hook_name, []).extend([data_source_name])
|
||||
|
||||
return archive_data_source_names
|
||||
return dumps_from_archive
|
||||
|
||||
|
||||
def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names):
|
||||
def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
'''
|
||||
Given a sequence of requested data source names to restore and a dict of hook name to the names
|
||||
of data sources found in an archive, return an expanded sequence of data source names to
|
||||
restore, replacing "all" with actual data source names as appropriate.
|
||||
Given restore arguments as an argparse.Namespace instance indicating which dumps to restore and
|
||||
a set of Dump instances representing the dumps found in an archive, return a set of specific
|
||||
Dump instances from the archive to restore. As part of this, replace any Dump having a data
|
||||
source name of "all" with multiple named Dump instances as appropriate.
|
||||
|
||||
Raise ValueError if any of the requested data source names cannot be found in the archive.
|
||||
Raise ValueError if any of the requested data source names cannot be found in the archive or if
|
||||
there are multiple archive dump matches for a given requested dump.
|
||||
'''
|
||||
# A map from data source hook name to the data source names to restore for that hook.
|
||||
restore_names = (
|
||||
{UNSPECIFIED_HOOK: requested_data_source_names}
|
||||
if requested_data_source_names
|
||||
else {UNSPECIFIED_HOOK: ['all']}
|
||||
requested_dumps = (
|
||||
{
|
||||
Dump(
|
||||
hook_name=(
|
||||
(
|
||||
restore_arguments.hook
|
||||
if restore_arguments.hook.endswith('_databases')
|
||||
else f'{restore_arguments.hook}_databases'
|
||||
)
|
||||
if restore_arguments.hook
|
||||
else UNSPECIFIED
|
||||
),
|
||||
data_source_name=name,
|
||||
hostname=restore_arguments.original_hostname or 'localhost',
|
||||
port=restore_arguments.original_port,
|
||||
)
|
||||
for name in restore_arguments.data_sources
|
||||
}
|
||||
if restore_arguments.data_sources
|
||||
else {
|
||||
Dump(
|
||||
hook_name=UNSPECIFIED,
|
||||
data_source_name='all',
|
||||
hostname=UNSPECIFIED,
|
||||
port=UNSPECIFIED,
|
||||
)
|
||||
}
|
||||
)
|
||||
missing_dumps = set()
|
||||
dumps_to_restore = set()
|
||||
|
||||
# If "all" is in restore_names, then replace it with the names of dumps found within the
|
||||
# archive.
|
||||
if 'all' in restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names[UNSPECIFIED_HOOK].remove('all')
|
||||
# If there's a requested "all" dump, add every dump from the archive to the dumps to restore.
|
||||
if any(dump for dump in requested_dumps if dump.data_source_name == 'all'):
|
||||
dumps_to_restore.update(dumps_from_archive)
|
||||
|
||||
for hook_name, data_source_names in archive_data_source_names.items():
|
||||
restore_names.setdefault(hook_name, []).extend(data_source_names)
|
||||
# If any archive dump matches a requested dump, add the archive dump to the dumps to restore.
|
||||
for requested_dump in requested_dumps:
|
||||
if requested_dump.data_source_name == 'all':
|
||||
continue
|
||||
|
||||
# If a data source is to be restored as part of "all", then remove it from restore names
|
||||
# so it doesn't get restored twice.
|
||||
for data_source_name in data_source_names:
|
||||
if data_source_name in restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names[UNSPECIFIED_HOOK].remove(data_source_name)
|
||||
|
||||
if not restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names.pop(UNSPECIFIED_HOOK)
|
||||
|
||||
combined_restore_names = set(
|
||||
name for data_source_names in restore_names.values() for name in data_source_names
|
||||
)
|
||||
combined_archive_data_source_names = set(
|
||||
name
|
||||
for data_source_names in archive_data_source_names.values()
|
||||
for name in data_source_names
|
||||
)
|
||||
|
||||
missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names)
|
||||
if missing_names:
|
||||
joined_names = ', '.join(f'"{name}"' for name in missing_names)
|
||||
raise ValueError(
|
||||
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
|
||||
matching_dumps = tuple(
|
||||
archive_dump
|
||||
for archive_dump in dumps_from_archive
|
||||
if dumps_match(requested_dump, archive_dump)
|
||||
)
|
||||
|
||||
return restore_names
|
||||
if len(matching_dumps) == 0:
|
||||
missing_dumps.add(requested_dump)
|
||||
elif len(matching_dumps) == 1:
|
||||
dumps_to_restore.add(matching_dumps[0])
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Cannot restore data source {render_dump_metadata(requested_dump)} because there are multiple matching dumps in the archive. Try adding flags to disambiguate.'
|
||||
)
|
||||
|
||||
if missing_dumps:
|
||||
rendered_dumps = ', '.join(
|
||||
f'{render_dump_metadata(dump)}' for dump in sorted(missing_dumps)
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive"
|
||||
)
|
||||
|
||||
return dumps_to_restore
|
||||
|
||||
|
||||
def ensure_data_sources_found(restore_names, remaining_restore_names, found_names):
|
||||
def ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored):
|
||||
'''
|
||||
Given a dict from hook name to data source names to restore, a dict from hook name to remaining
|
||||
data source names to restore, and a sequence of found (actually restored) data source names,
|
||||
raise ValueError if requested data source to restore were missing from the archive and/or
|
||||
Given a set of requested dumps to restore and a set of dumps actually restored, raise ValueError
|
||||
if any requested dumps to restore weren't restored, indicating that they were missing from the
|
||||
configuration.
|
||||
'''
|
||||
combined_restore_names = set(
|
||||
name
|
||||
for data_source_names in tuple(restore_names.values())
|
||||
+ tuple(remaining_restore_names.values())
|
||||
for name in data_source_names
|
||||
if not dumps_actually_restored:
|
||||
raise ValueError('No data source dumps were found to restore')
|
||||
|
||||
missing_dumps = sorted(
|
||||
dumps_to_restore - dumps_actually_restored, key=lambda dump: dump.data_source_name
|
||||
)
|
||||
|
||||
if not combined_restore_names and not found_names:
|
||||
raise ValueError('No data sources were found to restore')
|
||||
if missing_dumps:
|
||||
rendered_dumps = ', '.join(f'{render_dump_metadata(dump)}' for dump in missing_dumps)
|
||||
|
||||
missing_names = sorted(set(combined_restore_names) - set(found_names))
|
||||
if missing_names:
|
||||
joined_names = ', '.join(f'"{name}"' for name in missing_names)
|
||||
raise ValueError(
|
||||
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
|
||||
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration"
|
||||
)
|
||||
|
||||
|
||||
@@ -265,70 +420,83 @@ def run_restore(
|
||||
Run the "restore" action for the given repository, but only if the repository matches the
|
||||
requested repository in restore arguments.
|
||||
|
||||
Raise ValueError if a configured data source could not be found to restore.
|
||||
Raise ValueError if a configured data source could not be found to restore or there's no
|
||||
matching dump in the archive.
|
||||
'''
|
||||
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, restore_arguments.repository
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data sources from archive {restore_arguments.archive}'
|
||||
)
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Restoring data sources from archive {restore_arguments.archive}')
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
config, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
repository['path'],
|
||||
restore_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
archive_data_source_names = collect_archive_data_source_names(
|
||||
repository['path'],
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
restore_names = find_data_sources_to_restore(
|
||||
restore_arguments.data_sources, archive_data_source_names
|
||||
)
|
||||
found_names = set()
|
||||
remaining_restore_names = {}
|
||||
connection_params = {
|
||||
'hostname': restore_arguments.hostname,
|
||||
'port': restore_arguments.port,
|
||||
'username': restore_arguments.username,
|
||||
'password': restore_arguments.password,
|
||||
'restore_path': restore_arguments.restore_path,
|
||||
}
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
restore_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
dumps_from_archive = collect_dumps_from_archive(
|
||||
repository['path'],
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
|
||||
|
||||
for hook_name, data_source_names in restore_names.items():
|
||||
for data_source_name in data_source_names:
|
||||
found_hook_name, found_data_source = get_configured_data_source(
|
||||
config, archive_data_source_names, hook_name, data_source_name
|
||||
dumps_actually_restored = set()
|
||||
connection_params = {
|
||||
'hostname': restore_arguments.hostname,
|
||||
'port': restore_arguments.port,
|
||||
'username': restore_arguments.username,
|
||||
'password': restore_arguments.password,
|
||||
'restore_path': restore_arguments.restore_path,
|
||||
}
|
||||
|
||||
# Restore each dump.
|
||||
for restore_dump in dumps_to_restore:
|
||||
found_data_source = get_configured_data_source(
|
||||
config,
|
||||
restore_dump,
|
||||
)
|
||||
|
||||
# For a dump that wasn't found via an exact match in the configuration, try to fallback
|
||||
# to an "all" data source.
|
||||
if not found_data_source:
|
||||
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
|
||||
data_source_name
|
||||
found_data_source = get_configured_data_source(
|
||||
config,
|
||||
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
|
||||
)
|
||||
continue
|
||||
|
||||
found_names.add(data_source_name)
|
||||
restore_single_data_source(
|
||||
if not found_data_source:
|
||||
continue
|
||||
|
||||
found_data_source = dict(found_data_source)
|
||||
found_data_source['name'] = restore_dump.data_source_name
|
||||
|
||||
dumps_actually_restored.add(restore_dump)
|
||||
|
||||
restore_single_dump(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -336,45 +504,19 @@ def run_restore(
|
||||
local_path,
|
||||
remote_path,
|
||||
archive_name,
|
||||
found_hook_name or hook_name,
|
||||
restore_dump.hook_name,
|
||||
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
# For any data sources that weren't found via exact matches in the configuration, try to
|
||||
# fallback to "all" entries.
|
||||
for hook_name, data_source_names in remaining_restore_names.items():
|
||||
for data_source_name in data_source_names:
|
||||
found_hook_name, found_data_source = get_configured_data_source(
|
||||
config, archive_data_source_names, hook_name, data_source_name, 'all'
|
||||
)
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
if not found_data_source:
|
||||
continue
|
||||
|
||||
found_names.add(data_source_name)
|
||||
data_source = copy.copy(found_data_source)
|
||||
data_source['name'] = data_source_name
|
||||
|
||||
restore_single_data_source(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
archive_name,
|
||||
found_hook_name or hook_name,
|
||||
dict(data_source, **{'schemas': restore_arguments.schemas}),
|
||||
connection_params,
|
||||
)
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
ensure_data_sources_found(restore_names, remaining_restore_names, found_names)
|
||||
ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored)
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.commands.arguments
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -67,6 +68,7 @@ def run_arbitrary_borg(
|
||||
'ARCHIVE': archive if archive else '',
|
||||
},
|
||||
),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
@@ -37,6 +38,7 @@ def break_lock(
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def change_passphrase(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, change
|
||||
passphrase arguments, and optional local and remote Borg paths, change the repository passphrase
|
||||
based on an interactive prompt.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
|
||||
full_command = (
|
||||
(local_path, 'key', 'change-passphrase')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ flags.make_repository_flags(
|
||||
repository_path,
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info(f'{repository_path}: Skipping change password (dry run)')
|
||||
return
|
||||
|
||||
# If the original passphrase is set programmatically, then Borg won't prompt for a new one! So
|
||||
# don't give Borg any passphrase, and it'll ask the user for both old and new ones.
|
||||
config_without_passphrase = {
|
||||
option_name: value
|
||||
for (option_name, value) in config.items()
|
||||
if option_name not in ('encryption_passphrase', 'encryption_passcommand')
|
||||
}
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
full_command,
|
||||
output_file=borgmatic.execute.DO_NOT_CAPTURE,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config_without_passphrase),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
logger.answer(
|
||||
f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)"
|
||||
)
|
||||
+34
-10
@@ -2,7 +2,8 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
|
||||
from borgmatic.borg import environment, feature, flags, rinfo
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags, repo_info
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,10 +51,10 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_argument
|
||||
return ()
|
||||
|
||||
|
||||
def make_check_flags(checks, archive_filter_flags):
|
||||
def make_check_name_flags(checks, archive_filter_flags):
|
||||
'''
|
||||
Given a parsed sequence of checks and a sequence of flags to filter archives, transform the
|
||||
checks into tuple of command-line check flags.
|
||||
Given parsed checks set and a sequence of flags to filter archives, transform the checks into
|
||||
tuple of command-line check flags.
|
||||
|
||||
For example, given parsed checks of:
|
||||
|
||||
@@ -68,13 +69,13 @@ def make_check_flags(checks, archive_filter_flags):
|
||||
'''
|
||||
if 'data' in checks:
|
||||
data_flags = ('--verify-data',)
|
||||
checks += ('archives',)
|
||||
checks.update({'archives'})
|
||||
else:
|
||||
data_flags = ()
|
||||
|
||||
common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags
|
||||
|
||||
if {'repository', 'archives'}.issubset(set(checks)):
|
||||
if {'repository', 'archives'}.issubset(checks):
|
||||
return common_flags
|
||||
|
||||
return (
|
||||
@@ -94,7 +95,7 @@ def get_repository_id(
|
||||
'''
|
||||
try:
|
||||
return json.loads(
|
||||
rinfo.display_repository_info(
|
||||
repo_info.display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -134,11 +135,33 @@ def check_archives(
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
verbosity_flags = ('--debug', '--show-rc')
|
||||
|
||||
try:
|
||||
repository_check_config = next(
|
||||
check for check in config.get('checks', ()) if check.get('name') == 'repository'
|
||||
)
|
||||
except StopIteration:
|
||||
repository_check_config = {}
|
||||
|
||||
if check_arguments.max_duration and 'archives' in checks:
|
||||
raise ValueError('The archives check cannot run when the --max-duration flag is used')
|
||||
if repository_check_config.get('max_duration') and 'archives' in checks:
|
||||
raise ValueError(
|
||||
'The archives check cannot run when the repository check has the max_duration option set'
|
||||
)
|
||||
|
||||
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
|
||||
umask = config.get('umask')
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
full_command = (
|
||||
(local_path, 'check')
|
||||
+ (('--repair',) if check_arguments.repair else ())
|
||||
+ make_check_flags(checks, archive_filter_flags)
|
||||
+ (('--max-duration', str(max_duration)) if max_duration else ())
|
||||
+ make_check_name_flags(checks, archive_filter_flags)
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ verbosity_flags
|
||||
@@ -147,8 +170,7 @@ def check_archives(
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
# The Borg repair option triggers an interactive prompt, which won't work when output is
|
||||
# captured. And progress messes with the terminal directly.
|
||||
@@ -157,6 +179,7 @@ def check_archives(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -164,6 +187,7 @@ def check_archives(
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
@@ -49,6 +50,7 @@ def compact_segments(
|
||||
full_command,
|
||||
output_log_level=logging.INFO,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
+61
-166
@@ -1,13 +1,14 @@
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags, state
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import (
|
||||
DO_NOT_CAPTURE,
|
||||
execute_command,
|
||||
@@ -18,29 +19,6 @@ from borgmatic.execute import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_directory(directory):
|
||||
'''
|
||||
Given a directory path, expand any tilde (representing a user's home directory) and any globs
|
||||
therein. Return a list of one or more resulting paths.
|
||||
'''
|
||||
expanded_directory = os.path.expanduser(directory)
|
||||
|
||||
return glob.glob(expanded_directory) or [expanded_directory]
|
||||
|
||||
|
||||
def expand_directories(directories):
|
||||
'''
|
||||
Given a sequence of directory paths, expand tildes and globs in each one. Return all the
|
||||
resulting directories as a single flattened tuple.
|
||||
'''
|
||||
if directories is None:
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(expand_directory(directory) for directory in directories)
|
||||
)
|
||||
|
||||
|
||||
def expand_home_directories(directories):
|
||||
'''
|
||||
Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing.
|
||||
@@ -52,65 +30,6 @@ def expand_home_directories(directories):
|
||||
return tuple(os.path.expanduser(directory) for directory in directories)
|
||||
|
||||
|
||||
def map_directories_to_devices(directories):
|
||||
'''
|
||||
Given a sequence of directories, return a map from directory to an identifier for the device on
|
||||
which that directory resides or None if the path doesn't exist.
|
||||
|
||||
This is handy for determining whether two different directories are on the same filesystem (have
|
||||
the same device identifier).
|
||||
'''
|
||||
return {
|
||||
directory: os.stat(directory).st_dev if os.path.exists(directory) else None
|
||||
for directory in directories
|
||||
}
|
||||
|
||||
|
||||
def deduplicate_directories(directory_devices, additional_directory_devices):
|
||||
'''
|
||||
Given a map from directory to the identifier for the device on which that directory resides,
|
||||
return the directories as a sorted tuple with all duplicate child directories removed. For
|
||||
instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
|
||||
|
||||
The one exception to this rule is if two paths are on different filesystems (devices). In that
|
||||
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
|
||||
location.one_file_system option is true).
|
||||
|
||||
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
|
||||
child directories, because it will naturally spider the contents of the parent directory. And
|
||||
there are cases where Borg coming across the same file twice will result in duplicate reads and
|
||||
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
|
||||
Borg.
|
||||
|
||||
If any additional directory devices are given, also deduplicate against them, but don't include
|
||||
them in the returned directories.
|
||||
'''
|
||||
deduplicated = set()
|
||||
directories = sorted(directory_devices.keys())
|
||||
additional_directories = sorted(additional_directory_devices.keys())
|
||||
all_devices = {**directory_devices, **additional_directory_devices}
|
||||
|
||||
for directory in directories:
|
||||
deduplicated.add(directory)
|
||||
parents = pathlib.PurePath(directory).parents
|
||||
|
||||
# If another directory in the given list (or the additional list) is a parent of current
|
||||
# directory (even n levels up) and both are on the same filesystem, then the current
|
||||
# directory is a duplicate.
|
||||
for other_directory in directories + additional_directories:
|
||||
for parent in parents:
|
||||
if (
|
||||
pathlib.PurePath(other_directory) == parent
|
||||
and all_devices[directory] is not None
|
||||
and all_devices[other_directory] == all_devices[directory]
|
||||
):
|
||||
if directory in deduplicated:
|
||||
deduplicated.remove(directory)
|
||||
break
|
||||
|
||||
return tuple(sorted(deduplicated))
|
||||
|
||||
|
||||
def write_pattern_file(patterns=None, sources=None, pattern_file=None):
|
||||
'''
|
||||
Given a sequence of patterns and an optional sequence of source directories, write them to a
|
||||
@@ -215,37 +134,6 @@ def make_list_filter_flags(local_borg_version, dry_run):
|
||||
return f'{base_flags}-'
|
||||
|
||||
|
||||
def collect_borgmatic_source_directories(borgmatic_source_directory):
|
||||
'''
|
||||
Return a list of borgmatic-specific source directories used for state like database backups.
|
||||
'''
|
||||
if not borgmatic_source_directory:
|
||||
borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
return (
|
||||
[borgmatic_source_directory]
|
||||
if os.path.exists(os.path.expanduser(borgmatic_source_directory))
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
ROOT_PATTERN_PREFIX = 'R '
|
||||
|
||||
|
||||
def pattern_root_directories(patterns=None):
|
||||
'''
|
||||
Given a sequence of patterns, parse out and return just the root directories.
|
||||
'''
|
||||
if not patterns:
|
||||
return []
|
||||
|
||||
return [
|
||||
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
|
||||
for pattern in patterns
|
||||
if pattern.startswith(ROOT_PATTERN_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def special_file(path):
|
||||
'''
|
||||
Return whether the given path is a special file (character device, block device, or named pipe
|
||||
@@ -272,14 +160,24 @@ def any_parent_directories(path, candidate_parents):
|
||||
|
||||
|
||||
def collect_special_file_paths(
|
||||
create_command, config, local_path, working_directory, borg_environment, skip_directories
|
||||
create_command,
|
||||
config,
|
||||
local_path,
|
||||
working_directory,
|
||||
borg_environment,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a Borg create command as a tuple, a configuration dict, a local Borg path, a working
|
||||
directory, a dict of environment variables to pass to Borg, and a sequence of parent directories
|
||||
to skip, collect the paths for any special files (character devices, block devices, and named
|
||||
pipes / FIFOs) that Borg would encounter during a create. These are all paths that could cause
|
||||
Borg to hang if its --read-special flag is used.
|
||||
directory, a dict of environment variables to pass to Borg, and the borgmatic runtime directory,
|
||||
collect the paths for any special files (character devices, block devices, and named pipes /
|
||||
FIFOs) that Borg would encounter during a create. These are all paths that could cause Borg to
|
||||
hang if its --read-special flag is used.
|
||||
|
||||
Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
|
||||
its own special files there for database dumps. And if the borgmatic runtime directory is
|
||||
configured to be excluded from the files Borg backs up, error, because this means Borg won't be
|
||||
able to consume any database dumps and therefore borgmatic will hang.
|
||||
'''
|
||||
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
|
||||
# files including any named pipe we've created.
|
||||
@@ -298,36 +196,46 @@ def collect_special_file_paths(
|
||||
for path_line in paths_output.split('\n')
|
||||
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
|
||||
)
|
||||
skip_paths = {}
|
||||
|
||||
return tuple(
|
||||
path
|
||||
for path in paths
|
||||
if special_file(path) and not any_parent_directories(path, skip_directories)
|
||||
)
|
||||
if os.path.exists(borgmatic_runtime_directory):
|
||||
skip_paths = {
|
||||
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
|
||||
}
|
||||
|
||||
if not skip_paths:
|
||||
raise ValueError(
|
||||
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes. Please remove it from excludes or change the runtime directory.'
|
||||
)
|
||||
|
||||
return tuple(path for path in paths if special_file(path) if path not in skip_paths)
|
||||
|
||||
|
||||
def check_all_source_directories_exist(source_directories):
|
||||
'''
|
||||
Given a sequence of source directories, check that they all exist. If any do not, raise an
|
||||
exception.
|
||||
Given a sequence of source directories, check that the source directories all exist. If any do
|
||||
not, raise an exception.
|
||||
'''
|
||||
missing_directories = [
|
||||
source_directory
|
||||
for source_directory in source_directories
|
||||
if not all([os.path.exists(directory) for directory in expand_directory(source_directory)])
|
||||
if not os.path.exists(source_directory)
|
||||
]
|
||||
if missing_directories:
|
||||
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
|
||||
|
||||
|
||||
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
|
||||
|
||||
|
||||
def make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
config_paths,
|
||||
source_directories,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_source_directories,
|
||||
borgmatic_runtime_directory,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
@@ -343,25 +251,12 @@ def make_base_create_command(
|
||||
handle, open exclude file handle).
|
||||
'''
|
||||
if config.get('source_directories_must_exist', False):
|
||||
check_all_source_directories_exist(config.get('source_directories'))
|
||||
|
||||
sources = deduplicate_directories(
|
||||
map_directories_to_devices(
|
||||
expand_directories(
|
||||
tuple(config.get('source_directories', ()))
|
||||
+ borgmatic_source_directories
|
||||
+ tuple(config_paths if config.get('store_config_files', True) else ())
|
||||
)
|
||||
),
|
||||
additional_directory_devices=map_directories_to_devices(
|
||||
expand_directories(pattern_root_directories(config.get('patterns')))
|
||||
),
|
||||
)
|
||||
check_all_source_directories_exist(source_directories)
|
||||
|
||||
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
|
||||
|
||||
pattern_file = (
|
||||
write_pattern_file(config.get('patterns'), sources)
|
||||
write_pattern_file(config.get('patterns'), source_directories)
|
||||
if config.get('patterns') or config.get('patterns_from')
|
||||
else None
|
||||
)
|
||||
@@ -371,11 +266,14 @@ def make_base_create_command(
|
||||
chunker_params = config.get('chunker_params', None)
|
||||
compression = config.get('compression', None)
|
||||
upload_rate_limit = config.get('upload_rate_limit', None)
|
||||
upload_buffer_size = config.get('upload_buffer_size', None)
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
|
||||
files_cache = config.get('files_cache')
|
||||
archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT)
|
||||
archive_name_format = config.get(
|
||||
'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
|
||||
)
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
|
||||
|
||||
if feature.available(feature.Feature.ATIME, local_borg_version):
|
||||
@@ -412,7 +310,8 @@ def make_base_create_command(
|
||||
+ (('--chunker-params', chunker_params) if chunker_params else ())
|
||||
+ (('--compression', compression) if compression else ())
|
||||
+ upload_ratelimit_flags
|
||||
+ (('--one-file-system',) if config.get('one_file_system') or stream_processes else ())
|
||||
+ (('--upload-buffer', str(upload_buffer_size)) if upload_buffer_size else ())
|
||||
+ (('--one-file-system',) if config.get('one_file_system') else ())
|
||||
+ numeric_ids_flags
|
||||
+ atime_flags
|
||||
+ (('--noctime',) if config.get('ctime') is False else ())
|
||||
@@ -435,7 +334,7 @@ def make_base_create_command(
|
||||
|
||||
create_positional_arguments = flags.make_repository_archive_flags(
|
||||
repository_path, archive_name_format, local_borg_version
|
||||
) + (sources if not pattern_file else ())
|
||||
) + (tuple(source_directories) if not pattern_file else ())
|
||||
|
||||
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
|
||||
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
|
||||
@@ -443,12 +342,8 @@ def make_base_create_command(
|
||||
logger.warning(
|
||||
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
|
||||
)
|
||||
try:
|
||||
working_directory = os.path.expanduser(config.get('working_directory'))
|
||||
except TypeError:
|
||||
working_directory = None
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
logger.debug(f'{repository_path}: Collecting special file paths')
|
||||
special_file_paths = collect_special_file_paths(
|
||||
@@ -457,12 +352,17 @@ def make_base_create_command(
|
||||
local_path,
|
||||
working_directory,
|
||||
borg_environment,
|
||||
skip_directories=borgmatic_source_directories,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
if special_file_paths:
|
||||
truncated_special_file_paths = textwrap.shorten(
|
||||
', '.join(special_file_paths),
|
||||
width=MAX_SPECIAL_FILE_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.warning(
|
||||
f'{repository_path}: Excluding special files to prevent Borg from hanging: {", ".join(special_file_paths)}'
|
||||
f'{repository_path}: Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
|
||||
)
|
||||
exclude_file = write_pattern_file(
|
||||
expand_home_directories(
|
||||
@@ -479,9 +379,10 @@ def create_archive(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
config_paths,
|
||||
source_directories,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
@@ -499,19 +400,18 @@ def create_archive(
|
||||
create command while also triggering the given processes to produce output.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
borgmatic_source_directories = expand_directories(
|
||||
collect_borgmatic_source_directories(config.get('borgmatic_source_directory'))
|
||||
)
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
|
||||
make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
config_paths,
|
||||
source_directories,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_source_directories,
|
||||
borgmatic_runtime_directory,
|
||||
local_path,
|
||||
remote_path,
|
||||
progress,
|
||||
@@ -532,11 +432,6 @@ def create_archive(
|
||||
# the terminal directly.
|
||||
output_file = DO_NOT_CAPTURE if progress else None
|
||||
|
||||
try:
|
||||
working_directory = os.path.expanduser(config.get('working_directory'))
|
||||
except TypeError:
|
||||
working_directory = None
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
create_flags += (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.borg.flags
|
||||
import borgmatic.borg.repo_delete
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the delete action as an argparse.Namespace, and global arguments, return a command
|
||||
as a tuple to delete archives from the repository.
|
||||
'''
|
||||
return (
|
||||
(local_path, 'delete')
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
|
||||
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
|
||||
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
|
||||
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)
|
||||
+ (
|
||||
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
|
||||
if delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
# Ignore match_archives and archive_name_format options from configuration, so the user has
|
||||
# to be explicit on the command-line about the archives they want to delete.
|
||||
+ borgmatic.borg.flags.make_match_archives_flags(
|
||||
delete_arguments.match_archives or delete_arguments.archive,
|
||||
archive_name_format=None,
|
||||
local_borg_version=local_borg_version,
|
||||
default_archive_name_format='*',
|
||||
)
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
delete_arguments,
|
||||
excludes=('list_archives', 'force', 'match_archives', 'archive', 'repository'),
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
|
||||
|
||||
ARCHIVE_RELATED_ARGUMENT_NAMES = (
|
||||
'archive',
|
||||
'match_archives',
|
||||
'first',
|
||||
'last',
|
||||
'oldest',
|
||||
'newest',
|
||||
'older',
|
||||
'newer',
|
||||
)
|
||||
|
||||
|
||||
def delete_archives(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the delete action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, delete the selected archives from the
|
||||
repository. If no archives are selected, then delete the entire repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
if not any(
|
||||
getattr(delete_arguments, argument_name, None)
|
||||
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
|
||||
):
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
|
||||
):
|
||||
logger.warning(
|
||||
'Deleting an entire repository with the delete action is deprecated when using Borg 2.x+. Use the repo-delete action instead.'
|
||||
)
|
||||
|
||||
repo_delete_arguments = argparse.Namespace(
|
||||
repository=repository['path'],
|
||||
list_archives=delete_arguments.list_archives,
|
||||
force=delete_arguments.force,
|
||||
cache_only=delete_arguments.cache_only,
|
||||
keep_security_info=delete_arguments.keep_security_info,
|
||||
)
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
command = make_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_base_directory': 'BORG_BASE_DIR',
|
||||
'borg_config_directory': 'BORG_CONFIG_DIR',
|
||||
@@ -38,8 +40,8 @@ def make_environment(config):
|
||||
option_name,
|
||||
environment_variable_name,
|
||||
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
|
||||
value = config.get(option_name)
|
||||
if value is not None:
|
||||
if os.environ.get(environment_variable_name) is None:
|
||||
value = config.get(option_name)
|
||||
environment[environment_variable_name] = 'yes' if value else 'no'
|
||||
|
||||
for (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -18,9 +19,9 @@ def export_key(
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, and
|
||||
optional local and remote Borg paths, export the repository key to the destination path
|
||||
indicated in the export arguments.
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, export
|
||||
arguments, and optional local and remote Borg paths, export the repository key to the
|
||||
destination path indicated in the export arguments.
|
||||
|
||||
If the destination path is empty or "-", then print the key to stdout instead of to a file.
|
||||
|
||||
@@ -29,9 +30,10 @@ def export_key(
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if export_arguments.path and export_arguments.path != '-':
|
||||
if os.path.exists(export_arguments.path):
|
||||
if os.path.exists(os.path.join(working_directory or '', export_arguments.path)):
|
||||
raise FileExistsError(
|
||||
f'Destination path {export_arguments.path} already exists. Aborting.'
|
||||
)
|
||||
@@ -58,7 +60,7 @@ def export_key(
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logging.info(f'{repository_path}: Skipping key export (dry run)')
|
||||
logger.info(f'{repository_path}: Skipping key export (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
@@ -66,6 +68,7 @@ def export_key(
|
||||
output_file=output_file,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -70,6 +71,7 @@ def export_tar_archive(
|
||||
output_file=DO_NOT_CAPTURE if destination_path == '-' else None,
|
||||
output_log_level=output_log_level,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
+28
-11
@@ -2,8 +2,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
from borgmatic.borg import environment, feature, flags, rlist
|
||||
from borgmatic.borg import environment, feature, flags, repo_list
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,7 +30,7 @@ def extract_last_archive_dry_run(
|
||||
verbosity_flags = ('--info',)
|
||||
|
||||
try:
|
||||
last_archive_name = rlist.resolve_archive_name(
|
||||
last_archive_name = repo_list.resolve_archive_name(
|
||||
repository_path,
|
||||
'latest',
|
||||
config,
|
||||
@@ -58,8 +59,8 @@ def extract_last_archive_dry_run(
|
||||
|
||||
execute_command(
|
||||
full_extract_command,
|
||||
working_directory=None,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -104,8 +105,18 @@ def extract_archive(
|
||||
if not paths:
|
||||
raise ValueError('The --strip-components flag with "all" requires at least one --path')
|
||||
|
||||
# Calculate the maximum number of leading path components of the given paths.
|
||||
strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
|
||||
# Calculate the maximum number of leading path components of the given paths. "if piece"
|
||||
# ignores empty path components, e.g. those resulting from a leading slash. And the "- 1"
|
||||
# is so this doesn't count the final path component, e.g. the filename itself.
|
||||
strip_components = max(
|
||||
0,
|
||||
*(
|
||||
len(tuple(piece for piece in path.split(os.path.sep) if piece)) - 1
|
||||
for path in paths
|
||||
),
|
||||
)
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
full_command = (
|
||||
(local_path, 'extract')
|
||||
@@ -121,9 +132,12 @@ def extract_archive(
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--stdout',) if extract_to_stdout else ())
|
||||
+ flags.make_repository_archive_flags(
|
||||
# Make the repository path absolute so the working directory changes below don't
|
||||
# prevent Borg from finding the repo.
|
||||
borgmatic.config.validate.normalize_repository_path(repository),
|
||||
# Make the repository path absolute so the destination directory used below via changing
|
||||
# the working directory doesn't prevent Borg from finding the repo. But also apply the
|
||||
# user's configured working directory (if any) to the repo path.
|
||||
borgmatic.config.validate.normalize_repository_path(
|
||||
os.path.join(working_directory or '', repository)
|
||||
),
|
||||
archive,
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -132,6 +146,9 @@ def extract_archive(
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
full_destination_path = (
|
||||
os.path.join(working_directory or '', destination_path) if destination_path else None
|
||||
)
|
||||
|
||||
# The progress output isn't compatible with captured and logged output, as progress messes with
|
||||
# the terminal directly.
|
||||
@@ -139,8 +156,8 @@ def extract_archive(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
working_directory=destination_path,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -150,9 +167,9 @@ def extract_archive(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=subprocess.PIPE,
|
||||
working_directory=destination_path,
|
||||
run_to_completion=False,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -161,8 +178,8 @@ def extract_archive(
|
||||
# if the restore paths don't exist in the archive.
|
||||
execute_command(
|
||||
full_command,
|
||||
working_directory=destination_path,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -10,11 +10,13 @@ class Feature(Enum):
|
||||
NUMERIC_IDS = 4
|
||||
UPLOAD_RATELIMIT = 5
|
||||
SEPARATE_REPOSITORY_ARCHIVE = 6
|
||||
RCREATE = 7
|
||||
RLIST = 8
|
||||
RINFO = 9
|
||||
MATCH_ARCHIVES = 10
|
||||
EXCLUDED_FILES_MINUS = 11
|
||||
REPO_CREATE = 7
|
||||
REPO_LIST = 8
|
||||
REPO_INFO = 9
|
||||
REPO_DELETE = 10
|
||||
MATCH_ARCHIVES = 11
|
||||
EXCLUDED_FILES_MINUS = 12
|
||||
ARCHIVE_SERIES = 13
|
||||
|
||||
|
||||
FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
@@ -24,11 +26,13 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
Feature.NUMERIC_IDS: parse('1.2.0b3'), # borg create/extract/mount --numeric-ids
|
||||
Feature.UPLOAD_RATELIMIT: parse('1.2.0b3'), # borg create --upload-ratelimit
|
||||
Feature.SEPARATE_REPOSITORY_ARCHIVE: parse('2.0.0a2'), # --repo with separate archive
|
||||
Feature.RCREATE: parse('2.0.0a2'), # borg rcreate
|
||||
Feature.RLIST: parse('2.0.0a2'), # borg rlist
|
||||
Feature.RINFO: parse('2.0.0a2'), # borg rinfo
|
||||
Feature.REPO_CREATE: parse('2.0.0a2'), # borg repo-create
|
||||
Feature.REPO_LIST: parse('2.0.0a2'), # borg repo-list
|
||||
Feature.REPO_INFO: parse('2.0.0a2'), # borg repo-info
|
||||
Feature.REPO_DELETE: parse('2.0.0a2'), # borg repo-delete
|
||||
Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives
|
||||
Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes
|
||||
Feature.ARCHIVE_SERIES: parse('2.0.0b11'), # identically named archives form a series
|
||||
}
|
||||
|
||||
|
||||
|
||||
+44
-4
@@ -50,6 +50,9 @@ def make_repository_flags(repository_path, local_borg_version):
|
||||
) + (repository_path,)
|
||||
|
||||
|
||||
ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
|
||||
|
||||
|
||||
def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
'''
|
||||
Given the path of a Borg repository, an archive name or pattern, and the local Borg version,
|
||||
@@ -57,16 +60,42 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
and archive.
|
||||
'''
|
||||
return (
|
||||
('--repo', repository_path, archive)
|
||||
(
|
||||
'--repo',
|
||||
repository_path,
|
||||
(
|
||||
f'aid:{archive}'
|
||||
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
|
||||
and ARCHIVE_HASH_PATTERN.match(archive)
|
||||
and not archive.startswith('aid:')
|
||||
else archive
|
||||
),
|
||||
)
|
||||
if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
|
||||
else (f'{repository_path}::{archive}',)
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}' # noqa: FS003
|
||||
|
||||
|
||||
def make_match_archives_flags(match_archives, archive_name_format, local_borg_version):
|
||||
def get_default_archive_name_format(local_borg_version):
|
||||
'''
|
||||
Given the local Borg version, return the corresponding default archive name format.
|
||||
'''
|
||||
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version):
|
||||
return DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES
|
||||
|
||||
return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
|
||||
|
||||
|
||||
def make_match_archives_flags(
|
||||
match_archives,
|
||||
archive_name_format,
|
||||
local_borg_version,
|
||||
default_archive_name_format=None,
|
||||
):
|
||||
'''
|
||||
Return match archives flags based on the given match archives value, if any. If it isn't set,
|
||||
return match archives flags to match archives created with the given (or default) archive name
|
||||
@@ -78,12 +107,23 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
|
||||
return ()
|
||||
|
||||
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
|
||||
if (
|
||||
feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
|
||||
and ARCHIVE_HASH_PATTERN.match(match_archives)
|
||||
and not match_archives.startswith('aid:')
|
||||
):
|
||||
return ('--match-archives', f'aid:{match_archives}')
|
||||
|
||||
return ('--match-archives', match_archives)
|
||||
else:
|
||||
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
|
||||
|
||||
derived_match_archives = re.sub(
|
||||
r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or DEFAULT_ARCHIVE_NAME_FORMAT
|
||||
r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
|
||||
'*',
|
||||
archive_name_format
|
||||
or default_archive_name_format
|
||||
or get_default_archive_name_format(local_borg_version),
|
||||
)
|
||||
|
||||
if derived_match_archives == '*':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -35,6 +36,7 @@ def make_info_command(
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ (
|
||||
@@ -96,10 +98,12 @@ def display_archives_info(
|
||||
remote_path,
|
||||
)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
json_info = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -113,6 +117,7 @@ def display_archives_info(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
+31
-27
@@ -3,20 +3,21 @@ import copy
|
||||
import logging
|
||||
import re
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags, rlist
|
||||
from borgmatic.borg import environment, feature, flags, repo_list
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
|
||||
ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
|
||||
MAKE_FLAGS_EXCLUDES = (
|
||||
'repository',
|
||||
'archive',
|
||||
'paths',
|
||||
'find_paths',
|
||||
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST
|
||||
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST
|
||||
|
||||
|
||||
def make_list_command(
|
||||
@@ -33,8 +34,6 @@ def make_list_command(
|
||||
and local and remote Borg paths, return a command as a tuple to list archives or paths within an
|
||||
archive.
|
||||
'''
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
|
||||
return (
|
||||
(local_path, 'list')
|
||||
+ (
|
||||
@@ -48,8 +47,9 @@ def make_list_command(
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', lock_wait)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ flags.make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ (
|
||||
flags.make_repository_archive_flags(
|
||||
@@ -100,10 +100,11 @@ def capture_archive_listing(
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
|
||||
version, global arguments as an argparse.Namespace, the archive paths in which to list files,
|
||||
the Borg path format to use for the output, and local and remote Borg paths, capture the output
|
||||
of listing that archive and return it as a list of file paths.
|
||||
Given a local or remote repository path, an archive name, a configuration
|
||||
dict, the local Borg version, global arguments as an argparse.Namespace,
|
||||
the archive paths (or Borg patterns) in which to list files, the Borg path
|
||||
format to use for the output, and local and remote Borg paths, capture the
|
||||
output of listing that archive and return it as a list of file paths.
|
||||
'''
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
@@ -116,7 +117,7 @@ def capture_archive_listing(
|
||||
argparse.Namespace(
|
||||
repository=repository_path,
|
||||
archive=archive,
|
||||
paths=[f'sh:{path}' for path in list_paths] if list_paths else None,
|
||||
paths=[path for path in list_paths] if list_paths else None,
|
||||
find_paths=None,
|
||||
json=None,
|
||||
format=path_format or '{path}{NL}', # noqa: FS003
|
||||
@@ -126,6 +127,7 @@ def capture_archive_listing(
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -144,22 +146,22 @@ def list_archive(
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, global
|
||||
arguments as an argparse.Namespace, the arguments to the list action as an argparse.Namespace,
|
||||
and local and remote Borg paths, display the output of listing the files of a Borg archive (or
|
||||
return JSON output). If list_arguments.find_paths are given, list the files by searching across
|
||||
multiple archives. If neither find_paths nor archive name are given, instead list the archives
|
||||
in the given repository.
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the list action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, display the output of listing the files of
|
||||
a Borg archive (or return JSON output). If list_arguments.find_paths are given, list the files
|
||||
by searching across multiple archives. If neither find_paths nor archive name are given, instead
|
||||
list the archives in the given repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
if not list_arguments.archive and not list_arguments.find_paths:
|
||||
if feature.available(feature.Feature.RLIST, local_borg_version):
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version):
|
||||
logger.warning(
|
||||
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the rlist action instead.'
|
||||
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.'
|
||||
)
|
||||
|
||||
rlist_arguments = argparse.Namespace(
|
||||
repo_list_arguments = argparse.Namespace(
|
||||
repository=repository_path,
|
||||
short=list_arguments.short,
|
||||
format=list_arguments.format,
|
||||
@@ -170,18 +172,18 @@ def list_archive(
|
||||
first=list_arguments.first,
|
||||
last=list_arguments.last,
|
||||
)
|
||||
return rlist.list_repository(
|
||||
return repo_list.list_repository(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
if list_arguments.archive:
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
|
||||
if getattr(list_arguments, name, None):
|
||||
logger.warning(
|
||||
f"The --{name.replace('_', '-')} flag on the list action is ignored when using the --archive flag."
|
||||
@@ -198,7 +200,7 @@ def list_archive(
|
||||
# If there are any paths to find (and there's not a single archive already selected), start by
|
||||
# getting a list of archives to search.
|
||||
if list_arguments.find_paths and not list_arguments.archive:
|
||||
rlist_arguments = argparse.Namespace(
|
||||
repo_list_arguments = argparse.Namespace(
|
||||
repository=repository_path,
|
||||
short=True,
|
||||
format=None,
|
||||
@@ -213,16 +215,17 @@ def list_archive(
|
||||
# Ask Borg to list archives. Capture its output for use below.
|
||||
archive_lines = tuple(
|
||||
execute_command_and_capture_output(
|
||||
rlist.make_rlist_command(
|
||||
repo_list.make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -241,7 +244,7 @@ def list_archive(
|
||||
|
||||
# This list call is to show the files in a single archive, not list multiple archives. So
|
||||
# blank out any archive filtering flags. They'll break anyway in Borg 2.
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
|
||||
setattr(archive_arguments, name, None)
|
||||
|
||||
main_command = make_list_command(
|
||||
@@ -258,6 +261,7 @@ def list_archive(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
@@ -59,6 +60,7 @@ def mount_archive(
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
# Don't capture the output when foreground mode is used so that ctrl-C can work properly.
|
||||
if mount_arguments.foreground:
|
||||
@@ -66,6 +68,7 @@ def mount_archive(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -74,6 +77,7 @@ def mount_archive(
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command
|
||||
@@ -7,9 +8,10 @@ from borgmatic.execute import execute_command
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_prune_flags(config, local_borg_version):
|
||||
def make_prune_flags(config, prune_arguments, local_borg_version):
|
||||
'''
|
||||
Given a configuration dict mapping from option name to value, transform it into an sequence of
|
||||
Given a configuration dict mapping from option name to value, prune arguments as an
|
||||
argparse.Namespace instance, and the local Borg version, produce a corresponding sequence of
|
||||
command-line flags.
|
||||
|
||||
For example, given a retention config of:
|
||||
@@ -39,7 +41,7 @@ def make_prune_flags(config, local_borg_version):
|
||||
if prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
config.get('match_archives'),
|
||||
prune_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -68,7 +70,7 @@ def prune_archives(
|
||||
|
||||
full_command = (
|
||||
(local_path, 'prune')
|
||||
+ make_prune_flags(config, local_borg_version)
|
||||
+ make_prune_flags(config, prune_arguments, local_borg_version)
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
@@ -77,7 +79,7 @@ def prune_archives(
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ flags.make_flags_from_arguments(
|
||||
prune_arguments,
|
||||
excludes=('repository', 'stats', 'list_archives'),
|
||||
excludes=('repository', 'match_archives', 'stats', 'list_archives'),
|
||||
)
|
||||
+ (('--list',) if prune_arguments.list_archives else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -95,6 +97,7 @@ def prune_archives(
|
||||
full_command,
|
||||
output_log_level=output_log_level,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -3,13 +3,14 @@ import json
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from borgmatic.borg import environment, feature, flags, rinfo
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags, repo_info
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13}
|
||||
REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13, 15}
|
||||
|
||||
|
||||
def create_repository(
|
||||
@@ -39,7 +40,7 @@ def create_repository(
|
||||
'''
|
||||
try:
|
||||
info_data = json.loads(
|
||||
rinfo.display_repository_info(
|
||||
repo_info.display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -59,17 +60,18 @@ def create_repository(
|
||||
logger.info(f'{repository_path}: Repository already exists. Skipping creation.')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
if error.returncode not in RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
|
||||
if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
|
||||
raise
|
||||
|
||||
lock_wait = config.get('lock_wait')
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('rcreate', '')
|
||||
umask = config.get('umask')
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '')
|
||||
|
||||
rcreate_command = (
|
||||
repo_create_command = (
|
||||
(local_path,)
|
||||
+ (
|
||||
('rcreate',)
|
||||
if feature.available(feature.Feature.RCREATE, local_borg_version)
|
||||
('repo-create',)
|
||||
if feature.available(feature.Feature.REPO_CREATE, local_borg_version)
|
||||
else ('init',)
|
||||
)
|
||||
+ (('--encryption', encryption_mode) if encryption_mode else ())
|
||||
@@ -83,6 +85,7 @@ def create_repository(
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
@@ -93,9 +96,10 @@ def create_repository(
|
||||
|
||||
# Do not capture output here, so as to support interactive prompts.
|
||||
execute_command(
|
||||
rcreate_command,
|
||||
repo_create_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.borg.flags
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_repo_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the repo_delete action as an argparse.Namespace, and global arguments, return a command
|
||||
as a tuple to repo_delete the entire repository.
|
||||
'''
|
||||
return (
|
||||
(local_path,)
|
||||
+ (
|
||||
('repo-delete',)
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
|
||||
)
|
||||
else ('delete',)
|
||||
)
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
|
||||
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
|
||||
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
|
||||
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', repo_delete_arguments.list_archives)
|
||||
+ (
|
||||
(('--force',) + (('--force',) if repo_delete_arguments.force >= 2 else ()))
|
||||
if repo_delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
repo_delete_arguments, excludes=('list_archives', 'force', 'repository')
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
|
||||
|
||||
def delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the repo_delete action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, repo_delete the entire repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
command = make_repo_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.ANSWER,
|
||||
# Don't capture output when Borg is expected to prompt for interactive confirmation, or the
|
||||
# prompt won't work.
|
||||
output_file=(
|
||||
None
|
||||
if repo_delete_arguments.force or repo_delete_arguments.cache_only
|
||||
else borgmatic.execute.DO_NOT_CAPTURE
|
||||
),
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -11,14 +12,14 @@ def display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments,
|
||||
repo_info_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the rinfo action, and global arguments as an argparse.Namespace, display summary
|
||||
arguments to the repo_info action, and global arguments as an argparse.Namespace, display summary
|
||||
information for the Borg repository or return JSON summary information.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
@@ -27,34 +28,37 @@ def display_repository_info(
|
||||
full_command = (
|
||||
(local_path,)
|
||||
+ (
|
||||
('rinfo',)
|
||||
if feature.available(feature.Feature.RINFO, local_borg_version)
|
||||
('repo-info',)
|
||||
if feature.available(feature.Feature.REPO_INFO, local_borg_version)
|
||||
else ('info',)
|
||||
)
|
||||
+ (
|
||||
('--info',)
|
||||
if logger.getEffectiveLevel() == logging.INFO and not rinfo_arguments.json
|
||||
if logger.getEffectiveLevel() == logging.INFO and not repo_info_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--debug', '--show-rc')
|
||||
if logger.isEnabledFor(logging.DEBUG) and not rinfo_arguments.json
|
||||
if logger.isEnabledFor(logging.DEBUG) and not repo_info_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', lock_wait)
|
||||
+ (('--json',) if rinfo_arguments.json else ())
|
||||
+ (('--json',) if repo_info_arguments.json else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
extra_environment = environment.make_environment(config)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
if rinfo_arguments.json:
|
||||
if repo_info_arguments.json:
|
||||
return execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=extra_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -63,6 +67,7 @@ def display_repository_info(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=extra_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -31,9 +32,14 @@ def resolve_archive_name(
|
||||
full_command = (
|
||||
(
|
||||
local_path,
|
||||
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
|
||||
(
|
||||
'repo-list'
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else 'list'
|
||||
),
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ flags.make_flags('last', 1)
|
||||
@@ -44,6 +50,7 @@ def resolve_archive_name(
|
||||
output = execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -60,54 +67,59 @@ def resolve_archive_name(
|
||||
MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives')
|
||||
|
||||
|
||||
def make_rlist_command(
|
||||
def make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the rlist action, global arguments as an argparse.Namespace instance, and local and
|
||||
arguments to the repo_list action, global arguments as an argparse.Namespace instance, and local and
|
||||
remote Borg paths, return a command as a tuple to list archives with a repository.
|
||||
'''
|
||||
return (
|
||||
(
|
||||
local_path,
|
||||
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
|
||||
(
|
||||
'repo-list'
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else 'list'
|
||||
),
|
||||
)
|
||||
+ (
|
||||
('--info',)
|
||||
if logger.getEffectiveLevel() == logging.INFO and not rlist_arguments.json
|
||||
if logger.getEffectiveLevel() == logging.INFO and not repo_list_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--debug', '--show-rc')
|
||||
if logger.isEnabledFor(logging.DEBUG) and not rlist_arguments.json
|
||||
if logger.isEnabledFor(logging.DEBUG) and not repo_list_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ (
|
||||
(
|
||||
flags.make_flags('match-archives', f'sh:{rlist_arguments.prefix}*')
|
||||
flags.make_flags('match-archives', f'sh:{repo_list_arguments.prefix}*')
|
||||
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
|
||||
else flags.make_flags('glob-archives', f'{rlist_arguments.prefix}*')
|
||||
else flags.make_flags('glob-archives', f'{repo_list_arguments.prefix}*')
|
||||
)
|
||||
if rlist_arguments.prefix
|
||||
if repo_list_arguments.prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
rlist_arguments.match_archives or config.get('match_archives'),
|
||||
repo_list_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
)
|
||||
+ flags.make_flags_from_arguments(rlist_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ flags.make_flags_from_arguments(repo_list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
@@ -116,7 +128,7 @@ def list_repository(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
@@ -130,34 +142,36 @@ def list_repository(
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
main_command = make_rlist_command(
|
||||
main_command = make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
json_command = make_rlist_command(
|
||||
json_command = make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
argparse.Namespace(**dict(rlist_arguments.__dict__, json=True)),
|
||||
argparse.Namespace(**dict(repo_list_arguments.__dict__, json=True)),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
json_listing = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
if rlist_arguments.json:
|
||||
if repo_list_arguments.json:
|
||||
return json_listing
|
||||
|
||||
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
|
||||
@@ -166,6 +180,7 @@ def list_repository(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -29,6 +30,7 @@ def transfer_archives(
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait', None))
|
||||
+ (
|
||||
@@ -55,7 +57,8 @@ def transfer_archives(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
output_file=DO_NOT_CAPTURE if transfer_arguments.progress else None,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
extra_environment=environment.make_environment(config),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,5 +19,8 @@ def unmount_archive(config, mount_point, local_path='borg'):
|
||||
)
|
||||
|
||||
execute_command(
|
||||
full_command, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes')
|
||||
full_command,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment
|
||||
from borgmatic.execute import execute_command_and_capture_output
|
||||
|
||||
@@ -8,7 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def local_borg_version(config, local_path='borg'):
|
||||
'''
|
||||
Given a configuration dict and a local Borg binary path, return a version string for it.
|
||||
Given a configuration dict and a local Borg executable path, return a version string for it.
|
||||
|
||||
Raise OSError or CalledProcessError if there is a problem running Borg.
|
||||
Raise ValueError if the version cannot be parsed.
|
||||
@@ -21,6 +22,7 @@ def local_borg_version(config, local_path='borg'):
|
||||
output = execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
+351
-86
@@ -6,20 +6,22 @@ from argparse import ArgumentParser
|
||||
from borgmatic.config import collect
|
||||
|
||||
ACTION_ALIASES = {
|
||||
'rcreate': ['init', '-I'],
|
||||
'repo-create': ['rcreate', 'init', '-I'],
|
||||
'prune': ['-p'],
|
||||
'compact': [],
|
||||
'create': ['-C'],
|
||||
'check': ['-k'],
|
||||
'config': [],
|
||||
'delete': [],
|
||||
'extract': ['-x'],
|
||||
'export-tar': [],
|
||||
'mount': ['-m'],
|
||||
'umount': ['-u'],
|
||||
'restore': ['-r'],
|
||||
'rlist': [],
|
||||
'repo-delete': ['rdelete'],
|
||||
'repo-list': ['rlist'],
|
||||
'list': ['-l'],
|
||||
'rinfo': [],
|
||||
'repo-info': ['rinfo'],
|
||||
'info': ['-i'],
|
||||
'transfer': [],
|
||||
'break-lock': [],
|
||||
@@ -72,11 +74,11 @@ def omit_values_colliding_with_action_names(unparsed_arguments, parsed_arguments
|
||||
for action_name, parsed in parsed_arguments.items():
|
||||
for value in vars(parsed).values():
|
||||
if isinstance(value, str):
|
||||
if value in ACTION_ALIASES.keys():
|
||||
if value in ACTION_ALIASES.keys() and value in remaining_arguments:
|
||||
remaining_arguments.remove(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if item in ACTION_ALIASES.keys():
|
||||
if item in ACTION_ALIASES.keys() and item in remaining_arguments:
|
||||
remaining_arguments.remove(item)
|
||||
|
||||
return tuple(remaining_arguments)
|
||||
@@ -113,6 +115,54 @@ def parse_and_record_action_arguments(
|
||||
return tuple(argument for argument in remaining if argument != action_name)
|
||||
|
||||
|
||||
def argument_is_flag(argument):
|
||||
'''
|
||||
Return True if the given argument looks like a flag, e.g. '--some-flag', as opposed to a
|
||||
non-flag value.
|
||||
'''
|
||||
return isinstance(argument, str) and argument.startswith('--')
|
||||
|
||||
|
||||
def group_arguments_with_values(arguments):
|
||||
'''
|
||||
Given a sequence of arguments, return a sequence of tuples where each one contains either a
|
||||
single argument (such as for a stand-alone flag) or a flag argument and its corresponding value.
|
||||
|
||||
For instance, given the following arguments sequence as input:
|
||||
|
||||
('--foo', '--bar', '33', '--baz')
|
||||
|
||||
... return the following output:
|
||||
|
||||
(('--foo',), ('--bar', '33'), ('--baz',))
|
||||
'''
|
||||
grouped_arguments = []
|
||||
index = 0
|
||||
|
||||
while index < len(arguments):
|
||||
this_argument = arguments[index]
|
||||
|
||||
try:
|
||||
next_argument = arguments[index + 1]
|
||||
except IndexError:
|
||||
grouped_arguments.append((this_argument,))
|
||||
break
|
||||
|
||||
if (
|
||||
argument_is_flag(this_argument)
|
||||
and not argument_is_flag(next_argument)
|
||||
and next_argument not in ACTION_ALIASES
|
||||
):
|
||||
grouped_arguments.append((this_argument, next_argument))
|
||||
index += 2
|
||||
continue
|
||||
|
||||
grouped_arguments.append((this_argument,))
|
||||
index += 1
|
||||
|
||||
return tuple(grouped_arguments)
|
||||
|
||||
|
||||
def get_unparsable_arguments(remaining_action_arguments):
|
||||
'''
|
||||
Given a sequence of argument tuples (one per action parser that parsed arguments), determine the
|
||||
@@ -121,12 +171,21 @@ def get_unparsable_arguments(remaining_action_arguments):
|
||||
if not remaining_action_arguments:
|
||||
return ()
|
||||
|
||||
grouped_action_arguments = tuple(
|
||||
group_arguments_with_values(action_arguments)
|
||||
for action_arguments in remaining_action_arguments
|
||||
)
|
||||
|
||||
return tuple(
|
||||
argument
|
||||
for argument in dict.fromkeys(
|
||||
itertools.chain.from_iterable(remaining_action_arguments)
|
||||
).keys()
|
||||
if all(argument in action_arguments for action_arguments in remaining_action_arguments)
|
||||
itertools.chain.from_iterable(
|
||||
argument_group
|
||||
for argument_group in dict.fromkeys(
|
||||
itertools.chain.from_iterable(grouped_action_arguments)
|
||||
).keys()
|
||||
if all(
|
||||
argument_group in action_arguments for action_arguments in grouped_action_arguments
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -241,7 +300,7 @@ def make_parsers():
|
||||
'--config',
|
||||
dest='config_paths',
|
||||
action='append',
|
||||
help=f"Configuration filename or directory, can specify flag multiple times, defaults to: {' '.join(unexpanded_config_paths)}",
|
||||
help=f"Configuration filename or directory, can specify flag multiple times, defaults to: -c {' -c '.join(unexpanded_config_paths)}",
|
||||
)
|
||||
global_group.add_argument(
|
||||
'-n',
|
||||
@@ -343,51 +402,51 @@ def make_parsers():
|
||||
metavar='',
|
||||
help='Specify zero or more actions. Defaults to create, prune, compact, and check. Use --help with action for details:',
|
||||
)
|
||||
rcreate_parser = action_parsers.add_parser(
|
||||
'rcreate',
|
||||
aliases=ACTION_ALIASES['rcreate'],
|
||||
repo_create_parser = action_parsers.add_parser(
|
||||
'repo-create',
|
||||
aliases=ACTION_ALIASES['repo-create'],
|
||||
help='Create a new, empty Borg repository',
|
||||
description='Create a new, empty Borg repository',
|
||||
add_help=False,
|
||||
)
|
||||
rcreate_group = rcreate_parser.add_argument_group('rcreate arguments')
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group = repo_create_parser.add_argument_group('repo-create arguments')
|
||||
repo_create_group.add_argument(
|
||||
'-e',
|
||||
'--encryption',
|
||||
dest='encryption_mode',
|
||||
help='Borg repository encryption mode',
|
||||
required=True,
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--source-repository',
|
||||
'--other-repo',
|
||||
metavar='KEY_REPOSITORY',
|
||||
help='Path to an existing Borg repository whose key material should be reused [Borg 2.x+ only]',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one',
|
||||
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--copy-crypt-key',
|
||||
action='store_true',
|
||||
help='Copy the crypt key used for authenticated encryption from the source repository, defaults to a new random key [Borg 2.x+ only]',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--append-only',
|
||||
action='store_true',
|
||||
help='Create an append-only repository',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--storage-quota',
|
||||
help='Create a repository with a fixed storage quota',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--make-parent-dirs',
|
||||
action='store_true',
|
||||
help='Create any missing parent directories of the repository directory',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
@@ -401,7 +460,7 @@ def make_parsers():
|
||||
transfer_group = transfer_parser.add_argument_group('transfer arguments')
|
||||
transfer_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one',
|
||||
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--source-repository',
|
||||
@@ -410,7 +469,7 @@ def make_parsers():
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--archive',
|
||||
help='Name of single archive to transfer (or "latest"), defaults to transferring all archives',
|
||||
help='Name or hash of a single archive to transfer (or "latest"), defaults to transferring all archives',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--upgrader',
|
||||
@@ -427,7 +486,7 @@ def make_parsers():
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only transfer archives with names matching this pattern',
|
||||
help='Only transfer archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
@@ -474,14 +533,21 @@ def make_parsers():
|
||||
prune_group = prune_parser.add_argument_group('prune arguments')
|
||||
prune_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='When pruning, only consider archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'--stats',
|
||||
dest='stats',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Display statistics of archive',
|
||||
help='Display statistics of the pruned archive',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'--list', dest='list_archives', action='store_true', help='List archives kept/pruned'
|
||||
@@ -518,7 +584,7 @@ def make_parsers():
|
||||
compact_group = compact_parser.add_argument_group('compact arguments')
|
||||
compact_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
compact_group.add_argument(
|
||||
'--progress',
|
||||
@@ -554,7 +620,7 @@ def make_parsers():
|
||||
create_group = create_parser.add_argument_group('create arguments')
|
||||
create_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
create_group.add_argument(
|
||||
'--progress',
|
||||
@@ -588,7 +654,7 @@ def make_parsers():
|
||||
check_group = check_parser.add_argument_group('check arguments')
|
||||
check_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--progress',
|
||||
@@ -604,12 +670,17 @@ def make_parsers():
|
||||
action='store_true',
|
||||
help='Attempt to repair any inconsistencies found (for interactive use)',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--max-duration',
|
||||
metavar='SECONDS',
|
||||
help='How long to check the repository before interrupting the check, defaults to no interruption',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only check archives with names matching this pattern',
|
||||
help='Only check archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--only',
|
||||
@@ -627,6 +698,97 @@ def make_parsers():
|
||||
)
|
||||
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
delete_parser = action_parsers.add_parser(
|
||||
'delete',
|
||||
aliases=ACTION_ALIASES['delete'],
|
||||
help='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
description='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
add_help=False,
|
||||
)
|
||||
delete_group = delete_parser.add_argument_group('delete arguments')
|
||||
delete_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to delete or delete archives from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--archive',
|
||||
help='Archive name, hash, or series to delete',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--list',
|
||||
dest='list_archives',
|
||||
action='store_true',
|
||||
help='Show details for the deleted archives',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--stats',
|
||||
action='store_true',
|
||||
help='Display statistics for the deleted archives',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--cache-only',
|
||||
action='store_true',
|
||||
help='Delete only the local cache for the given repository',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--force',
|
||||
action='count',
|
||||
help='Force deletion of corrupted archives, can be given twice if once does not work',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--keep-security-info',
|
||||
action='store_true',
|
||||
help='Do not delete the local security info when deleting a repository',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--save-space',
|
||||
action='store_true',
|
||||
help='Work slower, but using less space [Not supported in Borg 2.x+]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--checkpoint-interval',
|
||||
type=int,
|
||||
metavar='SECONDS',
|
||||
help='Write a checkpoint at the given interval, defaults to 1800 seconds (30 minutes)',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only delete archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--first', metavar='N', help='Delete first N archives after other filters are applied'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--last', metavar='N', help='Delete last N archives after other filters are applied'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--oldest',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--newest',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--older',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--newer',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
extract_parser = action_parsers.add_parser(
|
||||
'extract',
|
||||
aliases=ACTION_ALIASES['extract'],
|
||||
@@ -637,10 +799,10 @@ def make_parsers():
|
||||
extract_group = extract_parser.add_argument_group('extract arguments')
|
||||
extract_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to extract, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--archive', help='Name of archive to extract (or "latest")', required=True
|
||||
'--archive', help='Name or hash of a single archive to extract (or "latest")', required=True
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--path',
|
||||
@@ -699,16 +861,30 @@ def make_parsers():
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to extract config files from',
|
||||
help='Path of repository to extract config files from, quoted globs supported',
|
||||
required=True,
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--local-path',
|
||||
help='Alternate Borg local executable. Defaults to "borg"',
|
||||
default='borg',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--remote-path',
|
||||
help='Alternate Borg remote executable. Defaults to "borg"',
|
||||
default='borg',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--user-runtime-directory',
|
||||
help='Path used for temporary runtime data like bootstrap metadata. Defaults to $XDG_RUNTIME_DIR or $TMPDIR or $TEMP or /var/run/$UID',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--borgmatic-source-directory',
|
||||
help='Path that stores the config files used to create an archive and additional source files used for temporary internal state like borgmatic database dumps. Defaults to ~/.borgmatic',
|
||||
help='Deprecated. Path formerly used for temporary runtime data like bootstrap metadata. Defaults to ~/.borgmatic',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--archive',
|
||||
help='Name of archive to extract config files from, defaults to "latest"',
|
||||
help='Name or hash of a single archive to extract config files from, defaults to "latest"',
|
||||
default='latest',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
@@ -797,10 +973,10 @@ def make_parsers():
|
||||
export_tar_group = export_tar_parser.add_argument_group('export-tar arguments')
|
||||
export_tar_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to export from, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--archive', help='Name of archive to export (or "latest")', required=True
|
||||
'--archive', help='Name or hash of a single archive to export (or "latest")', required=True
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--path',
|
||||
@@ -843,9 +1019,11 @@ def make_parsers():
|
||||
mount_group = mount_parser.add_argument_group('mount arguments')
|
||||
mount_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to use, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
mount_group.add_argument(
|
||||
'--archive', help='Name or hash of a single archive to mount (or "latest")'
|
||||
)
|
||||
mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
|
||||
mount_group.add_argument(
|
||||
'--mount-point',
|
||||
metavar='PATH',
|
||||
@@ -915,6 +1093,43 @@ def make_parsers():
|
||||
)
|
||||
umount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
repo_delete_parser = action_parsers.add_parser(
|
||||
'repo-delete',
|
||||
aliases=ACTION_ALIASES['repo-delete'],
|
||||
help='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
description='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
add_help=False,
|
||||
)
|
||||
repo_delete_group = repo_delete_parser.add_argument_group('delete arguments')
|
||||
repo_delete_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to delete, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--list',
|
||||
dest='list_archives',
|
||||
action='store_true',
|
||||
help='Show details for the archives in the given repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--force',
|
||||
action='count',
|
||||
help='Force deletion of corrupted archives, can be given twice if once does not work',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--cache-only',
|
||||
action='store_true',
|
||||
help='Delete only the local cache for the given repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--keep-security-info',
|
||||
action='store_true',
|
||||
help='Do not delete the local security info when deleting a repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
restore_parser = action_parsers.add_parser(
|
||||
'restore',
|
||||
aliases=ACTION_ALIASES['restore'],
|
||||
@@ -925,10 +1140,12 @@ def make_parsers():
|
||||
restore_group = restore_parser.add_argument_group('restore arguments')
|
||||
restore_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to restore from, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to restore from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--archive', help='Name of archive to restore from (or "latest")', required=True
|
||||
'--archive',
|
||||
help='Name or hash of a single archive to restore from (or "latest")',
|
||||
required=True,
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--data-source',
|
||||
@@ -936,7 +1153,7 @@ def make_parsers():
|
||||
metavar='NAME',
|
||||
dest='data_sources',
|
||||
action='append',
|
||||
help="Name of data source (e.g. database) to restore from archive, must be defined in borgmatic's configuration, can specify flag multiple times, defaults to all data sources in the archive",
|
||||
help="Name of data source (e.g. database) to restore from the archive, must be defined in borgmatic's configuration, can specify the flag multiple times, defaults to all data sources in the archive",
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--schema',
|
||||
@@ -965,69 +1182,90 @@ def make_parsers():
|
||||
'--restore-path',
|
||||
help='Path to restore SQLite database dumps to. Defaults to the "restore_path" option in borgmatic\'s configuration',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--original-hostname',
|
||||
help='The hostname where the dump to restore came from, only necessary if you need to disambiguate dumps',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--original-port',
|
||||
type=int,
|
||||
help="The port where the dump to restore came from (if that port is in borgmatic's configuration), only necessary if you need to disambiguate dumps",
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--hook',
|
||||
help='The name of the data source hook for the dump to restore, only necessary if you need to disambiguate dumps',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
rlist_parser = action_parsers.add_parser(
|
||||
'rlist',
|
||||
aliases=ACTION_ALIASES['rlist'],
|
||||
repo_list_parser = action_parsers.add_parser(
|
||||
'repo-list',
|
||||
aliases=ACTION_ALIASES['repo-list'],
|
||||
help='List repository',
|
||||
description='List the archives in a repository',
|
||||
add_help=False,
|
||||
)
|
||||
rlist_group = rlist_parser.add_argument_group('rlist arguments')
|
||||
rlist_group.add_argument(
|
||||
repo_list_group = repo_list_parser.add_argument_group('repo-list arguments')
|
||||
repo_list_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to list, defaults to the configured repositories',
|
||||
help='Path of repository to list, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--short', default=False, action='store_true', help='Output only archive names'
|
||||
)
|
||||
rlist_group.add_argument('--format', help='Format for archive listing')
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument('--format', help='Format for archive listing')
|
||||
repo_list_group.add_argument(
|
||||
'--json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only list archive names matching this pattern',
|
||||
help='Only list archive names, hashes, or series matching this pattern',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--first', metavar='N', help='List first N archives after other filters are applied'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--last', metavar='N', help='List last N archives after other filters are applied'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--oldest',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--newest',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--older',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--newer',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
repo_list_group.add_argument(
|
||||
'--deleted',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="List only deleted archives that haven't yet been compacted [Borg 2.x+ only]",
|
||||
)
|
||||
repo_list_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
list_parser = action_parsers.add_parser(
|
||||
'list',
|
||||
@@ -1039,9 +1277,11 @@ def make_parsers():
|
||||
list_group = list_parser.add_argument_group('list arguments')
|
||||
list_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository containing archive to list, defaults to the configured repositories',
|
||||
help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
list_group.add_argument(
|
||||
'--archive', help='Name or hash of a single archive to list (or "latest")'
|
||||
)
|
||||
list_group.add_argument('--archive', help='Name of the archive to list (or "latest")')
|
||||
list_group.add_argument(
|
||||
'--path',
|
||||
metavar='PATH',
|
||||
@@ -1096,22 +1336,24 @@ def make_parsers():
|
||||
)
|
||||
list_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
rinfo_parser = action_parsers.add_parser(
|
||||
'rinfo',
|
||||
aliases=ACTION_ALIASES['rinfo'],
|
||||
repo_info_parser = action_parsers.add_parser(
|
||||
'repo-info',
|
||||
aliases=ACTION_ALIASES['repo-info'],
|
||||
help='Show repository summary information such as disk space used',
|
||||
description='Show repository summary information such as disk space used',
|
||||
add_help=False,
|
||||
)
|
||||
rinfo_group = rinfo_parser.add_argument_group('rinfo arguments')
|
||||
rinfo_group.add_argument(
|
||||
repo_info_group = repo_info_parser.add_argument_group('repo-info arguments')
|
||||
repo_info_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to show info for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to show info for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
rinfo_group.add_argument(
|
||||
repo_info_group.add_argument(
|
||||
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
rinfo_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
repo_info_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
info_parser = action_parsers.add_parser(
|
||||
'info',
|
||||
@@ -1123,9 +1365,11 @@ def make_parsers():
|
||||
info_group = info_parser.add_argument_group('info arguments')
|
||||
info_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
info_group.add_argument(
|
||||
'--archive', help='Archive name, hash, or series to show info for (or "latest")'
|
||||
)
|
||||
info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
|
||||
info_group.add_argument(
|
||||
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
@@ -1139,7 +1383,7 @@ def make_parsers():
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only show info for archive names matching this pattern',
|
||||
help='Only show info for archive names, hashes, or series matching this pattern',
|
||||
)
|
||||
info_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
@@ -1184,7 +1428,7 @@ def make_parsers():
|
||||
break_lock_group = break_lock_parser.add_argument_group('break-lock arguments')
|
||||
break_lock_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to break the lock for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to break the lock for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
break_lock_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
@@ -1224,7 +1468,7 @@ def make_parsers():
|
||||
)
|
||||
key_export_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to export the key for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to export the key for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
key_export_group.add_argument(
|
||||
'--path',
|
||||
@@ -1235,6 +1479,23 @@ def make_parsers():
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
key_change_passphrase_parser = key_parsers.add_parser(
|
||||
'change-passphrase',
|
||||
help='Change the passphrase protecting the repository key',
|
||||
description='Change the passphrase protecting the repository key',
|
||||
add_help=False,
|
||||
)
|
||||
key_change_passphrase_group = key_change_passphrase_parser.add_argument_group(
|
||||
'key change-passphrase arguments'
|
||||
)
|
||||
key_change_passphrase_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to change the passphrase for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
key_change_passphrase_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
borg_parser = action_parsers.add_parser(
|
||||
'borg',
|
||||
aliases=ACTION_ALIASES['borg'],
|
||||
@@ -1245,9 +1506,11 @@ def make_parsers():
|
||||
borg_group = borg_parser.add_argument_group('borg arguments')
|
||||
borg_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to pass to Borg, defaults to the configured repositories',
|
||||
help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
borg_group.add_argument(
|
||||
'--archive', help='Archive name, hash, or series to pass to Borg (or "latest")'
|
||||
)
|
||||
borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
|
||||
borg_group.add_argument(
|
||||
'--',
|
||||
metavar='OPTION',
|
||||
@@ -1306,9 +1569,9 @@ def parse_arguments(*unparsed_arguments):
|
||||
)
|
||||
|
||||
if (
|
||||
('list' in arguments and 'rinfo' in arguments and arguments['list'].json)
|
||||
('list' in arguments and 'repo-info' in arguments and arguments['list'].json)
|
||||
or ('list' in arguments and 'info' in arguments and arguments['list'].json)
|
||||
or ('rinfo' in arguments and 'info' in arguments and arguments['rinfo'].json)
|
||||
or ('repo-info' in arguments and 'info' in arguments and arguments['repo-info'].json)
|
||||
):
|
||||
raise ValueError('With the --json flag, multiple actions cannot be used together.')
|
||||
|
||||
@@ -1326,9 +1589,11 @@ def parse_arguments(*unparsed_arguments):
|
||||
'With the list action, only one of --prefix or --match-archives flags can be used.'
|
||||
)
|
||||
|
||||
if 'rlist' in arguments and (arguments['rlist'].prefix and arguments['rlist'].match_archives):
|
||||
if 'repo-list' in arguments and (
|
||||
arguments['repo-list'].prefix and arguments['repo-list'].match_archives
|
||||
):
|
||||
raise ValueError(
|
||||
'With the rlist action, only one of --prefix or --match-archives flags can be used.'
|
||||
'With the repo-list action, only one of --prefix or --match-archives flags can be used.'
|
||||
)
|
||||
|
||||
if 'info' in arguments and (
|
||||
|
||||
@@ -8,16 +8,16 @@ import time
|
||||
from queue import Queue
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
import colorama
|
||||
|
||||
import borgmatic.actions.borg
|
||||
import borgmatic.actions.break_lock
|
||||
import borgmatic.actions.change_passphrase
|
||||
import borgmatic.actions.check
|
||||
import borgmatic.actions.compact
|
||||
import borgmatic.actions.config.bootstrap
|
||||
import borgmatic.actions.config.generate
|
||||
import borgmatic.actions.config.validate
|
||||
import borgmatic.actions.create
|
||||
import borgmatic.actions.delete
|
||||
import borgmatic.actions.export_key
|
||||
import borgmatic.actions.export_tar
|
||||
import borgmatic.actions.extract
|
||||
@@ -25,10 +25,11 @@ import borgmatic.actions.info
|
||||
import borgmatic.actions.list
|
||||
import borgmatic.actions.mount
|
||||
import borgmatic.actions.prune
|
||||
import borgmatic.actions.rcreate
|
||||
import borgmatic.actions.repo_create
|
||||
import borgmatic.actions.repo_delete
|
||||
import borgmatic.actions.repo_info
|
||||
import borgmatic.actions.repo_list
|
||||
import borgmatic.actions.restore
|
||||
import borgmatic.actions.rinfo
|
||||
import borgmatic.actions.rlist
|
||||
import borgmatic.actions.transfer
|
||||
import borgmatic.commands.completion.bash
|
||||
import borgmatic.commands.completion.fish
|
||||
@@ -36,7 +37,8 @@ from borgmatic.borg import umount as borg_umount
|
||||
from borgmatic.borg import version as borg_version
|
||||
from borgmatic.commands.arguments import parse_arguments
|
||||
from borgmatic.config import checks, collect, validate
|
||||
from borgmatic.hooks import command, dispatch, monitor
|
||||
from borgmatic.hooks import command, dispatch
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
from borgmatic.logger import DISABLED, add_custom_log_levels, configure_logging, should_do_markup
|
||||
from borgmatic.signals import configure_signals
|
||||
from borgmatic.verbosity import verbosity_to_log_level
|
||||
@@ -100,7 +102,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'initialize_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
@@ -109,7 +111,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitor.State.START,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -169,7 +171,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
continue
|
||||
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
break
|
||||
continue
|
||||
|
||||
yield from log_error_records(
|
||||
f'{repository.get("label", repository["path"])}: Error running actions for repository',
|
||||
@@ -185,7 +187,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitor.State.LOG,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -202,7 +204,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitor.State.FINISH,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -211,7 +213,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'destroy_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
@@ -238,7 +240,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitor.State.FAIL,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -247,7 +249,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
'destroy_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
@@ -286,10 +288,11 @@ def run_actions(
|
||||
global_arguments = arguments['global']
|
||||
dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
|
||||
hook_context = {
|
||||
'repository': repository_path,
|
||||
'repository_label': repository.get('label', ''),
|
||||
'log_file': global_arguments.log_file if global_arguments.log_file else '',
|
||||
# Deprecated: For backwards compatibility with borgmatic < 1.6.0.
|
||||
'repositories': ','.join([repo['path'] for repo in config['repositories']]),
|
||||
'log_file': global_arguments.log_file if global_arguments.log_file else '',
|
||||
'repository': repository_path,
|
||||
}
|
||||
skip_actions = set(get_skip_actions(config, arguments))
|
||||
|
||||
@@ -303,8 +306,8 @@ def run_actions(
|
||||
)
|
||||
|
||||
for action_name, action_arguments in arguments.items():
|
||||
if action_name == 'rcreate' and action_name not in skip_actions:
|
||||
borgmatic.actions.rcreate.run_rcreate(
|
||||
if action_name == 'repo-create' and action_name not in skip_actions:
|
||||
borgmatic.actions.repo_create.run_repo_create(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -418,8 +421,8 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'rlist' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.rlist.run_rlist(
|
||||
elif action_name == 'repo-list' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.repo_list.run_repo_list(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -438,8 +441,8 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'rinfo' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.rinfo.run_rinfo(
|
||||
elif action_name == 'repo-info' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.repo_info.run_repo_info(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -478,6 +481,36 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'change-passphrase' and action_name not in skip_actions:
|
||||
borgmatic.actions.change_passphrase.run_change_passphrase(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'delete' and action_name not in skip_actions:
|
||||
borgmatic.actions.delete.run_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'repo-delete' and action_name not in skip_actions:
|
||||
borgmatic.actions.repo_delete.run_repo_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'borg' and action_name not in skip_actions:
|
||||
borgmatic.actions.borg.run_borg(
|
||||
repository,
|
||||
@@ -556,7 +589,7 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
|
||||
)
|
||||
),
|
||||
logging.makeLogRecord(
|
||||
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
|
||||
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -577,6 +610,9 @@ def log_record(suppress_log=False, **kwargs):
|
||||
return record
|
||||
|
||||
|
||||
BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62
|
||||
|
||||
|
||||
def log_error_records(
|
||||
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
|
||||
):
|
||||
@@ -591,13 +627,13 @@ def log_error_records(
|
||||
level_name = logging._levelToName[levelno]
|
||||
|
||||
if not error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
return
|
||||
|
||||
try:
|
||||
raise error
|
||||
except CalledProcessError as error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
|
||||
if error.output:
|
||||
try:
|
||||
@@ -616,10 +652,17 @@ def log_error_records(
|
||||
suppress_log=True,
|
||||
)
|
||||
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
|
||||
|
||||
if error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
|
||||
yield log_record(
|
||||
levelno=levelno,
|
||||
levelname=level_name,
|
||||
msg='\nTo work around this, set either the "relocated_repo_access_is_ok" or "unknown_unencrypted_repo_access_is_ok" option to "true", as appropriate.',
|
||||
)
|
||||
except (ValueError, OSError) as error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
|
||||
except: # noqa: E722
|
||||
# Raising above only as a means of determining the error type. Swallow the exception here
|
||||
# because we don't want the exception to propagate out of this function.
|
||||
@@ -650,7 +693,9 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
|
||||
if 'bootstrap' in arguments:
|
||||
try:
|
||||
# No configuration file is needed for bootstrap.
|
||||
local_borg_version = borg_version.local_borg_version({}, 'borg')
|
||||
local_borg_version = borg_version.local_borg_version(
|
||||
{}, arguments['bootstrap'].local_path
|
||||
)
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
yield from log_error_records('Error getting local Borg version', error)
|
||||
return
|
||||
@@ -747,9 +792,6 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments):
|
||||
break
|
||||
|
||||
try:
|
||||
if 'extract' in arguments or 'mount' in arguments:
|
||||
validate.guard_single_repository_selected(repository, configs)
|
||||
|
||||
validate.guard_configuration_contains_repository(repository, configs)
|
||||
except ValueError as error:
|
||||
yield from log_error_records(str(error))
|
||||
@@ -871,7 +913,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
|
||||
getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
|
||||
)
|
||||
color_enabled = should_do_markup(global_arguments.no_color or any_json_flags, configs)
|
||||
colorama.init(autoreset=color_enabled, strip=not color_enabled)
|
||||
|
||||
try:
|
||||
configure_logging(
|
||||
verbosity_to_log_level(global_arguments.verbosity),
|
||||
|
||||
@@ -50,12 +50,15 @@ def apply_constants(value, constants, shell_escape=False):
|
||||
value[index] = apply_constants(list_value, constants, shell_escape)
|
||||
elif isinstance(value, dict):
|
||||
for option_name, option_value in value.items():
|
||||
shell_escape = (
|
||||
shell_escape
|
||||
or option_name.startswith('before_')
|
||||
or option_name.startswith('after_')
|
||||
or option_name == 'on_error'
|
||||
value[option_name] = apply_constants(
|
||||
option_value,
|
||||
constants,
|
||||
shell_escape=(
|
||||
shell_escape
|
||||
or option_name.startswith('before_')
|
||||
or option_name.startswith('after_')
|
||||
or option_name == 'on_error'
|
||||
),
|
||||
)
|
||||
value[option_name] = apply_constants(option_value, constants, shell_escape)
|
||||
|
||||
return value
|
||||
|
||||
@@ -44,12 +44,12 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
if schema_type == 'array':
|
||||
if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
|
||||
config = ruamel.yaml.comments.CommentedSeq(
|
||||
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
|
||||
)
|
||||
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
|
||||
elif schema_type == 'object':
|
||||
elif schema_type == 'object' or (isinstance(schema_type, list) and 'object' in schema_type):
|
||||
config = ruamel.yaml.comments.CommentedMap(
|
||||
[
|
||||
(field_name, schema_to_sample_configuration(sub_schema, level + 1))
|
||||
|
||||
@@ -68,6 +68,17 @@ def normalize(config_filename, config):
|
||||
'''
|
||||
logs = normalize_sections(config_filename, config)
|
||||
|
||||
if config.get('borgmatic_source_directory'):
|
||||
logs.append(
|
||||
logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Upgrade exclude_if_present from a string to a list.
|
||||
exclude_if_present = config.get('exclude_if_present')
|
||||
if isinstance(exclude_if_present, str):
|
||||
@@ -82,6 +93,25 @@ def normalize(config_filename, config):
|
||||
)
|
||||
config['exclude_if_present'] = [exclude_if_present]
|
||||
|
||||
# Unconditionally set the bootstrap hook so that it's enabled by default and config files get
|
||||
# stored in each Borg archive.
|
||||
config.setdefault('bootstrap', {})
|
||||
|
||||
# Move store_config_files from the global scope to the bootstrap hook.
|
||||
store_config_files = config.get('store_config_files')
|
||||
if store_config_files is not None:
|
||||
logs.append(
|
||||
logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The store_config_files option has moved under the bootstrap hook. Specifying store_config_files at the global scope is deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
)
|
||||
del config['store_config_files']
|
||||
config['bootstrap']['store_config_files'] = store_config_files
|
||||
|
||||
# Upgrade various monitoring hooks from a string to a dict.
|
||||
healthchecks = config.get('healthchecks')
|
||||
if isinstance(healthchecks, str):
|
||||
@@ -233,7 +263,11 @@ def normalize(config_filename, config):
|
||||
path=updated_repository_path,
|
||||
)
|
||||
)
|
||||
elif repository_path.startswith('ssh://'):
|
||||
elif (
|
||||
repository_path.startswith('ssh://')
|
||||
or repository_path.startswith('sftp://')
|
||||
or repository_path.startswith('rclone:')
|
||||
):
|
||||
config['repositories'].append(repository_dict)
|
||||
else:
|
||||
rewritten_repository_path = f"ssh://{repository_path.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
|
||||
@@ -242,7 +276,7 @@ def normalize(config_filename, config):
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: Remote repository paths without ssh:// syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
|
||||
msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_user_in_path(path):
|
||||
'''
|
||||
Given a directory path, expand any tildes in it.
|
||||
'''
|
||||
try:
|
||||
return os.path.expanduser(path or '') or None
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def get_working_directory(config): # pragma: no cover
|
||||
'''
|
||||
Given a configuration dict, get the working directory from it, expanding any tildes.
|
||||
'''
|
||||
return expand_user_in_path(config.get('working_directory'))
|
||||
|
||||
|
||||
def get_borgmatic_source_directory(config):
|
||||
'''
|
||||
Given a configuration dict, get the (deprecated) borgmatic source directory, expanding any
|
||||
tildes. Defaults to ~/.borgmatic.
|
||||
'''
|
||||
return expand_user_in_path(config.get('borgmatic_source_directory') or '~/.borgmatic')
|
||||
|
||||
|
||||
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
|
||||
|
||||
|
||||
def replace_temporary_subdirectory_with_glob(
|
||||
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
|
||||
):
|
||||
'''
|
||||
Given an absolute temporary directory path and an optional temporary directory prefix, look for
|
||||
a subdirectory within it starting with the temporary directory prefix (or a default) and replace
|
||||
it with an appropriate glob. For instance, given:
|
||||
|
||||
/tmp/borgmatic-aet8kn93/borgmatic
|
||||
|
||||
... replace it with:
|
||||
|
||||
/tmp/borgmatic-*/borgmatic
|
||||
|
||||
This is useful for finding previous temporary directories from prior borgmatic runs.
|
||||
'''
|
||||
return os.path.join(
|
||||
'/',
|
||||
*(
|
||||
(
|
||||
f'{temporary_directory_prefix}*'
|
||||
if subdirectory.startswith(temporary_directory_prefix)
|
||||
else subdirectory
|
||||
)
|
||||
for subdirectory in path.split(os.path.sep)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Runtime_directory:
|
||||
'''
|
||||
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
|
||||
storing temporary runtime data like streaming database dumps and bootstrap metadata.
|
||||
|
||||
Example use as a context manager:
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
do_something_with(borgmatic_runtime_directory)
|
||||
|
||||
For the scope of that "with" statement, the runtime directory is available. Afterwards, it
|
||||
automatically gets cleaned up as necessary.
|
||||
'''
|
||||
|
||||
def __init__(self, config, log_prefix):
|
||||
'''
|
||||
Given a configuration dict and a log prefix, determine the borgmatic runtime directory,
|
||||
creating a secure, temporary directory within it if necessary. Defaults to
|
||||
$XDG_RUNTIME_DIR/./borgmatic or $RUNTIME_DIRECTORY/./borgmatic or
|
||||
$TMPDIR/borgmatic-[random]/./borgmatic or $TEMP/borgmatic-[random]/./borgmatic or
|
||||
/tmp/borgmatic-[random]/./borgmatic where "[random]" is a randomly generated string intended
|
||||
to avoid path collisions.
|
||||
|
||||
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
|
||||
tack on a second "/borgmatic" path component.
|
||||
|
||||
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
|
||||
does not get stored in the file path within an archive. That way, the path of the runtime
|
||||
directory can change without leaving database dumps within an archive inaccessible.
|
||||
'''
|
||||
runtime_directory = (
|
||||
config.get('user_runtime_directory')
|
||||
or os.environ.get('XDG_RUNTIME_DIR') # Set by PAM on Linux.
|
||||
or os.environ.get('RUNTIME_DIRECTORY') # Set by systemd if configured.
|
||||
)
|
||||
|
||||
if runtime_directory:
|
||||
if not runtime_directory.startswith(os.path.sep):
|
||||
raise ValueError('The runtime directory must be an absolute path')
|
||||
|
||||
self.temporary_directory = None
|
||||
else:
|
||||
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
|
||||
|
||||
if not base_directory.startswith(os.path.sep):
|
||||
raise ValueError('The temporary directory must be an absolute path')
|
||||
|
||||
os.makedirs(base_directory, mode=0o700, exist_ok=True)
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix=TEMPORARY_DIRECTORY_PREFIX,
|
||||
dir=base_directory,
|
||||
)
|
||||
runtime_directory = self.temporary_directory.name
|
||||
|
||||
(base_path, final_directory) = os.path.split(runtime_directory.rstrip(os.path.sep))
|
||||
|
||||
self.runtime_path = expand_user_in_path(
|
||||
os.path.join(
|
||||
base_path if final_directory == 'borgmatic' else runtime_directory,
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
'borgmatic',
|
||||
)
|
||||
)
|
||||
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
|
||||
|
||||
logger.debug(f'{log_prefix}: Using runtime directory {os.path.normpath(self.runtime_path)}')
|
||||
|
||||
def __enter__(self):
|
||||
'''
|
||||
Return the borgmatic runtime path as a string.
|
||||
'''
|
||||
return self.runtime_path
|
||||
|
||||
def __exit__(self, exception, value, traceback):
|
||||
'''
|
||||
Delete any temporary directory that was created as part of initialization.
|
||||
'''
|
||||
if self.temporary_directory:
|
||||
try:
|
||||
self.temporary_directory.cleanup()
|
||||
# The cleanup() call errors if, for instance, there's still a
|
||||
# mounted filesystem within the temporary directory. There's
|
||||
# nothing we can do about that here, so swallow the error.
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def make_runtime_directory_glob(borgmatic_runtime_directory):
|
||||
'''
|
||||
Given a borgmatic runtime directory path, make a glob that would match that path, specifically
|
||||
replacing any randomly generated temporary subdirectory with "*" since such a directory's name
|
||||
changes on every borgmatic run.
|
||||
'''
|
||||
return os.path.join(
|
||||
*(
|
||||
'*' if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX) else subdirectory
|
||||
for subdirectory in os.path.normpath(borgmatic_runtime_directory).split(os.path.sep)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_borgmatic_state_directory(config):
|
||||
'''
|
||||
Given a configuration dict, get the borgmatic state directory used for storing borgmatic state
|
||||
files like records of when checks last ran. Defaults to $XDG_STATE_HOME/borgmatic or
|
||||
~/.local/state/./borgmatic.
|
||||
'''
|
||||
return expand_user_in_path(
|
||||
os.path.join(
|
||||
config.get('user_state_directory')
|
||||
or os.environ.get('XDG_STATE_HOME')
|
||||
or os.environ.get('STATE_DIRECTORY') # Set by systemd if configured.
|
||||
or '~/.local/state',
|
||||
'borgmatic',
|
||||
)
|
||||
)
|
||||
+682
-78
@@ -58,18 +58,17 @@ properties:
|
||||
working_directory:
|
||||
type: string
|
||||
description: |
|
||||
Working directory for the "borg create" command. Tildes are
|
||||
expanded. Useful for backing up using relative paths. See
|
||||
http://borgbackup.readthedocs.io/en/stable/usage/create.html for
|
||||
Working directory to use when running actions, useful for backing up
|
||||
using relative source directory paths. Does not currently apply to
|
||||
borgmatic configuration file paths or includes. Tildes are expanded.
|
||||
See http://borgbackup.readthedocs.io/en/stable/usage/create.html for
|
||||
details. Defaults to not set.
|
||||
example: /path/to/working/directory
|
||||
one_file_system:
|
||||
type: boolean
|
||||
description: |
|
||||
Stay in same file system; do not cross mount points beyond the given
|
||||
source directories. Defaults to false. But when a database hook is
|
||||
used, the setting here is ignored and one_file_system is considered
|
||||
true.
|
||||
source directories. Defaults to false.
|
||||
example: true
|
||||
numeric_ids:
|
||||
type: boolean
|
||||
@@ -206,18 +205,28 @@ properties:
|
||||
borgmatic_source_directory:
|
||||
type: string
|
||||
description: |
|
||||
Path for additional source files used for temporary internal state
|
||||
like borgmatic database dumps. Note that changing this path prevents
|
||||
"borgmatic restore" from finding any database dumps created before
|
||||
the change. Defaults to ~/.borgmatic
|
||||
Deprecated. Only used for locating database dumps and bootstrap
|
||||
metadata within backup archives created prior to deprecation.
|
||||
Replaced by borgmatic_runtime_directory and
|
||||
borgmatic_state_directory. Defaults to ~/.borgmatic
|
||||
example: /tmp/borgmatic
|
||||
store_config_files:
|
||||
type: boolean
|
||||
user_runtime_directory:
|
||||
type: string
|
||||
description: |
|
||||
Store configuration files used to create a backup in the backup
|
||||
itself. Defaults to true. Changing this to false prevents "borgmatic
|
||||
bootstrap" from extracting configuration files from the backup.
|
||||
example: false
|
||||
Path for storing temporary runtime data like streaming database
|
||||
dumps and bootstrap metadata. borgmatic automatically creates and
|
||||
uses a "borgmatic" subdirectory here. Defaults to $XDG_RUNTIME_DIR
|
||||
or or $TMPDIR or $TEMP or /run/user/$UID.
|
||||
example: /run/user/1001
|
||||
user_state_directory:
|
||||
type: string
|
||||
description: |
|
||||
Path for storing borgmatic state files like records of when checks
|
||||
last ran. borgmatic automatically creates and uses a "borgmatic"
|
||||
subdirectory here. If you change this option, borgmatic must
|
||||
create the check records again (and therefore re-run checks).
|
||||
Defaults to $XDG_STATE_HOME or ~/.local/state.
|
||||
example: /var/lib/borgmatic
|
||||
source_directories_must_exist:
|
||||
type: boolean
|
||||
description: |
|
||||
@@ -231,7 +240,10 @@ properties:
|
||||
key. Only use on repositories that were initialized with
|
||||
passcommand/repokey/keyfile encryption. Note that if both
|
||||
encryption_passcommand and encryption_passphrase are set, then
|
||||
encryption_passphrase takes precedence. Defaults to not set.
|
||||
encryption_passphrase takes precedence. This can also be used to
|
||||
access encrypted systemd service credentials. Defaults to not set.
|
||||
For more details, see:
|
||||
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
|
||||
example: "secret-tool lookup borg-repository repo-name"
|
||||
encryption_passphrase:
|
||||
type: string
|
||||
@@ -269,7 +281,8 @@ properties:
|
||||
compression:
|
||||
type: string
|
||||
description: |
|
||||
Type of compression to use when creating archives. See
|
||||
Type of compression to use when creating archives. (Compression
|
||||
level can be added separated with a comma, like "zstd,7".) See
|
||||
http://borgbackup.readthedocs.io/en/stable/usage/create.html for
|
||||
details. Defaults to "lz4".
|
||||
example: lz4
|
||||
@@ -279,6 +292,11 @@ properties:
|
||||
Remote network upload rate limit in kiBytes/second. Defaults to
|
||||
unlimited.
|
||||
example: 100
|
||||
upload_buffer_size:
|
||||
type: integer
|
||||
description: |
|
||||
Size of network upload buffer in MiB. Defaults to no buffer.
|
||||
example: 160
|
||||
retries:
|
||||
type: integer
|
||||
description: |
|
||||
@@ -387,10 +405,13 @@ properties:
|
||||
archive_name_format:
|
||||
type: string
|
||||
description: |
|
||||
Name of the archive. Borg placeholders can be used. See the output
|
||||
of "borg help placeholders" for details. Defaults to
|
||||
"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". When running actions like
|
||||
rlist, info, or check, borgmatic automatically tries to match only
|
||||
Name of the archive to create. Borg placeholders can be used. See
|
||||
the output of "borg help placeholders" for details. Defaults to
|
||||
"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}" with Borg 1 and
|
||||
"{hostname}" with Borg 2, as Borg 2 does not require unique
|
||||
archive names; identical archive names form a common "series" that
|
||||
can be targeted together. When running actions like repo-list,
|
||||
info, or check, borgmatic automatically tries to match only
|
||||
archives created with this name format.
|
||||
example: "{hostname}-documents-{now}"
|
||||
match_archives:
|
||||
@@ -510,24 +531,25 @@ properties:
|
||||
name:
|
||||
type: string
|
||||
enum:
|
||||
- repository
|
||||
- archives
|
||||
- data
|
||||
- extract
|
||||
- disabled
|
||||
description: |
|
||||
Name of consistency check to run: "repository",
|
||||
"archives", "data", "spot", and/or "extract".
|
||||
"repository" checks the consistency of the
|
||||
repository, "archives" checks all of the
|
||||
archives, "data" verifies the integrity of the
|
||||
data within the archives, "spot" checks that
|
||||
some percentage of source files are found in the
|
||||
most recent archive (with identical contents),
|
||||
and "extract" does an extraction dry-run of the
|
||||
most recent archive. Note that "data" implies
|
||||
"archives". See "skip_actions" for disabling
|
||||
checks altogether.
|
||||
Name of the consistency check to run:
|
||||
* "repository" checks the consistency of the
|
||||
repository.
|
||||
* "archives" checks all of the archives.
|
||||
* "data" verifies the integrity of the data
|
||||
within the archives and implies the "archives"
|
||||
check as well.
|
||||
* "spot" checks that some percentage of source
|
||||
files are found in the most recent archive (with
|
||||
identical contents).
|
||||
* "extract" does an extraction dry-run of the
|
||||
most recent archive.
|
||||
* See "skip_actions" for disabling checks
|
||||
altogether.
|
||||
example: spot
|
||||
frequency:
|
||||
type: string
|
||||
@@ -541,6 +563,80 @@ properties:
|
||||
"always": running this check every time checks
|
||||
are run.
|
||||
example: 2 weeks
|
||||
only_run_on:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: |
|
||||
After the "frequency" duration has elapsed, only
|
||||
run this check if the current day of the week
|
||||
matches one of these values (the name of a day of
|
||||
the week in the current locale). "weekday" and
|
||||
"weekend" are also accepted. Defaults to running
|
||||
the check on any day of the week.
|
||||
example:
|
||||
- Saturday
|
||||
- Sunday
|
||||
- required: [name]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
enum:
|
||||
- repository
|
||||
description: |
|
||||
Name of the consistency check to run:
|
||||
* "repository" checks the consistency of the
|
||||
repository.
|
||||
* "archives" checks all of the archives.
|
||||
* "data" verifies the integrity of the data
|
||||
within the archives and implies the "archives"
|
||||
check as well.
|
||||
* "spot" checks that some percentage of source
|
||||
files are found in the most recent archive (with
|
||||
identical contents).
|
||||
* "extract" does an extraction dry-run of the
|
||||
most recent archive.
|
||||
* See "skip_actions" for disabling checks
|
||||
altogether.
|
||||
example: spot
|
||||
frequency:
|
||||
type: string
|
||||
description: |
|
||||
How frequently to run this type of consistency
|
||||
check (as a best effort). The value is a number
|
||||
followed by a unit of time. E.g., "2 weeks" to
|
||||
run this consistency check no more than every
|
||||
two weeks for a given repository or "1 month" to
|
||||
run it no more than monthly. Defaults to
|
||||
"always": running this check every time checks
|
||||
are run.
|
||||
example: 2 weeks
|
||||
only_run_on:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: |
|
||||
After the "frequency" duration has elapsed, only
|
||||
run this check if the current day of the week
|
||||
matches one of these values (the name of a day of
|
||||
the week in the current locale). "weekday" and
|
||||
"weekend" are also accepted. Defaults to running
|
||||
the check on any day of the week.
|
||||
example:
|
||||
- Saturday
|
||||
- Sunday
|
||||
max_duration:
|
||||
type: integer
|
||||
description: |
|
||||
How many seconds to check the repository before
|
||||
interrupting the check. Useful for splitting a
|
||||
long-running repository check into multiple
|
||||
partial checks. Defaults to no interruption. Only
|
||||
applies to the "repository" check, does not check
|
||||
the repository index, and is not compatible with a
|
||||
simultaneous "archives" check or "--repair" flag.
|
||||
example: 3600
|
||||
- required:
|
||||
- name
|
||||
- count_tolerance_percentage
|
||||
@@ -553,18 +649,20 @@ properties:
|
||||
enum:
|
||||
- spot
|
||||
description: |
|
||||
Name of consistency check to run: "repository",
|
||||
"archives", "data", "spot", and/or "extract".
|
||||
"repository" checks the consistency of the
|
||||
repository, "archives" checks all of the
|
||||
archives, "data" verifies the integrity of the
|
||||
data within the archives, "spot" checks that
|
||||
some percentage of source files are found in the
|
||||
most recent archive (with identical contents),
|
||||
and "extract" does an extraction dry-run of the
|
||||
most recent archive. Note that "data" implies
|
||||
"archives". See "skip_actions" for disabling
|
||||
checks altogether.
|
||||
Name of the consistency check to run:
|
||||
* "repository" checks the consistency of the
|
||||
repository.
|
||||
* "archives" checks all of the archives.
|
||||
* "data" verifies the integrity of the data
|
||||
within the archives and implies the "archives"
|
||||
check as well.
|
||||
* "spot" checks that some percentage of source
|
||||
files are found in the most recent archive (with
|
||||
identical contents).
|
||||
* "extract" does an extraction dry-run of the
|
||||
most recent archive.
|
||||
* See "skip_actions" for disabling checks
|
||||
altogether.
|
||||
example: repository
|
||||
frequency:
|
||||
type: string
|
||||
@@ -578,6 +676,20 @@ properties:
|
||||
"always": running this check every time checks
|
||||
are run.
|
||||
example: 2 weeks
|
||||
only_run_on:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: |
|
||||
After the "frequency" duration has elapsed, only
|
||||
run this check if the current day of the week
|
||||
matches one of these values (the name of a day of
|
||||
the week in the current locale). "weekday" and
|
||||
"weekend" are also accepted. Defaults to running
|
||||
the check on any day of the week.
|
||||
example:
|
||||
- Saturday
|
||||
- Sunday
|
||||
count_tolerance_percentage:
|
||||
type: number
|
||||
description: |
|
||||
@@ -586,7 +698,8 @@ properties:
|
||||
archive file count that is allowed before the
|
||||
entire consistency check fails. This can catch
|
||||
problems like incorrect excludes, inadvertent
|
||||
deletes, etc. Only applies to the "spot" check.
|
||||
deletes, etc. Required (and only valid) for the
|
||||
"spot" check.
|
||||
example: 10
|
||||
data_sample_percentage:
|
||||
type: number
|
||||
@@ -594,7 +707,8 @@ properties:
|
||||
The percentage of total files in the source
|
||||
directories to randomly sample and compare to
|
||||
their corresponding files in the most recent
|
||||
backup archive. Only applies to the "spot" check.
|
||||
backup archive. Required (and only valid) for the
|
||||
"spot" check.
|
||||
example: 1
|
||||
data_tolerance_percentage:
|
||||
type: number
|
||||
@@ -606,7 +720,7 @@ properties:
|
||||
been bulk-changed by malware, backups that have
|
||||
been tampered with, etc. The value must be lower
|
||||
than or equal to the "contents_sample_percentage".
|
||||
Only applies to the "spot" check.
|
||||
Required (and only valid) for the "spot" check.
|
||||
example: 0.5
|
||||
xxh64sum_command:
|
||||
type: string
|
||||
@@ -615,7 +729,7 @@ properties:
|
||||
source files, usually found in an OS package named
|
||||
"xxhash". Do not substitute with a different hash
|
||||
type (SHA, MD5, etc.) or the check will never
|
||||
succeed. Only applies to the "spot" check.
|
||||
succeed. Only valid for the "spot" check.
|
||||
example: /usr/local/bin/xxh64sum
|
||||
description: |
|
||||
List of one or more consistency checks to run on a periodic basis
|
||||
@@ -650,21 +764,23 @@ properties:
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- rcreate
|
||||
- repo-create
|
||||
- transfer
|
||||
- prune
|
||||
- compact
|
||||
- create
|
||||
- check
|
||||
- delete
|
||||
- extract
|
||||
- config
|
||||
- export-tar
|
||||
- mount
|
||||
- umount
|
||||
- repo-delete
|
||||
- restore
|
||||
- rlist
|
||||
- repo-list
|
||||
- list
|
||||
- rinfo
|
||||
- repo-info
|
||||
- info
|
||||
- break-lock
|
||||
- key
|
||||
@@ -817,6 +933,20 @@ properties:
|
||||
of them (after any action).
|
||||
example:
|
||||
- "echo Completed actions."
|
||||
bootstrap:
|
||||
type: object
|
||||
properties:
|
||||
store_config_files:
|
||||
type: boolean
|
||||
description: |
|
||||
Store configuration files used to create a backup inside the
|
||||
backup itself. Defaults to true. Changing this to false
|
||||
prevents "borgmatic bootstrap" from extracting configuration
|
||||
files from the backup.
|
||||
example: false
|
||||
description: |
|
||||
Support for the "borgmatic bootstrap" action, used to extract
|
||||
borgmatic configuration files from a backup archive.
|
||||
postgresql_databases:
|
||||
type: array
|
||||
items:
|
||||
@@ -831,8 +961,8 @@ properties:
|
||||
dump all databases on the host. (Also set the "format"
|
||||
to dump each database to a separate file instead of one
|
||||
combined file.) Note that using this database hook
|
||||
implicitly enables both read_special and one_file_system
|
||||
(see above) to support dump and restore streaming.
|
||||
implicitly enables read_special (see above) to support
|
||||
dump and restore streaming.
|
||||
example: users
|
||||
hostname:
|
||||
type: string
|
||||
@@ -943,9 +1073,12 @@ properties:
|
||||
description: |
|
||||
Command to use instead of "pg_dump" or "pg_dumpall".
|
||||
This can be used to run a specific pg_dump version
|
||||
(e.g., one inside a running container). Defaults to
|
||||
"pg_dump" for single database dump or "pg_dumpall" to
|
||||
dump all databases.
|
||||
(e.g., one inside a running container). If you run it
|
||||
from within a container, make sure to mount your
|
||||
host's ".borgmatic" folder into the container using
|
||||
the same directory structure. Defaults to "pg_dump"
|
||||
for single database dump or "pg_dumpall" to dump all
|
||||
databases.
|
||||
example: docker exec my_pg_container pg_dump
|
||||
pg_restore_command:
|
||||
type: string
|
||||
@@ -1010,9 +1143,8 @@ properties:
|
||||
description: |
|
||||
Database name (required if using this hook). Or "all" to
|
||||
dump all databases on the host. Note that using this
|
||||
database hook implicitly enables both read_special and
|
||||
one_file_system (see above) to support dump and restore
|
||||
streaming.
|
||||
database hook implicitly enables read_special (see
|
||||
above) to support dump and restore streaming.
|
||||
example: users
|
||||
hostname:
|
||||
type: string
|
||||
@@ -1059,7 +1191,10 @@ properties:
|
||||
description: |
|
||||
Command to use instead of "mariadb-dump". This can be
|
||||
used to run a specific mariadb_dump version (e.g., one
|
||||
inside a running container). Defaults to "mariadb-dump".
|
||||
inside a running container). If you run it from within
|
||||
a container, make sure to mount your host's
|
||||
".borgmatic" folder into the container using the same
|
||||
directory structure. Defaults to "mariadb-dump".
|
||||
example: docker exec mariadb_container mariadb-dump
|
||||
mariadb_command:
|
||||
type: string
|
||||
@@ -1134,9 +1269,8 @@ properties:
|
||||
description: |
|
||||
Database name (required if using this hook). Or "all" to
|
||||
dump all databases on the host. Note that using this
|
||||
database hook implicitly enables both read_special and
|
||||
one_file_system (see above) to support dump and restore
|
||||
streaming.
|
||||
database hook implicitly enables read_special (see
|
||||
above) to support dump and restore streaming.
|
||||
example: users
|
||||
hostname:
|
||||
type: string
|
||||
@@ -1187,9 +1321,12 @@ properties:
|
||||
mysql_dump_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "mysqldump". This can be used
|
||||
to run a specific mysql_dump version (e.g., one inside a
|
||||
running container). Defaults to "mysqldump".
|
||||
Command to use instead of "mysqldump". This can be
|
||||
used to run a specific mysql_dump version (e.g., one
|
||||
inside a running container). If you run it from within
|
||||
a container, make sure to mount your host's
|
||||
".borgmatic" folder into the container using the same
|
||||
directory structure. Defaults to "mysqldump".
|
||||
example: docker exec mysql_container mysqldump
|
||||
mysql_command:
|
||||
type: string
|
||||
@@ -1266,9 +1403,9 @@ properties:
|
||||
description: |
|
||||
Path to the SQLite database file to dump. If relative,
|
||||
it is relative to the current working directory. Note
|
||||
that using this database hook implicitly enables both
|
||||
read_special and one_file_system (see above) to support
|
||||
dump and restore streaming.
|
||||
that using this database hook implicitly enables
|
||||
read_special (see above) to support dump and restore
|
||||
streaming.
|
||||
example: /var/lib/sqlite/users.db
|
||||
restore_path:
|
||||
type: string
|
||||
@@ -1288,9 +1425,8 @@ properties:
|
||||
description: |
|
||||
Database name (required if using this hook). Or "all" to
|
||||
dump all databases on the host. Note that using this
|
||||
database hook implicitly enables both read_special and
|
||||
one_file_system (see above) to support dump and restore
|
||||
streaming.
|
||||
database hook implicitly enables read_special (see
|
||||
above) to support dump and restore streaming.
|
||||
example: users
|
||||
hostname:
|
||||
type: string
|
||||
@@ -1492,6 +1628,349 @@ properties:
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
pushover:
|
||||
type: object
|
||||
required: ['token', 'user']
|
||||
additionalProperties: false
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: |
|
||||
Your application's API token.
|
||||
example: 7ms6TXHpTokTou2P6x4SodDeentHRa
|
||||
user:
|
||||
type: string
|
||||
description: |
|
||||
Your user/group key (or that of your target user), viewable
|
||||
when logged into your dashboard: often referred to as
|
||||
USER_KEY in Pushover documentation and code examples.
|
||||
example: hwRwoWsXMBWwgrSecfa9EfPey55WSN
|
||||
start:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
Message to be sent to the user or group. If omitted
|
||||
the default is the name of the state.
|
||||
example: A backup job has started.
|
||||
priority:
|
||||
type: integer
|
||||
description: |
|
||||
A value of -2, -1, 0 (default), 1 or 2 that
|
||||
indicates the message priority.
|
||||
example: 0
|
||||
expire:
|
||||
type: integer
|
||||
description: |
|
||||
How many seconds your notification will continue
|
||||
to be retried (every retry seconds). Defaults to
|
||||
600. This settings only applies to priority 2
|
||||
notifications.
|
||||
example: 600
|
||||
retry:
|
||||
type: integer
|
||||
description: |
|
||||
The retry parameter specifies how often
|
||||
(in seconds) the Pushover servers will send the
|
||||
same notification to the user. Defaults to 30. This
|
||||
settings only applies to priority 2 notifications.
|
||||
example: 30
|
||||
device:
|
||||
type: string
|
||||
description: |
|
||||
The name of one of your devices to send just to
|
||||
that device instead of all devices.
|
||||
example: pixel8
|
||||
html:
|
||||
type: boolean
|
||||
description: |
|
||||
Set to True to enable HTML parsing of the message.
|
||||
Set to False for plain text.
|
||||
example: True
|
||||
sound:
|
||||
type: string
|
||||
description: |
|
||||
The name of a supported sound to override your
|
||||
default sound choice. All options can be found
|
||||
here: https://pushover.net/api#sounds
|
||||
example: bike
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
Your message's title, otherwise your app's name is
|
||||
used.
|
||||
example: A backup job has started.
|
||||
ttl:
|
||||
type: integer
|
||||
description: |
|
||||
The number of seconds that the message will live,
|
||||
before being deleted automatically. The ttl
|
||||
parameter is ignored for messages with a priority.
|
||||
value of 2.
|
||||
example: 3600
|
||||
url:
|
||||
type: string
|
||||
description: |
|
||||
A supplementary URL to show with your message.
|
||||
example: https://pushover.net/apps/xxxxx-borgbackup
|
||||
url_title:
|
||||
type: string
|
||||
description: |
|
||||
A title for the URL specified as the url parameter,
|
||||
otherwise just the URL is shown.
|
||||
example: Pushover Link
|
||||
finish:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
Message to be sent to the user or group. If omitted
|
||||
the default is the name of the state.
|
||||
example: A backup job has finished.
|
||||
priority:
|
||||
type: integer
|
||||
description: |
|
||||
A value of -2, -1, 0 (default), 1 or 2 that
|
||||
indicates the message priority.
|
||||
example: 0
|
||||
expire:
|
||||
type: integer
|
||||
description: |
|
||||
How many seconds your notification will continue
|
||||
to be retried (every retry seconds). Defaults to
|
||||
600. This settings only applies to priority 2
|
||||
notifications.
|
||||
example: 600
|
||||
retry:
|
||||
type: integer
|
||||
description: |
|
||||
The retry parameter specifies how often
|
||||
(in seconds) the Pushover servers will send the
|
||||
same notification to the user. Defaults to 30. This
|
||||
settings only applies to priority 2 notifications.
|
||||
example: 30
|
||||
device:
|
||||
type: string
|
||||
description: |
|
||||
The name of one of your devices to send just to
|
||||
that device instead of all devices.
|
||||
example: pixel8
|
||||
html:
|
||||
type: boolean
|
||||
description: |
|
||||
Set to True to enable HTML parsing of the message.
|
||||
Set to False for plain text.
|
||||
example: True
|
||||
sound:
|
||||
type: string
|
||||
description: |
|
||||
The name of a supported sound to override your
|
||||
default sound choice. All options can be found
|
||||
here: https://pushover.net/api#sounds
|
||||
example: bike
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
Your message's title, otherwise your app's name is
|
||||
used.
|
||||
example: A backup job has started.
|
||||
ttl:
|
||||
type: integer
|
||||
description: |
|
||||
The number of seconds that the message will live,
|
||||
before being deleted automatically. The ttl
|
||||
parameter is ignored for messages with a priority.
|
||||
value of 2.
|
||||
example: 3600
|
||||
url:
|
||||
type: string
|
||||
description: |
|
||||
A supplementary URL to show with your message.
|
||||
example: https://pushover.net/apps/xxxxx-borgbackup
|
||||
url_title:
|
||||
type: string
|
||||
description: |
|
||||
A title for the URL specified as the url parameter,
|
||||
otherwise just the URL is shown.
|
||||
example: Pushover Link
|
||||
fail:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
Message to be sent to the user or group. If omitted
|
||||
the default is the name of the state.
|
||||
example: A backup job has failed.
|
||||
priority:
|
||||
type: integer
|
||||
description: |
|
||||
A value of -2, -1, 0 (default), 1 or 2 that
|
||||
indicates the message priority.
|
||||
example: 0
|
||||
expire:
|
||||
type: integer
|
||||
description: |
|
||||
How many seconds your notification will continue
|
||||
to be retried (every retry seconds). Defaults to
|
||||
600. This settings only applies to priority 2
|
||||
notifications.
|
||||
example: 600
|
||||
retry:
|
||||
type: integer
|
||||
description: |
|
||||
The retry parameter specifies how often
|
||||
(in seconds) the Pushover servers will send the
|
||||
same notification to the user. Defaults to 30. This
|
||||
settings only applies to priority 2 notifications.
|
||||
example: 30
|
||||
device:
|
||||
type: string
|
||||
description: |
|
||||
The name of one of your devices to send just to
|
||||
that device instead of all devices.
|
||||
example: pixel8
|
||||
html:
|
||||
type: boolean
|
||||
description: |
|
||||
Set to True to enable HTML parsing of the message.
|
||||
Set to False for plain text.
|
||||
example: True
|
||||
sound:
|
||||
type: string
|
||||
description: |
|
||||
The name of a supported sound to override your
|
||||
default sound choice. All options can be found
|
||||
here: https://pushover.net/api#sounds
|
||||
example: bike
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
Your message's title, otherwise your app's name is
|
||||
used.
|
||||
example: A backup job has started.
|
||||
ttl:
|
||||
type: integer
|
||||
description: |
|
||||
The number of seconds that the message will live,
|
||||
before being deleted automatically. The ttl
|
||||
parameter is ignored for messages with a priority.
|
||||
value of 2.
|
||||
example: 3600
|
||||
url:
|
||||
type: string
|
||||
description: |
|
||||
A supplementary URL to show with your message.
|
||||
example: https://pushover.net/apps/xxxxx-borgbackup
|
||||
url_title:
|
||||
type: string
|
||||
description: |
|
||||
A title for the URL specified as the url parameter,
|
||||
otherwise just the URL is shown.
|
||||
example: Pushover Link
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
uniqueItems: true
|
||||
description: |
|
||||
List of one or more monitoring states to ping for: "start",
|
||||
"finish", and/or "fail". Defaults to pinging for failure
|
||||
only.
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
zabbix:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
itemid:
|
||||
type: integer
|
||||
description: |
|
||||
The ID of the Zabbix item used for collecting data.
|
||||
Unique across the entire Zabbix system.
|
||||
example: 55105
|
||||
host:
|
||||
type: string
|
||||
description: |
|
||||
Host name where the item is stored. Required if "itemid"
|
||||
is not set.
|
||||
example: borg-server
|
||||
key:
|
||||
type: string
|
||||
description: |
|
||||
Key of the host where the item is stored. Required if
|
||||
"itemid" is not set.
|
||||
example: borg.status
|
||||
server:
|
||||
type: string
|
||||
description: |
|
||||
The address of your Zabbix instance.
|
||||
example: https://zabbix.your-domain.com
|
||||
username:
|
||||
type: string
|
||||
description: |
|
||||
The username used for authentication. Not needed if using
|
||||
an API key.
|
||||
example: testuser
|
||||
password:
|
||||
type: string
|
||||
description: |
|
||||
The password used for authentication. Not needed if using
|
||||
an API key.
|
||||
example: fakepassword
|
||||
api_key:
|
||||
type: string
|
||||
description: |
|
||||
The API key used for authentication. Not needed if using
|
||||
an username/password.
|
||||
example: fakekey
|
||||
start:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: ["integer", "string"]
|
||||
description: |
|
||||
The value to set the item to on start.
|
||||
example: STARTED
|
||||
finish:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: ["integer", "string"]
|
||||
description: |
|
||||
The value to set the item to on finish.
|
||||
example: FINISH
|
||||
fail:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: ["integer", "string"]
|
||||
description: |
|
||||
The value to set the item to on fail.
|
||||
example: ERROR
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
uniqueItems: true
|
||||
description: |
|
||||
List of one or more monitoring states to ping for: "start",
|
||||
"finish", and/or "fail". Defaults to pinging for failure
|
||||
only.
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
apprise:
|
||||
type: object
|
||||
required: ['services']
|
||||
@@ -1662,11 +2141,51 @@ properties:
|
||||
states.
|
||||
example:
|
||||
- finish
|
||||
create_slug:
|
||||
type: boolean
|
||||
description: |
|
||||
Create the check if it does not exist. Only works with
|
||||
the slug URL scheme (https://hc-ping.com/<ping-key>/<slug>
|
||||
as opposed to https://hc-ping.com/<uuid>).
|
||||
Defaults to false.
|
||||
example: true
|
||||
description: |
|
||||
Configuration for a monitoring integration with Healthchecks. Create
|
||||
an account at https://healthchecks.io (or self-host Healthchecks) if
|
||||
you'd like to use this service. See borgmatic monitoring
|
||||
documentation for details.
|
||||
uptime_kuma:
|
||||
type: object
|
||||
required: ['push_url']
|
||||
additionalProperties: false
|
||||
properties:
|
||||
push_url:
|
||||
type: string
|
||||
description: |
|
||||
Uptime Kuma push URL without query string (do not include the
|
||||
question mark or anything after it).
|
||||
example: https://example.uptime.kuma/api/push/abcd1234
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
uniqueItems: true
|
||||
description: |
|
||||
List of one or more monitoring states to push for: "start",
|
||||
"finish", and/or "fail". Defaults to pushing for all
|
||||
states.
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
description: |
|
||||
Configuration for a monitoring integration with Uptime Kuma using
|
||||
the Push monitor type.
|
||||
See more information here: https://uptime.kuma.pet
|
||||
cronitor:
|
||||
type: object
|
||||
required: ['ping_url']
|
||||
@@ -1731,14 +2250,99 @@ properties:
|
||||
Allows setting custom labels for the logging stream. At
|
||||
least one label is required. "__hostname" gets replaced by
|
||||
the machine hostname automatically. "__config" gets replaced
|
||||
by just the name of the configuration file. "__config_path"
|
||||
gets replaced by the full path of the configuration file.
|
||||
by the name of the configuration file. "__config_path" gets
|
||||
replaced by the full path of the configuration file.
|
||||
example:
|
||||
app: "borgmatic"
|
||||
config: "__config"
|
||||
hostname: "__hostname"
|
||||
description: |
|
||||
Configuration for a monitoring integration with Grafana loki. You
|
||||
Configuration for a monitoring integration with Grafana Loki. You
|
||||
can send the logs to a self-hosted instance or create an account at
|
||||
https://grafana.com/auth/sign-up/create-user. See borgmatic
|
||||
monitoring documentation for details.
|
||||
|
||||
zfs:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
zfs_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "zfs".
|
||||
example: /usr/local/bin/zfs
|
||||
mount_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "mount".
|
||||
example: /usr/local/bin/mount
|
||||
umount_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "umount".
|
||||
example: /usr/local/bin/umount
|
||||
description: |
|
||||
Configuration for integration with the ZFS filesystem.
|
||||
btrfs:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
btrfs_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "btrfs".
|
||||
example: /usr/local/bin/btrfs
|
||||
findmnt_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "findmnt".
|
||||
example: /usr/local/bin/findmnt
|
||||
description: |
|
||||
Configuration for integration with the Btrfs filesystem.
|
||||
lvm:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
snapshot_size:
|
||||
type: string
|
||||
description: |
|
||||
Size to allocate for each snapshot taken, including the
|
||||
units to use for that size. Defaults to "10%ORIGIN" (10%
|
||||
of the size of logical volume being snapshotted). See the
|
||||
lvcreate "--size" and "--extents" documentation for more
|
||||
information:
|
||||
https://www.man7.org/linux/man-pages/man8/lvcreate.8.html
|
||||
example: 5GB
|
||||
lvcreate_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "lvcreate".
|
||||
example: /usr/local/bin/lvcreate
|
||||
lvremove_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "lvremove".
|
||||
example: /usr/local/bin/lvremove
|
||||
lvs_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "lvs".
|
||||
example: /usr/local/bin/lvs
|
||||
lsblk_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "lsblk".
|
||||
example: /usr/local/bin/lsblk
|
||||
mount_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "mount".
|
||||
example: /usr/local/bin/mount
|
||||
umount_command:
|
||||
type: string
|
||||
description: |
|
||||
Command to use instead of "umount".
|
||||
example: /usr/local/bin/umount
|
||||
description: |
|
||||
Configuration for integration with Linux LVM (Logical Volume
|
||||
Manager).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fnmatch
|
||||
import os
|
||||
|
||||
import jsonschema
|
||||
@@ -149,18 +150,30 @@ def normalize_repository_path(repository):
|
||||
return repository
|
||||
|
||||
|
||||
def glob_match(first, second):
|
||||
'''
|
||||
Given two strings, return whether the first matches the second. Globs are
|
||||
supported.
|
||||
'''
|
||||
if first is None or second is None:
|
||||
return False
|
||||
|
||||
return fnmatch.fnmatch(first, second) or fnmatch.fnmatch(second, first)
|
||||
|
||||
|
||||
def repositories_match(first, second):
|
||||
'''
|
||||
Given two repository dicts with keys 'path' (relative and/or absolute),
|
||||
and 'label', or two repository paths, return whether they match.
|
||||
Given two repository dicts with keys "path" (relative and/or absolute),
|
||||
and "label", two repository paths as strings, or a mix of the two formats,
|
||||
return whether they match. Globs are supported.
|
||||
'''
|
||||
if isinstance(first, str):
|
||||
first = {'path': first, 'label': first}
|
||||
if isinstance(second, str):
|
||||
second = {'path': second, 'label': second}
|
||||
return (first.get('label') == second.get('label')) or (
|
||||
normalize_repository_path(first.get('path'))
|
||||
== normalize_repository_path(second.get('path'))
|
||||
|
||||
return glob_match(first.get('label'), second.get('label')) or glob_match(
|
||||
normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path'))
|
||||
)
|
||||
|
||||
|
||||
@@ -186,26 +199,3 @@ def guard_configuration_contains_repository(repository, configurations):
|
||||
|
||||
if count == 0:
|
||||
raise ValueError(f'Repository "{repository}" not found in configuration files')
|
||||
|
||||
|
||||
def guard_single_repository_selected(repository, configurations):
|
||||
'''
|
||||
Given a repository path and a dict mapping from config filename to corresponding parsed config
|
||||
dict, ensure either a single repository exists across all configuration files or a repository
|
||||
path was given.
|
||||
'''
|
||||
if repository:
|
||||
return
|
||||
|
||||
count = len(
|
||||
tuple(
|
||||
config_repository
|
||||
for config in configurations.values()
|
||||
for config_repository in config['repositories']
|
||||
)
|
||||
)
|
||||
|
||||
if count != 1:
|
||||
raise ValueError(
|
||||
"Can't determine which repository to use. Use --repository to disambiguate"
|
||||
)
|
||||
|
||||
+26
-10
@@ -2,8 +2,9 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from borgmatic import execute
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +28,20 @@ def interpolate_context(config_filename, hook_description, command, context):
|
||||
return command
|
||||
|
||||
|
||||
def make_environment(current_environment, sys_module=sys):
|
||||
'''
|
||||
Given the existing system environment as a map from environment variable name to value, return
|
||||
(in the same form) any extra environment variables that should be used when running command
|
||||
hooks.
|
||||
'''
|
||||
# Detect whether we're running within a PyInstaller bundle. If so, set or clear LD_LIBRARY_PATH
|
||||
# based on the value of LD_LIBRARY_PATH_ORIG. This prevents library version information errors.
|
||||
if getattr(sys_module, 'frozen', False) and hasattr(sys_module, '_MEIPASS'):
|
||||
return {'LD_LIBRARY_PATH': current_environment.get('LD_LIBRARY_PATH_ORIG', '')}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
|
||||
'''
|
||||
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
|
||||
@@ -65,14 +80,15 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
|
||||
|
||||
try:
|
||||
for command in commands:
|
||||
if not dry_run:
|
||||
execute.execute_command(
|
||||
[command],
|
||||
output_log_level=(
|
||||
logging.ERROR if description == 'on-error' else logging.WARNING
|
||||
),
|
||||
shell=True,
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
[command],
|
||||
output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING),
|
||||
shell=True,
|
||||
extra_environment=make_environment(os.environ),
|
||||
)
|
||||
finally:
|
||||
if original_umask:
|
||||
os.umask(original_umask)
|
||||
@@ -90,7 +106,7 @@ def considered_soft_failure(config_filename, error):
|
||||
|
||||
if exit_code == SOFT_FAIL_EXIT_CODE:
|
||||
logger.info(
|
||||
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining actions',
|
||||
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import glob
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import borgmatic.config.paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic
|
||||
configuration file paths, the borgmatic runtime directory, the configured source directories,
|
||||
and whether this is a dry run, create a borgmatic manifest file to store the paths of the
|
||||
configuration files used to create the archive. But skip this if the bootstrap
|
||||
store_config_files option is False or if this is a dry run.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
'''
|
||||
if hook_config and hook_config.get('store_config_files') is False:
|
||||
return []
|
||||
|
||||
borgmatic_manifest_path = os.path.join(
|
||||
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return []
|
||||
|
||||
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
|
||||
|
||||
with open(borgmatic_manifest_path, 'w') as manifest_file:
|
||||
json.dump(
|
||||
{
|
||||
'borgmatic_version': importlib.metadata.version('borgmatic'),
|
||||
'config_paths': config_paths,
|
||||
},
|
||||
manifest_file,
|
||||
)
|
||||
|
||||
source_directories.extend(config_paths)
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic runtime
|
||||
directory, and whether this is a dry run, then remove the manifest file created above. If this
|
||||
is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
manifest_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'bootstrap',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
|
||||
)
|
||||
|
||||
for manifest_directory in glob.glob(manifest_glob):
|
||||
manifest_file_path = os.path.join(manifest_directory, 'manifest.json')
|
||||
logger.debug(
|
||||
f'{log_prefix}: Removing bootstrap manifest at {manifest_file_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
os.remove(manifest_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.rmdir(manifest_directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
generic "restore".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
generic "restore".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,346 @@
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
def get_filesystem_mount_points(findmnt_command):
|
||||
'''
|
||||
Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
|
||||
'''
|
||||
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(findmnt_command.split(' '))
|
||||
+ (
|
||||
'-t', # Filesystem type.
|
||||
'btrfs',
|
||||
'--json',
|
||||
'--list', # Request a flat list instead of a nested subvolume hierarchy.
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems']
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {findmnt_command} JSON output: {error}')
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {findmnt_command} output: Missing key "{error}"')
|
||||
|
||||
|
||||
def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
|
||||
'''
|
||||
Given a Btrfs command to run and a Btrfs filesystem mount point, get the sorted subvolumes for
|
||||
that filesystem. Include the filesystem itself.
|
||||
'''
|
||||
btrfs_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
'subvolume',
|
||||
'list',
|
||||
filesystem_mount_point,
|
||||
)
|
||||
)
|
||||
|
||||
if not filesystem_mount_point.strip():
|
||||
return ()
|
||||
|
||||
return (filesystem_mount_point,) + tuple(
|
||||
sorted(
|
||||
subvolume_path
|
||||
for line in btrfs_output.splitlines()
|
||||
for subvolume_subpath in (line.rstrip().split(' ')[-1],)
|
||||
for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
|
||||
if subvolume_subpath.strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Subvolume = collections.namedtuple(
|
||||
'Subvolume', ('path', 'contained_source_directories'), defaults=((),)
|
||||
)
|
||||
|
||||
|
||||
def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
|
||||
'''
|
||||
Given a Btrfs command to run and a sequence of configured source directories, find the
|
||||
intersection between the current Btrfs filesystem and subvolume mount points and the configured
|
||||
borgmatic source directories. The idea is that these are the requested subvolumes to snapshot.
|
||||
|
||||
If the source directories is None, then return all subvolumes, sorted by path.
|
||||
|
||||
Return the result as a sequence of matching subvolume mount points.
|
||||
'''
|
||||
candidate_source_directories = set(source_directories or ())
|
||||
subvolumes = []
|
||||
|
||||
# For each filesystem mount point, find its subvolumes and match them again the given source
|
||||
# directories to find the subvolumes to backup. And within this loop, sort the subvolumes from
|
||||
# longest to shortest mount points, so longer mount points get a whack at the candidate source
|
||||
# directory piñata before their parents do. (Source directories are consumed during this
|
||||
# process, so no two datasets get the same contained source directories.)
|
||||
for mount_point in get_filesystem_mount_points(findmnt_command):
|
||||
subvolumes.extend(
|
||||
Subvolume(subvolume_path, contained_source_directories)
|
||||
for subvolume_path in reversed(
|
||||
get_subvolumes_for_filesystem(btrfs_command, mount_point)
|
||||
)
|
||||
for contained_source_directories in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
subvolume_path, candidate_source_directories
|
||||
),
|
||||
)
|
||||
if source_directories is None or contained_source_directories
|
||||
)
|
||||
|
||||
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
|
||||
|
||||
|
||||
def make_snapshot_path(subvolume_path): # pragma: no cover
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding snapshot path for it.
|
||||
'''
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
# Included so that the snapshot ends up in the Borg archive at the "original" subvolume
|
||||
# path.
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
|
||||
def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding exclude path for its embedded snapshot path.
|
||||
This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory of a
|
||||
subvolume, then the snapshot's own initial directory component shows up as an empty directory
|
||||
within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and make a
|
||||
snapshot of it at:
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt
|
||||
|
||||
... then the snapshot itself will have an empty directory at:
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
|
||||
|
||||
So to prevent that from ending up in the Borg archive, this function produces its path for
|
||||
exclusion.
|
||||
'''
|
||||
snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
snapshot_directory,
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
snapshot_directory,
|
||||
)
|
||||
|
||||
|
||||
def make_borg_source_directory_path(subvolume_path, source_directory): # pragma: no cover
|
||||
'''
|
||||
Given the path to a subvolume and a source directory inside it, make a corresponding path for
|
||||
the source directory within a snapshot path intended for giving to Borg.
|
||||
'''
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
|
||||
'''
|
||||
Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
|
||||
Btrfs snapshot of the subvolume.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
'subvolume',
|
||||
'snapshot',
|
||||
'-r', # Read-only,
|
||||
subvolume_path,
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic
|
||||
configuration file paths, the borgmatic runtime directory, the configured source directories,
|
||||
and whether this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed
|
||||
in the given source directories. Also update those source directories, replacing subvolume mount
|
||||
points with corresponding snapshot directories so they get stored in the Borg archive instead.
|
||||
Use the log prefix in any log entries.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'{log_prefix}: Snapshotting Btrfs subvolumes{dry_run_label}')
|
||||
|
||||
# Based on the configured source directories, determine Btrfs subvolumes to backup.
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
|
||||
subvolumes = get_subvolumes(btrfs_command, findmnt_command, source_directories)
|
||||
|
||||
if not subvolumes:
|
||||
logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
|
||||
|
||||
# Snapshot each subvolume, rewriting source directories to use their snapshot paths.
|
||||
for subvolume in subvolumes:
|
||||
logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
|
||||
|
||||
snapshot_path = make_snapshot_path(subvolume.path)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
|
||||
|
||||
for source_directory in subvolume.contained_source_directories:
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
source_directories.append(
|
||||
make_borg_source_directory_path(subvolume.path, source_directory)
|
||||
)
|
||||
|
||||
config.setdefault('exclude_patterns', []).append(make_snapshot_exclude_path(subvolume.path))
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
|
||||
'''
|
||||
Given a Btrfs command to run and the name of a snapshot path, delete it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
'subvolume',
|
||||
'delete',
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic runtime
|
||||
directory, and whether this is a dry run, delete any Btrfs snapshots created by borgmatic. Use
|
||||
the log prefix in any log entries. If this is a dry run or Btrfs isn't configured in borgmatic's
|
||||
configuration, then don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
|
||||
|
||||
try:
|
||||
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
|
||||
# subvolumes before the shorter mount point paths of parent subvolumes.
|
||||
for subvolume in reversed(all_subvolumes):
|
||||
subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(make_snapshot_path(subvolume.path)),
|
||||
temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
|
||||
for snapshot_path in glob.glob(subvolume_snapshots_glob):
|
||||
if not os.path.isdir(snapshot_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
delete_snapshot(btrfs_command, snapshot_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
# Strip off the subvolume path from the end of the snapshot path and then delete the
|
||||
# resulting directory.
|
||||
shutil.rmtree(snapshot_path.rsplit(subvolume.path, 1)[0])
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,76 @@
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
|
||||
'''
|
||||
Given a borgmatic runtime directory and a data source hook name, construct a data source dump
|
||||
path.
|
||||
'''
|
||||
return os.path.join(borgmatic_runtime_directory, data_source_hook_name)
|
||||
|
||||
|
||||
def make_data_source_dump_filename(dump_path, name, hostname=None, port=None):
|
||||
'''
|
||||
Based on the given dump directory path, data source name, hostname, and port, return a filename
|
||||
to use for the data source dump. The hostname defaults to localhost.
|
||||
|
||||
Raise ValueError if the data source name is invalid.
|
||||
'''
|
||||
if os.path.sep in name:
|
||||
raise ValueError(f'Invalid data source name {name}')
|
||||
|
||||
return os.path.join(
|
||||
dump_path, (hostname or 'localhost') + ('' if port is None else f':{port}'), name
|
||||
)
|
||||
|
||||
|
||||
def create_parent_directory_for_dump(dump_path):
|
||||
'''
|
||||
Create a directory to contain the given dump path.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def create_named_pipe_for_dump(dump_path):
|
||||
'''
|
||||
Create a named pipe at the given dump path.
|
||||
'''
|
||||
create_parent_directory_for_dump(dump_path)
|
||||
os.mkfifo(dump_path, mode=0o600)
|
||||
|
||||
|
||||
def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run):
|
||||
'''
|
||||
Remove all data source dumps in the given dump directory path (including the directory itself).
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if os.path.exists(dump_path):
|
||||
shutil.rmtree(dump_path)
|
||||
|
||||
|
||||
def convert_glob_patterns_to_borg_pattern(patterns):
|
||||
'''
|
||||
Convert a sequence of shell glob patterns like "/etc/*", "/tmp/*" to the corresponding Borg
|
||||
regular expression archive pattern as a single string like "re:etc/.*|tmp/.*".
|
||||
'''
|
||||
# Remove the "\Z" generated by fnmatch.translate() because we don't want the pattern to match
|
||||
# only at the end of a path, as directory format dumps require extracting files with paths
|
||||
# longer than the pattern. E.g., a pattern of "borgmatic/*/foo_databases/test" should also match
|
||||
# paths like "borgmatic/*/foo_databases/test/toc.dat"
|
||||
return 're:' + '|'.join(
|
||||
fnmatch.translate(pattern.lstrip('/')).replace('\\Z', '') for pattern in patterns
|
||||
)
|
||||
@@ -0,0 +1,405 @@
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
Logical_volume = collections.namedtuple(
|
||||
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_source_directories')
|
||||
)
|
||||
|
||||
|
||||
def get_logical_volumes(lsblk_command, source_directories=None):
|
||||
'''
|
||||
Given an lsblk command to run and a sequence of configured source directories, find the
|
||||
intersection between the current LVM logical volume mount points and the configured borgmatic
|
||||
source directories. The idea is that these are the requested logical volumes to snapshot.
|
||||
|
||||
If source directories is None, include all logical volume mounts points, not just those in
|
||||
source directories.
|
||||
|
||||
Return the result as a sequence of Logical_volume instances.
|
||||
'''
|
||||
try:
|
||||
devices_info = json.loads(
|
||||
borgmatic.execute.execute_command_and_capture_output(
|
||||
# Use lsblk instead of lvs here because lvs can't show active mounts.
|
||||
tuple(lsblk_command.split(' '))
|
||||
+ (
|
||||
'--output',
|
||||
'name,path,mountpoint,type',
|
||||
'--json',
|
||||
'--list',
|
||||
)
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
|
||||
|
||||
candidate_source_directories = set(source_directories or ())
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
Logical_volume(
|
||||
device['name'], device['path'], device['mountpoint'], contained_source_directories
|
||||
)
|
||||
for device in devices_info['blockdevices']
|
||||
if device['mountpoint'] and device['type'] == 'lvm'
|
||||
for contained_source_directories in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
device['mountpoint'], candidate_source_directories
|
||||
),
|
||||
)
|
||||
if not source_directories or contained_source_directories
|
||||
)
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
|
||||
|
||||
|
||||
def snapshot_logical_volume(
|
||||
lvcreate_command,
|
||||
snapshot_name,
|
||||
logical_volume_device,
|
||||
snapshot_size,
|
||||
):
|
||||
'''
|
||||
Given an lvcreate command to run, a snapshot name, the path to the logical volume device to
|
||||
snapshot, and a snapshot size string, create a new LVM snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(lvcreate_command.split(' '))
|
||||
+ (
|
||||
'--snapshot',
|
||||
('--extents' if '%' in snapshot_size else '--size'),
|
||||
snapshot_size,
|
||||
'--permission',
|
||||
'r', # Read-only.
|
||||
'--name',
|
||||
snapshot_name,
|
||||
logical_volume_device,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a mount command to run, the device path for an existing snapshot, and the path where the
|
||||
snapshot should be mounted, mount the snapshot as read-only (making any necessary directories
|
||||
first).
|
||||
'''
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
'-o',
|
||||
'ro',
|
||||
snapshot_device,
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic configuration
|
||||
file paths, the borgmatic runtime directory, the configured source directories, and whether this
|
||||
is a dry run, auto-detect and snapshot any LVM logical volume mount points listed in the given
|
||||
source directories. Also update those source directories, replacing logical volume mount points
|
||||
with corresponding snapshot directories so they get stored in the Borg archive instead. Use the
|
||||
log prefix in any log entries.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'{log_prefix}: Snapshotting LVM logical volumes{dry_run_label}')
|
||||
|
||||
# List logical volumes to get their mount points.
|
||||
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
|
||||
requested_logical_volumes = get_logical_volumes(lsblk_command, source_directories)
|
||||
|
||||
# Snapshot each logical volume, rewriting source directories to use the snapshot paths.
|
||||
snapshot_suffix = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_logical_volumes:
|
||||
logger.warning(f'{log_prefix}: No LVM logical volumes found to snapshot{dry_run_label}')
|
||||
|
||||
for logical_volume in requested_logical_volumes:
|
||||
snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
|
||||
logger.debug(
|
||||
f'{log_prefix}: Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
snapshot_logical_volume(
|
||||
hook_config.get('lvcreate_command', 'lvcreate'),
|
||||
snapshot_name,
|
||||
logical_volume.device_path,
|
||||
hook_config.get('snapshot_size', DEFAULT_SNAPSHOT_SIZE),
|
||||
)
|
||||
|
||||
# Get the device path for the snapshot we just created.
|
||||
try:
|
||||
snapshot = get_snapshots(
|
||||
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
|
||||
)[0]
|
||||
except IndexError:
|
||||
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
|
||||
|
||||
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
|
||||
# up in the Borg archive at the "original" logical volume mount point path.
|
||||
snapshot_mount_path = os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
logical_volume.mount_point.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
|
||||
)
|
||||
|
||||
# Update the path for each contained source directory, so Borg sees it within the
|
||||
# mounted snapshot.
|
||||
for source_directory in logical_volume.contained_source_directories:
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
source_directories.append(
|
||||
os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
)
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a umount command to run and the mount path of a snapshot, unmount it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
|
||||
'''
|
||||
Given an lvremove command to run and the device path of a snapshot, remove it it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(lvremove_command.split(' '))
|
||||
+ (
|
||||
'--force', # Suppress an interactive "are you sure?" type prompt.
|
||||
snapshot_device_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
Snapshot = collections.namedtuple(
|
||||
'Snapshot',
|
||||
('name', 'device_path'),
|
||||
)
|
||||
|
||||
|
||||
def get_snapshots(lvs_command, snapshot_name=None):
|
||||
'''
|
||||
Given an lvs command to run, return all LVM snapshots as a sequence of Snapshot instances.
|
||||
|
||||
If a snapshot name is given, filter the results to that snapshot.
|
||||
'''
|
||||
try:
|
||||
snapshot_info = json.loads(
|
||||
borgmatic.execute.execute_command_and_capture_output(
|
||||
# Use lvs instead of lsblk here because lsblk can't filter to just snapshots.
|
||||
tuple(lvs_command.split(' '))
|
||||
+ (
|
||||
'--report-format',
|
||||
'json',
|
||||
'--options',
|
||||
'lv_name,lv_path',
|
||||
'--select',
|
||||
'lv_attr =~ ^s', # Filter to just snapshots.
|
||||
)
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
Snapshot(snapshot['lv_name'], snapshot['lv_path'])
|
||||
for snapshot in snapshot_info['report'][0]['lv']
|
||||
if snapshot_name is None or snapshot['lv_name'] == snapshot_name
|
||||
)
|
||||
except IndexError:
|
||||
raise ValueError(f'Invalid {lvs_command} output: Missing report data')
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic runtime
|
||||
directory, and whether this is a dry run, unmount and delete any LVM snapshots created by
|
||||
borgmatic. Use the log prefix in any log entries. If this is a dry run or LVM isn't configured
|
||||
in borgmatic's configuration, then don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
# Unmount snapshots.
|
||||
try:
|
||||
logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'lvm_snapshots',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
umount_command = hook_config.get('umount_command', 'umount')
|
||||
|
||||
for snapshots_directory in glob.glob(snapshots_glob):
|
||||
if not os.path.isdir(snapshots_directory):
|
||||
continue
|
||||
|
||||
for logical_volume in logical_volumes:
|
||||
snapshot_mount_path = os.path.join(
|
||||
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
|
||||
)
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
# This might fail if the directory is already mounted, but we swallow errors here since
|
||||
# we'll do another recursive delete below. The point of doing it here is that we don't
|
||||
# want to try to unmount a non-mounted directory (which *will* fail).
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
|
||||
|
||||
# If the delete was successful, that means there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
unmount_snapshot(umount_command, snapshot_mount_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
|
||||
# Delete snapshots.
|
||||
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
|
||||
|
||||
try:
|
||||
snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
for snapshot in snapshots:
|
||||
# Only delete snapshots that borgmatic actually created!
|
||||
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'{log_prefix}: Deleting LVM snapshot {snapshot.name}{dry_run_label}')
|
||||
|
||||
if not dry_run:
|
||||
remove_snapshot(lvremove_command, snapshot.device_path)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -3,23 +3,22 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'mariadb_databases'
|
||||
)
|
||||
return dump.make_data_source_dump_path(base_directory, 'mariadb_databases')
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
@@ -74,7 +73,10 @@ def execute_dump_command(
|
||||
'''
|
||||
database_name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database['name'], database.get('hostname')
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
@@ -123,15 +125,24 @@ def use_streaming(databases, config, log_prefix):
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
configuration dict to construct the destination path and the given log prefix in any log
|
||||
entries.
|
||||
borgmatic runtime directory to construct the destination path and the given log prefix in any
|
||||
log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
@@ -139,7 +150,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(config)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
@@ -179,36 +190,61 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mariadb_databases'))
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the given
|
||||
configuration dict to construct the destination path and the log prefix in any log entries. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'MariaDB', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MariaDB', log_prefix, dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
|
||||
database name to match, return the corresponding glob patterns to match the database dump in an
|
||||
archive.
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
|
||||
borgmatic runtime directory, and a database name to match, return the corresponding glob
|
||||
patterns to match the database dump in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
configuration dict, but the given hook configuration is ignored. The given log prefix is used
|
||||
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
|
||||
given active extract process (an instance of subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
@@ -1,19 +1,19 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'mongodb_databases'
|
||||
)
|
||||
return dump.make_data_source_dump_path(base_directory, 'mongodb_databases')
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
@@ -24,14 +24,24 @@ def use_streaming(databases, config, log_prefix):
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the configuration
|
||||
dict to construct the destination path and the given log prefix in any log entries.
|
||||
dicts, one dict describing each database as per the configuration schema. Use the borgmatic
|
||||
runtime directory to construct the destination path (used for the directory format and the given
|
||||
log prefix in any log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
|
||||
@@ -41,7 +51,10 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
for database in databases:
|
||||
name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), name, database.get('hostname')
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
@@ -60,6 +73,9 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mongodb_databases'))
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
@@ -91,26 +107,49 @@ def build_dump_command(database, dump_filename, dump_format):
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the log
|
||||
prefix in any log entries. Use the given configuration dict to construct the destination path.
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MongoDB', log_prefix, dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of database configurations dicts, a configuration dict, a prefix to log with,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
|
||||
borgmatic runtime directory, and a database name to match, return the corresponding glob
|
||||
patterns to match the database dump in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
@@ -124,7 +163,9 @@ def restore_data_source_dump(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), data_source['name'], data_source.get('hostname')
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
)
|
||||
restore_command = build_restore_command(
|
||||
extract_process, data_source, dump_filename, connection_params
|
||||
@@ -3,23 +3,22 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'mysql_databases'
|
||||
)
|
||||
return dump.make_data_source_dump_path(base_directory, 'mysql_databases')
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
@@ -74,7 +73,10 @@ def execute_dump_command(
|
||||
'''
|
||||
database_name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database['name'], database.get('hostname')
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
@@ -122,14 +124,24 @@ def use_streaming(databases, config, log_prefix):
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
|
||||
of dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
configuration dict to construct the destination path and the given log prefix in any log entries.
|
||||
borgmatic runtime directory to construct the destination path and the given log prefix in any
|
||||
log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
@@ -137,7 +149,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
logger.info(f'{log_prefix}: Dumping MySQL databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(config)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
@@ -177,36 +189,61 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mysql_databases'))
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the given
|
||||
configuration dict to construct the destination path and the log prefix in any log entries. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'MySQL', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MySQL', log_prefix, dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
|
||||
database name to match, return the corresponding glob patterns to match the database dump in an
|
||||
archive.
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
|
||||
borgmatic runtime directory, and a database name to match, return the corresponding glob
|
||||
patterns to match the database dump in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
configuration dict, but the given hook configuration is ignored. The given log prefix is used
|
||||
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
|
||||
given active extract process (an instance of subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
@@ -2,25 +2,25 @@ import csv
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'postgresql_databases'
|
||||
)
|
||||
return dump.make_data_source_dump_path(base_directory, 'postgresql_databases')
|
||||
|
||||
|
||||
def make_extra_environment(database, restore_connection_params=None):
|
||||
@@ -104,15 +104,24 @@ def use_streaming(databases, config, log_prefix):
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
configuration dict to construct the destination path and the given log prefix in any log
|
||||
entries.
|
||||
borgmatic runtime directory to construct the destination path and the given log prefix in any
|
||||
log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
|
||||
Raise ValueError if the databases to dump cannot be determined.
|
||||
'''
|
||||
@@ -123,7 +132,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
|
||||
for database in databases:
|
||||
extra_environment = make_extra_environment(database)
|
||||
dump_path = make_dump_path(config)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
)
|
||||
@@ -142,7 +151,10 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
for part in shlex.split(database.get('pg_dump_command') or default_dump_command)
|
||||
)
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database_name, database.get('hostname')
|
||||
dump_path,
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
@@ -203,36 +215,63 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'postgresql_databases'))
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the given
|
||||
configuration dict to construct the destination path and the log prefix in any log entries. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', log_prefix, dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
|
||||
database name to match, return the corresponding glob patterns to match the database dump in an
|
||||
archive.
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
|
||||
borgmatic runtime directory, and a database name to match, return the corresponding glob
|
||||
patterns to match the database dump in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
configuration dict, but the given hook configuration is ignored. The given borgmatic runtime
|
||||
directory is used to construct the destination path (used for the directory format), and the
|
||||
given log prefix is used for any log entries. If this is a dry run, then don't actually restore
|
||||
anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce
|
||||
output to consume.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
@@ -253,7 +292,9 @@ def restore_data_source_dump(
|
||||
|
||||
all_databases = bool(data_source['name'] == 'all')
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), data_source['name'], data_source.get('hostname')
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
)
|
||||
psql_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('psql_command') or 'psql')
|
||||
@@ -291,7 +332,7 @@ def restore_data_source_dump(
|
||||
if 'restore_options' in data_source
|
||||
else ()
|
||||
)
|
||||
+ (() if extract_process else (dump_filename,))
|
||||
+ (() if extract_process else (str(pathlib.Path(dump_filename)),))
|
||||
+ tuple(
|
||||
itertools.chain.from_iterable(('--schema', schema) for schema in data_source['schemas'])
|
||||
if data_source.get('schemas')
|
||||
@@ -0,0 +1,30 @@
|
||||
import pathlib
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
def get_contained_directories(parent_directory, candidate_contained_directories):
|
||||
'''
|
||||
Given a parent directory and a set of candidate directories potentially inside it, get the
|
||||
subset of contained directories for which the parent directory is actually the parent, a
|
||||
grandparent, the very same directory, etc. The idea is if, say, /var/log and /var/lib are
|
||||
candidate contained directories, but there's a parent directory (logical volume, dataset,
|
||||
subvolume, etc.) at /var, then /var is what we want to snapshot.
|
||||
|
||||
Also mutate the given set of candidate contained directories to remove any actually contained
|
||||
directories from it. That way, this function can be called multiple times, successively
|
||||
processing candidate directories until none are left—and avoiding assigning any candidate
|
||||
directory to more than one parent directory.
|
||||
'''
|
||||
if not candidate_contained_directories:
|
||||
return ()
|
||||
|
||||
contained = tuple(
|
||||
candidate
|
||||
for candidate in candidate_contained_directories
|
||||
if pathlib.PurePath(parent_directory) == pathlib.PurePath(candidate)
|
||||
or pathlib.PurePath(parent_directory) in pathlib.PurePath(candidate).parents
|
||||
)
|
||||
candidate_contained_directories -= set(contained)
|
||||
|
||||
return contained
|
||||
@@ -2,19 +2,18 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'sqlite_databases'
|
||||
)
|
||||
return dump.make_data_source_dump_path(base_directory, 'sqlite_databases')
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
@@ -25,14 +24,23 @@ def use_streaming(databases, config, log_prefix):
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given SQLite databases to a named pipe. The databases are supplied as a sequence of
|
||||
configuration dicts, as per the configuration schema. Use the given configuration dict to
|
||||
construct the destination path and the given log prefix in any log entries.
|
||||
configuration dicts, as per the configuration schema. Use the given borgmatic runtime directory
|
||||
to construct the destination path and the given log prefix in any log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
@@ -49,7 +57,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
f'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped'
|
||||
)
|
||||
|
||||
dump_path = make_dump_path(config)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_filename = dump.make_data_source_dump_filename(dump_path, database['name'])
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
@@ -74,36 +82,61 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'sqlite_databases'))
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove the given SQLite database dumps from the filesystem. The databases are supplied as a
|
||||
sequence of configuration dicts, as per the configuration schema. Use the given configuration
|
||||
dict to construct the destination path and the given log prefix in any log entries. If this is a
|
||||
dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'SQLite', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'SQLite', log_prefix, dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Make a pattern that matches the given SQLite databases. The databases are supplied as a sequence
|
||||
of configuration dicts, as per the configuration schema.
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
|
||||
borgmatic runtime directory, and a database name to match, return the corresponding glob
|
||||
patterns to match the database dump in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name)
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
configuration dict, but the given hook configuration is ignored. The given log prefix is used
|
||||
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
|
||||
given active extract process (an instance of subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
database_path = connection_params['restore_path'] or data_source.get(
|
||||
@@ -0,0 +1,392 @@
|
||||
import collections
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
|
||||
|
||||
|
||||
Dataset = collections.namedtuple(
|
||||
'Dataset',
|
||||
('name', 'mount_point', 'auto_backup', 'contained_source_directories'),
|
||||
defaults=(False, ()),
|
||||
)
|
||||
|
||||
|
||||
def get_datasets_to_backup(zfs_command, source_directories):
|
||||
'''
|
||||
Given a ZFS command to run and a sequence of configured source directories, find the
|
||||
intersection between the current ZFS dataset mount points and the configured borgmatic source
|
||||
directories. The idea is that these are the requested datasets to snapshot. But also include any
|
||||
datasets tagged with a borgmatic-specific user property, whether or not they appear in source
|
||||
directories.
|
||||
|
||||
Return the result as a sequence of Dataset instances, sorted by mount point.
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Sort from longest to shortest mount points, so longer mount points get a whack at the
|
||||
# candidate source directory piñata before their parents do. (Source directories are
|
||||
# consumed during the second loop below, so no two datasets get the same contained source
|
||||
# directories.)
|
||||
datasets = sorted(
|
||||
(
|
||||
Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
|
||||
for line in list_output.splitlines()
|
||||
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
|
||||
),
|
||||
key=lambda dataset: dataset.mount_point,
|
||||
reverse=True,
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid {zfs_command} list output')
|
||||
|
||||
candidate_source_directories = set(source_directories)
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
(
|
||||
Dataset(
|
||||
dataset.name,
|
||||
dataset.mount_point,
|
||||
dataset.auto_backup,
|
||||
contained_source_directories,
|
||||
)
|
||||
for dataset in datasets
|
||||
for contained_source_directories in (
|
||||
(
|
||||
(dataset.mount_point,)
|
||||
if dataset.auto_backup
|
||||
else borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
dataset.mount_point, candidate_source_directories
|
||||
)
|
||||
),
|
||||
)
|
||||
if contained_source_directories
|
||||
),
|
||||
key=lambda dataset: dataset.mount_point,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_all_dataset_mount_points(zfs_command):
|
||||
'''
|
||||
Given a ZFS command to run, return all ZFS datasets as a sequence of sorted mount points.
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
'mountpoint',
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
|
||||
|
||||
|
||||
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
'''
|
||||
Given a ZFS command to run and a snapshot name of the form "dataset@snapshot", create a new ZFS
|
||||
snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'snapshot',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a mount command to run, an existing snapshot name of the form "dataset@snapshot", and the
|
||||
path where the snapshot should be mounted, mount the snapshot (making any necessary directories
|
||||
first).
|
||||
'''
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
'-t',
|
||||
'zfs',
|
||||
'-o',
|
||||
'ro',
|
||||
full_snapshot_name,
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic configuration
|
||||
file paths, the borgmatic runtime directory, the configured source directories, and whether this
|
||||
is a dry run, auto-detect and snapshot any ZFS dataset mount points listed in the given source
|
||||
directories and any dataset with a borgmatic-specific user property. Also update those source
|
||||
directories, replacing dataset mount points with corresponding snapshot directories so they get
|
||||
stored in the Borg archive instead. Use the log prefix in any log entries.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'{log_prefix}: Snapshotting ZFS datasets{dry_run_label}')
|
||||
|
||||
# List ZFS datasets to get their mount points.
|
||||
zfs_command = hook_config.get('zfs_command', 'zfs')
|
||||
requested_datasets = get_datasets_to_backup(zfs_command, source_directories)
|
||||
|
||||
# Snapshot each dataset, rewriting source directories to use the snapshot paths.
|
||||
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_datasets:
|
||||
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
|
||||
|
||||
for dataset in requested_datasets:
|
||||
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
|
||||
logger.debug(
|
||||
f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
snapshot_dataset(zfs_command, full_snapshot_name)
|
||||
|
||||
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
|
||||
# up in the Borg archive at the "original" dataset mount point path.
|
||||
snapshot_mount_path = os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
dataset.mount_point.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
|
||||
)
|
||||
|
||||
for source_directory in dataset.contained_source_directories:
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
source_directories.append(
|
||||
os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
)
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a umount command to run and the mount path of a snapshot, unmount it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
'''
|
||||
Given a ZFS command to run and the name of a snapshot in the form "dataset@snapshot", destroy
|
||||
it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'destroy',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def get_all_snapshots(zfs_command):
|
||||
'''
|
||||
Given a ZFS command to run, return all ZFS snapshots as a sequence of full snapshot names of the
|
||||
form "dataset@snapshot".
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'snapshot',
|
||||
'-o',
|
||||
'name',
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(line.rstrip() for line in list_output.splitlines())
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic runtime
|
||||
directory, and whether this is a dry run, unmount and destroy any ZFS snapshots created by
|
||||
borgmatic. Use the log prefix in any log entries. If this is a dry run or ZFS isn't configured
|
||||
in borgmatic's configuration, then don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
# Unmount snapshots.
|
||||
zfs_command = hook_config.get('zfs_command', 'zfs')
|
||||
|
||||
try:
|
||||
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{zfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'zfs_snapshots',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
umount_command = hook_config.get('umount_command', 'umount')
|
||||
|
||||
for snapshots_directory in glob.glob(snapshots_glob):
|
||||
if not os.path.isdir(snapshots_directory):
|
||||
continue
|
||||
|
||||
# Reversing the sorted datasets ensures that we unmount the longer mount point paths of
|
||||
# child datasets before the shorter mount point paths of parent datasets.
|
||||
for mount_point in reversed(dataset_mount_points):
|
||||
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
# This might fail if the path is already mounted, but we swallow errors here since we'll
|
||||
# do another recursive delete below. The point of doing it here is that we don't want to
|
||||
# try to unmount a non-mounted directory (which *will* fail), and probing for whether a
|
||||
# directory is mounted is tough to do in a cross-platform way.
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
|
||||
|
||||
# If the delete was successful, that means there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
unmount_snapshot(umount_command, snapshot_mount_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
|
||||
# Destroy snapshots.
|
||||
full_snapshot_names = get_all_snapshots(zfs_command)
|
||||
|
||||
for full_snapshot_name in full_snapshot_names:
|
||||
# Only destroy snapshots that borgmatic actually created!
|
||||
if not full_snapshot_name.split('@')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'{log_prefix}: Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
|
||||
|
||||
if not dry_run:
|
||||
destroy_snapshot(zfs_command, full_snapshot_name)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
+57
-44
@@ -1,65 +1,74 @@
|
||||
import enum
|
||||
import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
from borgmatic.hooks import (
|
||||
apprise,
|
||||
cronhub,
|
||||
cronitor,
|
||||
healthchecks,
|
||||
loki,
|
||||
mariadb,
|
||||
mongodb,
|
||||
mysql,
|
||||
ntfy,
|
||||
pagerduty,
|
||||
postgresql,
|
||||
sqlite,
|
||||
)
|
||||
import borgmatic.hooks.data_source
|
||||
import borgmatic.hooks.monitoring
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOOK_NAME_TO_MODULE = {
|
||||
'apprise': apprise,
|
||||
'cronhub': cronhub,
|
||||
'cronitor': cronitor,
|
||||
'healthchecks': healthchecks,
|
||||
'mariadb_databases': mariadb,
|
||||
'mongodb_databases': mongodb,
|
||||
'mysql_databases': mysql,
|
||||
'ntfy': ntfy,
|
||||
'pagerduty': pagerduty,
|
||||
'postgresql_databases': postgresql,
|
||||
'sqlite_databases': sqlite,
|
||||
'loki': loki,
|
||||
}
|
||||
|
||||
class Hook_type(enum.Enum):
|
||||
DATA_SOURCE = 'data_source'
|
||||
MONITORING = 'monitoring'
|
||||
|
||||
|
||||
def get_submodule_names(parent_module): # pragma: no cover
|
||||
'''
|
||||
Given a parent module, return the names of its direct submodules as a tuple of strings.
|
||||
'''
|
||||
return tuple(module_info.name for module_info in pkgutil.iter_modules(parent_module.__path__))
|
||||
|
||||
|
||||
def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to the given hook name. Supply that call with the configuration
|
||||
for this hook (if any), the log prefix, and any given args and kwargs. Return any return value.
|
||||
for this hook (if any), the log prefix, and any given args and kwargs. Return the return value
|
||||
of that call or None if the module in question is not a hook.
|
||||
|
||||
Raise ValueError if the hook name is unknown.
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
Raise anything else that the called function raises.
|
||||
'''
|
||||
hook_config = config.get(hook_name, {})
|
||||
if hook_name in config or f'{hook_name}_databases' in config:
|
||||
hook_config = config.get(hook_name) or config.get(f'{hook_name}_databases') or {}
|
||||
else:
|
||||
hook_config = None
|
||||
|
||||
try:
|
||||
module = HOOK_NAME_TO_MODULE[hook_name]
|
||||
except KeyError:
|
||||
module_name = hook_name.split('_databases')[0]
|
||||
|
||||
# Probe for a data source or monitoring hook module corresponding to the hook name.
|
||||
for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
|
||||
if module_name not in get_submodule_names(parent_module):
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f'{parent_module.__name__}.{module_name}')
|
||||
|
||||
# If this module is explicitly flagged as not a hook, bail.
|
||||
if not getattr(module, 'IS_A_HOOK', True):
|
||||
return None
|
||||
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Unknown hook name: {hook_name}')
|
||||
|
||||
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
|
||||
|
||||
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
|
||||
|
||||
|
||||
def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
def call_hooks(function_name, config, log_prefix, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to each given hook name. Supply each call with the configuration
|
||||
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
|
||||
dict from hook name to return value.
|
||||
the Python module corresponding to each hook of the given hook type (either "data_source" or
|
||||
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
|
||||
given args and kwargs.
|
||||
|
||||
Collect any return values into a dict from module name to return value. Note that the module
|
||||
name is the name of the hook module itself, which might be different from the hook configuration
|
||||
option (e.g. "postgresql" for the former vs. "postgresql_databases" for the latter).
|
||||
|
||||
If the hook name is not present in the hooks configuration, then don't call the function for it
|
||||
and omit it from the return values.
|
||||
@@ -70,22 +79,26 @@ def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
'''
|
||||
return {
|
||||
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
|
||||
for hook_name in hook_names
|
||||
if config.get(hook_name)
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
if hook_name in config or f'{hook_name}_databases' in config
|
||||
}
|
||||
|
||||
|
||||
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to each given hook name. Supply each call with the configuration
|
||||
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
|
||||
dict from hook name to return value.
|
||||
the Python module corresponding to each hook of the given hook type (either "data_source" or
|
||||
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
|
||||
given args and kwargs. Collect any return values into a dict from hook name to return value.
|
||||
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
Raise anything else that a called function raises. An error stops calls to subsequent functions.
|
||||
'''
|
||||
return {
|
||||
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
|
||||
for hook_name in hook_names
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_SOURCE_HOOK_NAMES = (
|
||||
'mariadb_databases',
|
||||
'mysql_databases',
|
||||
'mongodb_databases',
|
||||
'postgresql_databases',
|
||||
'sqlite_databases',
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_path(borgmatic_source_directory, data_source_hook_name):
|
||||
'''
|
||||
Given a borgmatic source directory (or None) and a data source hook name, construct a data
|
||||
source dump path.
|
||||
'''
|
||||
if not borgmatic_source_directory:
|
||||
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
return os.path.join(borgmatic_source_directory, data_source_hook_name)
|
||||
|
||||
|
||||
def make_data_source_dump_filename(dump_path, name, hostname=None):
|
||||
'''
|
||||
Based on the given dump directory path, data source name, and hostname, return a filename to use
|
||||
for the data source dump. The hostname defaults to localhost.
|
||||
|
||||
Raise ValueError if the data source name is invalid.
|
||||
'''
|
||||
if os.path.sep in name:
|
||||
raise ValueError(f'Invalid data source name {name}')
|
||||
|
||||
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
|
||||
|
||||
|
||||
def create_parent_directory_for_dump(dump_path):
|
||||
'''
|
||||
Create a directory to contain the given dump path.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def create_named_pipe_for_dump(dump_path):
|
||||
'''
|
||||
Create a named pipe at the given dump path.
|
||||
'''
|
||||
create_parent_directory_for_dump(dump_path)
|
||||
os.mkfifo(dump_path, mode=0o600)
|
||||
|
||||
|
||||
def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run):
|
||||
'''
|
||||
Remove all data source dumps in the given dump directory path (including the directory itself).
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
|
||||
expanded_path = os.path.expanduser(dump_path)
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if os.path.exists(expanded_path):
|
||||
shutil.rmtree(expanded_path)
|
||||
|
||||
|
||||
def convert_glob_patterns_to_borg_patterns(patterns):
|
||||
'''
|
||||
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
|
||||
patterns like "sh:etc/*".
|
||||
'''
|
||||
return [f'sh:{pattern.lstrip(os.path.sep)}' for pattern in patterns]
|
||||
@@ -1,10 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
|
||||
|
||||
|
||||
class State(Enum):
|
||||
START = 1
|
||||
FINISH = 2
|
||||
FAIL = 3
|
||||
LOG = 4
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import operator
|
||||
|
||||
import borgmatic.hooks.logs
|
||||
import borgmatic.hooks.monitor
|
||||
import borgmatic.hooks.monitoring.logs
|
||||
import borgmatic.hooks.monitoring.monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,12 +22,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
|
||||
|
||||
logs_size_limit = max(
|
||||
hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES)
|
||||
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
0,
|
||||
)
|
||||
|
||||
borgmatic.hooks.logs.add_handler(
|
||||
borgmatic.hooks.logs.Forgetful_buffering_handler(
|
||||
borgmatic.hooks.monitoring.logs.add_handler(
|
||||
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
|
||||
HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level
|
||||
)
|
||||
)
|
||||
@@ -82,11 +82,13 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
body = state_config.get('body')
|
||||
|
||||
if state in (
|
||||
borgmatic.hooks.monitor.State.FINISH,
|
||||
borgmatic.hooks.monitor.State.FAIL,
|
||||
borgmatic.hooks.monitor.State.LOG,
|
||||
borgmatic.hooks.monitoring.monitor.State.FINISH,
|
||||
borgmatic.hooks.monitoring.monitor.State.FAIL,
|
||||
borgmatic.hooks.monitoring.monitor.State.LOG,
|
||||
):
|
||||
formatted_logs = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
|
||||
formatted_logs = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
|
||||
HANDLER_IDENTIFIER
|
||||
)
|
||||
if formatted_logs:
|
||||
body += f'\n\n{formatted_logs}'
|
||||
|
||||
@@ -106,4 +108,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
|
||||
Remove the monitor handler that was added to the root logger. This prevents the handler from
|
||||
getting reused by other instances of this monitor.
|
||||
'''
|
||||
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.logs
|
||||
from borgmatic.hooks import monitor
|
||||
import borgmatic.hooks.monitoring.logs
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +15,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
|
||||
monitor.State.LOG: 'log',
|
||||
}
|
||||
|
||||
DEFAULT_PING_BODY_LIMIT_BYTES = 1500
|
||||
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
|
||||
HANDLER_IDENTIFIER = 'healthchecks'
|
||||
|
||||
|
||||
@@ -29,12 +30,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
|
||||
|
||||
ping_body_limit = max(
|
||||
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
|
||||
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
0,
|
||||
)
|
||||
|
||||
borgmatic.hooks.logs.add_handler(
|
||||
borgmatic.hooks.logs.Forgetful_buffering_handler(
|
||||
borgmatic.hooks.monitoring.logs.add_handler(
|
||||
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
|
||||
HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
|
||||
)
|
||||
)
|
||||
@@ -59,15 +60,27 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
)
|
||||
return
|
||||
|
||||
ping_url_is_uuid = re.search(r'\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$', ping_url)
|
||||
|
||||
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
|
||||
if healthchecks_state:
|
||||
ping_url = f'{ping_url}/{healthchecks_state}'
|
||||
|
||||
if hook_config.get('create_slug'):
|
||||
if ping_url_is_uuid:
|
||||
logger.warning(
|
||||
f'{config_filename}: Healthchecks UUIDs do not support auto provisionning; ignoring'
|
||||
)
|
||||
else:
|
||||
ping_url = f'{ping_url}?create=1'
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
|
||||
|
||||
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
|
||||
payload = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
|
||||
payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
|
||||
HANDLER_IDENTIFIER
|
||||
)
|
||||
else:
|
||||
payload = ''
|
||||
|
||||
@@ -88,4 +101,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
|
||||
Remove the monitor handler that was added to the root logger. This prevents the handler from
|
||||
getting reused by other instances of this monitor.
|
||||
'''
|
||||
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
IS_A_HOOK = False
|
||||
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import enum
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
class State(enum.Enum):
|
||||
START = 1
|
||||
FINISH = 2
|
||||
FAIL = 3
|
||||
LOG = 4
|
||||
@@ -5,7 +5,7 @@ import platform
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,9 +40,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
return
|
||||
|
||||
hostname = platform.node()
|
||||
local_timestamp = (
|
||||
datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().isoformat()
|
||||
)
|
||||
local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()
|
||||
payload = json.dumps(
|
||||
{
|
||||
'routing_key': hook_config['integration_key'],
|
||||
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
EMERGENCY_PRIORITY = 2
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
ping_url, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Post a message to the configured Pushover application.
|
||||
If this is a dry run, then don't actually update anything.
|
||||
'''
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() not in run_states:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually updating)' if dry_run else ''
|
||||
|
||||
state_config = hook_config.get(state.name.lower(), {})
|
||||
|
||||
token = hook_config.get('token')
|
||||
user = hook_config.get('user')
|
||||
|
||||
logger.info(f'{config_filename}: Updating Pushover{dry_run_label}')
|
||||
|
||||
if state_config.get('priority') == EMERGENCY_PRIORITY:
|
||||
if 'expire' not in state_config:
|
||||
logger.info(f'{config_filename}: Setting expire to default (10 min).')
|
||||
state_config['expire'] = 600
|
||||
if 'retry' not in state_config:
|
||||
logger.info(f'{config_filename}: Setting retry to default (30 sec).')
|
||||
state_config['retry'] = 30
|
||||
else:
|
||||
if 'expire' in state_config or 'retry' in state_config:
|
||||
raise ValueError(
|
||||
'The configuration parameters retry and expire should not be set when priority is not equal to 2. Please remove them from the configuration.'
|
||||
)
|
||||
|
||||
state_config = {
|
||||
key: (int(value) if key == 'html' else value) for key, value in state_config.items()
|
||||
}
|
||||
|
||||
data = dict(
|
||||
{
|
||||
'token': token,
|
||||
'user': user,
|
||||
# Default to state name. Can be overwritten by state_config below.
|
||||
'message': state.name.lower(),
|
||||
},
|
||||
**state_config,
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://api.pushover.net/1/messages.json',
|
||||
headers={'Content-type': 'application/x-www-form-urlencoded'},
|
||||
data=data,
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Pushover error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
push_url, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Make a get request to the configured Uptime Kuma push_url. Use the given configuration filename
|
||||
in any log entries. If this is a dry run, then don't actually push anything.
|
||||
'''
|
||||
run_states = hook_config.get('states', ['start', 'finish', 'fail'])
|
||||
|
||||
if state.name.lower() not in run_states:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually pushing)' if dry_run else ''
|
||||
status = 'down' if state.name.lower() == 'fail' else 'up'
|
||||
push_url = hook_config.get('push_url', 'https://example.uptime.kuma/api/push/abcd1234')
|
||||
query = f'status={status}&msg={state.name.lower()}'
|
||||
logger.info(
|
||||
f'{config_filename}: Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}'
|
||||
)
|
||||
logger.debug(f'{config_filename}: Full Uptime Kuma state URL {push_url}?{query}')
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
|
||||
try:
|
||||
response = requests.get(f'{push_url}?{query}')
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Uptime Kuma error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
push_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
@@ -0,0 +1,135 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
ping_url, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Update the configured Zabbix item using either the itemid, or a host and key.
|
||||
If this is a dry run, then don't actually update anything.
|
||||
'''
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() not in run_states:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually updating)' if dry_run else ''
|
||||
|
||||
state_config = hook_config.get(
|
||||
state.name.lower(),
|
||||
{
|
||||
'value': state.name.lower(),
|
||||
},
|
||||
)
|
||||
|
||||
server = hook_config.get('server')
|
||||
username = hook_config.get('username')
|
||||
password = hook_config.get('password')
|
||||
api_key = hook_config.get('api_key')
|
||||
itemid = hook_config.get('itemid')
|
||||
host = hook_config.get('host')
|
||||
key = hook_config.get('key')
|
||||
value = state_config.get('value')
|
||||
headers = {'Content-Type': 'application/json-rpc'}
|
||||
|
||||
logger.info(f'{config_filename}: Updating Zabbix{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Zabbix URL: {server}')
|
||||
|
||||
if server is None:
|
||||
logger.warning(f'{config_filename}: Server missing for Zabbix')
|
||||
return
|
||||
|
||||
# Determine the Zabbix method used to store the value: itemid or host/key
|
||||
if itemid is not None:
|
||||
logger.info(f'{config_filename}: Updating {itemid} on Zabbix')
|
||||
data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'history.push',
|
||||
'params': {'itemid': itemid, 'value': value},
|
||||
'id': 1,
|
||||
}
|
||||
|
||||
elif (host and key) is not None:
|
||||
logger.info(f'{config_filename}: Updating Host:{host} and Key:{key} on Zabbix')
|
||||
data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'history.push',
|
||||
'params': {'host': host, 'key': key, 'value': value},
|
||||
'id': 1,
|
||||
}
|
||||
|
||||
elif host is not None:
|
||||
logger.warning(f'{config_filename}: Key missing for Zabbix')
|
||||
return
|
||||
|
||||
elif key is not None:
|
||||
logger.warning(f'{config_filename}: Host missing for Zabbix.')
|
||||
return
|
||||
else:
|
||||
logger.warning(f'{config_filename}: No zabbix itemid or host/key provided.')
|
||||
return
|
||||
|
||||
# Determine the authentication method: API key or username/password
|
||||
if api_key is not None:
|
||||
logger.info(f'{config_filename}: Using API key auth for Zabbix')
|
||||
headers['Authorization'] = 'Bearer ' + api_key
|
||||
|
||||
elif (username and password) is not None:
|
||||
logger.info(f'{config_filename}: Using user/pass auth with user {username} for Zabbix')
|
||||
auth_data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'user.login',
|
||||
'params': {'username': username, 'password': password},
|
||||
'id': 1,
|
||||
}
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(server, headers=headers, json=auth_data)
|
||||
data['auth'] = response.json().get('result')
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Zabbix error: {error}')
|
||||
return
|
||||
|
||||
elif username is not None:
|
||||
logger.warning(f'{config_filename}: Password missing for Zabbix authentication')
|
||||
return
|
||||
|
||||
elif password is not None:
|
||||
logger.warning(f'{config_filename}: Username missing for Zabbix authentication')
|
||||
return
|
||||
else:
|
||||
logger.warning(f'{config_filename}: Authentication data missing for Zabbix')
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(server, headers=headers, json=data)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Zabbix error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
+40
-14
@@ -1,10 +1,9 @@
|
||||
import enum
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
|
||||
import colorama
|
||||
|
||||
|
||||
def to_bool(arg):
|
||||
'''
|
||||
@@ -33,12 +32,12 @@ def interactive_console():
|
||||
def should_do_markup(no_color, configs):
|
||||
'''
|
||||
Given the value of the command-line no-color argument, and a dict of configuration filename to
|
||||
corresponding parsed configuration, determine if we should enable colorama marking up.
|
||||
corresponding parsed configuration, determine if we should enable color marking up.
|
||||
'''
|
||||
if no_color:
|
||||
return False
|
||||
|
||||
if any(config.get('output', {}).get('color') is False for config in configs.values()):
|
||||
if any(config.get('color', True) is False for config in configs.values()):
|
||||
return False
|
||||
|
||||
if os.environ.get('NO_COLOR', None):
|
||||
@@ -88,30 +87,55 @@ class Multi_stream_handler(logging.Handler):
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
class Console_no_color_formatter(logging.Formatter):
|
||||
def format(self, record): # pragma: no cover
|
||||
return record.msg
|
||||
|
||||
|
||||
class Color(enum.Enum):
|
||||
RESET = 0
|
||||
RED = 31
|
||||
GREEN = 32
|
||||
YELLOW = 33
|
||||
MAGENTA = 35
|
||||
CYAN = 36
|
||||
|
||||
|
||||
class Console_color_formatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
add_custom_log_levels()
|
||||
|
||||
color = {
|
||||
logging.CRITICAL: colorama.Fore.RED,
|
||||
logging.ERROR: colorama.Fore.RED,
|
||||
logging.WARN: colorama.Fore.YELLOW,
|
||||
logging.ANSWER: colorama.Fore.MAGENTA,
|
||||
logging.INFO: colorama.Fore.GREEN,
|
||||
logging.DEBUG: colorama.Fore.CYAN,
|
||||
}.get(record.levelno)
|
||||
color = (
|
||||
{
|
||||
logging.CRITICAL: Color.RED,
|
||||
logging.ERROR: Color.RED,
|
||||
logging.WARN: Color.YELLOW,
|
||||
logging.ANSWER: Color.MAGENTA,
|
||||
logging.INFO: Color.GREEN,
|
||||
logging.DEBUG: Color.CYAN,
|
||||
}
|
||||
.get(record.levelno)
|
||||
.value
|
||||
)
|
||||
|
||||
return color_text(color, record.msg)
|
||||
|
||||
|
||||
def ansi_escape_code(color): # pragma: no cover
|
||||
'''
|
||||
Given a color value, produce the corresponding ANSI escape code.
|
||||
'''
|
||||
return f'\x1b[{color}m'
|
||||
|
||||
|
||||
def color_text(color, message):
|
||||
'''
|
||||
Give colored text.
|
||||
Given a color value and a message, return the message colored with ANSI escape codes.
|
||||
'''
|
||||
if not color:
|
||||
return message
|
||||
|
||||
return f'{color}{message}{colorama.Style.RESET_ALL}'
|
||||
return f'{ansi_escape_code(color)}{message}{ansi_escape_code(Color.RESET.value)}'
|
||||
|
||||
|
||||
def add_logging_level(level_name, level_number):
|
||||
@@ -198,6 +222,8 @@ def configure_logging(
|
||||
|
||||
if color_enabled:
|
||||
console_handler.setFormatter(Console_color_formatter())
|
||||
else:
|
||||
console_handler.setFormatter(Console_no_color_formatter())
|
||||
|
||||
console_handler.setLevel(console_log_level)
|
||||
|
||||
|
||||
+7
-5
@@ -1,14 +1,15 @@
|
||||
FROM docker.io/alpine:3.17.1 as borgmatic
|
||||
FROM docker.io/alpine:3.20.1 AS borgmatic
|
||||
|
||||
COPY . /app
|
||||
RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
|
||||
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
|
||||
RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && chmod +r /etc/borgmatic/config.yaml
|
||||
RUN borgmatic --help > /command-line.txt \
|
||||
&& for action in rcreate transfer create prune compact check extract config "config bootstrap" "config generate" "config validate" export-tar mount umount restore rlist list rinfo info break-lock borg; do \
|
||||
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key change-passphrase" borg; do \
|
||||
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
|
||||
&& borgmatic $action --help >> /command-line.txt; done
|
||||
RUN /app/docs/fetch-contributors >> /contributors.html
|
||||
|
||||
FROM docker.io/node:19.5.0-alpine as html
|
||||
FROM docker.io/node:22.4.0-alpine AS html
|
||||
|
||||
ARG ENVIRONMENT=production
|
||||
|
||||
@@ -24,11 +25,12 @@ RUN npm install @11ty/eleventy \
|
||||
markdown-it-replace-link
|
||||
COPY --from=borgmatic /etc/borgmatic/config.yaml /source/docs/_includes/borgmatic/config.yaml
|
||||
COPY --from=borgmatic /command-line.txt /source/docs/_includes/borgmatic/command-line.txt
|
||||
COPY --from=borgmatic /contributors.html /source/docs/_includes/borgmatic/contributors.html
|
||||
COPY . /source
|
||||
RUN NODE_ENV=${ENVIRONMENT} npx eleventy --input=/source/docs --output=/output/docs \
|
||||
&& mv /output/docs/index.html /output/index.html
|
||||
|
||||
FROM docker.io/nginx:1.22.1-alpine
|
||||
FROM docker.io/nginx:1.26.1-alpine
|
||||
|
||||
COPY --from=html /output /usr/share/nginx/html
|
||||
COPY --from=borgmatic /etc/borgmatic/config.yaml /usr/share/nginx/html/docs/reference/config.yaml
|
||||
|
||||
@@ -167,12 +167,11 @@ li {
|
||||
padding: .25em 0;
|
||||
}
|
||||
li ul {
|
||||
margin: .5em 0;
|
||||
padding-left: 1em;
|
||||
list-style-type: disc;
|
||||
padding-left: 2em;
|
||||
}
|
||||
li li {
|
||||
padding-top: .1em;
|
||||
padding-bottom: .1em;
|
||||
li li:last-child {
|
||||
padding-bottom: 0em;
|
||||
}
|
||||
|
||||
/* Syntax highlighting and Code blocks */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="generator" content="{{ eleventy.generator }}">
|
||||
<link rel="icon" href="docs/static/borgmatic.png" type="image/x-icon">
|
||||
<title>{{ subtitle + ' - ' if subtitle}}{{ title }}</title>
|
||||
{%- set css %}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
version: '3'
|
||||
services:
|
||||
docs:
|
||||
image: borgmatic-docs
|
||||
container_name: docs
|
||||
container_name: borgmatic-docs
|
||||
ports:
|
||||
- 8080:80
|
||||
build:
|
||||
@@ -12,7 +11,7 @@ services:
|
||||
ENVIRONMENT: development
|
||||
message:
|
||||
image: alpine
|
||||
container_name: message
|
||||
container_name: borgmatic-docs-message
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
'''
|
||||
A script to fetch recent contributors to borgmatic, used during documentation generation.
|
||||
'''
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import operator
|
||||
import subprocess
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def list_merged_pulls(url):
|
||||
'''
|
||||
Given a Gitea or GitHub API endpoint URL for pull requests, fetch and return the corresponding
|
||||
JSON for all such merged pull requests.
|
||||
'''
|
||||
response = requests.get(f'{url}?state=closed', headers={'Accept': 'application/json', 'Content-Type': 'application/json'})
|
||||
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
return tuple(pull for pull in response.json() if pull.get('merged_at'))
|
||||
|
||||
|
||||
API_ENDPOINT_URLS = (
|
||||
'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/pulls',
|
||||
'https://api.github.com/repos/borgmatic-collective/borgmatic/pulls',
|
||||
)
|
||||
RECENT_CONTRIBUTORS_CUTOFF_DAYS = 365
|
||||
|
||||
|
||||
def print_contributors():
|
||||
'''
|
||||
Display the recent contributors as a row of avatars in an HTML fragment.
|
||||
'''
|
||||
pulls = tuple(itertools.chain.from_iterable(list_merged_pulls(url) for url in API_ENDPOINT_URLS))
|
||||
seen_user_ids = set()
|
||||
|
||||
print('<p>')
|
||||
|
||||
for pull in sorted(pulls, key=operator.itemgetter('merged_at'), reverse=True):
|
||||
merged_at = pull.get('merged_at')
|
||||
user = pull.get('user')
|
||||
|
||||
if not merged_at or not user:
|
||||
continue
|
||||
|
||||
user_id = user.get('id')
|
||||
|
||||
if not user_id or user_id in seen_user_ids:
|
||||
continue
|
||||
|
||||
if datetime.datetime.fromisoformat(merged_at) < datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=RECENT_CONTRIBUTORS_CUTOFF_DAYS):
|
||||
continue
|
||||
|
||||
seen_user_ids.add(user_id)
|
||||
print(
|
||||
f'''<a href="{user.get('html_url')}?tab=activity"><img src="{user.get('avatar_url')}" width="50" height="50" title="{user.get('full_name') or user.get('login')}" /></a>'''
|
||||
)
|
||||
|
||||
print('</p>')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print_contributors()
|
||||
@@ -3,7 +3,7 @@ title: How to add preparation and cleanup steps to backups
|
||||
eleventyNavigation:
|
||||
key: 🧹 Add preparation and cleanup steps
|
||||
parent: How-to guides
|
||||
order: 9
|
||||
order: 10
|
||||
---
|
||||
## Preparation and cleanup hooks
|
||||
|
||||
@@ -84,6 +84,9 @@ variables you can use here:
|
||||
path of the borgmatic log file, only set when the `--log-file` flag is used
|
||||
* `repository`: path of the current repository as configured in the current
|
||||
borgmatic configuration file
|
||||
* `repository_label` <span class="minilink minilink-addedin">New in version
|
||||
1.8.12</span>: label of the current repository as configured in the current
|
||||
borgmatic configuration file
|
||||
|
||||
Note that you can also interpolate in [arbitrary environment
|
||||
variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/).
|
||||
|
||||
@@ -3,7 +3,7 @@ title: How to backup to a removable drive or an intermittent server
|
||||
eleventyNavigation:
|
||||
key: 💾 Backup to a removable drive/server
|
||||
parent: How-to guides
|
||||
order: 10
|
||||
order: 11
|
||||
---
|
||||
## Occasional backups
|
||||
|
||||
@@ -34,9 +34,14 @@ test in the form of a borgmatic hook to see if backups should proceed or not.
|
||||
|
||||
The way the test works is that if any of your hook commands return a special
|
||||
exit status of 75, that indicates to borgmatic that it's a temporary failure,
|
||||
and borgmatic should skip all subsequent actions for that configuration file.
|
||||
If you return any other status, then it's a standard success or error. (Zero is
|
||||
success; anything else other than 75 is an error).
|
||||
and borgmatic should skip all subsequent actions for the current repository.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.0</span> Soft
|
||||
failures skipped subsequent actions for *all* repositories in the
|
||||
configuration file, rather than just for the current repository.
|
||||
|
||||
If you return any status besides 75, then it's a standard success or error.
|
||||
(Zero is success; anything else other than 75 is an error).
|
||||
|
||||
So for instance, if you have an external drive that's only sometimes mounted,
|
||||
declare its repository in its own [separate configuration
|
||||
@@ -71,9 +76,15 @@ option in the `hooks:` section of your configuration.
|
||||
|
||||
What this does is check if the `findmnt` command errors when probing for a
|
||||
particular mount point. If it does error, then it returns exit code 75 to
|
||||
borgmatic. borgmatic logs the soft failure, skips all further actions in that
|
||||
configurable file, and proceeds onward to any other borgmatic configuration
|
||||
files you may have.
|
||||
borgmatic. borgmatic logs the soft failure, skips all further actions for the
|
||||
current repository, and proceeds onward to any other repositories and/or
|
||||
configuration files you may have.
|
||||
|
||||
If you'd prefer not to use a separate configuration file, and you'd rather
|
||||
have multiple repositories in a single configuration file, you can make your
|
||||
`before_backup` soft failure test [vary by
|
||||
repository](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation).
|
||||
That might require calling out to a separate script though.
|
||||
|
||||
Note that `before_backup` only runs on the `create` action. See below about
|
||||
optionally using `before_actions` instead.
|
||||
@@ -121,13 +132,16 @@ There are some caveats you should be aware of with this feature.
|
||||
executing. So, like a standard error, it is an "early out". Unlike a standard
|
||||
error, borgmatic does not display it in angry red text or consider it a
|
||||
failure.
|
||||
* The soft failure only applies to the scope of a single borgmatic
|
||||
configuration file. So put anything that you don't want soft-failed, like
|
||||
always-online cloud backups, in separate configuration files from your
|
||||
soft-failing repositories.
|
||||
* The soft failure doesn't have to apply to a repository. You can even perform
|
||||
a test to make sure that individual source directories are mounted and
|
||||
available. Use your imagination!
|
||||
* Any given soft failure only applies to the a single borgmatic repository
|
||||
(as of borgmatic 1.9.0). So if you have other repositories you don't want
|
||||
soft-failed, then make your soft fail test [vary by
|
||||
repository](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation)—or
|
||||
put anything that you don't want soft-failed (like always-online cloud
|
||||
backups) in separate configuration files from your soft-failing
|
||||
repositories.
|
||||
* The soft failure doesn't have to test anything related to a repository. You
|
||||
can even perform a test to make sure that individual source directories are
|
||||
mounted and available. Use your imagination!
|
||||
* The soft failure feature also works for before/after hooks for other
|
||||
actions as well. But it is not implemented for `before_everything` or
|
||||
`after_everything`.
|
||||
|
||||
@@ -63,14 +63,9 @@ dump formats, which can't stream and therefore do consume temporary disk
|
||||
space. Additionally, prior to borgmatic 1.5.3, all database dumps consumed
|
||||
temporary disk space.)
|
||||
|
||||
To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by
|
||||
default. To customize this path, set the `borgmatic_source_directory` option
|
||||
in borgmatic's configuration.
|
||||
|
||||
Also note that using a database hook implicitly enables both the
|
||||
`read_special` and `one_file_system` configuration settings (even if they're
|
||||
disabled in your configuration) to support this dump and restore streaming.
|
||||
See Limitations below for more on this.
|
||||
Also note that using a database hook implicitly enables the `read_special`
|
||||
configuration option (even if it's disabled in your configuration) to support
|
||||
this dump and restore streaming. See Limitations below for more on this.
|
||||
|
||||
Here's a more involved example that connects to remote databases:
|
||||
|
||||
@@ -118,6 +113,40 @@ additional customization of the options passed to database commands (when
|
||||
listing databases, restoring databases, etc.).
|
||||
|
||||
|
||||
### Runtime directory
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.0</span> To support
|
||||
streaming database dumps to Borg, borgmatic uses a runtime directory for
|
||||
temporary file storage, probing the following locations (in order) to find it:
|
||||
|
||||
1. The `user_runtime_directory` borgmatic configuration option.
|
||||
2. The `XDG_RUNTIME_DIR` environment variable, usually `/run/user/$UID`
|
||||
(where `$UID` is the current user's ID), automatically set by PAM on Linux
|
||||
for a user with a session.
|
||||
3. <span class="minilink minilink-addedin">New in version 1.9.2</span>The
|
||||
`RUNTIME_DIRECTORY` environment variable, set by systemd if
|
||||
`RuntimeDirectory=borgmatic` is added to borgmatic's systemd service file.
|
||||
4. <span class="minilink minilink-addedin">New in version 1.9.1</span>The
|
||||
`TMPDIR` environment variable, set on macOS for a user with a session,
|
||||
among other operating systems.
|
||||
5. <span class="minilink minilink-addedin">New in version 1.9.1</span>The
|
||||
`TEMP` environment variable, set on various systems.
|
||||
6. <span class="minilink minilink-addedin">New in version 1.9.2</span>
|
||||
Hard-coded `/tmp`. <span class="minilink minilink-addedin">Prior to
|
||||
version 1.9.2</span>This was instead hard-coded to `/run/user/$UID`.
|
||||
|
||||
Regardless of the runtime directory selected, borgmatic stores its files
|
||||
within a `borgmatic` subdirectory of the runtime directory. Additionally, in
|
||||
the case of `TMPDIR`, `TEMP`, and the hard-coded `/tmp`, borgmatic creates a
|
||||
randomly named subdirectory in an effort to reduce path collisions in shared
|
||||
system temporary directories.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.0</span>
|
||||
borgmatic created temporary streaming database dumps within the `~/.borgmatic`
|
||||
directory by default. At that time, the path was configurable by the
|
||||
`borgmatic_source_directory` configuration option (now deprecated).
|
||||
|
||||
|
||||
### All databases
|
||||
|
||||
If you want to dump all databases on a host, use `all` for the database name:
|
||||
@@ -211,8 +240,8 @@ Normally, borgmatic dumps a database by running a database dump command (e.g.
|
||||
connects to your containerized database via the given `hostname` and `port`.
|
||||
But if you don't have any database dump commands installed on your host and
|
||||
you'd rather use the commands inside your database container itself, borgmatic
|
||||
supports that too. Just configure borgmatic to `exec` into your container to
|
||||
run the dump command.
|
||||
supports that too. For that, configure borgmatic to `exec` into your container
|
||||
to run the dump command.
|
||||
|
||||
For instance, if using Docker and PostgreSQL, something like this might work:
|
||||
|
||||
@@ -229,7 +258,20 @@ hooks:
|
||||
|
||||
... where `my_pg_container` is the name of your database container. In this
|
||||
example, you'd also need to set the `pg_restore_command` and `psql_command`
|
||||
options.
|
||||
options. If you choose to use the `pg_dump` command within the container
|
||||
though, note that it will output the database dump to a file inside the
|
||||
container. So you'll have to mount the `.borgmatic` folder from your host's
|
||||
home folder into the container using the same directory structure.
|
||||
|
||||
See the following Docker compose file an as example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
volumes:
|
||||
- /home/USERNAME/.borgmatic:/home/USERNAME/.borgmatic
|
||||
```
|
||||
|
||||
Similar command override options are available for (some of) the other
|
||||
supported database types as well. See the [configuration
|
||||
@@ -298,13 +340,14 @@ borgmatic does not currently create a database upon restore.
|
||||
|
||||
To restore a database dump from an archive, use the `borgmatic restore`
|
||||
action. But the first step is to figure out which archive to restore from. A
|
||||
good way to do that is to use the `rlist` action:
|
||||
good way to do that is to use the `repo-list` action:
|
||||
|
||||
```bash
|
||||
borgmatic rlist
|
||||
borgmatic repo-list
|
||||
```
|
||||
|
||||
(No borgmatic `rlist` action? Try `list` instead or upgrade borgmatic!)
|
||||
(No borgmatic `repo-list` action? Try `rlist` or `list` instead or upgrade
|
||||
borgmatic!)
|
||||
|
||||
That should yield output looking something like:
|
||||
|
||||
@@ -322,15 +365,15 @@ borgmatic restore --archive host-2023-01-02T04:06:07.080910
|
||||
|
||||
(No borgmatic `restore` action? Upgrade borgmatic!)
|
||||
|
||||
With newer versions of borgmatic, you can simplify this to:
|
||||
Or you can simplify this to:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive latest
|
||||
```
|
||||
|
||||
The `--archive` value is the name of the archive to restore from. This
|
||||
restores all databases dumps that borgmatic originally backed up to that
|
||||
archive.
|
||||
The `--archive` value is the name of the archive or archive hash to restore
|
||||
from. This restores all databases dumps that borgmatic originally backed up to
|
||||
that archive.
|
||||
|
||||
This is a destructive action! `borgmatic restore` replaces live databases by
|
||||
restoring dumps from the selected archive. So be very careful when and where
|
||||
@@ -348,7 +391,7 @@ with the repository's path or its label as configured in your borgmatic
|
||||
configuration file.
|
||||
|
||||
```bash
|
||||
borgmatic restore --repository repo.borg --archive host-2023-...
|
||||
borgmatic restore --repository repo.borg --archive latest
|
||||
```
|
||||
|
||||
### Restore particular databases
|
||||
@@ -358,7 +401,7 @@ restore one of them, use the `--database` flag to select one or more
|
||||
databases. For instance:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive host-2023-... --database users --database orders
|
||||
borgmatic restore --archive latest --database users --database orders
|
||||
```
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.7.6</span> You can
|
||||
@@ -366,29 +409,99 @@ also restore individual databases even if you dumped them as "all"—as long as
|
||||
you dumped them into separate files via use of the "format" option. See above
|
||||
for more information.
|
||||
|
||||
### Restore databases sharing a name
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.5</span> If you've
|
||||
backed up multiple databases that happen to share the same name but different
|
||||
hostnames, ports, or hooks, you can include additional flags to disambiguate
|
||||
which database you'd like to restore. For instance, let's say you've backed up
|
||||
the following configured databases:
|
||||
|
||||
```yaml
|
||||
postgresql_databases:
|
||||
- name: users
|
||||
hostname: host1.example.org
|
||||
- name: users
|
||||
hostname: host2.example.org
|
||||
```
|
||||
|
||||
... then you can run the following command to restore only one of them:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive latest --database users --original-hostname host1.example.org
|
||||
```
|
||||
|
||||
This selects a `users` database to restore, but only if it originally came
|
||||
from the host `host1.example.org`. This command won't restore `users`
|
||||
databases from any other hosts.
|
||||
|
||||
Here's another example configuration:
|
||||
|
||||
```yaml
|
||||
postgresql_databases:
|
||||
- name: users
|
||||
hostname: example.org
|
||||
port: 5433
|
||||
- name: users
|
||||
hostname: example.org
|
||||
port: 5434
|
||||
```
|
||||
|
||||
And a command to restore just one of the databases:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive latest --database users --original-port 5433
|
||||
```
|
||||
|
||||
That restores a `users` database only if it originally came from port `5433`
|
||||
*and* if that port is in borgmatic's configuration, e.g. `port: 5433`.
|
||||
|
||||
Finally, check out this configuration:
|
||||
|
||||
```yaml
|
||||
postgresql_databases:
|
||||
- name: users
|
||||
hostname: example.org
|
||||
mariadb_databases:
|
||||
- name: users
|
||||
hostname: example.org
|
||||
```
|
||||
|
||||
And to select just one of the databases to restore:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive latest --database users --hook postgresql
|
||||
```
|
||||
|
||||
That restores a `users` database only if it was dumped using the
|
||||
`postgresql_databases:` data source hook. This command won't restore `users`
|
||||
databases that were dumped using other hooks.
|
||||
|
||||
Note that these flags don't change the hostname or port to which the database
|
||||
is actually restored. For that, see below about restoring to an alternate
|
||||
host.
|
||||
|
||||
|
||||
### Restore all databases
|
||||
|
||||
To restore all databases:
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive host-2023-... --database all
|
||||
borgmatic restore --archive latest --database all
|
||||
```
|
||||
|
||||
Or omit the `--database` flag entirely:
|
||||
|
||||
|
||||
```bash
|
||||
borgmatic restore --archive host-2023-...
|
||||
borgmatic restore --archive latest
|
||||
```
|
||||
|
||||
Prior to borgmatic version 1.7.6, this restores a combined "all" database
|
||||
dump from the archive.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.7.6</span> Restoring
|
||||
"all" databases restores each database found in the selected archive. That
|
||||
includes any combined dump file named "all" and any other individual database
|
||||
dumps found in the archive.
|
||||
dumps found in the archive. Prior to borgmatic version 1.7.6, restoring "all"
|
||||
only restored a combined "all" database dump from the archive.
|
||||
|
||||
|
||||
### Restore particular schemas
|
||||
@@ -423,46 +536,21 @@ postgresql_databases:
|
||||
restore_password: trustsome1
|
||||
```
|
||||
|
||||
|
||||
### Limitations
|
||||
|
||||
There are a few important limitations with borgmatic's current database
|
||||
restoration feature that you should know about:
|
||||
|
||||
1. You must restore as the same Unix user that created the archive containing
|
||||
the database dump. That's because the user's home directory path is encoded
|
||||
into the path of the database dump within the archive.
|
||||
2. As mentioned above, borgmatic can only restore a database that's defined in
|
||||
borgmatic's own configuration file. So include your configuration file in
|
||||
backups to avoid getting caught without a way to restore a database.
|
||||
3. borgmatic does not currently support backing up or restoring multiple
|
||||
databases that share the exact same name on different hosts.
|
||||
4. Because database hooks implicitly enable the `read_special` configuration,
|
||||
any special files are excluded from backups (named pipes, block devices,
|
||||
character devices, and sockets) to prevent hanging. Try a command like `find
|
||||
/your/source/path -type b -or -type c -or -type p -or -type s` to find such
|
||||
files. Common directories to exclude are `/dev` and `/run`, but that may not
|
||||
be exhaustive. <span class="minilink minilink-addedin">New in version
|
||||
1.7.3</span> When database hooks are enabled, borgmatic automatically excludes
|
||||
special files (and symlinks to special files) that may cause Borg to hang, so
|
||||
generally you no longer need to manually exclude them. There are potential
|
||||
edge cases though in which applications on your system create new special files
|
||||
*after* borgmatic constructs its exclude list, resulting in Borg hangs. If that
|
||||
occurs, you can resort to the manual excludes described above. And to opt out
|
||||
of the auto-exclude feature entirely, explicitly set `read_special` to true.
|
||||
|
||||
|
||||
### Manual restoration
|
||||
|
||||
If you prefer to restore a database without the help of borgmatic, first
|
||||
[extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
|
||||
archive containing a database dump.
|
||||
|
||||
borgmatic extracts the dump file into the *`username`*`/.borgmatic/` directory
|
||||
within the extraction destination path, where *`username`* is the user that
|
||||
created the backup. For example, if you created the backup with the `root`
|
||||
user and you're extracting to `/tmp`, then the dump will be in
|
||||
`/tmp/root/.borgmatic`.
|
||||
borgmatic extracts the dump file into the `borgmatic/` directory within the
|
||||
extraction destination path. For example, if you're extracting to `/tmp`, then
|
||||
the dump will be in `/tmp/borgmatic/`.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.0</span> borgmatic
|
||||
extracts the dump file into the *`username`*`/.borgmatic/` directory within the
|
||||
extraction destination path, where *`username`* is the user that created the
|
||||
backup. For example, if you created the backup with the `root` user and you're
|
||||
extracting to `/tmp`, then the dump will be in `/tmp/root/.borgmatic`.
|
||||
|
||||
After extraction, you can manually restore the dump file using native database
|
||||
commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
|
||||
@@ -471,6 +559,49 @@ Also see the documentation on [listing database
|
||||
dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
|
||||
|
||||
|
||||
## Limitations
|
||||
|
||||
There are a few important limitations with borgmatic's current database
|
||||
hooks that you should know about:
|
||||
|
||||
1. When database hooks are enabled, borgmatic instructs Borg to consume
|
||||
special files (via `--read-special`) to support database dump
|
||||
streaming—regardless of the value of your `read_special` configuration option.
|
||||
And because this can cause Borg to hang, borgmatic also automatically excludes
|
||||
special files (and symlinks to them) that Borg may get stuck on. Even so,
|
||||
there are still potential edge cases in which applications on your system
|
||||
create new special files *after* borgmatic constructs its exclude list,
|
||||
resulting in Borg hangs. If that occurs, you can resort to manually excluding
|
||||
those files. And if you explicitly set the `read_special` option to `true`,
|
||||
borgmatic will opt you out of the auto-exclude feature entirely, but will
|
||||
still instruct Borg to consume special files—and you will be on your own to
|
||||
exclude them. <span class="minilink minilink-addedin">Prior to version
|
||||
1.7.3</span>Special files were not auto-excluded, and you were responsible for
|
||||
excluding them yourself. Common directories to exclude are `/dev` and `/run`,
|
||||
but that may not be exhaustive.
|
||||
2. <span class="minilink minilink-addedin">Prior to version 1.9.5</span>
|
||||
borgmatic did not support backing up or restoring multiple databases that
|
||||
shared the exact same name on different hosts or with different ports.
|
||||
3. <span class="minilink minilink-addedin">Prior to version 1.9.0</span>
|
||||
Database hooks also implicitly enabled the `one_file_system` option, which
|
||||
meant Borg wouldn't cross filesystem boundaries when looking for files to
|
||||
backup. When borgmatic was running in a container, this often required a
|
||||
work-around to explicitly add each mounted backup volume to
|
||||
`source_directories` instead of relying on Borg to include them implicitly via
|
||||
a parent directory. But as of borgmatic 1.9.0, `one_file_system` is no longer
|
||||
auto-enabled and such work-arounds aren't necessary.
|
||||
4. <span class="minilink minilink-addedin">Prior to version 1.9.0</span> You
|
||||
must restore as the same Unix user that created the archive containing the
|
||||
database dump. That's because the user's home directory path is encoded into
|
||||
the path of the database dump within the archive.
|
||||
5. <span class="minilink minilink-addedin">Prior to version 1.7.15</span> As
|
||||
mentioned above, borgmatic can only restore a database that's defined in
|
||||
borgmatic's own configuration file. So include your configuration files in
|
||||
backups to avoid getting caught without a way to restore a database. But
|
||||
starting from version 1.7.15, borgmatic includes your configuration files
|
||||
automatically.
|
||||
|
||||
|
||||
## Preparation and cleanup hooks
|
||||
|
||||
If this database integration is too limited for needs, borgmatic also supports
|
||||
@@ -489,9 +620,9 @@ With PostgreSQL, MariaDB, and MySQL, if you're getting authentication errors
|
||||
when borgmatic tries to connect to your database, a natural reaction is to
|
||||
increase your borgmatic verbosity with `--verbosity 2` and go looking in the
|
||||
logs. You'll notice though that your database password does not show up in the
|
||||
logs. This is likely not the cause of the authentication problem unless you
|
||||
mistyped your password, however; borgmatic passes your password to the
|
||||
database via an environment variable that does not appear in the logs.
|
||||
logs. But this is likely not the cause of the authentication problem unless
|
||||
you mistyped your password; borgmatic passes your password to the database via
|
||||
an environment variable that does not appear in the logs.
|
||||
|
||||
The cause of an authentication error is often on the database side—in the
|
||||
configuration of which users are allowed to connect and how they are
|
||||
|
||||
@@ -3,7 +3,7 @@ title: How to customize warnings and errors
|
||||
eleventyNavigation:
|
||||
key: 💥 Customize warnings/errors
|
||||
parent: How-to guides
|
||||
order: 12
|
||||
order: 13
|
||||
---
|
||||
## When things go wrong
|
||||
|
||||
@@ -48,7 +48,7 @@ support for [more granular exit
|
||||
codes](https://borgbackup.readthedocs.io/en/1.4-maint/usage/general.html#return-codes)
|
||||
means that you can configure borgmatic to respond to specific Borg conditions.
|
||||
See the full list of [Borg 1.4 error and warning exit
|
||||
codes](https://borgbackup.readthedocs.io/en/1.4.0b1/internals/frontends.html#message-ids).
|
||||
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
|
||||
The `rc:` numeric value there tells you the exit code for each.
|
||||
|
||||
For instance, this borgmatic configuration elevates all Borg backup file
|
||||
|
||||
@@ -20,7 +20,7 @@ default action ordering was `prune`, `compact`, `create`, and `check`.
|
||||
### A la carte actions
|
||||
|
||||
If you find yourself wanting to customize the actions, you have some options.
|
||||
First, you can run borgmatic's `prune`, `compact`, `create`, or `check`
|
||||
First, you can run borgmatic's `create`, `prune`, `compact`, or `check`
|
||||
actions separately. For instance, the following optional actions are
|
||||
available (among others):
|
||||
|
||||
@@ -62,8 +62,8 @@ actions](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-acti
|
||||
Another option is to customize your consistency checks. By default, if you
|
||||
omit consistency checks from configuration, borgmatic runs full-repository
|
||||
checks (`repository`) and per-archive checks (`archives`) within each
|
||||
repository. (Although see below about check frequency.) This is equivalent to
|
||||
what `borg check` does if run without options.
|
||||
repository, running the checks on a monthly basis. (See below about setting
|
||||
your own check frequency.)
|
||||
|
||||
But if you find that archive checks are too slow, for example, you can
|
||||
configure borgmatic to run repository checks only. Configure this in the
|
||||
@@ -87,13 +87,16 @@ checks:
|
||||
```
|
||||
|
||||
|
||||
Here are the available checks from fastest to slowest:
|
||||
Here are the available checks, roughly from fastest to slowest:
|
||||
|
||||
* `repository`: Checks the consistency of the repository itself.
|
||||
* `archives`: Checks all of the archives in the repository.
|
||||
* `archives`: Checks all of the archives' metadata in the repository.
|
||||
* `repository`: Checks the consistency of the whole repository. The checks run
|
||||
on the server and do not cause significant network traffic.
|
||||
* `extract`: Performs an extraction dry-run of the latest archive.
|
||||
* `data`: Verifies the data integrity of all archives contents, decrypting and decompressing all data.
|
||||
* `spot`: Compares file counts and contents between your source files and the latest archive.
|
||||
* `data`: Verifies the data integrity of all archives contents, decrypting
|
||||
and decompressing all data.
|
||||
* `spot`: Compares file counts and contents between your source files and the
|
||||
latest archive.
|
||||
|
||||
Note that the `data` check is a more thorough version of the `archives` check,
|
||||
so enabling the `data` check implicitly enables the `archives` check as well.
|
||||
@@ -108,21 +111,21 @@ for more information.
|
||||
The various consistency checks all have trade-offs around speed and
|
||||
thoroughness, but most of them don't even look at your original source
|
||||
files—arguably one important way to ensure your backups contain the files
|
||||
you'll want to restore in the case of catastrophe (or just an accidentally
|
||||
deleted file). Because if something goes wrong with your source files, most
|
||||
you'll want to restore in the case of catastrophe (or an accidentally deleted
|
||||
file). Because if something goes wrong with your source files, most
|
||||
consistency checks will still pass with flying colors and you won't discover
|
||||
there's a problem until you go to restore.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.10</span> <span
|
||||
class="minilink minilink-addedin">Beta feature</span> That's where the spot
|
||||
check comes in. This check actually compares your source file counts and data
|
||||
against those in the latest archive, potentially catching problems like
|
||||
incorrect excludes, inadvertent deletes, files changed by malware, etc.
|
||||
<span class="minilink minilink-addedin">New in version 1.8.10</span> That's
|
||||
where the spot check comes in. This check actually compares your source file
|
||||
counts and data against those in the latest archive, potentially catching
|
||||
problems like incorrect excludes, inadvertent deletes, files changed by
|
||||
malware, etc.
|
||||
|
||||
However, because an exhaustive comparison of all source files against the
|
||||
latest archive might be too slow, the spot check supports *sampling* a
|
||||
percentage of your source files for the comparison, ensuring it falls within
|
||||
configured tolerances.
|
||||
But because an exhaustive comparison of all source files against the latest
|
||||
archive might be too slow, the spot check supports *sampling* a percentage of
|
||||
your source files for the comparison, ensuring they fall within configured
|
||||
tolerances.
|
||||
|
||||
Here's how it works. Start by installing the `xxhash` OS package if you don't
|
||||
already have it, so the spot check can run the `xxh64sum` command and
|
||||
@@ -149,17 +152,17 @@ fail.)
|
||||
The `data_sample_percentage` is the percentage of total files in the source
|
||||
directories to randomly sample and compare to their corresponding files in the
|
||||
latest backup archive. A higher value allows a more accurate check—and a
|
||||
slower one. The comparison is performed by hashing the selected files in each
|
||||
of the source paths and counting hashes that don't match the latest archive.
|
||||
For instance, if you have 1,000 source files and your sample percentage is 1%,
|
||||
then only 10 source files will be compared against the latest archive. These
|
||||
sampled files are selected randomly each time, so in effect the spot check is
|
||||
probabilistic.
|
||||
slower one. The comparison is performed by hashing the selected source files
|
||||
and counting hashes that don't match the latest archive. For instance, if you
|
||||
have 1,000 source files and your sample percentage is 1%, then only 10 source
|
||||
files will be compared against the latest archive. These sampled files are
|
||||
selected randomly each time, so in effect the spot check is probabilistic.
|
||||
|
||||
The `data_tolerance_percentage` is the percentage of total files in the source
|
||||
directories that can fail a spot check data comparison without failing the
|
||||
entire consistency check. The value must be lower than or equal to the
|
||||
`contents_sample_percentage`.
|
||||
`data_sample_percentage`, because `data_tolerance_percentage` only looks at
|
||||
at the sampled files as determined by `data_sample_percentage`.
|
||||
|
||||
All three options are required when using the spot check. And because the
|
||||
check relies on these configured tolerances, it may not be a
|
||||
@@ -175,10 +178,10 @@ want the spot check to fail the next time it's run? Run `borgmatic create` to
|
||||
create a new backup, thereby allowing the next spot check to run against an
|
||||
archive that contains your recent changes.
|
||||
|
||||
As long as the spot check feature is in beta, it may be subject to breaking
|
||||
changes. But feel free to use it in production if you're okay with that
|
||||
caveat, and please [provide any
|
||||
feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
|
||||
Because the spot check only looks at the most recent archive, you may not want
|
||||
to run it immediately after a `create` action (borgmatic's default behavior).
|
||||
Instead, it may make more sense to run the spot check on a separate schedule
|
||||
from `create`.
|
||||
|
||||
|
||||
### Check frequency
|
||||
@@ -220,15 +223,31 @@ repository, at most once a month.
|
||||
|
||||
Unlike a real scheduler like cron, borgmatic only makes a best effort to run
|
||||
checks on the configured frequency. It compares that frequency with how long
|
||||
it's been since the last check for a given repository (as recorded in a file
|
||||
within `~/.borgmatic/checks`). If it hasn't been long enough, the check is
|
||||
skipped. And you still have to run `borgmatic check` (or `borgmatic` without
|
||||
actions) in order for checks to run, even when a `frequency` is configured!
|
||||
it's been since the last check for a given repository If it hasn't been long
|
||||
enough, the check is skipped. And you still have to run `borgmatic check` (or
|
||||
`borgmatic` without actions) in order for checks to run, even when a
|
||||
`frequency` is configured!
|
||||
|
||||
This also applies *across* configuration files that have the same repository
|
||||
configured. Make sure you have the same check frequency configured in each
|
||||
though—or the most frequently configured check will apply.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.0</span>To support
|
||||
this frequency logic, borgmatic records check timestamps within the
|
||||
`~/.local/state/borgmatic/checks` directory. To override the `~/.local/state`
|
||||
portion of this path, set the `user_state_directory` configuration option.
|
||||
Alternatively, set the `XDG_STATE_HOME` environment variable.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.2</span>The
|
||||
`STATE_DIRECTORY` environment variable also works for this purpose. It's set
|
||||
by systemd if `StateDirectory=borgmatic` is added to borgmatic's systemd
|
||||
service file.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.0</span>
|
||||
borgmatic recorded check timestamps within the `~/.borgmatic` directory. At
|
||||
that time, the path was configurable by the `borgmatic_source_directory`
|
||||
configuration option (now deprecated).
|
||||
|
||||
If you want to temporarily ignore your configured frequencies, you can invoke
|
||||
`borgmatic check --force` to run checks unconditionally.
|
||||
|
||||
@@ -237,6 +256,57 @@ check --force` runs `check` even if it's specified in the `skip_actions`
|
||||
option.
|
||||
|
||||
|
||||
### Check days
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.13</span> You can
|
||||
optionally configure checks to only run on particular days of the week. For
|
||||
instance:
|
||||
|
||||
```yaml
|
||||
checks:
|
||||
- name: repository
|
||||
only_run_on:
|
||||
- Saturday
|
||||
- Sunday
|
||||
- name: archives
|
||||
only_run_on:
|
||||
- weekday
|
||||
- name: spot
|
||||
only_run_on:
|
||||
- Friday
|
||||
- weekend
|
||||
```
|
||||
|
||||
Each day of the week is specified in the current locale (system
|
||||
language/country settings). `weekend` and `weekday` are also accepted.
|
||||
|
||||
As with `frequency`, borgmatic only makes a best effort to run checks on the
|
||||
given day of the week. For instance, if you run `borgmatic check` daily, then
|
||||
every day borgmatic will have an opportunity to determine whether your checks
|
||||
are configured to run on that day. If they are, then the checks run. If not,
|
||||
they are skipped.
|
||||
|
||||
For instance, with the above configuration, if borgmatic is run on a Saturday,
|
||||
the `repository` check will run. But on a Monday? The repository check will
|
||||
get skipped. And if borgmatic is never run on a Saturday or a Sunday, that
|
||||
check will never get a chance to run.
|
||||
|
||||
Also, the day of the week configuration applies *after* any configured
|
||||
`frequency` for a check. So for instance, imagine the following configuration:
|
||||
|
||||
```yaml
|
||||
checks:
|
||||
- name: repository
|
||||
frequency: 2 weeks
|
||||
only_run_on:
|
||||
- Monday
|
||||
```
|
||||
|
||||
If you run borgmatic daily with that configuration, then borgmatic will first
|
||||
wait two weeks after the previous check before running the check again—on the
|
||||
first Monday after the `frequency` duration elapses.
|
||||
|
||||
|
||||
### Running only checks
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.7.1</span> If you
|
||||
|
||||
@@ -3,13 +3,13 @@ title: How to develop on borgmatic
|
||||
eleventyNavigation:
|
||||
key: 🏗️ Develop on borgmatic
|
||||
parent: How-to guides
|
||||
order: 14
|
||||
order: 15
|
||||
---
|
||||
## Source code
|
||||
|
||||
To get set up to develop on borgmatic, first [`install
|
||||
pipx`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation)
|
||||
to make managing your borgmatic environment easy without impacting other
|
||||
to make managing your borgmatic environment easier without impacting other
|
||||
Python applications on your system.
|
||||
|
||||
Then, clone borgmatic via HTTPS or SSH:
|
||||
@@ -102,9 +102,9 @@ and depend on containers for runtime dependencies. These tests do run on the
|
||||
continuous integration (CI) server, and running them on your developer machine
|
||||
is the closest thing to dev-CI parity.
|
||||
|
||||
If you would like to run the full test suite, first install Docker (or Podman;
|
||||
see below) and [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
Then run:
|
||||
If you would like to run the end-to-end tests, first install Docker (or
|
||||
Podman; see below) and [Docker
|
||||
Compose](https://docs.docker.com/compose/install/). Then run:
|
||||
|
||||
```bash
|
||||
scripts/run-end-to-end-tests
|
||||
@@ -152,12 +152,21 @@ the following deviations from it:
|
||||
* In general, spell out words in variable names instead of shortening them.
|
||||
So, think `index` instead of `idx`. There are some notable exceptions to
|
||||
this though (like `config`).
|
||||
* Favor blank lines around logical code groupings, `if` statements,
|
||||
`return`s, etc. Readability is more important than packing code tightly.
|
||||
* Import fully qualified Python modules instead of importing individual
|
||||
functions, classes, or constants. E.g., do `import os.path` instead of
|
||||
`from os import path`. (Some exceptions to this are made in tests.)
|
||||
* Only use classes and OOP as a last resort, such as when integrating with
|
||||
Python libraries that require it.
|
||||
* Prefer functional code where it makes sense, e.g. when constructing a
|
||||
command (to subsequently execute imperatively).
|
||||
|
||||
borgmatic code uses the [Black](https://black.readthedocs.io/en/stable/) code
|
||||
borgmatic uses the [Black](https://black.readthedocs.io/en/stable/) code
|
||||
formatter, the [Flake8](http://flake8.pycqa.org/en/latest/) code checker, and
|
||||
the [isort](https://github.com/timothycrosley/isort) import orderer, so
|
||||
certain code style requirements will be enforced when running automated tests.
|
||||
See the Black, Flake8, and isort documentation for more information.
|
||||
certain code style requirements are enforced when running automated tests. See
|
||||
the Black, Flake8, and isort documentation for more information.
|
||||
|
||||
|
||||
## Continuous integration
|
||||
|
||||
@@ -9,13 +9,14 @@ eleventyNavigation:
|
||||
|
||||
When the worst happens—or you want to test your backups—the first step is
|
||||
to figure out which archive to extract. A good way to do that is to use the
|
||||
`rlist` action:
|
||||
`repo-list` action:
|
||||
|
||||
```bash
|
||||
borgmatic rlist
|
||||
borgmatic repo-list
|
||||
```
|
||||
|
||||
(No borgmatic `rlist` action? Try `list` instead or upgrade borgmatic!)
|
||||
(No borgmatic `repo-list` action? Try `rlist` or `list` instead or upgrade
|
||||
borgmatic!)
|
||||
|
||||
That should yield output looking something like:
|
||||
|
||||
@@ -39,10 +40,10 @@ Or simplify this to:
|
||||
borgmatic extract --archive latest
|
||||
```
|
||||
|
||||
The `--archive` value is the name of the archive to extract. This extracts the
|
||||
entire contents of the archive to the current directory, so make sure you're
|
||||
in the right place before running the command—or see below about the
|
||||
`--destination` flag.
|
||||
The `--archive` value is the name of the archive or archive hash to extract.
|
||||
This extracts the entire contents of the archive to the current directory, so
|
||||
make sure you're in the right place before running the command—or see below
|
||||
about the `--destination` flag.
|
||||
|
||||
## Repository selection
|
||||
|
||||
@@ -130,6 +131,14 @@ Or use the "latest" value for the archive to mount the latest archive:
|
||||
borgmatic mount --archive latest --mount-point /mnt
|
||||
```
|
||||
|
||||
<span class="minilink minilink-addedin">New in borgmatic version 1.9.0 with
|
||||
Borg version 2.x</span>You can provide a series name for the `--archive` value
|
||||
to mount multiple archives in that series:
|
||||
|
||||
```bash
|
||||
borgmatic mount --archive seriesname --mount-point /mnt
|
||||
```
|
||||
|
||||
If you'd like to restrict the mounted filesystem to only particular paths from
|
||||
your archive, use the `--path` flag, similar to the `extract` action above.
|
||||
For instance:
|
||||
@@ -147,12 +156,19 @@ borgmatic umount --mount-point /mnt
|
||||
|
||||
## Extract the configuration files used to create an archive
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic
|
||||
automatically stores all the configuration files used to create an archive
|
||||
inside the archive itself. They are stored in the archive using their full
|
||||
paths from the machine being backed up. This is useful in cases where you've
|
||||
lost a configuration file or you want to see what configurations were used to
|
||||
create a particular archive.
|
||||
<span class="minilink minilink-addedin">New in version 1.7.15</span> As part
|
||||
of creating a backup archive, borgmatic automatically includes all of the
|
||||
configuration files used when creating it, storing them inside the archive
|
||||
itself with their full paths from the machine being backed up. This is useful
|
||||
in cases where you've lost a configuration file or you want to see what
|
||||
configurations were used to create a particular archive.
|
||||
|
||||
To support this, borgmatic creates a manifest file that records the paths of
|
||||
all the borgmatic configuration files stored within an archive. The file gets
|
||||
written to borgmatic's runtime directory on disk and then stored within the
|
||||
archive. See the [runtime directory
|
||||
documentation](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory)
|
||||
for how and where that happens.
|
||||
|
||||
To extract the configuration files from an archive, use the `config bootstrap`
|
||||
action. For example:
|
||||
@@ -186,13 +202,25 @@ borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:0
|
||||
See the output of `config bootstrap --help` for additional flags you may need
|
||||
for bootstrapping.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.1</span> Set the
|
||||
`store_config_files` option to `false` to disable the automatic backup of
|
||||
borgmatic configuration files, for instance if they contain sensitive
|
||||
information you don't want to store even inside your encrypted backups. If you
|
||||
do this though, the `config bootstrap` action will no longer work.
|
||||
<span class="minilink minilink-addedin">New in version 1.9.3</span>
|
||||
If your borgmatic configuration files contain sensitive information you don't
|
||||
want to store even inside your encrypted backups, you can disable the
|
||||
automatic backup of the configuration files. To do this, set the
|
||||
`store_config_files` option under the `bootstrap` hook to `false`. For
|
||||
instance:
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.7</span> Included
|
||||
configuration files are stored in each backup archive. This means that the
|
||||
`config bootstrap` action not only extracts the top-level configuration files
|
||||
but also the includes they depend upon.
|
||||
```yaml
|
||||
bootstrap:
|
||||
store_config_files: false
|
||||
```
|
||||
|
||||
If you do this though, the `config bootstrap` action will no longer work.
|
||||
|
||||
<span class="minilink minilink-addedin">In version 1.8.1 through 1.9.2</span>
|
||||
The `store_config_files` option was at the global scope instead of under the
|
||||
`bootstrap` hook.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.7</span>
|
||||
Configuration file includes are stored in each backup archive. This means that
|
||||
the `config bootstrap` action not only extracts the top-level configuration
|
||||
files but also the includes they depend upon.
|
||||
|
||||
@@ -62,13 +62,13 @@ for available values.
|
||||
|
||||
(No borgmatic `list` or `info` actions? Upgrade borgmatic!)
|
||||
|
||||
<span class="minilink minilink-addedin">New in borgmatic version 1.7.0</span>
|
||||
There are also `rlist` and `rinfo` actions for displaying repository
|
||||
information with Borg 2.x:
|
||||
<span class="minilink minilink-addedin">New in version 1.9.0</span> There are
|
||||
also `repo-list` and `repo-info` actions for displaying repository information
|
||||
with Borg 2.x:
|
||||
|
||||
```bash
|
||||
borgmatic rlist
|
||||
borgmatic rinfo
|
||||
borgmatic repo-list
|
||||
borgmatic repo-info
|
||||
```
|
||||
|
||||
See the [borgmatic command-line
|
||||
@@ -107,12 +107,28 @@ hooks](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/), you
|
||||
can list backed up database dumps via borgmatic. For example:
|
||||
|
||||
```bash
|
||||
borgmatic list --archive latest --find .borgmatic/*_databases
|
||||
borgmatic list --archive latest --find *borgmatic/*_databases
|
||||
```
|
||||
|
||||
This gives you a listing of all database dump files contained in the latest
|
||||
archive, complete with file sizes.
|
||||
|
||||
<span class="minilink minilink-addedin">New in borgmatic version
|
||||
1.9.0</span>Database dump files are stored at `/borgmatic` within a backup
|
||||
archive, regardless of the user who performs the backup. (Note that Borg
|
||||
doesn't store the leading `/`.)
|
||||
|
||||
<span class="minilink minilink-addedin">With Borg version 1.2 and
|
||||
earlier</span>Database dump files are stored at a path dependent on the [runtime
|
||||
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory)
|
||||
in use at the time the archive was created, as Borg 1.2 and earlier do not
|
||||
support path rewriting.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to borgmatic version
|
||||
1.9.0</span>Database dump files were instead stored at `~/.borgmatic` within
|
||||
the backup archive (where `~` was expanded to the home directory of the user
|
||||
who performed the backup). This applied with all versions of Borg.
|
||||
|
||||
|
||||
## Logging
|
||||
|
||||
|
||||
@@ -82,20 +82,25 @@ this option in the `storage:` section of your configuration.
|
||||
|
||||
This example means that when borgmatic creates an archive, its name will start
|
||||
with the string `home-directories-` and end with a timestamp for its creation
|
||||
time. If `archive_name_format` is unspecified, the default is
|
||||
time. If `archive_name_format` is unspecified, the default with Borg 1 is
|
||||
`{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}`, meaning your system hostname plus a
|
||||
timestamp in a particular format.
|
||||
|
||||
<span class="minilink minilink-addedin">New in borgmatic version 1.9.0 with
|
||||
Borg version 2.x</span>The default is just `{hostname}`, as Borg 2 does not
|
||||
require unique archive names; identical archive names form a common "series"
|
||||
that can be targeted together.
|
||||
|
||||
|
||||
### Archive filtering
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.7.11</span> borgmatic
|
||||
uses the `archive_name_format` option to automatically limit which archives
|
||||
get used for actions operating on multiple archives. This prevents, for
|
||||
instance, duplicate archives from showing up in `rlist` or `info` results—even
|
||||
if the same repository appears in multiple borgmatic configuration files. To
|
||||
take advantage of this feature, use a different `archive_name_format` in each
|
||||
configuration file.
|
||||
instance, duplicate archives from showing up in `repo-list` or `info`
|
||||
results—even if the same repository appears in multiple borgmatic
|
||||
configuration files. To take advantage of this feature, use a different
|
||||
`archive_name_format` in each configuration file.
|
||||
|
||||
Under the hood, borgmatic accomplishes this by substituting globs for certain
|
||||
ephemeral data placeholders in your `archive_name_format`—and using the result
|
||||
@@ -113,7 +118,7 @@ this option in the `storage:` section of your configuration.
|
||||
borgmatic considers `{now}` an emphemeral data placeholder that will probably
|
||||
change per archive, while `{hostname}` won't. So it turns the example value
|
||||
into `{hostname}-user-data-*` and applies it to filter down the set of
|
||||
archives used for actions like `rlist`, `info`, `prune`, `check`, etc.
|
||||
archives used for actions like `repo-list`, `info`, `prune`, `check`, etc.
|
||||
|
||||
The end result is that when borgmatic runs the actions for a particular
|
||||
application-specific configuration file, it only operates on the archives
|
||||
@@ -129,11 +134,14 @@ archive_name_format: {hostname}-user-data-{now}
|
||||
match_archives: sh:myhost-user-data-*
|
||||
```
|
||||
|
||||
For Borg 1.x, use a shell pattern for the `match_archives` value and see the
|
||||
[Borg patterns
|
||||
<span class="minilink minilink-addedin">With Borg version 1.x</span>Use a shell
|
||||
pattern for the `match_archives` value and see the [Borg patterns
|
||||
documentation](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns)
|
||||
for more information. For Borg 2.x, see the [match archives
|
||||
documentation](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/help.html#borg-help-match-archives).
|
||||
for more information.
|
||||
|
||||
<span class="minilink minilink-addedin">With Borg version 2.x</span>See the
|
||||
[match archives
|
||||
documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/help.html#borg-help-match-archives).
|
||||
|
||||
Some borgmatic command-line actions also have a `--match-archives` flag that
|
||||
overrides both the auto-matching behavior and the `match_archives`
|
||||
|
||||
@@ -39,13 +39,16 @@ below for how to configure this.
|
||||
borgmatic integrates with these monitoring services and libraries, pinging
|
||||
them as backups happen:
|
||||
|
||||
* [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
|
||||
* [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
|
||||
* [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
|
||||
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
|
||||
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
|
||||
* [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
|
||||
* [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
|
||||
* [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
|
||||
* [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
|
||||
* [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
|
||||
* [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
|
||||
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
|
||||
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
|
||||
* [Pushover](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook)
|
||||
* [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook)
|
||||
* [Zabbix](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook)
|
||||
|
||||
The idea is that you'll receive an alert when something goes wrong or when the
|
||||
service doesn't hear from borgmatic for a configured interval (if supported).
|
||||
@@ -208,8 +211,8 @@ cronitor:
|
||||
this option in the `hooks:` section of your configuration.
|
||||
|
||||
With this configuration, borgmatic pings your Cronitor monitor when a backup
|
||||
begins, ends, or errors, but only when any of the `prune`, `compact`,
|
||||
`create`, or `check` actions are run. Then, if the actions complete
|
||||
begins, ends, or errors, but only when any of the `create`, `prune`,
|
||||
`compact`, or `check` actions are run. Then, if the actions complete
|
||||
successfully or errors, borgmatic notifies Cronitor accordingly.
|
||||
|
||||
You can configure Cronitor to notify you by a [variety of
|
||||
@@ -235,8 +238,8 @@ cronhub:
|
||||
this option in the `hooks:` section of your configuration.
|
||||
|
||||
With this configuration, borgmatic pings your Cronhub monitor when a backup
|
||||
begins, ends, or errors, but only when any of the `prune`, `compact`,
|
||||
`create`, or `check` actions are run. Then, if the actions complete
|
||||
begins, ends, or errors, but only when any of the `create`, `prune`,
|
||||
`compact`, or `check` actions are run. Then, if the actions complete
|
||||
successfully or errors, borgmatic notifies Cronhub accordingly.
|
||||
|
||||
Note that even though you configure borgmatic with the "start" variant of the
|
||||
@@ -288,6 +291,76 @@ If you have any issues with the integration, [please contact
|
||||
us](https://torsion.org/borgmatic/#support-and-contributing).
|
||||
|
||||
|
||||
## Pushover hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.2</span>
|
||||
[Pushover](https://pushover.net) makes it easy to get real-time notifications
|
||||
on your Android, iPhone, iPad, and Desktop (Android Wear and Apple Watch,
|
||||
too!).
|
||||
|
||||
First, create a Pushover account and login on your mobile device. Create an
|
||||
Application in your Pushover dashboard.
|
||||
|
||||
Then, configure borgmatic with your user's unique "User Key" found in your
|
||||
Pushover dashboard and the unique "API Token" from the created Application.
|
||||
|
||||
Here's a basic example:
|
||||
|
||||
|
||||
```yaml
|
||||
pushover:
|
||||
token: 7ms6TXHpTokTou2P6x4SodDeentHRa
|
||||
user: hwRwoWsXMBWwgrSecfa9EfPey55WSN
|
||||
```
|
||||
|
||||
|
||||
With this configuration, borgmatic creates a Pushover event for your service
|
||||
whenever borgmatic fails, but only when any of the `create`, `prune`, `compact`,
|
||||
or `check` actions are run. Note that borgmatic does not contact Pushover
|
||||
when a backup starts or when it ends without error by default.
|
||||
|
||||
You can configure Pushover to have custom parameters declared for borgmatic's
|
||||
`start`, `fail` and `finish` hooks states.
|
||||
|
||||
Here's a more advanced example:
|
||||
|
||||
|
||||
```yaml
|
||||
pushover:
|
||||
token: 7ms6TXHpTokTou2P6x4SodDeentHRa
|
||||
user: hwRwoWsXMBWwgrSecfa9EfPey55WSN
|
||||
start:
|
||||
message: "Backup <b>Started</b>"
|
||||
priority: -2
|
||||
title: "Backup Started"
|
||||
html: True
|
||||
ttl: 10 # Message will be deleted after 10 seconds.
|
||||
fail:
|
||||
message: "Backup <font color='#ff6961'>Failed</font>"
|
||||
priority: 2 # Requests acknowledgement for messages.
|
||||
expire: 600 # Used only for priority 2. Default is 600 seconds.
|
||||
retry: 30 # Used only for priority 2. Default is 30 seconds.
|
||||
device: "pixel8"
|
||||
title: "Backup Failed"
|
||||
html: True
|
||||
sound: "siren"
|
||||
url: "https://ticketing-system.example.com/login"
|
||||
url_title: "Login to ticketing system"
|
||||
finish:
|
||||
message: "Backup <font color='#77dd77'>Finished</font>"
|
||||
priority: 0
|
||||
title: "Backup Finished"
|
||||
html: True
|
||||
ttl: 60
|
||||
url: "https://ticketing-system.example.com/login"
|
||||
url_title: "Login to ticketing system"
|
||||
states:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
```
|
||||
|
||||
|
||||
## ntfy hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.6.3</span>
|
||||
@@ -368,7 +441,7 @@ loki:
|
||||
```
|
||||
|
||||
With this configuration, borgmatic sends its logs to your Loki instance as any
|
||||
of the `prune`, `compact`, `create`, or `check` actions are run. Then, after
|
||||
of the `create`, `prune`, `compact`, or `check` actions are run. Then, after
|
||||
the actions complete, borgmatic notifies Loki of success or failure.
|
||||
|
||||
This hook supports sending arbitrary labels to Loki. For instance:
|
||||
@@ -420,7 +493,8 @@ pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation),
|
||||
run the following to install Apprise so borgmatic can use it:
|
||||
|
||||
```bash
|
||||
sudo pipx install --force borgmatic[Apprise]
|
||||
sudo pipx uninstall borgmatic
|
||||
sudo pipx install borgmatic[Apprise]
|
||||
```
|
||||
|
||||
Omit `sudo` if borgmatic is installed as a non-root user.
|
||||
@@ -435,11 +509,16 @@ apprise:
|
||||
label: gotify
|
||||
- url: mastodons://access_key@hostname/@user
|
||||
label: mastodon
|
||||
states:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
```
|
||||
|
||||
With this configuration, borgmatic pings each of the configured Apprise
|
||||
services when a backup begins, ends, or errors, but only when any of the
|
||||
`prune`, `compact`, `create`, or `check` actions are run.
|
||||
`create`, `prune`, `compact`, or `check` actions are run. (By default, if
|
||||
`states` is not specified, Apprise services are only pinged on error.)
|
||||
|
||||
You can optionally customize the contents of the default messages sent to
|
||||
these services:
|
||||
@@ -499,12 +578,125 @@ See the [configuration
|
||||
reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
|
||||
details.
|
||||
|
||||
## Uptime Kuma hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.8.13</span> [Uptime
|
||||
Kuma](https://uptime.kuma.pet) is a self-hosted monitoring tool and can
|
||||
provide a Push monitor type to accept HTTP `GET` requests from a service
|
||||
instead of contacting it directly.
|
||||
|
||||
Uptime Kuma allows you to see a history of monitor states and can in turn
|
||||
alert via ntfy, Gotify, Matrix, Apprise, Email, and many more.
|
||||
|
||||
An example configuration is shown here with all the available options:
|
||||
|
||||
```yaml
|
||||
uptime_kuma:
|
||||
push_url: https://kuma.my-domain.com/api/push/abcd1234
|
||||
states:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
```
|
||||
|
||||
The `push_url` is provided to your from your Uptime Kuma service and
|
||||
originally includes a query string—the text including and after the question
|
||||
mark (`?`). But please do not include the query string in the `push_url`
|
||||
configuration; borgmatic will add this automatically depending on the state of
|
||||
your backup.
|
||||
|
||||
Using `start`, `finish` and `fail` states means you will get two "up beats" in
|
||||
Uptime Kuma for successful backups and the ability to see failures if and when
|
||||
the backup started (was there a `start` beat?).
|
||||
|
||||
A reasonable base-level configuration for an Uptime Kuma Monitor for a backup
|
||||
is below:
|
||||
|
||||
```ini
|
||||
# These are to be entered into Uptime Kuma and not into your borgmatic
|
||||
# configuration.
|
||||
|
||||
# Push monitors wait for the client to contact Uptime Kuma instead of Uptime
|
||||
# Kuma contacting the client. This is perfect for backup monitoring.
|
||||
Monitor Type = Push
|
||||
|
||||
Heartbeat Interval = 90000 # = 25 hours = 1 day + 1 hour
|
||||
|
||||
# Wait 6 times the Heartbeat Retry (below) before logging a heartbeat missed.
|
||||
Retries = 6
|
||||
|
||||
# Multiplied by Retries this gives a grace period within which the monitor
|
||||
# goes into the "Pending" state.
|
||||
Heartbeat Retry = 360 # = 10 minutes
|
||||
|
||||
# For each Heartbeat Interval if the backup fails repeatedly, a notification
|
||||
# is sent each time.
|
||||
Resend Notification every X times = 1
|
||||
```
|
||||
|
||||
## Zabbix hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.0</span>
|
||||
[Zabbix](https://www.zabbix.com/) is an open-source monitoring tool used for
|
||||
tracking and managing the performance and availability of networks, servers,
|
||||
and applications in real-time.
|
||||
|
||||
This hook does not do any notifications on its own. Instead, it relies on your
|
||||
Zabbix instance to notify and perform escalations based on the Zabbix
|
||||
configuration. The `states` defined in the configuration determine which
|
||||
states will trigger the hook. The value defined in the configuration of each
|
||||
state is used to populate the data of the configured Zabbix item. If none are
|
||||
provided, it defaults to a lower-case string of the state.
|
||||
|
||||
An example configuration is shown here with all the available options.
|
||||
|
||||
```yaml
|
||||
zabbix:
|
||||
server: http://cloud.zabbix.com/zabbix/api_jsonrpc.php
|
||||
|
||||
username: myuser
|
||||
password: secret
|
||||
api_key: b2ecba64d8beb47fc161ae48b164cfd7104a79e8e48e6074ef5b141d8a0aeeca
|
||||
|
||||
host: "borg-server"
|
||||
key: borg.status
|
||||
itemid: 55105
|
||||
|
||||
start:
|
||||
value: "STARTED"
|
||||
finish:
|
||||
value: "OK"
|
||||
fail:
|
||||
value: "ERROR"
|
||||
states:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
```
|
||||
|
||||
This hook requires the Zabbix server be running version 7.0+
|
||||
|
||||
|
||||
### Authentication methods
|
||||
|
||||
Authentication can be accomplished via `api_key` or both `username` and
|
||||
`password`. If all three are declared, only `api_key` is used.
|
||||
|
||||
|
||||
### Items
|
||||
|
||||
The item to be updated can be chosen by either declaring the `itemid` or both
|
||||
`host` and `key`. If all three are declared, only `itemid` is used.
|
||||
|
||||
Keep in mind that `host` is referring to the "Host name" on the Zabbix server
|
||||
and not the "Visual name".
|
||||
|
||||
|
||||
## Scripting borgmatic
|
||||
|
||||
To consume the output of borgmatic in other software, you can include an
|
||||
optional `--json` flag with `create`, `rlist`, `rinfo`, or `info` to get the
|
||||
output formatted as JSON.
|
||||
optional `--json` flag with `create`, `repo-list`, `repo-info`, or `info` to
|
||||
get the output formatted as JSON.
|
||||
|
||||
Note that when you specify the `--json` flag, Borg's other non-JSON output is
|
||||
suppressed so as not to interfere with the captured JSON. Also note that JSON
|
||||
@@ -515,7 +707,7 @@ output only shows up at the console and not in syslog.
|
||||
|
||||
All borgmatic actions that accept an `--archive` flag allow you to specify an
|
||||
archive name of `latest`. This lets you get the latest archive without having
|
||||
to first run `borgmatic rlist` manually, which can be handy in automated
|
||||
to first run `borgmatic repo-list` manually, which can be handy in automated
|
||||
scripts. Here's an example:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,55 @@ For example, to ask the *Pass* password manager to provide the passphrase:
|
||||
encryption_passcommand: pass path/to/borg-repokey
|
||||
```
|
||||
|
||||
### Using systemd service credentials
|
||||
|
||||
Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).
|
||||
|
||||
Save your password as an encrypted credential to `/etc/credstore.encrypted/borgmatic.pw`, e.g.,
|
||||
|
||||
```
|
||||
# systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw
|
||||
```
|
||||
|
||||
Then uncomment or use the following in your configuration file:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw"
|
||||
```
|
||||
|
||||
Note that the name `borgmatic.pw` is hardcoded in the systemd service file.
|
||||
|
||||
To use multiple different passwords, save them as encrypted credentials to `/etc/credstore.encrypted/borgmatic/`, e.g.,
|
||||
|
||||
```
|
||||
# mkdir /etc/credstore.encrypted/borgmatic
|
||||
# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_backupserver1 - /etc/credstore.encrypted/borgmatic/backupserver1
|
||||
# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_pw2 - /etc/credstore.encrypted/borgmatic/pw2
|
||||
...
|
||||
```
|
||||
|
||||
Ensure that the file names, (e.g. `backupserver1`) match the corresponding part of
|
||||
the `--name` option *after* the underscore (_), and that the part *before*
|
||||
the underscore matches the directory name (e.g. `borgmatic`).
|
||||
|
||||
Then, uncomment the appropriate line in the systemd service file:
|
||||
|
||||
```
|
||||
# systemctl edit borgmatic.service
|
||||
...
|
||||
# Load multiple encrypted credentials.
|
||||
LoadCredentialEncrypted=borgmatic:/etc/credstore.encrypted/borgmatic/
|
||||
```
|
||||
|
||||
Finally, use the following in your configuration file:
|
||||
|
||||
```
|
||||
encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1"
|
||||
```
|
||||
|
||||
Adjust `borgmatic_backupserver1` according to the name given to the credential
|
||||
and the directory set in the service file.
|
||||
|
||||
### Environment variable interpolation
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.6.4</span> borgmatic
|
||||
|
||||
@@ -3,7 +3,7 @@ title: How to run arbitrary Borg commands
|
||||
eleventyNavigation:
|
||||
key: 🔧 Run arbitrary Borg commands
|
||||
parent: How-to guides
|
||||
order: 11
|
||||
order: 12
|
||||
---
|
||||
## Running Borg with borgmatic
|
||||
|
||||
@@ -22,7 +22,7 @@ following, all based on your borgmatic configuration files or command-line
|
||||
arguments:
|
||||
|
||||
* configured repositories, running your Borg command once for each one
|
||||
* local and remote Borg binary paths
|
||||
* local and remote Borg executable paths
|
||||
* SSH settings and Borg environment variables
|
||||
* lock wait settings
|
||||
* verbosity
|
||||
@@ -45,10 +45,14 @@ be preferred though for most uses.)
|
||||
You can also specify Borg options for relevant commands. For instance:
|
||||
|
||||
```bash
|
||||
borgmatic borg rlist --short
|
||||
borgmatic borg repo-list --short
|
||||
```
|
||||
|
||||
This runs Borg's `rlist` command once on each configured borgmatic repository.
|
||||
(No borgmatic `repo-list` action? Try `rlist` or `list` instead or upgrade
|
||||
borgmatic!)
|
||||
|
||||
This runs Borg's `repo-list` command once on each configured borgmatic
|
||||
repository.
|
||||
|
||||
What if you only want to run Borg on a single configured borgmatic repository
|
||||
when you've got several configured? Not a problem. The `--repository` argument
|
||||
@@ -88,7 +92,7 @@ string instead of trying to resolve it from borgmatic's shell where it's not
|
||||
yet set.
|
||||
|
||||
Or if you don't need borgmatic to resolve an archive name like `latest`, you
|
||||
can just do:
|
||||
can do:
|
||||
|
||||
```bash
|
||||
borgmatic borg list ::your-actual-archive-name
|
||||
@@ -133,6 +137,8 @@ borgmatic's `borg` action is not without limitations:
|
||||
borgmatic action. In this case, only the Borg command is run.
|
||||
* Unlike normal borgmatic actions that support JSON, the `borg` action will
|
||||
not disable certain borgmatic logs to avoid interfering with JSON output.
|
||||
* The `borg` action bypasses most of borgmatic's machinery, so for instance
|
||||
monitoring hooks will not get triggered when running `borgmatic borg create`.
|
||||
* <span class="minilink minilink-addedin">Prior to version 1.8.0</span>
|
||||
borgmatic implicitly injected the repository/archive arguments on the Borg
|
||||
command-line for you (based on your borgmatic configuration or the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user