pax_global_header00006660000000000000000000000064147636172600014526gustar00rootroot0000000000000052 comment=fbdb09b87d5cd3482e117e71a26a6948e80e9caf borgmatic/000077500000000000000000000000001476361726000130415ustar00rootroot00000000000000borgmatic/.dockerignore000066400000000000000000000000121476361726000155060ustar00rootroot00000000000000.git .tox borgmatic/.eleventy.js000066400000000000000000000031301476361726000153050ustar00rootroot00000000000000const pluginSyntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); const codeClipboard = require("eleventy-plugin-code-clipboard"); const inclusiveLangPlugin = require("@11ty/eleventy-plugin-inclusive-language"); const navigationPlugin = require("@11ty/eleventy-navigation"); module.exports = function(eleventyConfig) { eleventyConfig.addPlugin(pluginSyntaxHighlight); eleventyConfig.addPlugin(inclusiveLangPlugin); eleventyConfig.addPlugin(navigationPlugin); eleventyConfig.addPlugin(codeClipboard); let markdownIt = require("markdown-it"); let markdownItAnchor = require("markdown-it-anchor"); let markdownItReplaceLink = require("markdown-it-replace-link"); let markdownItOptions = { html: true, breaks: false, linkify: true, replaceLink: function (link, env) { if (process.env.NODE_ENV == "production") { return link; } return link.replace('https://torsion.org/borgmatic/', 'http://localhost:8080/'); } }; let markdownItAnchorOptions = { permalink: markdownItAnchor.permalink.headerLink() }; eleventyConfig.setLibrary( "md", markdownIt(markdownItOptions) .use(markdownItAnchor, markdownItAnchorOptions) .use(markdownItReplaceLink) .use(codeClipboard.markdownItCopyButton) ); eleventyConfig.addPassthroughCopy({"docs/static": "static"}); eleventyConfig.setLiquidOptions({dynamicPartials: false}); return { templateFormats: [ "md", "txt" ] } }; borgmatic/.gitea/000077500000000000000000000000001476361726000142105ustar00rootroot00000000000000borgmatic/.gitea/issue_template/000077500000000000000000000000001476361726000172335ustar00rootroot00000000000000borgmatic/.gitea/issue_template/bug_template.yaml000066400000000000000000000041011476361726000225630ustar00rootroot00000000000000name: "Bug or question/support" about: "For filing a bug or getting support" body: - type: textarea id: problem attributes: label: What I'm trying to do and why validations: required: true - type: textarea id: repro_steps attributes: label: Steps to reproduce description: Include (sanitized) borgmatic configuration files if applicable. validations: required: false - type: textarea id: actual_behavior attributes: label: Actual behavior description: Include (sanitized) `--verbosity 2` output if applicable. validations: required: false - type: textarea id: expected_behavior attributes: label: Expected behavior validations: required: false - type: textarea id: notes attributes: label: Other notes / implementation ideas validations: required: false - type: input id: borgmatic_version attributes: label: borgmatic version description: Use `sudo borgmatic --version` or `sudo pip show borgmatic | grep ^Version` validations: required: false - type: input id: borgmatic_install_method attributes: label: borgmatic installation method description: e.g., pip install, Debian package, container, etc. validations: required: false - type: input id: borg_version attributes: label: Borg version description: Use `sudo borg --version` validations: required: false - type: input id: python_version attributes: label: Python version description: Use `python3 --version` validations: required: false - type: input id: database_version attributes: label: Database version (if applicable) description: Use `psql --version` / `mysql --version` / `mongodump --version` / `sqlite3 --version` validations: required: false - type: input id: operating_system_version attributes: label: Operating system and version description: On Linux, use `cat /etc/os-release` validations: required: false borgmatic/.gitea/issue_template/config.yaml000066400000000000000000000000331476361726000213600ustar00rootroot00000000000000blank_issues_enabled: true borgmatic/.gitea/issue_template/feature_template.yaml000066400000000000000000000005161476361726000234470ustar00rootroot00000000000000name: "Feature" about: "For filing a feature request or idea" body: - type: textarea id: request attributes: label: What I'd like to do and why validations: required: true - type: textarea id: notes attributes: label: Other notes / implementation ideas validations: required: false borgmatic/.gitea/workflows/000077500000000000000000000000001476361726000162455ustar00rootroot00000000000000borgmatic/.gitea/workflows/build.yaml000066400000000000000000000020621476361726000202300ustar00rootroot00000000000000name: build run-name: ${{ gitea.actor }} is building on: push: branches: [main] jobs: test: runs-on: host steps: - uses: actions/checkout@v4 - run: scripts/run-end-to-end-tests docs: needs: [test] runs-on: host env: IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs steps: - uses: actions/checkout@v4 - run: podman login --username "$USERNAME" --password "$PASSWORD" projects.torsion.org env: USERNAME: "${{ secrets.REGISTRY_USERNAME }}" 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 borgmatic/.gitignore000066400000000000000000000001511476361726000150260ustar00rootroot00000000000000*.egg-info *.pyc *.swp .cache .coverage* .pytest_cache .tox __pycache__ build/ dist/ pip-wheel-metadata/ borgmatic/AUTHORS000066400000000000000000000011671476361726000141160ustar00rootroot00000000000000Dan Helfman : Main developer Alexander Görtz: Python 3 compatibility Florian Lindner: Logging rewrite Henning Schroeder: Copy editing Johannes Feichtner: Support for user hooks Michele Lazzeri: Custom archive names Nick Whyte: Support prefix filtering for archive consistency checks newtonne: Read encryption password from external file Robin `ypid` Schneider: Support additional options of Borg and add validate-borgmatic-config command Scott Squires: Custom archive names Thomas LÉVEIL: Support for a keep_minutely prune option. Support for the --json option And many others! See the output of "git log". borgmatic/LICENSE000066400000000000000000001044621476361726000140550ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. {one line to give the program's name and a brief idea of what it does.} Copyright (C) {year} {name of author} This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: {project} Copyright (C) {year} {fullname} This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . borgmatic/MANIFEST.in000066400000000000000000000000721476361726000145760ustar00rootroot00000000000000include borgmatic/config/schema.yaml graft sample/systemd borgmatic/NEWS000066400000000000000000002623011476361726000135440ustar00rootroot000000000000001.9.14 * #409: With the PagerDuty monitoring hook, send borgmatic logs to PagerDuty so they show up in the incident UI. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook * #936: Clarify Zabbix monitoring hook documentation about creating items: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook * #1017: Fix a regression in which some MariaDB/MySQL passwords were not escaped correctly. * #1021: Fix a regression in which the "exclude_patterns" option didn't expand "~" (the user's home directory). This fix means that all "patterns" and "patterns_from" also now expand "~". * #1023: Fix an error in the Btrfs hook when attempting to snapshot a read-only subvolume. Now, read-only subvolumes are ignored since Btrfs can't actually snapshot them. 1.9.13 * #975: Add a "compression" option to the PostgreSQL database hook. * #1001: Fix a ZFS error during snapshot cleanup. * #1003: In the Zabbix monitoring hook, support Zabbix 7.2's authentication changes. * #1009: Send database passwords to MariaDB and MySQL via anonymous pipe, which is more secure than using an environment variable. * #1013: Send database passwords to MongoDB via anonymous pipe, which is more secure than using "--password" on the command-line! * #1015: When ctrl-C is pressed, more strongly encourage Borg to actually exit. * Add a "verify_tls" option to the Uptime Kuma monitoring hook for disabling TLS verification. * Add "tls" options to the MariaDB and MySQL database hooks to enable or disable TLS encryption between client and server. 1.9.12 * #1005: Fix the credential hooks to avoid using Python 3.12+ string features. Now borgmatic will work with Python 3.9, 3.10, and 3.11 again. 1.9.11 * #795: Add credential loading from file, KeePassXC, and Docker/Podman secrets. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/ * #996: Fix the "create" action to omit the repository label prefix from Borg's output when databases are enabled. * #998: Send the "encryption_passphrase" option to Borg via an anonymous pipe, which is more secure than using an environment variable. * #999: Fix a runtime directory error from a conflict between "extra_borg_options" and special file detection. * #1001: For the ZFS, Btrfs, and LVM hooks, only make snapshots for root patterns that come from a borgmatic configuration option (e.g. "source_directories")—not from other hooks within borgmatic. * #1001: Fix a ZFS/LVM error due to colliding snapshot mount points for nested datasets or logical volumes. * #1001: Don't try to snapshot ZFS datasets that have the "canmount=off" property. * Fix another error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's source directories. 1.9.10 * #966: Add a "{credential ...}" syntax for loading systemd credentials into borgmatic configuration files. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/ * #987: Fix a "list" action error when the "encryption_passcommand" option is set. * #987: When both "encryption_passcommand" and "encryption_passphrase" are configured, prefer "encryption_passphrase" even if it's an empty value. * #988: With the "max_duration" option or the "--max-duration" flag, run the archives and repository checks separately so they don't interfere with one another. Previously, borgmatic refused to run checks in this situation. * #989: Fix the log message code to avoid using Python 3.10+ logging features. Now borgmatic will work with Python 3.9 again. * Capture and delay any log records produced before logging is fully configured, so early log records don't get lost. * Add support for Python 3.13. 1.9.9 * #635: Log the repository path or label on every relevant log message, not just some logs. * #961: When the "encryption_passcommand" option is set, call the command once from borgmatic to collect the encryption passphrase and then pass it to Borg multiple times. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/ * #981: Fix a "spot" check file count delta error. * #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded subdirectories. * #983: Fix the Btrfs hook to support subvolumes with names like "@home" different from their mount points. * #985: Change the default value for the "--original-hostname" flag from "localhost" to no host specified. This way, the "restore" action works without a hostname if there's a single matching database dump. 1.9.8 * #979: Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg. * Expand the recent contributors documentation section to include ticket submitters—not just code contributors—because there are multiple ways to contribute to the project! See: https://torsion.org/borgmatic/#recent-contributors 1.9.7 * #855: Add a Sentry monitoring hook. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-hook * #968: Fix for a "spot" check error when a filename in the most recent archive contains a newline. * #970: Fix for an error when there's a blank line in the configured patterns or excludes. * #971: Fix for "exclude_from" files being completely ignored. * #977: Fix for "exclude_patterns" and "exclude_from" not supporting explicit pattern styles (e.g., "sh:" or "re:"). 1.9.6 * #959: Fix an error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's source directories. * #960: Fix for archives storing relative source directory paths such that they contain the working directory. * #960: Fix the "spot" check to support relative source directory paths. * #962: For the ZFS, Btrfs, and LVM hooks, perform path rewriting for excludes and patterns in addition to the existing source directories rewriting. * #962: Under the hood, merge all configured source directories, excludes, and patterns into a unified temporary patterns file for passing to Borg. The borgmatic configuration options remain unchanged. * #962: For the LVM hook, add support for nested logical volumes. * #965: Fix a borgmatic runtime directory error when running the "spot" check with a database hook enabled. * #969: Fix the "restore" action to work on database dumps without a port when a default port is present in configuration. * Fix the "spot" check to no longer consider pipe files within an archive for file comparisons. * Fix the "spot" check to have a nicer error when there are no source paths to compare. * Fix auto-excluding of special files (when databases are configured) to support relative source directory paths. * Drop support for Python 3.8, which has been end-of-lifed. 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 catch problems like incorrect excludes, inadvertent deletes, files changed by malware, etc. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#spot-check * #779: When "--match-archives *" is used with "check" action, don't skip Borg's orphaned objects check. * #842: When a command hook exits with a soft failure, ping the log and finish states for any configured monitoring hooks. * #843: Add documentation link to Loki dashboard for borgmatic: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook * #847: Fix "--json" error when Borg includes non-JSON warnings in JSON output. * #848: SECURITY: Mask the password when logging a MongoDB dump or restore command. * Fix handling of the NO_COLOR environment variable to ignore an empty value. * Add documentation about backing up containerized databases by configuring borgmatic to exec into a container to run a dump command: https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers 1.8.9 * #311: Add custom dump/restore command options for MySQL and MariaDB. * #811: Add an "access_token" option to the ntfy monitoring hook for authenticating without username/password. * #827: When the "--json" flag is given, suppress console escape codes so as not to interfere with JSON output. * #829: Fix "--override" values containing deprecated section headers not actually overriding configuration options under deprecated section headers. * #835: Add support for the NO_COLOR environment variable. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#colored-output * #839: Add log sending for the Apprise logging hook, enabled by default. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook * #839: Document a potentially breaking shell quoting edge case within error hooks: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks * #840: When running the "rcreate" action and the repository already exists but with a different encryption mode than requested, error. * Switch from Drone to Gitea Actions for continuous integration. * Rename scripts/run-end-to-end-dev-tests to scripts/run-end-to-end-tests and use it in both dev and CI for better dev-CI parity. * Clarify documentation about restoring a database: borgmatic does not create the database upon restore. 1.8.8 * #370: For the PostgreSQL hook, pass the "PGSSLMODE" environment variable through to Borg when the database's configuration omits the "ssl_mode" option. * #818: Allow the "--repository" flag to match across multiple configuration files. * #820: Fix broken repository detection in the "rcreate" action with Borg 1.4. The issue did not occur with other versions of Borg. * #822: Fix broken escaping logic in the PostgreSQL hook's "pg_dump_command" option. * SECURITY: Prevent additional shell injection attacks within the PostgreSQL hook. 1.8.7 * #736: Store included configuration files within each backup archive in support of the "config bootstrap" action. Previously, only top-level configuration files were stored. * #798: Elevate specific Borg warnings to errors or squash errors to * warnings. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/customize-warnings-and-errors/ * #810: SECURITY: Prevent shell injection attacks within the PostgreSQL hook, the MongoDB hook, the SQLite hook, the "borgmatic borg" action, and command hook variable/constant interpolation. * #814: Fix a traceback when providing an invalid "--override" value for a list option. 1.8.6 * #767: Add an "--ssh-command" flag to the "config bootstrap" action for setting a custom SSH command, as no configuration is available (including the "ssh_command" option) until bootstrapping completes. * #794: Fix a traceback when the "repositories" option contains both strings and key/value pairs. * #800: Add configured repository labels to the JSON output for all actions. * #802: The "check --force" flag now runs checks even if "check" is in "skip_actions". * #804: Validate the configured action names in the "skip_actions" option. * #807: Stream SQLite databases directly to Borg instead of dumping to an intermediate file. * When logging commands that borgmatic executes, log the environment variables that borgmatic sets for those commands. (But don't log their values, since they often contain passwords.) 1.8.5 * #701: Add a "skip_actions" option to skip running particular actions, handy for append-only or checkless configurations. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions * #701: Deprecate the "disabled" value for the "checks" option in favor of the new "skip_actions" option. * #745: Constants now apply to included configuration, not just the file doing the includes. As a side effect of this change, constants no longer apply to option names and only substitute into configuration values. * #779: Add a "--match-archives" flag to the "check" action for selecting the archives to check, overriding the existing "archive_name_format" and "match_archives" options in configuration. * #779: Only parse "--override" values as complex data types when they're for options of those types. * #782: Fix environment variable interpolation within configured repository paths. * #782: Add configuration constant overriding via the existing "--override" flag. * #783: Upgrade ruamel.yaml dependency to support version 0.18.x. * #784: Drop support for Python 3.7, which has been end-of-lifed. 1.8.4 * #715: Add a monitoring hook for sending backup status to a variety of monitoring services via the Apprise library. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook * #748: When an archive filter causes no matching archives for the "rlist" or "info" actions, warn the user and suggest how to remove the filter. * #768: Fix a traceback when an invalid command-line flag or action is used. * #771: Fix normalization of deprecated sections ("location:", "storage:", "hooks:", etc.) to support empty sections without erroring. * #774: Disallow the "--dry-run" flag with the "borg" action, as borgmatic can't guarantee the Borg command won't have side effects. 1.8.3 * #665: BREAKING: Simplify logging logic as follows: Syslog verbosity is now disabled by default, but setting the "--syslog-verbosity" flag enables it regardless of whether you're at an interactive console. Additionally, "--log-file-verbosity" and "--monitoring-verbosity" now default to 1 (info about steps borgmatic is taking) instead of 0. And both syslog logging and file logging can be enabled simultaneously. * #743: Add a monitoring hook for sending backup status and logs to Grafana Loki. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook * #753: When "archive_name_format" is not set, filter archives using the default archive name format. * #754: Fix error handling to log command output as one record per line instead of truncating too-long output and swallowing the end of some Borg error messages. * #757: Update documentation so "sudo borgmatic" works for pipx borgmatic installations. * #761: Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C. * Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-borgmatic 1.8.2 * #345: Add "key export" action to export a copy of the repository key for safekeeping in case the original goes missing or gets damaged. * #727: Add a MariaDB database hook that uses native MariaDB commands instead of the deprecated MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are only restorable with a "mysql_databases:" configuration. * #738: Fix for potential data loss (data not getting restored) in which the database "restore" action didn't actually restore anything and indicated success anyway. * Remove the deprecated use of the MongoDB hook's "--db" flag for database restoration. * Add source code reference documentation for getting oriented with the borgmatic code as a developer: https://torsion.org/borgmatic/docs/reference/source-code/ 1.8.1 * #326: Add documentation for restoring a database to an alternate host: https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#restore-to-an-alternate-host * #697: Add documentation for "bootstrap" action: https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive * #725: Add "store_config_files" option for disabling the automatic backup of configuration files used by the "config bootstrap" action. * #728: Fix for "prune" action error when using the "keep_exclude_tags" option. * #730: Fix for Borg's interactive prompt on the "check --repair" action automatically getting answered "NO" even when the "check_i_know_what_i_am_doing" option isn't set. * #732: Include multiple configuration files with a single "!include". See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#multiple-merge-includes * #734: Omit "--glob-archives" or "--match-archives" Borg flag when its value would be "*" (meaning all archives). 1.8.0 * #575: BREAKING: For the "borgmatic borg" action, instead of implicitly injecting repository/archive into the resulting Borg command-line, pass repository to Borg via an environment variable and make archive available for explicit use in your commands. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/ * #719: Fix an error when running "borg key export" through borgmatic. * #720: Fix an error when dumping a database and the "exclude_nodump" option is set. * #724: Add "check_i_know_what_i_am_doing" option to bypass Borg confirmation prompt when running "check --repair". * When merging two configuration files, error gracefully if the two files do not adhere to the same format. * #721: Remove configuration sections ("location:", "storage:", "hooks:", etc.), while still keeping deprecated support for them. Now, all options are at the same level, and you don't need to worry about commenting/uncommenting section headers when you change an option (if you remove your sections first). * #721: BREAKING: The retention prefix and the consistency prefix can no longer have different values (unless one is not set). * #721: BREAKING: The storage umask and the hooks umask can no longer have different values (unless one is not set). * BREAKING: Flags like "--config" that previously took multiple values now need to be given once per value, e.g. "--config first.yaml --config second.yaml" instead of "--config first.yaml second.yaml". This prevents argument parsing errors on ambiguous commands. * BREAKING: Remove the deprecated (and silently ignored) "--successful" flag on the "list" action, as newer versions of Borg list successful (non-checkpoint) archives by default. * All deprecated configuration option values now generate warning logs. * Remove the deprecated (and non-functional) "--excludes" flag in favor of excludes within configuration. * Fix an error when logging too-long command output during error handling. Now, long command output is truncated before logging. 1.7.15 * #326: Add configuration options and command-line flags for backing up a database from one location while restoring it somewhere else. * #399: Add a documentation troubleshooting note for MySQL/MariaDB authentication errors. * #529: Remove upgrade-borgmatic-config command for upgrading borgmatic 1.1.0 INI-style configuration. * #529: Deprecate generate-borgmatic-config in favor of new "config generate" action. * #529: Deprecate validate-borgmatic-config in favor of new "config validate" action. * #697, #712, #716: Extract borgmatic configuration from backup via new "config bootstrap" action—even when borgmatic has no configuration yet! * #669: Add sample systemd user service for running borgmatic as a non-root user. * #711, #713: Fix an error when "data" check time files are accessed without getting upgraded first. 1.7.14 * #484: Add a new verbosity level (-2) to disable output entirely (for console, syslog, log file, or monitoring), so not even errors are shown. * #688: Tweak archive check probing logic to use the newest timestamp found when multiple exist. * #659: Add Borg 2 date-based matching flags to various actions for archive selection. * #703: Fix an error when loading the configuration schema on Fedora Linux. * #704: Fix "check" action error when repository and archive checks are configured but the archive check gets skipped due to the configured frequency. * #706: Fix "--archive latest" on "list" and "info" actions that only worked on the first of multiple configured repositories. 1.7.13 * #375: Restore particular PostgreSQL schemas from a database dump via "borgmatic restore --schema" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#restore-particular-schemas * #678: Fix error from PostgreSQL when dumping a database with a "format" of "plain". * #678: Fix PostgreSQL hook to support "psql_command" and "pg_restore_command" options containing commands with arguments. * #678: Fix calls to psql in PostgreSQL hook to ignore "~/.psqlrc", whose settings can break database dumping. * #680: Add support for logging each log line as a JSON object via global "--log-json" flag. * #682: Fix "source_directories_must_exist" option to expand globs and tildes in source directories. * #684: Rename "master" development branch to "main" to use more inclusive language. You'll need to update your development checkouts accordingly. * #686: Add fish shell completion script so you can tab-complete on the borgmatic command-line. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion * #687: Fix borgmatic error when not finding the configuration schema for certain "pip install --editable" development installs. * #688: Fix archive checks being skipped even when particular archives haven't been checked recently. This occurred when using multiple borgmatic configuration files with different "archive_name_format"s, for instance. * #691: Fix error in "borgmatic restore" action when the configured repository path is relative instead of absolute. * #694: Run "borgmatic borg" action without capturing output so interactive prompts and flags like "--progress" still work. 1.7.12 * #413: Add "log_file" context to command hooks so your scripts can consume the borgmatic log file. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/ * #666, #670: Fix error when running the "info" action with the "--match-archives" or "--archive" flags. Also fix the "--match-archives"/"--archive" flags to correctly override the "match_archives" configuration option for the "transfer", "list", "rlist", and "info" actions. * #668: Fix error when running the "prune" action with both "archive_name_format" and "prefix" options set. * #672: Selectively shallow merge certain mappings or sequences when including configuration files. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#shallow-merge * #672: Selectively omit list values when including configuration files. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#list-merge * #673: View the results of configuration file merging via "validate-borgmatic-config --show" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#debugging-includes * Add optional support for running end-to-end tests and building documentation with rootless Podman instead of Docker. 1.7.11 * #479, #588: BREAKING: Automatically use the "archive_name_format" option to filter which archives get used for borgmatic actions that operate on multiple archives. Override this behavior with the new "match_archives" option in the storage section. This change is "breaking" in that it silently changes which archives get considered for "rlist", "prune", "check", etc. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#archive-naming * #479, #588: The "prefix" options have been deprecated in favor of the new "archive_name_format" auto-matching behavior and the "match_archives" option. * #658: Add "--log-file-format" flag for customizing the log message format. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#logging-to-file * #662: Fix regression in which the "check_repositories" option failed to match repositories. * #663: Fix regression in which the "transfer" action produced a traceback. * Add spellchecking of source code during test runs. 1.7.10 * #396: When a database command errors, display and log the error message instead of swallowing it. * #501: Optionally error if a source directory does not exist via "source_directories_must_exist" option in borgmatic's location configuration. * #576: Add support for "file://" paths within "repositories" option. * #612: Define and use custom constants in borgmatic configuration files. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#constant-interpolation * #618: Add support for BORG_FILES_CACHE_TTL environment variable via "borg_files_cache_ttl" option in borgmatic's storage configuration. * #623: Fix confusing message when an error occurs running actions for a configuration file. * #635: Add optional repository labels so you can select a repository via "--repository yourlabel" at the command-line. See the configuration reference for more information: https://torsion.org/borgmatic/docs/reference/configuration/ * #649: Add documentation on backing up a database running in a container: https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers * #655: Fix error when databases are configured and a source directory doesn't exist. * Add code style plugins to enforce use of Python f-strings and prevent single-letter variables. To join in the pedantry, refresh your test environment with "tox --recreate". * Rename scripts/run-full-dev-tests to scripts/run-end-to-end-dev-tests and make it run end-to-end tests only. Continue using tox to run unit and integration tests. 1.7.9 * #295: Add a SQLite database dump/restore hook. * #304: Change the default action order when no actions are specified on the command-line to: "create", "prune", "compact", "check". If you'd like to retain the old ordering ("prune" and "compact" first), then specify actions explicitly on the command-line. * #304: Run any command-line actions in the order specified instead of using a fixed ordering. * #564: Add "--repository" flag to all actions where it makes sense, so you can run borgmatic on a single configured repository instead of all of them. * #628: Add a Healthchecks "log" state to send borgmatic logs to Healthchecks without signalling success or failure. * #647: Add "--strip-components all" feature on the "extract" action to remove leading path components of files you extract. Must be used with the "--path" flag. * Add support for Python 3.11. 1.7.8 * #620: With the "create" action and the "--list" ("--files") flag, only show excluded files at verbosity 2. * #621: Add optional authentication to the ntfy monitoring hook. * With the "create" action, only one of "--list" ("--files") and "--progress" flags can be used. This lines up with the new behavior in Borg 2.0.0b5. * Internally support new Borg 2.0.0b5 "--filter" status characters / item flags for the "create" action. * Fix the "create" action with the "--dry-run" flag querying for databases when a PostgreSQL/MySQL "all" database is configured. Now, these queries are skipped due to the dry run. * Add "--repository" flag to the "rcreate" action to optionally select one configured repository to create. * Add "--progress" flag to the "transfer" action, new in Borg 2.0.0b5. * Add "checkpoint_volume" configuration option to creates checkpoints every specified number of bytes during a long-running backup, new in Borg 2.0.0b5. 1.7.7 * #642: Add MySQL database hook "add_drop_database" configuration option to control whether dumped MySQL databases get dropped right before restore. * #643: Fix for potential data loss (data not getting backed up) when dumping large "directory" format PostgreSQL/MongoDB databases. Prior to the fix, these dumps would not finish writing to disk before Borg consumed them. Now, the dumping process completes before Borg starts. This only applies to "directory" format databases; other formats still stream to Borg without using temporary disk space. * Fix MongoDB "directory" format to work with mongodump/mongorestore without error. Prior to this fix, only the "archive" format worked. 1.7.6 * #393, #438, #560: Optionally dump "all" PostgreSQL/MySQL databases to separate files instead of one combined dump file, allowing more convenient restores of individual databases. You can enable this by specifying the database dump "format" option when the database is named "all". * #602: Fix logs that interfere with JSON output by making warnings go to stderr instead of stdout. * #622: Fix traceback when include merging configuration files on ARM64. * #629: Skip warning about excluded special files when no special files have been excluded. * #630: Add configuration options for database command customization: "list_options", "restore_options", and "analyze_options" for PostgreSQL, "restore_options" for MySQL, and "restore_options" for MongoDB. 1.7.5 * #311: Override PostgreSQL dump/restore commands via configuration options. * #604: Fix traceback when a configuration section is present but lacking any options. * #607: Clarify documentation examples for include merging and deep merging. * #611: Fix "data" consistency check to support "check_last" and consistency "prefix" options. * #613: Clarify documentation about multiple repositories and separate configuration files. 1.7.4 * #596: Fix special file detection erroring when broken symlinks are encountered. * #597, #598: Fix regression in which "check" action errored on certain systems ("Cannot determine Borg repository ID"). 1.7.3 * #357: Add "break-lock" action for removing any repository and cache locks leftover from Borg aborting. * #360: To prevent Borg hangs, unconditionally delete stale named pipes before dumping databases. * #587: When database hooks are enabled, auto-exclude special files from a "create" action to prevent Borg from hanging. You can override/prevent this behavior by explicitly setting the "read_special" option to true. * #587: Warn when ignoring a configured "read_special" value of false, as true is needed when database hooks are enabled. * #589: Update sample systemd service file to allow system "idle" (e.g. a video monitor turning off) while borgmatic is running. * #590: Fix for potential data loss (data not getting backed up) when the "patterns_from" option was used with "source_directories" (or the "~/.borgmatic" path existed, which got injected into "source_directories" implicitly). The fix is for borgmatic to convert "source_directories" into patterns whenever "patterns_from" is used, working around a Borg bug: https://github.com/borgbackup/borg/issues/6994 * #590: In "borgmatic create --list" output, display which files get excluded from the backup due to patterns or excludes. * #591: Add support for Borg 2's "--match-archives" flag. This replaces "--glob-archives", which borgmatic now treats as an alias for "--match-archives". But note that the two flags have slightly different syntax. See the Borg 2 changelog for more information: https://borgbackup.readthedocs.io/en/2.0.0b3/changes.html#version-2-0-0b3-2022-10-02 * Fix for "borgmatic --archive latest" not finding the latest archive when a verbosity is set. 1.7.2 * #577: Fix regression in which "borgmatic info --archive ..." showed repository info instead of archive info with Borg 1. * #582: Fix hang when database hooks are enabled and "patterns" contains a parent directory of "~/.borgmatic". 1.7.1 * #542: Make the "source_directories" option optional. This is useful for "check"-only setups or using "patterns" exclusively. * #574: Fix for potential data loss (data not getting backed up) when the "patterns" option was used with "source_directories" (or the "~/.borgmatic" path existed, which got injected into "source_directories" implicitly). The fix is for borgmatic to convert "source_directories" into patterns whenever "patterns" is used, working around a Borg bug: https://github.com/borgbackup/borg/issues/6994 1.7.0 * #463: Add "before_actions" and "after_actions" command hooks that run before/after all the actions for each repository. These new hooks are a good place to run per-repository steps like mounting/unmounting a remote filesystem. * #463: Update documentation to cover per-repository configurations: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/ * #557: Support for Borg 2 while still working with Borg 1. This includes new borgmatic actions like "rcreate" (replaces "init"), "rlist" (list archives in repository), "rinfo" (show repository info), and "transfer" (for upgrading Borg repositories). For the most part, borgmatic tries to smooth over differences between Borg 1 and 2 to make your upgrade process easier. However, there are still a few cases where Borg made breaking changes. See the Borg 2.0 changelog for more information: https://www.borgbackup.org/releases/borg-2.0.html * #557: If you install Borg 2, you'll need to manually upgrade your existing Borg 1 repositories before use. Note that Borg 2 stable is not yet released as of this borgmatic release, so don't use Borg 2 for production until it is! See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-borg * #557: Rename several configuration options to match Borg 2: "remote_rate_limit" is now "upload_rate_limit", "numeric_owner" is "numeric_ids", and "bsd_flags" is "flags". borgmatic still works with the old options. * #557: Remote repository paths without the "ssh://" syntax are deprecated but still supported for now. Remote repository paths containing "~" are deprecated in borgmatic and no longer work in Borg 2. * #557: Omitting the "--archive" flag on the "list" action is deprecated when using Borg 2. Use the new "rlist" action instead. * #557: The "--dry-run" flag can now be used with the "rcreate"/"init" action. * #565: Fix handling of "repository" and "data" consistency checks to prevent invalid Borg flags. * #566: Modify "mount" and "extract" actions to require the "--repository" flag when multiple repositories are configured. * #571: BREAKING: Remove old-style command-line action flags like "--create, "--list", etc. If you're already using actions like "create" and "list" instead, this change should not affect you. * #571: BREAKING: Rename "--files" flag on "prune" action to "--list", as it lists archives, not files. * #571: Add "--list" as alias for "--files" flag on "create" and "export-tar" actions. * Add support for disabling TLS verification in Healthchecks monitoring hook with "verify_tls" option. 1.6.6 * #559: Update documentation about configuring multiple consistency checks or multiple databases. * #560: Fix all database hooks to error when the requested database to restore isn't present in the Borg archive. * #561: Fix command-line "--override" flag to continue supporting old configuration file formats. * #563: Fix traceback with "create" action and "--json" flag when a database hook is configured. 1.6.5 * #553: Fix logging to include the full traceback when Borg experiences an internal error, not just the first few lines. * #554: Fix all monitoring hooks to warn if the server returns an HTTP 4xx error. This can happen with Healthchecks, for instance, when using an invalid ping URL. * #555: Fix environment variable plumbing so options like "encryption_passphrase" and "encryption_passcommand" in one configuration file aren't used for other configuration files. 1.6.4 * #546, #382: Keep your repository passphrases and database passwords outside of borgmatic's configuration file with environment variable interpolation. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/ 1.6.3 * #541: Add "borgmatic list --find" flag for searching for files across multiple archives, useful for hunting down that file you accidentally deleted so you can extract it. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file * #543: Add a monitoring hook for sending push notifications via ntfy. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook * Fix Bash completion script to no longer alter your shell's settings (complain about unset variables or error on pipe failures). * Deprecate "borgmatic list --successful" flag, as listing only non-checkpoint (successful) archives is now the default in newer versions of Borg. 1.6.2 * #523: Reduce the default consistency check frequency and support configuring the frequency independently for each check. Also add "borgmatic check --force" flag to ignore configured frequencies. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-frequency * #536: Fix generate-borgmatic-config to support more complex schema changes like the new Healthchecks configuration options when the "--source" flag is used. * #538: Add support for "borgmatic borg debug" command. * #539: Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file. * Add Bash completion script so you can tab-complete the borgmatic command-line. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion 1.6.1 * #294: Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of logs to send to the Healthchecks server. * #402: Remove the error when "archive_name_format" is specified but a retention prefix isn't. * #420: Warn when an unsupported variable is used in a hook command. * #439: Change connection failures for monitoring hooks (Healthchecks, Cronitor, PagerDuty, and Cronhub) to be warnings instead of errors. This way, the monitoring system failing does not block backups. * #460: Add Healthchecks monitoring hook "send_logs" option to enable/disable sending borgmatic logs to the Healthchecks server. * #525: Add Healthchecks monitoring hook "states" option to only enable pinging for particular monitoring states (start, finish, fail). * #528: Improve the error message when a configuration override contains an invalid value. * #531: BREAKING: When deep merging common configuration, merge colliding list values by appending them. Previously, one list replaced the other. * #532: When a configuration include is a relative path, load it from either the current working directory or from the directory containing the file doing the including. Previously, only the working directory was used. * Add a randomized delay to the sample systemd timer to spread out the load on a server. * Change the configuration format for borgmatic monitoring hooks (Healthchecks, Cronitor, PagerDuty, and Cronhub) to specify the ping URL / integration key as a named option. The intent is to support additional options (some in this release). This change is backwards-compatible. * Add emojis to documentation table of contents to make it easier to find particular how-to and reference guides at a glance. 1.6.0 * #381: BREAKING: Greatly simplify configuration file reuse by deep merging when including common configuration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#include-merging * #473: BREAKING: Instead of executing "before" command hooks before all borgmatic actions run (and "after" hooks after), execute these hooks right before/after the corresponding action. E.g., "before_check" now runs immediately before the "check" action. This better supports running timing-sensitive tasks like pausing containers. Side effect: before/after command hooks now run once for each configured repository instead of once per configuration file. Additionally, the "repositories" interpolated variable has been changed to "repository", containing the path to the current repository for the hook. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/ * #513: Add mention of sudo's "secure_path" option to borgmatic installation documentation. * #515: Fix "borgmatic borg key ..." to pass parameters to Borg in the correct order. * #516: Fix handling of TERM signal to exit borgmatic, not just forward the signal to Borg. * #517: Fix borgmatic exit code (so it's zero) when initial Borg calls fail but later retries succeed. * Change Healthchecks logs truncation size from 10k bytes to 100k bytes, corresponding to that same change on Healthchecks.io. 1.5.24 * #431: Add "working_directory" option to support source directories with relative paths. * #444: When loading a configuration file that is unreadable due to file permissions, warn instead of erroring. This supports running borgmatic as a non-root user with configuration in ~/.config even if there is an unreadable global configuration file in /etc. * #469: Add "repositories" context to "before_*" and "after_*" command action hooks. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/ * #486: Fix handling of "patterns_from" and "exclude_from" options to error instead of warning when referencing unreadable files and "create" action is run. * #507: Fix Borg usage error in the "compact" action when running "borgmatic --dry-run". Now, skip "compact" entirely during a dry run. 1.5.23 * #394: Compact repository segments and free space with new "borgmatic compact" action. Borg 1.2+ only. Also run "compact" by default when no actions are specified, as "prune" in Borg 1.2 no longer frees up space unless "compact" is run. * #394: When using the "atime", "bsd_flags", "numeric_owner", or "remote_rate_limit" options, tailor the flags passed to Borg depending on the Borg version. * #480, #482: Fix traceback when a YAML validation error occurs. 1.5.22 * #288: Add database dump hook for MongoDB. * #470: Move mysqldump options to the beginning of the command due to MySQL bug 30994. * #471: When command-line configuration override produces a parse error, error cleanly instead of tracebacking. * #476: Fix unicode error when restoring particular MySQL databases. * Drop support for Python 3.6, which has been end-of-lifed. * Add support for Python 3.10. 1.5.21 * #28: Optionally retry failing backups via "retries" and "retry_wait" configuration options. * #306: Add "list_options" MySQL configuration option for passing additional arguments to MySQL list command. * #459: Add support for old version (2.x) of jsonschema library. 1.5.20 * Re-release with correct version without dev0 tag. 1.5.19 * #387: Fix error when configured source directories are not present on the filesystem at the time of backup. Now, Borg will complain, but the backup will still continue. * #455: Mention changing borgmatic path in cron documentation. * Update sample systemd service file with more granular read-only filesystem settings. * Move Gitea and GitHub hosting from a personal namespace to an organization for better collaboration with related projects. * 1k ★s on GitHub! 1.5.18 * #389: Fix "message too long" error when logging to rsyslog. * #440: Fix traceback that can occur when dumping a database. 1.5.17 * #437: Fix error when configuration file contains "umask" option. * Remove test dependency on vim and /dev/urandom. 1.5.16 * #379: Suppress console output in sample crontab and systemd service files. * #407: Fix syslog logging on FreeBSD. * #430: Fix hang when restoring a PostgreSQL "tar" format database dump. * Better error messages! Switch the library used for validating configuration files (from pykwalify to jsonschema). * Link borgmatic Ansible role from installation documentation: https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install 1.5.15 * #419: Document use case of running backups conditionally based on laptop power level: https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/ * #425: Run arbitrary Borg commands with new "borgmatic borg" action. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/ 1.5.14 * #390: Add link to Hetzner storage offering from the documentation. * #398: Clarify canonical home of borgmatic in documentation. * #406: Clarify that spaces in path names should not be backslashed in path names. * #423: Fix error handling to error loudly when Borg gets killed due to running out of memory! * Fix build so as not to attempt to build and push documentation for a non-main branch. * "Fix" build failure with Alpine Edge by switching from Edge to Alpine 3.13. * Move #borgmatic IRC channel from Freenode to Libera Chat due to Freenode takeover drama. IRC connection info: https://torsion.org/borgmatic/#issues 1.5.13 * #373: Document that passphrase is used for Borg keyfile encryption, not just repokey encryption. * #404: Add support for ruamel.yaml 0.17.x YAML parsing library. * Update systemd service example to return a permission error when a system call isn't permitted (instead of terminating borgmatic outright). * Drop support for Python 3.5, which has been end-of-lifed. * Add support for Python 3.9. * Update versions of test dependencies (test_requirements.txt and test containers). * Only support black code formatter on Python 3.8+. New black dependencies make installation difficult on older versions of Python. * Replace "improve this documentation" form with link to support and ticket tracker. 1.5.12 * Fix for previous release with incorrect version suffix in setup.py. No other changes. 1.5.11 * #341: Add "temporary_directory" option for changing Borg's temporary directory. * #352: Lock down systemd security settings in sample systemd service file. * #355: Fix traceback when a database hook value is null in a configuration file. * #361: Merge override values when specifying the "--override" flag multiple times. The previous behavior was to take the value of the last "--override" flag only. * #367: Fix traceback when upgrading old INI-style configuration with upgrade-borgmatic-config. * #368: Fix signal forwarding from borgmatic to Borg resulting in recursion traceback. * #369: Document support for Borg placeholders in repository names. 1.5.10 * #347: Add hooks that run for the "extract" action: "before_extract" and "after_extract". * #350: Fix traceback when a configuration directory is non-readable due to directory permissions. * Add documentation navigation links on left side of all documentation pages. * Clarify documentation on configuration overrides, specifically the portion about list syntax: http://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides * Clarify documentation overview of monitoring options: http://torsion.org/borgmatic/docs/how-to/monitor-your-backups/ 1.5.9 * #300: Add "borgmatic export-tar" action to export an archive to a tar-formatted file or stream. * #339: Fix for intermittent timing-related test failure of logging function. * Clarify database documentation about excluding named pipes and character/block devices to prevent hangs. * Add documentation on how to make backups redundant with multiple repositories: https://torsion.org/borgmatic/docs/how-to/make-backups-redundant/ 1.5.8 * #336: Fix for traceback when running Cronitor, Cronhub, and PagerDuty monitor hooks. 1.5.7 * #327: Fix broken pass-through of BORG_* environment variables to Borg. * #328: Fix duplicate logging to Healthchecks and send "after_*" hooks output to Healthchecks. * #331: Add SSL support to PostgreSQL database configuration. * #333: Fix for potential data loss (data not getting backed up) when borgmatic omitted configured source directories in certain situations. Specifically, this occurred when two source directories on different filesystems were related by parentage (e.g. "/foo" and "/foo/bar/baz") and the one_file_system option was enabled. * Update documentation code fragments theme to better match the rest of the page. * Improve configuration reference documentation readability via more aggressive word-wrapping in configuration schema descriptions. 1.5.6 * #292: Allow before_backup and similar hooks to exit with a soft failure without altering the monitoring status on Healthchecks or other providers. Support this by waiting to ping monitoring services with a "start" status until after before_* hooks finish. Failures in before_* hooks still trigger a monitoring "fail" status. * #316: Fix hang when a stale database dump named pipe from an aborted borgmatic run remains on disk. * #323: Fix for certain configuration options like ssh_command impacting Borg invocations for separate configuration files. * #324: Add "borgmatic extract --strip-components" flag to remove leading path components when extracting an archive. * Tweak comment indentation in generated configuration file for clarity. * Link to Borgmacator GNOME AppIndicator from monitoring documentation. 1.5.5 * #314: Fix regression in support for PostgreSQL's "directory" dump format. Unlike other dump formats, the "directory" dump format does not stream directly to/from Borg. * #315: Fix enabled database hooks to implicitly set one_file_system configuration option to true. This prevents Borg from reading devices like /dev/zero and hanging. * #316: Fix hang when streaming a database dump to Borg with implicit duplicate source directories by deduplicating them first. * #319: Fix error message when there are no MySQL databases to dump for "all" databases. * Improve documentation around the installation process. Specifically, making borgmatic commands runnable via the system PATH and offering a global install option. 1.5.4 * #310: Fix legitimate database dump command errors (exit code 1) not being treated as errors by borgmatic. * For database dumps, replace the named pipe on every borgmatic run. This prevent hangs on stale pipes left over from previous runs. * Fix error handling to handle more edge cases when executing commands. 1.5.3 * #258: Stream database dumps and restores directly to/from Borg without using any additional filesystem space. This feature is automatic, and works even on restores from archives made with previous versions of borgmatic. * #293: Documentation on macOS launchd permissions issues with work-around for Full Disk Access. * Remove "borgmatic restore --progress" flag, as it now conflicts with streaming database restores. 1.5.2 * #301: Fix MySQL restore error on "all" database dump by excluding system tables. * Fix PostgreSQL restore error on "all" database dump by using "psql" for the restore instead of "pg_restore". 1.5.1 * #289: Tired of looking up the latest successful archive name in order to pass it to borgmatic actions? Me too. Now you can specify "--archive latest" to all actions that accept an archive flag. * #290: Fix the "--stats" and "--files" flags so that they yield output at verbosity 0. * Reduce the default verbosity of borgmatic logs sent to Healthchecks monitoring hook. Now, it's warnings and errors only. You can increase the verbosity via the "--monitoring-verbosity" flag. * Add security policy documentation in SECURITY.md. 1.5.0 * #245: Monitor backups with PagerDuty hook integration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook * #255: Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check". * #274: Add ~/.config/borgmatic.d as another configuration directory default. * #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag. * #280: Change "exclude_if_present" option to support multiple filenames that indicate a directory should be excluded from backups, rather than just a single filename. * #284: Backup to a removable drive or intermittent server via "soft failure" feature. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/ * #287: View consistency check progress via "--progress" flag for "check" action. * For "create" and "prune" actions, no longer list files or show detailed stats at any verbosities by default. You can opt back in with "--files" or "--stats" flags. * For "list" and "info" actions, show repository names even at verbosity 0. 1.4.22 * #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON output. * After a backup of a database dump in directory format, properly remove the dump directory. * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. 1.4.21 * #268: Override particular configuration options from the command-line via "--override" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides * #270: Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check" actions, and not for other actions. * When pruning with verbosity level 1, list pruned and kept archives. Previously, this information was only shown at verbosity level 2. 1.4.20 * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option. * #249: Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and "prune" actions, not just "create". 1.4.19 * #259: Optionally change the internal database dump path via "borgmatic_source_directory" option in location configuration section. * #271: Support piping "borgmatic list" output to grep by logging certain log levels to console stdout and others to stderr. * Retain colored output when piping or redirecting in an interactive terminal. * Add end-to-end tests for database dump and restore. These are run on developer machines with Docker Compose for approximate parity with continuous integration tests. 1.4.18 * Fix "--repository" flag to accept relative paths. * Fix "borgmatic umount" so it only runs Borg once instead of once per repository / configuration file. * #253: Mount whole repositories via "borgmatic mount" without any "--archive" flag. * #269: Filter listed paths via "borgmatic list --path" flag. 1.4.17 * #235: Pass extra options directly to particular Borg commands, handy for Borg options that borgmatic does not yet support natively. Use "extra_borg_options" in the storage configuration section. * #266: Attempt to repair any inconsistencies found during a consistency check via "borgmatic check --repair" flag. 1.4.16 * #256: Fix for "before_backup" hook not triggering an error when the command contains "borg" and has an exit code of 1. * #257: Fix for garbled Borg file listing when using "borgmatic create --progress" with verbosity level 1 or 2. * #260: Fix for missing Healthchecks monitoring payload or HTTP 500 due to incorrect unicode encoding. 1.4.15 * Fix for database dump removal incorrectly skipping some database dumps. * #123: Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and unmounting via "borgmatic umount". See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#mount-a-filesystem 1.4.14 * Show summary log errors regardless of verbosity level, and log the "summary:" header with a log level based on the contained summary logs. 1.4.13 * Show full error logs at "--verbosity 0" so you can see command output without upping the verbosity level. 1.4.12 * #247: With "borgmatic check", consider Borg warnings as errors. * Dial back the display of inline error logs a bit, so failed command output doesn't appear multiple times in the logs (well, except for the summary). 1.4.11 * #241: When using the Healthchecks monitoring hook, include borgmatic logs in the payloads for completion and failure pings. * With --verbosity level 1 or 2, show error logs both inline when they occur and in the summary logs at the bottom. With lower verbosity levels, suppress the summary and show error logs when they occur. 1.4.10 * #246: Fix for "borgmatic restore" showing success and incorrectly extracting archive files, even when no databases are configured to restore. As this can overwrite files from the archive and lead to data loss, please upgrade to get the fix before using "borgmatic restore". * Reopen the file given by "--log-file" flag if an external program rotates the log file while borgmatic is running. 1.4.9 * #228: Database dump hooks for MySQL/MariaDB, so you can easily dump your databases before backups run. * #243: Fix repository does not exist error with "borgmatic extract" when repository is remote. 1.4.8 * Monitor backups with Cronhub hook integration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook * Fix Healthchecks/Cronitor hooks to skip actions when the borgmatic "--dry-run" flag is used. 1.4.7 * #238: In documentation, clarify when Healthchecks/Cronitor hooks fire in relation to other hooks. * #239: Upgrade your borgmatic configuration to get new options and comments via "generate-borgmatic-config --source". See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration 1.4.6 * Verbosity level "-1" for even quieter output: Errors only (#236). 1.4.5 * Log to file instead of syslog via command-line "--log-file" flag (#233). 1.4.4 * #234: Support for Borg --keep-exclude-tags and --exclude-nodump options. 1.4.3 * Monitor backups with Cronitor hook integration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook 1.4.2 * Extract files to a particular directory via "borgmatic extract --destination" flag. * Rename "borgmatic extract --restore-path" flag to "--path" to reduce confusion with the separate "borgmatic restore" action. Any uses of "--restore-path" will continue working. 1.4.1 * #229: Restore backed up PostgreSQL databases via "borgmatic restore" action. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/backup-your-databases/ * Documentation on how to develop borgmatic's documentation: https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/#documentation-development 1.4.0 * #225: Database dump hooks for PostgreSQL, so you can easily dump your databases before backups run. * #230: Rename "borgmatic list --pattern-from" flag to "--patterns-from" to match Borg. 1.3.26 * #224: Fix "borgmatic list --successful" with a slightly better heuristic for listing successful (non-checkpoint) archives. 1.3.25 * #223: Dead man's switch to detect when backups start failing silently, implemented via healthchecks.io hook integration. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook * Documentation on monitoring and alerting options for borgmatic backups: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/ * Automatically rewrite links when developing on documentation locally. 1.3.24 * #86: Add "borgmatic list --successful" flag to only list successful (non-checkpoint) archives. * Add a suggestion form to all documentation pages, so users can submit ideas for improving the documentation. * Update documentation link to community Arch Linux borgmatic package. 1.3.23 * #174: More detailed error alerting via runtime context available in "on_error" hook. 1.3.22 * #144: When backups to one of several repositories fails, keep backing up to the other repositories and report errors afterwards. 1.3.21 * #192: User-defined hooks for global setup or cleanup that run before/after all actions. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/ 1.3.20 * #205: More robust sample systemd service: boot delay, network dependency, lowered CPU/IO priority, etc. * #221: Fix "borgmatic create --progress" output so that it updates on the console in real-time. 1.3.19 * #219: Fix visibility of "borgmatic prune --stats" output. 1.3.18 * #220: Fix regression of argument parsing for default actions. 1.3.17 * #217: Fix error with "borgmatic check --only" command-line flag with "extract" consistency check. 1.3.16 * #210: Support for Borg check --verify-data flag via borgmatic "data" consistency check. * #210: Override configured consistency checks via "borgmatic check --only" command-line flag. * When generating sample configuration with generate-borgmatic-config, add a space after each "#" comment indicator. 1.3.15 * #208: Fix for traceback when the "checks" option has an empty value. * #209: Bypass Borg error about a moved repository via "relocated_repo_access_is_ok" option in borgmatic storage configuration section. * #213: Reorder arguments passed to Borg to fix duplicate directories when using Borg patterns. * #214: Fix for hook erroring with exit code 1 not being interpreted as an error. 1.3.14 * #204: Do not treat Borg warnings (exit code 1) as failures. * When validating configuration files, require strings instead of allowing any scalar type. 1.3.13 * #199: Add note to documentation about using spaces instead of tabs for indentation, as YAML does not allow tabs. * #203: Fix compatibility with ruamel.yaml 0.16.x. * If a "prefix" option in borgmatic's configuration has an empty value (blank or ""), then disable default prefix. 1.3.12 * Only log to syslog when run from a non-interactive console (e.g. a cron job). * Remove unicode byte order mark from syslog output so it doesn't show up as a literal in rsyslog output. See discussion on #197. 1.3.11 * #193: Pass through several "borg list" and "borg info" flags like --short, --format, --sort-by, --first, --last, etc. via borgmatic command-line flags. * Add borgmatic info --repository and --archive command-line flags to display info for individual repositories or archives. * Support for Borg --noatime, --noctime, and --nobirthtime flags via corresponding options in borgmatic configuration location section. 1.3.10 * #198: Fix for Borg create error output not showing up at borgmatic verbosity level zero. 1.3.9 * #195: Switch to command-line actions as more traditional sub-commands, e.g. "borgmatic create", "borgmatic prune", etc. However, the classic dashed options like "--create" still work! 1.3.8 * #191: Disable console color via "color" option in borgmatic configuration output section. 1.3.7 * #196: Fix for unclear error message for invalid YAML merge include. * #197: Don't color syslog output. * Change default syslog verbosity to show errors only. 1.3.6 * #53: Log to syslog in addition to existing console logging. Add --syslog-verbosity flag to customize the log level. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/ * #178: Look for .yml configuration file extension in addition to .yaml. * #189: Set umask used when executing hooks via "umask" option in borgmatic hooks section. * Remove Python cache files before each Tox run. * Add #borgmatic Freenode IRC channel to documentation. * Add Borg/borgmatic hosting providers section to documentation. * Add files for building documentation into a Docker image for web serving. * Upgrade project build server from Drone 0.8 to 1.1. * Build borgmatic documentation during continuous integration. * We're nearly at 500 ★s on GitHub. We can do this! 1.3.5 * #153: Support for various Borg directory environment variables (BORG_CONFIG_DIR, BORG_CACHE_DIR, etc.) via options in borgmatic's storage configuration. * #177: Fix for regression with missing verbose log entries. 1.3.4 * Part of #125: Color borgmatic (but not Borg) output when using an interactive terminal. * #166: Run tests for all installed versions of Python. * #168: Update README with continuous integration badge. * #169: Automatically sort Python imports in code. * Document installing borgmatic with pip install --user instead of a system Python install. * Get more reproducible builds by pinning the versions of pip and tox used to run tests. * Factor out build/test configuration from tox.ini file. 1.3.3 * Add validate-borgmatic-config command, useful for validating borgmatic config generated by configuration management or even edited by hand. 1.3.2 * #160: Fix for hooks executing when using --dry-run. Now hooks are skipped during a dry run. 1.3.1 * #155: Fix for invalid JSON output when using multiple borgmatic configuration files. * #157: Fix for seemingly random filename ordering when running through a directory of configuration files. * Fix for empty JSON output when using --create --json. * Now capturing Borg output only when --json flag is used. Previously, borgmatic delayed Borg output even without the --json flag. 1.3.0 * #148: Configuration file includes and merging via "!include" tag to support reuse of common options across configuration files. 1.2.18 * #147: Support for Borg create/extract --numeric-owner flag via "numeric_owner" option in borgmatic's location section. 1.2.17 * #140: List the files within an archive via --list --archive option. 1.2.16 * #119: Include a sample borgmatic configuration file in the documentation. * #123: Support for Borg archive restoration via borgmatic --extract command-line flag. * Refactor documentation into multiple separate pages for clarity and findability. * Organize options within command-line help into logical groups. * Exclude tests from distribution packages. 1.2.15 * #127: Remove date echo from schema example, as it's not a substitute for real logging. * #132: Leave exclude_patterns glob expansion to Borg, since doing it in borgmatic leads to confusing behavior. * #136: Handle and format validation errors raised during argument parsing. * #138: Allow use of --stats flag when --create or --prune flags are implied. 1.2.14 * #103: When generating sample configuration with generate-borgmatic-config, document the defaults for each option. * #116: When running multiple configuration files, attempt all configuration files even if one of them errors. Log a summary of results at the end. * Add borgmatic --version command-line flag to get the current installed version number. 1.2.13 * #100: Support for --stats command-line flag independent of --verbosity. * #117: With borgmatic --init command-line flag, proceed without erroring if a repository already exists. 1.2.12 * #110: Support for Borg repository initialization via borgmatic --init command-line flag. * #111: Update Borg create --filter values so a dry run lists files to back up. * #113: Update README with link to a new/forked Docker image. * Prevent deprecated --excludes command-line option from being used. * Refactor README a bit to flow better for first-time users. * Update README with a few additional borgmatic packages (Debian and Ubuntu). 1.2.11 * #108: Support for Borg create --progress via borgmatic command-line flag. 1.2.10 * #105: Support for Borg --chunker-params create option via "chunker_params" option in borgmatic's storage section. 1.2.9 * #102: Fix for syntax error that occurred in Python 3.5 and below. * Make automated tests support running in Python 3.5. 1.2.8 * #73: Enable consistency checks for only certain repositories via "check_repositories" option in borgmatic's consistency configuration. Handy for large repositories that take forever to check. * Include link to issue tracker within various command output. * Run continuous integration tests on a matrix of Python and Borg versions. 1.2.7 * #98: Support for Borg --keep-secondly prune option. * Use Black code formatter and Flake8 code checker as part of running automated tests. * Add an end-to-end automated test that actually integrates with Borg. * Set up continuous integration for borgmatic automated tests on projects.evoworx.org. 1.2.6 * Fix generated configuration to also include a "keep_daily" value so pruning works out of the box. 1.2.5 * #57: When generating sample configuration with generate-borgmatic-config, comment out all optional configuration so as to streamline the initial configuration process. 1.2.4 * Fix for archive checking traceback due to parameter mismatch. 1.2.3 * #64, #90, #92: Rewrite of logging system. Now verbosity flags passed to Borg are derived from borgmatic's log level. Note that the output of borgmatic might slightly change. * Part of #80: Support for Borg create --read-special via "read_special" option in borgmatic's location configuration. * #87: Support for Borg create --checkpoint-interval via "checkpoint_interval" option in borgmatic's storage configuration. * #88: Fix declared pykwalify compatibility version range in setup.py to prevent use of ancient versions of pykwalify with large version numbers. * #89: Pass --show-rc option to Borg when at highest verbosity level. * #94: Support for Borg --json option via borgmatic command-line to --create archives. 1.2.2 * #85: Fix compatibility issue between pykwalify and ruamel.yaml 0.15.52, which manifested in borgmatic as a pykwalify RuleError. 1.2.1 * Skip before/after backup hooks when only doing --prune, --check, --list, and/or --info. * #71: Support for XDG_CONFIG_HOME environment variable for specifying alternate user ~/.config/ path. * #74, #83: Support for Borg --json option via borgmatic command-line to --list archives or show archive --info in JSON format, ideal for programmatic consumption. * #38, #76: Upgrade ruamel.yaml compatibility version range and fix support for Python 3.7. * #77: Skip non-"*.yaml" config filenames in /etc/borgmatic.d/ so as not to parse backup files, editor swap files, etc. * #81: Document user-defined hooks run before/after backup, or on error. * Add code style guidelines to the documentation. 1.2.0 * #61: Support for Borg --list option via borgmatic command-line to list all archives. * #61: Support for Borg --info option via borgmatic command-line to display summary information. * #62: Update README to mention other ways of installing borgmatic. * Support for Borg --prefix option for consistency checks via "prefix" option in borgmatic's consistency configuration. * Add introductory screencast link to documentation. * #59: Ignore "check_last" and consistency "prefix" when "archives" not in consistency checks. * #60: Add "Persistent" flag to systemd timer example. * #63: Support for Borg --nobsdflags option to skip recording bsdflags (e.g. NODUMP, IMMUTABLE) in archive. * #69: Support for Borg prune --umask option using value of existing "umask" option in borgmatic's storage configuration. * Update tox.ini to only assume Python 3.x instead of Python 3.4 specifically. * Add ~/.config/borgmatic/config.yaml to default configuration path probing. * Document how to develop on and contribute to borgmatic. 1.1.15 * Support for Borg BORG_PASSCOMMAND environment variable to read a password from an external file. * Fix for Borg create error when using borgmatic's --dry-run and --verbosity options together. Work-around for behavior introduced in Borg 1.1.3: https://github.com/borgbackup/borg/issues/3298 * #55: Fix for missing tags/releases on Gitea and GitHub project hosting. * #56: Support for Borg --lock-wait option for the maximum wait for a repository/cache lock. * #58: Support for using tilde in exclude_patterns to reference home directory. 1.1.14 * #49: Fix for typo in --patterns-from option. * #47: Support for Borg --dry-run option via borgmatic command-line. 1.1.13 * #54: Fix for incorrect consistency check flags passed to Borg when all three checks ("repository", "archives", and "extract") are specified in borgmatic configuration. * #48: Add "local_path" to configuration for specifying an alternative Borg executable path. * #49: Support for Borg experimental --patterns-from and --patterns options for specifying mixed includes/excludes. * Moved issue tracker from Taiga to integrated Gitea tracker at https://projects.torsion.org/borgmatic-collective/borgmatic/issues 1.1.12 * #46: Declare dependency on pykwalify 1.6 or above, as older versions yield "Unknown key: version" rule errors. * Support for Borg --keep-minutely prune option. 1.1.11 * #26: Add "ssh_command" to configuration for specifying a custom SSH command or options. * Fix for incorrect /etc/borgmatic.d/ configuration path probing on macOS. This problem manifested as an error on startup: "[Errno 2] No such file or directory: '/etc/borgmatic.d'". 1.1.10 * Pass several Unix signals through to child processes like Borg. This means that Borg now properly shuts down if borgmatic is terminated (e.g. due to a system suspend). * #30: Support for using tilde in repository paths to reference home directory. * #43: Support for Borg --files-cache option for setting the files cache operation mode. * #45: Support for Borg --remote-ratelimit option for limiting upload rate. * Log invoked Borg commands when at highest verbosity level. 1.1.9 * #17, #39: Support for user-defined hooks before/after backup, or on error. * #34: Improve clarity of logging spew at high verbosity levels. * #30: Support for using tilde in source directory path to reference home directory. * Require "prefix" in retention section when "archive_name_format" is set. This is to avoid accidental pruning of archives with a different archive name format. For similar reasons, default "prefix" to "{hostname}-" if not specified. * Convert main source repository from Mercurial to Git. * Update dead links to Borg documentation. 1.1.8 * #40: Fix to make /etc/borgmatic/config.yaml optional rather than required when using the default config paths. 1.1.7 * #29: Add "archive_name_format" to configuration for customizing archive names. * Fix for traceback when "exclude_from" value is empty in configuration file. * When pruning, make highest verbosity level list archives kept and pruned. * Clarification of Python 3 pip usage in documentation. 1.1.6 * #13, #36: Support for Borg --exclude-from, --exclude-caches, and --exclude-if-present options. 1.1.5 * #35: New "extract" consistency check that performs a dry-run extraction of the most recent archive. 1.1.4 * #18: Added command-line flags for performing a borgmatic run with only pruning, creating, or checking enabled. This supports use cases like running consistency checks from a different cron job with a different frequency, or running pruning with a different verbosity level. 1.1.3 * #15: Support for running multiple config files in /etc/borgmatic.d/ from a single borgmatic run. * Fix for generate-borgmatic-config writing config with invalid one_file_system value. 1.1.2 * #33: Fix for passing check_last as integer to subprocess when calling Borg. 1.1.1 * Part of #33: Fix for upgrade-borgmatic-config converting check_last option as a string instead of an integer. * Fix for upgrade-borgmatic-config erroring when consistency checks option is not present. 1.1.0 * Switched config file format to YAML. Run upgrade-borgmatic-config to upgrade. * Added generate-borgmatic-config command for initial config creation. * Dropped Python 2 support. Now Python 3 only. * #19: Fix for README mention of sample files not included in package. * #23: Sample files for triggering borgmatic from a systemd timer. * Support for backing up to multiple repositories. * To free up space, now pruning backups prior to creating a new backup. * Enabled test coverage output during tox runs. * Added logo. 1.0.3 * #22: Fix for verbosity flag not actually causing verbose output. 1.0.2 * #21: Fix for traceback when remote_path option is missing. 1.0.1 * #20: Support for Borg's --remote-path option to use an alternate Borg executable. See sample/config. 1.0.0 * Attic is no longer supported, as there hasn't been any recent development on it. Dropping Attic support will allow faster iteration on Borg-specific features. If you're still using Attic, this is a good time to switch to Borg! * Project renamed from atticmatic to borgmatic. See the borgmatic README for information on upgrading. 0.1.8 * Fix for handling of spaces in source_directories which resulted in backup up everything. * Fix for broken links to Borg documentation. * At verbosity zero, suppressing Borg check stderr spew about "Checking segments". * Support for Borg --one-file-system. * Support for Borg create --umask. * Support for file globs in source_directories. 0.1.7 * #12: Fixed parsing of punctuation in configuration file. * Better error message when configuration file is missing. 0.1.6 * #10: New configuration option for the encryption passphrase. * #11: Support for Borg's new archive compression feature. 0.1.5 * Changes to support release on PyPI. Now pip installable by name! 0.1.4 * Adding test that setup.py version matches release version. 0.1.3 * #2: Add support for "borg check --last N" to Borg backend. 0.1.2 * As a convenience to new users, allow a missing default excludes file. * New issue tracker, linked from documentation. 0.1.1 * Adding borgmatic cron example, and updating documentation to refer to it. 0.1.0 * New "borgmatic" command to support Borg backup software, a fork of Attic. 0.0.7 * Flag for multiple levels of verbosity: some, and lots. * Improved mocking of Python builtins in unit tests. 0.0.6 * New configuration section for customizing which Attic consistency checks run, if any. 0.0.5 * Fixed regression with --verbose output being buffered. This means dropping the helpful error message introduced in 0.0.4. 0.0.4 * Now using tox to run tests against multiple versions of Python in one go. * Helpful error message about how to create a repository if one is missing. * Troubleshooting section with steps to deal with broken pipes. * Nosetests config file (setup.cfg) with defaults. 0.0.3 * After pruning, run attic's consistency checks on all archives. * Integration tests for argument parsing. * Documentation updates about repository encryption. 0.0.2 * Configuration support for additional attic prune flags: keep_within, keep_hourly, keep_yearly, and prefix. 0.0.1 * Initial release. borgmatic/README.md000066400000000000000000000210161476361726000143200ustar00rootroot00000000000000--- title: borgmatic permalink: index.html --- ## It's your data. Keep it that way. borgmatic logo borgmatic is simple, configuration-driven backup software for servers and workstations. Protect your files with client-side encryption. Backup your databases too. Monitor it all with integrated third-party services. The canonical home of borgmatic is at https://torsion.org/borgmatic/ Here's an example configuration file: ```yaml # List of source directories to backup. source_directories: - /home - /etc # Paths of local or remote repositories to backup to. repositories: - path: ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/./repo label: borgbase - path: /var/lib/backups/local.borg label: local # Retention policy for how many backups to keep. keep_daily: 7 keep_weekly: 4 keep_monthly: 6 # List of checks to run to validate your backups. checks: - name: repository - name: archives frequency: 2 weeks # Custom preparation scripts to run. before_backup: - prepare-for-backup.sh # Databases to dump and include in backups. postgresql_databases: - name: users # Third-party services to notify you if backups aren't happening. healthchecks: ping_url: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c ``` borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). ## Integrations ### Data PostgreSQL MySQL MariaDB MongoDB SQLite OpenZFS Btrfs LVM rclone BorgBase ### Monitoring Healthchecks Uptime Kuma Cronitor Cronhub PagerDuty Pushover ntfy Loki Apprise Zabbix Sentry ### Credentials Sentry Docker Podman Podman ## Getting started Your first step is to [install and configure borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/). For additional documentation, check out the links above (left panel on wide screens) for borgmatic how-to and reference guides. ## Hosting providers Need somewhere to store your encrypted off-site backups? The following hosting providers include specific support for Borg/borgmatic—and fund borgmatic development and hosting when you use these referral links to sign up:
  • BorgBase: Borg hosting service with support for monitoring, 2FA, and append-only repos
  • Hetzner: A "storage box" that includes support for Borg
Additionally, rsync.net has a compatible storage offering, but does not fund borgmatic development or hosting. ## Support and contributing ### Issues Are you experiencing an issue with borgmatic? Or do you have an idea for a feature enhancement? Head on over to our [issue tracker](https://projects.torsion.org/borgmatic-collective/borgmatic/issues). In order to create a new issue or add a comment, you'll need to [register](https://projects.torsion.org/user/sign_up?invite_code=borgmatic) first. If you prefer to use an existing GitHub account, you can skip account creation and [login directly](https://projects.torsion.org/user/login). Also see the [security policy](https://torsion.org/borgmatic/docs/security-policy/) for any security issues. ### Social Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic). ### Chat To chat with borgmatic developers or users, check out the `#borgmatic` IRC channel on Libera Chat, either via web chat or a native IRC client. If you don't get a response right away, please hang around a while—or file a ticket instead. ### Other Other questions or comments? Contact [witten@torsion.org](mailto:witten@torsion.org). ### Contributing borgmatic [source code is available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience. borgmatic is licensed under the GNU General Public License version 3 or any later version. If you'd like to contribute to borgmatic development, please feel free to submit a [Pull Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or open an [issue](https://projects.torsion.org/borgmatic-collective/borgmatic/issues) to discuss your idea. Note that you'll need to [register](https://projects.torsion.org/user/sign_up?invite_code=borgmatic) first. We also accept Pull Requests on GitHub, if that's more your thing. In 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 Thanks to all borgmatic contributors! There are multiple ways to contribute to this project, so the following includes those who have fixed bugs, contributed features, *or* filed tickets. {% include borgmatic/contributors.html %} borgmatic/SECURITY.md000066400000000000000000000012221476361726000146270ustar00rootroot00000000000000--- title: Security policy permalink: security-policy/index.html --- ## Supported versions While we want to hear about security vulnerabilities in all versions of borgmatic, security fixes are only made to the most recently released version. It's not practical for our small volunteer effort to maintain multiple release branches and put out separate security patches for each. ## Reporting a vulnerability If you find a security vulnerability, please [file a ticket](https://torsion.org/borgmatic/#issues) or [send email directly](mailto:witten@torsion.org) as appropriate. You should expect to hear back within a few days at most and generally sooner. borgmatic/borgmatic/000077500000000000000000000000001476361726000150105ustar00rootroot00000000000000borgmatic/borgmatic/__init__.py000066400000000000000000000000001476361726000171070ustar00rootroot00000000000000borgmatic/borgmatic/actions/000077500000000000000000000000001476361726000164505ustar00rootroot00000000000000borgmatic/borgmatic/actions/__init__.py000066400000000000000000000000001476361726000205470ustar00rootroot00000000000000borgmatic/borgmatic/actions/arguments.py000066400000000000000000000005071476361726000210310ustar00rootroot00000000000000import argparse def update_arguments(arguments, **updates): ''' Given an argparse.Namespace instance of command-line arguments and one or more keyword argument updates to perform, return a copy of the arguments with those updates applied. ''' return argparse.Namespace(**dict(vars(arguments), **updates)) borgmatic/borgmatic/actions/borg.py000066400000000000000000000021501476361726000177510ustar00rootroot00000000000000import logging import borgmatic.borg.borg import borgmatic.borg.repo_list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_borg( repository, config, local_borg_version, borg_arguments, global_arguments, local_path, remote_path, ): ''' Run the "borg" action for the given repository. ''' if borg_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, borg_arguments.repository ): logger.info('Running arbitrary Borg command') archive_name = borgmatic.borg.repo_list.resolve_archive_name( repository['path'], borg_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ) borgmatic.borg.borg.run_arbitrary_borg( repository['path'], config, local_borg_version, options=borg_arguments.options, archive=archive_name, local_path=local_path, remote_path=remote_path, ) borgmatic/borgmatic/actions/break_lock.py000066400000000000000000000014501476361726000211160ustar00rootroot00000000000000import logging import borgmatic.borg.break_lock import borgmatic.config.validate logger = logging.getLogger(__name__) def run_break_lock( repository, config, local_borg_version, break_lock_arguments, global_arguments, local_path, remote_path, ): ''' Run the "break-lock" action for the given repository. ''' if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, break_lock_arguments.repository ): logger.info('Breaking repository and cache locks') borgmatic.borg.break_lock.break_lock( repository['path'], config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, ) borgmatic/borgmatic/actions/change_passphrase.py000066400000000000000000000016501476361726000225020ustar00rootroot00000000000000import 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-passphrase" 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('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, ) borgmatic/borgmatic/actions/check.py000066400000000000000000000644001476361726000201030ustar00rootroot00000000000000import calendar import datetime import hashlib import itertools 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.repo_list import borgmatic.borg.state import borgmatic.config.paths import borgmatic.config.validate import borgmatic.execute import borgmatic.hooks.command DEFAULT_CHECKS = ( {'name': 'repository', 'frequency': '1 month'}, {'name': 'archives', 'frequency': '1 month'}, ) logger = logging.getLogger(__name__) def parse_checks(config, only_checks=None): ''' Given a configuration dict with a "checks" sequence of dicts and an optional list of override checks, return a tuple of named checks to run. For example, given a config of: {'checks': ({'name': 'repository'}, {'name': 'archives'})} This will be returned as: ('repository', 'archives') If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value has a name of "disabled", return an empty tuple, meaning that no checks should be run. ''' checks = only_checks or tuple( check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS) ) checks = tuple(check.lower() for check in checks) if 'disabled' in checks: logger.warning( 'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead' ) if len(checks) > 1: logger.warning( 'Multiple checks are configured, but one of them is "disabled"; not running any checks' ) return () return checks def parse_frequency(frequency): ''' Given a frequency string with a number and a unit of time, return a corresponding datetime.timedelta instance or None if the frequency is None or "always". For instance, given "3 weeks", return datetime.timedelta(weeks=3) Raise ValueError if the given frequency cannot be parsed. ''' if not frequency: return None frequency = frequency.strip().lower() if frequency == 'always': return None try: number, time_unit = frequency.split(' ') number = int(number) except ValueError: raise ValueError(f"Could not parse consistency check frequency '{frequency}'") if not time_unit.endswith('s'): time_unit += 's' if time_unit == 'months': number *= 30 time_unit = 'days' elif time_unit == 'years': number *= 365 time_unit = 'days' try: return datetime.timedelta(**{time_unit: number}) except TypeError: 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 of checks, whether to force checks to run, and an ID for the archives check potentially being run (if any), filter down those checks based on the configured "frequency" for each check as compared to its check time file. In other words, a check whose check time file's timestamp is too new (based on the configured frequency) will get cut from the returned sequence of checks. Example: config = { 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, ] } When this function is called with that config and "archives" in checks, "archives" will get filtered out of the returned result if its check time file is newer than 2 weeks old, indicating that it's not yet time to run that check again. Raise ValueError if a frequency cannot be parsed. ''' if not checks: return checks filtered_checks = list(checks) if force: return tuple(filtered_checks) for check_config in config.get('checks', DEFAULT_CHECKS): check = check_config['name'] 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 check_time = probe_for_check_time(config, borg_repository_id, check, archives_check_id) if not check_time: continue # If we've not yet reached the time when the frequency dictates we're ready for another # check, skip this check. 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)' ) filtered_checks.remove(check) return tuple(filtered_checks) def make_archives_check_id(archive_filter_flags): ''' Given a sequence of flags to filter archives, return a unique hash corresponding to those particular flags. If there are no flags, return None. ''' if not archive_filter_flags: return None return hashlib.sha256(' '.join(archive_filter_flags).encode()).hexdigest() def make_check_time_path(config, borg_repository_id, check_type, archives_check_id=None): ''' 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 path for recording that check's time (the time of that check last occurring). ''' borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config) if check_type in ('archives', 'data'): return os.path.join( borgmatic_state_directory, 'checks', borg_repository_id, check_type, archives_check_id if archives_check_id else 'all', ) return os.path.join( borgmatic_state_directory, 'checks', borg_repository_id, check_type, ) def write_check_time(path): # pragma: no cover ''' Record a check time of now as the modification time of the given path. ''' logger.debug(f'Writing check time at {path}') os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) pathlib.Path(path, mode=0o600).touch() def read_check_time(path): ''' Return the check time based on the modification time of the given path. Return None if the path doesn't exist. ''' logger.debug(f'Reading check time from {path}') try: return datetime.datetime.fromtimestamp(os.stat(path).st_mtime) except FileNotFoundError: return None 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 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 the check time, e.g.: ~/.borgmatic/checks/1234567890/archives/9876543210 ~/.borgmatic/checks/1234567890/archives/all ... and returns the maximum modification time of the files found (if any). The first path represents a more specific archives check time (a check on a subset of archives), and the second is a fallback to the last "all" archives check. For other check types, this function reads from a single check time path, e.g.: ~/.borgmatic/checks/1234567890/repository ''' check_times = ( read_check_time(group[0]) for group in itertools.groupby( ( make_check_time_path(config, borg_repository_id, check, archives_check_id), make_check_time_path(config, borg_repository_id, check), ) ) ) try: return max(check_time for check_time in check_times if check_time) except ValueError: return None 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. One upgrade performed is moving the checks directory from: {borgmatic_source_directory}/checks (e.g., ~/.borgmatic/checks) to: {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) temporary_path = f'{old_path}.temp' if not os.path.isfile(old_path) and not os.path.isfile(temporary_path): continue logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}') try: shutil.move(old_path, temporary_path) except FileNotFoundError: pass os.mkdir(old_path) shutil.move(temporary_path, new_path) def collect_spot_check_source_paths( 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). ''' stream_processes = any( borgmatic.hooks.dispatch.call_hooks( 'use_streaming', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, ).values() ) working_directory = borgmatic.config.paths.get_working_directory(config) (create_flags, create_positional_arguments, pattern_file) = ( borgmatic.borg.create.make_base_create_command( dry_run=True, repository_path=repository['path'], config=config, patterns=borgmatic.actions.create.process_patterns( borgmatic.actions.create.collect_patterns(config), working_directory, ), local_borg_version=local_borg_version, global_arguments=global_arguments, borgmatic_runtime_directory=borgmatic_runtime_directory, local_path=local_path, remote_path=remote_path, list_files=True, stream_processes=stream_processes, ) ) 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, environment=borgmatic.borg.environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) paths = tuple( path_line.split(' ', 1)[1] for path_line in paths_output.splitlines() if path_line and path_line.startswith('- ') or path_line.startswith('+ ') ) return tuple( path for path in paths if os.path.isfile(os.path.join(working_directory or '', path)) ) BORG_DIRECTORY_FILE_TYPE = 'd' BORG_PIPE_FILE_TYPE = 'p' def collect_spot_check_archive_paths( 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, 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). These paths do not have a leading slash, as that's how Borg stores them. As a result, we don't know whether they came from absolute or relative source directories. ''' borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config) return tuple( path for line in borgmatic.borg.list.capture_archive_listing( repository['path'], archive, config, local_borg_version, global_arguments, path_format='{type} {path}{NUL}', # noqa: FS003 local_path=local_path, remote_path=remote_path, ) for (file_type, path) in (line.split(' ', 1),) if file_type not in (BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE) if pathlib.Path('borgmatic') not in pathlib.Path(path).parents if pathlib.Path(borgmatic_source_directory.lstrip(os.path.sep)) not in pathlib.Path(path).parents if pathlib.Path(borgmatic_runtime_directory.lstrip(os.path.sep)) not in pathlib.Path(path).parents ) SAMPLE_PATHS_SUBSET_COUNT = 10000 def compare_spot_check_hashes( repository, archive, config, local_borg_version, global_arguments, local_path, remote_path, source_paths, ): ''' 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, the remote Borg path, and spot check source paths, compare the hashes for a sampling of the source paths with hashes from corresponding paths in the given archive. Return a sequence of the paths that fail that hash comparison. ''' # Based on the configured sample percentage, come up with a list of random sample files from the # source directories. spot_check_config = next(check for check in config['checks'] if check['name'] == 'spot') sample_count = max( 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(os.path.join(working_directory or '', source_path)) } logger.debug( f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check' ) source_sample_paths_iterator = iter(source_sample_paths) source_hashes = {} archive_hashes = {} # 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 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}{NUL}', # noqa: FS003 local_path=local_path, remote_path=remote_path, ) if line ) ) # Compare the source hashes with the archive hashes to see how many match. failing_paths = [] for path, source_hash in source_hashes.items(): archive_hash = archive_hashes.get(path.lstrip(os.path.sep)) if archive_hash is not None and archive_hash == source_hash: continue failing_paths.append(path) return tuple(failing_paths) def spot_check( repository, config, local_borg_version, 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, 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. ''' logger.debug('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( 'The data_tolerance_percentage must be less than or equal to the data_sample_percentage' ) source_paths = collect_spot_check_source_paths( repository, config, local_borg_version, global_arguments, local_path, remote_path, borgmatic_runtime_directory, ) logger.debug(f'{len(source_paths)} total source paths for spot check') archive = borgmatic.borg.repo_list.resolve_archive_name( repository['path'], 'latest', config, local_borg_version, global_arguments, local_path, remote_path, ) logger.debug(f'Using archive {archive} for spot check') archive_paths = collect_spot_check_archive_paths( repository, archive, config, local_borg_version, global_arguments, local_path, remote_path, borgmatic_runtime_directory, ) logger.debug(f'{len(archive_paths)} total archive paths for spot check') if len(source_paths) == 0: logger.debug( f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}' ) raise ValueError( 'Spot check failed: There are no source paths to compare against the archive' ) # Calculate the percentage delta between the source paths count and the archive paths count, and # compare that delta to the configured count tolerance percentage. count_delta_percentage = abs(len(source_paths) - len(archive_paths)) / len(source_paths) * 100 if count_delta_percentage > spot_check_config['count_tolerance_percentage']: rootless_source_paths = set(path.lstrip(os.path.sep) for path in source_paths) logger.debug( f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}' ) logger.debug( f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_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"]}%)' ) failing_paths = compare_spot_check_hashes( repository, archive, config, local_borg_version, global_arguments, local_path, remote_path, source_paths, ) # Error if the percentage of failing hashes exceeds the configured tolerance percentage. logger.debug(f'{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'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'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta' ) def run_check( config_filename, repository, config, hook_context, local_borg_version, check_arguments, global_arguments, local_path, remote_path, ): ''' Run the "check" action for the given repository. Raise ValueError if the Borg repository ID cannot be determined. ''' if check_arguments.repository and not borgmatic.config.validate.repositories_match( repository, check_arguments.repository ): return borgmatic.hooks.command.execute_hook( config.get('before_check'), config.get('umask'), config_filename, 'pre-check', global_arguments.dry_run, **hook_context, ) logger.info('Running consistency checks') repository_id = borgmatic.borg.check.get_repository_id( repository['path'], config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, ) upgrade_check_times(config, repository_id) configured_checks = parse_checks(config, check_arguments.only_checks) archive_filter_flags = borgmatic.borg.check.make_archive_filter_flags( local_borg_version, config, configured_checks, check_arguments ) archives_check_id = make_archives_check_id(archive_filter_flags) checks = filter_checks_on_frequency( config, repository_id, configured_checks, check_arguments.force, archives_check_id, ) borg_specific_checks = set(checks).intersection({'repository', 'archives', 'data'}) if borg_specific_checks: borgmatic.borg.check.check_archives( repository['path'], config, local_borg_version, check_arguments, global_arguments, borg_specific_checks, archive_filter_flags, local_path=local_path, remote_path=remote_path, ) for check in borg_specific_checks: write_check_time(make_check_time_path(config, repository_id, check, archives_check_id)) if 'extract' in checks: borgmatic.borg.extract.extract_last_archive_dry_run( config, local_borg_version, global_arguments, repository['path'], config.get('lock_wait'), local_path, remote_path, ) write_check_time(make_check_time_path(config, repository_id, 'extract')) if 'spot' in checks: with borgmatic.config.paths.Runtime_directory(config) 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( config.get('after_check'), config.get('umask'), config_filename, 'post-check', global_arguments.dry_run, **hook_context, ) borgmatic/borgmatic/actions/compact.py000066400000000000000000000033601476361726000204520ustar00rootroot00000000000000import logging import borgmatic.borg.compact import borgmatic.borg.feature import borgmatic.config.validate import borgmatic.hooks.command logger = logging.getLogger(__name__) def run_compact( config_filename, repository, config, hook_context, local_borg_version, compact_arguments, global_arguments, dry_run_label, local_path, remote_path, ): ''' Run the "compact" action for the given repository. ''' if compact_arguments.repository and not borgmatic.config.validate.repositories_match( repository, compact_arguments.repository ): return borgmatic.hooks.command.execute_hook( config.get('before_compact'), config.get('umask'), config_filename, 'pre-compact', global_arguments.dry_run, **hook_context, ) if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version): logger.info(f'Compacting segments{dry_run_label}') borgmatic.borg.compact.compact_segments( global_arguments.dry_run, repository['path'], config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, progress=compact_arguments.progress, cleanup_commits=compact_arguments.cleanup_commits, threshold=compact_arguments.threshold, ) else: # pragma: nocover logger.info('Skipping compact (only available/needed in Borg 1.2+)') borgmatic.hooks.command.execute_hook( config.get('after_compact'), config.get('umask'), config_filename, 'post-compact', global_arguments.dry_run, **hook_context, ) borgmatic/borgmatic/actions/config/000077500000000000000000000000001476361726000177155ustar00rootroot00000000000000borgmatic/borgmatic/actions/config/__init__.py000066400000000000000000000000001476361726000220140ustar00rootroot00000000000000borgmatic/borgmatic/actions/config/bootstrap.py000066400000000000000000000120661476361726000223110ustar00rootroot00000000000000import json import logging import os import borgmatic.borg.extract import borgmatic.borg.repo_list import borgmatic.config.paths import borgmatic.config.validate import borgmatic.hooks.command logger = logging.getLogger(__name__) def make_bootstrap_config(bootstrap_arguments): ''' 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 = borgmatic.config.paths.get_borgmatic_source_directory( {'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory} ) config = make_bootstrap_config(bootstrap_arguments) # Probe for the manifest file 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 the manifest from previously created archives as well. with borgmatic.config.paths.Runtime_directory( {'user_runtime_directory': bootstrap_arguments.user_runtime_directory}, ) 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' ) 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) except json.JSONDecodeError as error: raise ValueError( f'Cannot read configuration paths from archive due to invalid bootstrap manifest JSON: {error}' ) try: return manifest_data['config_paths'] except KeyError: raise ValueError( 'Cannot read configuration paths from archive due to invalid bootstrap manifest' ) def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version): ''' Run the "bootstrap" action for the given repository. 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( archive_name, bootstrap_arguments, global_arguments, local_borg_version ) logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}") borgmatic.borg.extract.extract_archive( global_arguments.dry_run, bootstrap_arguments.repository, 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, progress=bootstrap_arguments.progress, ) borgmatic/borgmatic/actions/config/generate.py000066400000000000000000000031261476361726000220630ustar00rootroot00000000000000import logging import borgmatic.config.generate import borgmatic.config.validate import borgmatic.logger logger = logging.getLogger(__name__) def run_generate(generate_arguments, global_arguments): ''' Given the generate arguments and the global arguments, each as an argparse.Namespace instance, run the "generate" action. Raise FileExistsError if a file already exists at the destination path and the generate arguments do not have overwrite set. ''' borgmatic.logger.add_custom_log_levels() dry_run_label = ' (dry run; not actually writing anything)' if global_arguments.dry_run else '' logger.answer( f'Generating a configuration file at: {generate_arguments.destination_filename}{dry_run_label}' ) borgmatic.config.generate.generate_sample_configuration( global_arguments.dry_run, generate_arguments.source_filename, generate_arguments.destination_filename, borgmatic.config.validate.schema_filename(), overwrite=generate_arguments.overwrite, ) if generate_arguments.source_filename: logger.answer( f''' Merged in the contents of configuration file at: {generate_arguments.source_filename} To review the changes made, run: diff --unified {generate_arguments.source_filename} {generate_arguments.destination_filename}''' ) logger.answer( ''' This includes all available configuration options with example values, the few required options as indicated. Please edit the file to suit your needs. If you ever need help: https://torsion.org/borgmatic/#issues''' ) borgmatic/borgmatic/actions/config/validate.py000066400000000000000000000015321476361726000220610ustar00rootroot00000000000000import logging import borgmatic.config.generate import borgmatic.logger logger = logging.getLogger(__name__) def run_validate(validate_arguments, configs): ''' Given the validate arguments as an argparse.Namespace instance and a dict of configuration filename to corresponding parsed configuration, run the "validate" action. Most of the validation is actually performed implicitly by the standard borgmatic configuration loading machinery prior to here, so this function mainly exists to support additional validate flags like "--show". ''' borgmatic.logger.add_custom_log_levels() if validate_arguments.show: for config_path, config in configs.items(): if len(configs) > 1: logger.answer('---') logger.answer(borgmatic.config.generate.render_configuration(config)) borgmatic/borgmatic/actions/create.py000066400000000000000000000323521476361726000202720ustar00rootroot00000000000000import glob import itertools import logging import os import pathlib import borgmatic.actions.json import borgmatic.borg.create import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.config.validate import borgmatic.hooks.command import borgmatic.hooks.dispatch logger = logging.getLogger(__name__) def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE): ''' Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and return it. ''' try: (pattern_type, remainder) = pattern_line.split(' ', maxsplit=1) except ValueError: raise ValueError(f'Invalid pattern: {pattern_line}') try: (parsed_pattern_style, path) = remainder.split(':', maxsplit=1) pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style) except ValueError: pattern_style = default_style path = remainder return borgmatic.borg.pattern.Pattern( path, borgmatic.borg.pattern.Pattern_type(pattern_type), borgmatic.borg.pattern.Pattern_style(pattern_style), source=borgmatic.borg.pattern.Pattern_source.CONFIG, ) def collect_patterns(config): ''' Given a configuration dict, produce a single sequence of patterns comprised of the configured source directories, patterns, excludes, pattern files, and exclude files. The idea is that Borg has all these different ways of specifying includes, excludes, source directories, etc., but we'd like to collapse them all down to one common format (patterns) for ease of manipulation within borgmatic. ''' try: return ( tuple( borgmatic.borg.pattern.Pattern( source_directory, source=borgmatic.borg.pattern.Pattern_source.CONFIG ) for source_directory in config.get('source_directories', ()) ) + tuple( parse_pattern(pattern_line.strip()) for pattern_line in config.get('patterns', ()) if not pattern_line.lstrip().startswith('#') if pattern_line.strip() ) + tuple( parse_pattern( f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}', borgmatic.borg.pattern.Pattern_style.FNMATCH, ) for exclude_line in config.get('exclude_patterns', ()) ) + tuple( parse_pattern(pattern_line.strip()) for filename in config.get('patterns_from', ()) for pattern_line in open(filename).readlines() if not pattern_line.lstrip().startswith('#') if pattern_line.strip() ) + tuple( parse_pattern( f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}', borgmatic.borg.pattern.Pattern_style.FNMATCH, ) for filename in config.get('exclude_from', ()) for exclude_line in open(filename).readlines() if not exclude_line.lstrip().startswith('#') if exclude_line.strip() ) ) except (FileNotFoundError, OSError) as error: logger.debug(error) raise ValueError(f'Cannot read patterns_from/exclude_from file: {error.filename}') def expand_directory(directory, working_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. Take into account the given working directory so that relative paths are supported. ''' expanded_directory = os.path.expanduser(directory) # This would be a lot easier to do with glob(..., root_dir=working_directory), but root_dir is # only available in Python 3.10+. normalized_directory = os.path.join(working_directory or '', expanded_directory) glob_paths = glob.glob(normalized_directory) if not glob_paths: return [expanded_directory] working_directory_prefix = os.path.join(working_directory or '', '') return [ ( glob_path # If these are equal, that means we didn't add any working directory prefix above. if normalized_directory == expanded_directory # Remove the working directory prefix that we added above in order to make glob() work. # We can't use os.path.relpath() here because it collapses any use of Borg's slashdot # hack. else glob_path.removeprefix(working_directory_prefix) ) for glob_path in glob_paths ] def expand_patterns(patterns, working_directory=None, skip_paths=None): ''' Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory, expand tildes and globs in each root pattern and expand just tildes in each non-root pattern. The idea is that non-root patterns may be regular expressions or other pattern styles containing "*" that borgmatic should not expand as a shell glob. Return all the resulting patterns as a tuple. If a set of paths are given to skip, then don't expand any patterns matching them. ''' if patterns is None: return () return tuple( itertools.chain.from_iterable( ( ( borgmatic.borg.pattern.Pattern( expanded_path, pattern.type, pattern.style, pattern.device, pattern.source, ) for expanded_path in expand_directory(pattern.path, working_directory) ) if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT and pattern.path not in (skip_paths or ()) else ( borgmatic.borg.pattern.Pattern( os.path.expanduser(pattern.path), pattern.type, pattern.style, pattern.device, pattern.source, ), ) ) for pattern in patterns ) ) def device_map_patterns(patterns, working_directory=None): ''' Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory, determine the identifier for the device on which the pattern's path resides—or None if the path doesn't exist or is from a non-root pattern. Return an updated sequence of patterns with the device field populated. But if the device field is already set, don't bother setting it again. This is handy for determining whether two different pattern paths are on the same filesystem (have the same device identifier). ''' return tuple( borgmatic.borg.pattern.Pattern( pattern.path, pattern.type, pattern.style, device=pattern.device or ( os.stat(full_path).st_dev if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT and os.path.exists(full_path) else None ), source=pattern.source, ) for pattern in patterns for full_path in (os.path.join(working_directory or '', pattern.path),) ) def deduplicate_patterns(patterns): ''' Given a sequence of borgmatic.borg.pattern.Pattern instances, return them with all duplicate root child patterns removed. For instance, if two root patterns are given with paths "/foo" and "/foo/bar", return just the one with "/foo". Non-root patterns are passed through without modification. The one exception to deduplication is two paths are on different filesystems (devices). In that case, they won't get deduplicated, in case they both need to be passed to Borg (e.g. the one_file_system option is true). The idea is that if Borg is given a root parent pattern, then it doesn't also need to be given child patterns, because it will naturally spider the contents of the parent pattern's path. 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. ''' deduplicated = {} # Use just the keys as an ordered set. for pattern in patterns: if pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT: deduplicated[pattern] = True continue parents = pathlib.PurePath(pattern.path).parents # If another directory in the given 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_pattern in patterns: if other_pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT: continue if any( pathlib.PurePath(other_pattern.path) == parent and pattern.device is not None and other_pattern.device == pattern.device for parent in parents ): break else: deduplicated[pattern] = True return tuple(deduplicated.keys()) def process_patterns(patterns, working_directory, skip_expand_paths=None): ''' Given a sequence of Borg patterns and a configured working directory, expand and deduplicate any "root" patterns, returning the resulting root and non-root patterns as a list. If any paths are given to skip, don't expand them. ''' skip_paths = set(skip_expand_paths or ()) return list( deduplicate_patterns( device_map_patterns( expand_patterns( patterns, working_directory=working_directory, skip_paths=skip_paths, ) ) ) ) def run_create( config_filename, repository, config, config_paths, hook_context, local_borg_version, create_arguments, global_arguments, dry_run_label, local_path, remote_path, ): ''' Run the "create" action for the given repository. If create_arguments.json is True, yield the JSON output from creating the archive. ''' if create_arguments.repository and not borgmatic.config.validate.repositories_match( repository, create_arguments.repository ): return borgmatic.hooks.command.execute_hook( config.get('before_backup'), config.get('umask'), config_filename, 'pre-backup', global_arguments.dry_run, **hook_context, ) logger.info(f'Creating archive{dry_run_label}') working_directory = borgmatic.config.paths.get_working_directory(config) with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory: borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, borgmatic_runtime_directory, global_arguments.dry_run, ) patterns = process_patterns(collect_patterns(config), working_directory) active_dumps = borgmatic.hooks.dispatch.call_hooks( 'dump_data_sources', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, config_paths, borgmatic_runtime_directory, patterns, global_arguments.dry_run, ) # Process the patterns 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. patterns = process_patterns(patterns, working_directory, skip_expand_paths=config_paths) 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, patterns, 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, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, borgmatic_runtime_directory, global_arguments.dry_run, ) borgmatic.hooks.command.execute_hook( config.get('after_backup'), config.get('umask'), config_filename, 'post-backup', global_arguments.dry_run, **hook_context, ) borgmatic/borgmatic/actions/delete.py000066400000000000000000000024501476361726000202650ustar00rootroot00000000000000import 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('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, ) borgmatic/borgmatic/actions/export_key.py000066400000000000000000000014571476361726000212220ustar00rootroot00000000000000import logging import borgmatic.borg.export_key import borgmatic.config.validate logger = logging.getLogger(__name__) def run_export_key( repository, config, local_borg_version, export_arguments, global_arguments, local_path, remote_path, ): ''' Run the "key export" action for the given repository. ''' if export_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, export_arguments.repository ): logger.info('Exporting repository key') borgmatic.borg.export_key.export_key( repository['path'], config, local_borg_version, export_arguments, global_arguments, local_path=local_path, remote_path=remote_path, ) borgmatic/borgmatic/actions/export_tar.py000066400000000000000000000027151476361726000212160ustar00rootroot00000000000000import logging import borgmatic.borg.export_tar import borgmatic.borg.repo_list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_export_tar( repository, config, local_borg_version, export_tar_arguments, global_arguments, local_path, remote_path, ): ''' Run the "export-tar" action for the given repository. ''' if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, export_tar_arguments.repository ): logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file') borgmatic.borg.export_tar.export_tar_archive( global_arguments.dry_run, repository['path'], borgmatic.borg.repo_list.resolve_archive_name( repository['path'], export_tar_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ), export_tar_arguments.paths, export_tar_arguments.destination, config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, tar_filter=export_tar_arguments.tar_filter, list_files=export_tar_arguments.list_files, strip_components=export_tar_arguments.strip_components, ) borgmatic/borgmatic/actions/extract.py000066400000000000000000000035551476361726000205040ustar00rootroot00000000000000import logging import borgmatic.borg.extract import borgmatic.borg.repo_list import borgmatic.config.validate import borgmatic.hooks.command logger = logging.getLogger(__name__) def run_extract( config_filename, repository, config, hook_context, local_borg_version, extract_arguments, global_arguments, local_path, remote_path, ): ''' Run the "extract" action for the given repository. ''' borgmatic.hooks.command.execute_hook( config.get('before_extract'), config.get('umask'), config_filename, 'pre-extract', global_arguments.dry_run, **hook_context, ) if extract_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, extract_arguments.repository ): logger.info(f'Extracting archive {extract_arguments.archive}') borgmatic.borg.extract.extract_archive( global_arguments.dry_run, repository['path'], borgmatic.borg.repo_list.resolve_archive_name( repository['path'], extract_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ), extract_arguments.paths, config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, destination_path=extract_arguments.destination, strip_components=extract_arguments.strip_components, progress=extract_arguments.progress, ) borgmatic.hooks.command.execute_hook( config.get('after_extract'), config.get('umask'), config_filename, 'post-extract', global_arguments.dry_run, **hook_context, ) borgmatic/borgmatic/actions/info.py000066400000000000000000000027351476361726000177640ustar00rootroot00000000000000import logging import borgmatic.actions.arguments import borgmatic.actions.json import borgmatic.borg.info import borgmatic.borg.repo_list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_info( repository, config, local_borg_version, info_arguments, global_arguments, local_path, remote_path, ): ''' Run the "info" action for the given repository and archive. If info_arguments.json is True, yield the JSON output from the info for the archive. ''' if info_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, info_arguments.repository ): if not info_arguments.json: logger.answer('Displaying archive summary information') archive_name = borgmatic.borg.repo_list.resolve_archive_name( repository['path'], info_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ) json_output = borgmatic.borg.info.display_archives_info( repository['path'], config, local_borg_version, borgmatic.actions.arguments.update_arguments(info_arguments, archive=archive_name), global_arguments, local_path, remote_path, ) if json_output: yield borgmatic.actions.json.parse_json(json_output, repository.get('label')) borgmatic/borgmatic/actions/json.py000066400000000000000000000015131476361726000177730ustar00rootroot00000000000000import json import logging logger = logging.getLogger(__name__) def parse_json(borg_json_output, label): ''' Given a Borg JSON output string, parse it as JSON into a dict. Inject the given borgmatic repository label into it and return the dict. Raise JSONDecodeError if the JSON output cannot be parsed. ''' lines = borg_json_output.splitlines() start_line_index = 0 # Scan forward to find the first line starting with "{" and assume that's where the JSON starts. for line_index, line in enumerate(lines): if line.startswith('{'): start_line_index = line_index break json_data = json.loads('\n'.join(lines[start_line_index:])) if 'repository' not in json_data: return json_data json_data['repository']['label'] = label or '' return json_data borgmatic/borgmatic/actions/list.py000066400000000000000000000031211476361726000177720ustar00rootroot00000000000000import logging import borgmatic.actions.arguments import borgmatic.actions.json import borgmatic.borg.list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_list( repository, config, local_borg_version, list_arguments, global_arguments, local_path, remote_path, ): ''' Run the "list" action for the given repository and archive. If list_arguments.json is True, yield the JSON output from listing the archive. ''' if list_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, list_arguments.repository ): if not list_arguments.json: if list_arguments.find_paths: # pragma: no cover logger.answer('Searching archives') elif not list_arguments.archive: # pragma: no cover logger.answer('Listing archives') archive_name = borgmatic.borg.repo_list.resolve_archive_name( repository['path'], list_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ) json_output = borgmatic.borg.list.list_archive( repository['path'], config, local_borg_version, borgmatic.actions.arguments.update_arguments(list_arguments, archive=archive_name), global_arguments, local_path, remote_path, ) if json_output: yield borgmatic.actions.json.parse_json(json_output, repository.get('label')) borgmatic/borgmatic/actions/mount.py000066400000000000000000000023641476361726000201710ustar00rootroot00000000000000import logging import borgmatic.borg.mount import borgmatic.borg.repo_list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_mount( repository, config, local_borg_version, mount_arguments, global_arguments, local_path, remote_path, ): ''' Run the "mount" action for the given repository. ''' if mount_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, mount_arguments.repository ): if mount_arguments.archive: logger.info(f'Mounting archive {mount_arguments.archive}') else: # pragma: nocover logger.info('Mounting repository') borgmatic.borg.mount.mount_archive( repository['path'], borgmatic.borg.repo_list.resolve_archive_name( repository['path'], mount_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ), mount_arguments, config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, ) borgmatic/borgmatic/actions/prune.py000066400000000000000000000024651476361726000201620ustar00rootroot00000000000000import logging import borgmatic.borg.prune import borgmatic.config.validate import borgmatic.hooks.command logger = logging.getLogger(__name__) def run_prune( config_filename, repository, config, hook_context, local_borg_version, prune_arguments, global_arguments, dry_run_label, local_path, remote_path, ): ''' Run the "prune" action for the given repository. ''' if prune_arguments.repository and not borgmatic.config.validate.repositories_match( repository, prune_arguments.repository ): return borgmatic.hooks.command.execute_hook( config.get('before_prune'), config.get('umask'), config_filename, 'pre-prune', global_arguments.dry_run, **hook_context, ) logger.info(f'Pruning archives{dry_run_label}') borgmatic.borg.prune.prune_archives( global_arguments.dry_run, repository['path'], config, local_borg_version, prune_arguments, global_arguments, local_path=local_path, remote_path=remote_path, ) borgmatic.hooks.command.execute_hook( config.get('after_prune'), config.get('umask'), config_filename, 'post-prune', global_arguments.dry_run, **hook_context, ) borgmatic/borgmatic/actions/repo_create.py000066400000000000000000000021071476361726000213120ustar00rootroot00000000000000import 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('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, ) borgmatic/borgmatic/actions/repo_delete.py000066400000000000000000000015331476361726000213130ustar00rootroot00000000000000import 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( '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, ) borgmatic/borgmatic/actions/repo_info.py000066400000000000000000000022521476361726000210030ustar00rootroot00000000000000import logging import borgmatic.actions.json import borgmatic.borg.repo_info import borgmatic.config.validate logger = logging.getLogger(__name__) def run_repo_info( repository, config, local_borg_version, repo_info_arguments, global_arguments, local_path, remote_path, ): ''' Run the "repo-info" action for the given repository. If repo_info_arguments.json is True, yield the JSON output from the info for the repository. ''' if repo_info_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, repo_info_arguments.repository ): if not repo_info_arguments.json: logger.answer('Displaying repository summary information') json_output = borgmatic.borg.repo_info.display_repository_info( repository['path'], config, local_borg_version, repo_info_arguments=repo_info_arguments, global_arguments=global_arguments, local_path=local_path, remote_path=remote_path, ) if json_output: yield borgmatic.actions.json.parse_json(json_output, repository.get('label')) borgmatic/borgmatic/actions/repo_list.py000066400000000000000000000022061476361726000210220ustar00rootroot00000000000000import logging import borgmatic.actions.json import borgmatic.borg.repo_list import borgmatic.config.validate logger = logging.getLogger(__name__) def run_repo_list( repository, config, local_borg_version, repo_list_arguments, global_arguments, local_path, remote_path, ): ''' Run the "repo-list" action for the given repository. If repo_list_arguments.json is True, yield the JSON output from listing the repository. ''' if repo_list_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, repo_list_arguments.repository ): if not repo_list_arguments.json: logger.answer('Listing repository') json_output = borgmatic.borg.repo_list.list_repository( repository['path'], config, local_borg_version, repo_list_arguments=repo_list_arguments, global_arguments=global_arguments, local_path=local_path, remote_path=remote_path, ) if json_output: yield borgmatic.actions.json.parse_json(json_output, repository.get('label')) borgmatic/borgmatic/actions/restore.py000066400000000000000000000435471476361726000205220ustar00rootroot00000000000000import 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.repo_list import borgmatic.config.paths import borgmatic.config.validate import borgmatic.hooks.data_source.dump import borgmatic.hooks.dispatch logger = logging.getLogger(__name__) UNSPECIFIED = object() Dump = collections.namedtuple( 'Dump', ('hook_name', 'data_source_name', 'hostname', 'port'), defaults=('localhost', None), ) def dumps_match(first, second, default_port=None): ''' Compare two Dump instances for equality while supporting a field value of UNSPECIFIED, which indicates that the field should match any value. If a default port is given, then consider any dump having that port to match with a dump having a None port. ''' for field_name in first._fields: first_value = getattr(first, field_name) second_value = getattr(second, field_name) if default_port is not None and field_name == 'port': if first_value == default_port and second_value is None: continue if second_value == default_port and first_value is None: continue 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 UNSPECIFIED 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: metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}' 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_config) in hooks_to_search.items() for hook_data_source in hook_config for default_port in ( borgmatic.hooks.dispatch.call_hook( function_name='get_default_port', config=config, hook_name=hook_name, ), ) if dumps_match( Dump( hook_name, hook_data_source.get('name'), hook_data_source.get('hostname', 'localhost'), hook_data_source.get('port'), ), restore_dump, default_port, ) ) if not matching_dumps: return None 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, global_arguments, local_path, remote_path, archive_name, hook_name, data_source, connection_params, 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. ''' dump_metadata = render_dump_metadata( Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port')) ) logger.info(f'Restoring data source {dump_metadata}') dump_patterns = borgmatic.hooks.dispatch.call_hooks( 'make_data_source_dump_patterns', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, borgmatic_runtime_directory, data_source['name'], )[hook_name.split('_databases', 1)[0]] 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_hook( function_name='restore_data_source_dump', config=config, 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_dumps_from_archive( repository, archive, config, local_borg_version, 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, 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 = 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, config, local_borg_version, global_arguments, list_paths=[ '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('/'), ) ], local_path=local_path, remote_path=remote_path, ) # 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: 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'Ignoring invalid data source dump path "{dump_path}" in archive {archive}' ) return dumps_from_archive def get_dumps_to_restore(restore_arguments, dumps_from_archive): ''' 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 or if there are multiple archive dump matches for a given requested dump. ''' 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 UNSPECIFIED, port=restore_arguments.original_port, ) for name in restore_arguments.data_sources or (UNSPECIFIED,) } if restore_arguments.hook or restore_arguments.data_sources or restore_arguments.original_hostname or restore_arguments.original_port else { Dump( hook_name=UNSPECIFIED, data_source_name='all', hostname=UNSPECIFIED, port=UNSPECIFIED, ) } ) missing_dumps = set() dumps_to_restore = set() # 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) # 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 matching_dumps = tuple( archive_dump for archive_dump in dumps_from_archive if dumps_match(requested_dump, archive_dump) ) 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_requested_dumps_restored(dumps_to_restore, dumps_actually_restored): ''' 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. ''' 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 missing_dumps: rendered_dumps = ', '.join(f'{render_dump_metadata(dump)}' for dump in missing_dumps) raise ValueError( f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration" ) def run_restore( repository, config, local_borg_version, restore_arguments, global_arguments, local_path, remote_path, ): ''' 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 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'Restoring data sources from archive {restore_arguments.archive}') with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory: borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, borgmatic_runtime_directory, global_arguments.dry_run, ) 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) 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: found_data_source = get_configured_data_source( config, Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port), ) 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, global_arguments, local_path, remote_path, archive_name, restore_dump.hook_name, dict(found_data_source, **{'schemas': restore_arguments.schemas}), connection_params, borgmatic_runtime_directory, ) borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE, borgmatic_runtime_directory, global_arguments.dry_run, ) ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored) borgmatic/borgmatic/actions/transfer.py000066400000000000000000000012031476361726000206420ustar00rootroot00000000000000import logging import borgmatic.borg.transfer logger = logging.getLogger(__name__) def run_transfer( repository, config, local_borg_version, transfer_arguments, global_arguments, local_path, remote_path, ): ''' Run the "transfer" action for the given repository. ''' logger.info('Transferring archives to repository') borgmatic.borg.transfer.transfer_archives( global_arguments.dry_run, repository['path'], config, local_borg_version, transfer_arguments, global_arguments, local_path=local_path, remote_path=remote_path, ) borgmatic/borgmatic/borg/000077500000000000000000000000001476361726000157415ustar00rootroot00000000000000borgmatic/borgmatic/borg/__init__.py000066400000000000000000000000001476361726000200400ustar00rootroot00000000000000borgmatic/borgmatic/borg/borg.py000066400000000000000000000050111476361726000172410ustar00rootroot00000000000000import 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 logger = logging.getLogger(__name__) BORG_SUBCOMMANDS_WITH_SUBCOMMANDS = {'key', 'debug'} def run_arbitrary_borg( repository_path, config, local_borg_version, options, archive=None, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, a sequence of arbitrary command-line Borg options, and an optional archive name, run an arbitrary Borg command, passing in REPOSITORY and ARCHIVE environment variables for optional use in the command. ''' borgmatic.logger.add_custom_log_levels() lock_wait = config.get('lock_wait', None) try: options = options[1:] if options[0] == '--' else options # Borg commands like "key" have a sub-command ("export", etc.) that must follow it. command_options_start_index = 2 if options[0] in BORG_SUBCOMMANDS_WITH_SUBCOMMANDS else 1 borg_command = tuple(options[:command_options_start_index]) command_options = tuple(options[command_options_start_index:]) if borg_command and borg_command[0] in borgmatic.commands.arguments.ACTION_ALIASES.keys(): logger.warning( f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}" ) except IndexError: borg_command = () command_options = () full_command = ( (local_path,) + borg_command + (('--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('lock-wait', lock_wait) + command_options ) return execute_command( tuple(shlex.quote(part) for part in full_command), output_file=DO_NOT_CAPTURE, shell=True, environment=dict( (environment.make_environment(config) or {}), **{ 'BORG_REPO': repository_path, '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'), ) borgmatic/borgmatic/borg/break_lock.py000066400000000000000000000027321476361726000204130ustar00rootroot00000000000000import logging import borgmatic.config.paths from borgmatic.borg import environment, flags from borgmatic.execute import execute_command logger = logging.getLogger(__name__) def break_lock( repository_path, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, an argparse.Namespace of global arguments, and optional local and remote Borg paths, break any repository and cache locks leftover from Borg aborting. ''' umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) full_command = ( (local_path, 'break-lock') + (('--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) ) execute_command( full_command, 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'), ) borgmatic/borgmatic/borg/change_passphrase.py000066400000000000000000000045041476361726000217740ustar00rootroot00000000000000import 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('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, 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)" ) borgmatic/borgmatic/borg/check.py000066400000000000000000000155561476361726000174040ustar00rootroot00000000000000import argparse import json import logging 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__) def make_archive_filter_flags(local_borg_version, config, checks, check_arguments): ''' Given the local Borg version, a configuration dict, a parsed sequence of checks, and check arguments as an argparse.Namespace instance, transform the checks into tuple of command-line flags for filtering archives in a check command. If "check_last" is set in the configuration and "archives" is in checks, then include a "--last" flag. And if "prefix" is set in configuration and "archives" is in checks, then include a "--match-archives" flag. ''' check_last = config.get('check_last', None) prefix = config.get('prefix') if 'archives' in checks or 'data' in checks: return (('--last', str(check_last)) if check_last else ()) + ( ( ('--match-archives', f'sh:{prefix}*') if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else ('--glob-archives', f'{prefix}*') ) if prefix else ( flags.make_match_archives_flags( check_arguments.match_archives or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) if check_last: logger.warning( 'Ignoring check_last option, as "archives" or "data" are not in consistency checks' ) if prefix: logger.warning( 'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks' ) return () def make_check_name_flags(checks, archive_filter_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: ('repository',) This will be returned as: ('--repository-only',) However, if both "repository" and "archives" are in checks, then omit the "only" flags from the returned flags because Borg does both checks by default. Note that a "data" check only works along with an "archives" check. ''' data_flags = ('--verify-data',) if 'data' in checks else () common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags if {'repository', 'archives'}.issubset(checks): return common_flags return ( tuple(f'--{check}-only' for check in checks if check in ('repository', 'archives')) + common_flags ) def get_repository_id( repository_path, config, local_borg_version, global_arguments, local_path, remote_path ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, global arguments, and local/remote commands to run, return the corresponding Borg repository ID. Raise ValueError if the Borg repository ID cannot be determined. ''' try: return json.loads( repo_info.display_repository_info( repository_path, config, local_borg_version, argparse.Namespace(json=True), global_arguments, local_path, remote_path, ) )['repository']['id'] except (json.JSONDecodeError, KeyError): raise ValueError(f'Cannot determine Borg repository ID for {repository_path}') def check_archives( repository_path, config, local_borg_version, check_arguments, global_arguments, checks, archive_filter_flags, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, check arguments as an argparse.Namespace instance, global arguments, a set of named Borg checks to run (some combination "repository", "archives", and/or "data"), archive filter flags, and local/remote commands to run, check the contained Borg archives for consistency. ''' lock_wait = config.get('lock_wait') extra_borg_options = config.get('extra_borg_options', {}).get('check', '') verbosity_flags = () if logger.isEnabledFor(logging.INFO): verbosity_flags = ('--info',) 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 = {} max_duration = check_arguments.max_duration or repository_check_config.get('max_duration') umask = config.get('umask') borg_exit_codes = config.get('borg_exit_codes') working_directory = borgmatic.config.paths.get_working_directory(config) if 'data' in checks: checks.add('archives') grouped_checks = (checks,) # If max_duration is set, then archives and repository checks need to be run separately, as Borg # doesn't support --max-duration along with an archives checks. if max_duration and 'archives' in checks and 'repository' in checks: checks.remove('repository') grouped_checks = (checks, {'repository'}) for checks_subset in grouped_checks: full_command = ( (local_path, 'check') + (('--repair',) if check_arguments.repair else ()) + ( ('--max-duration', str(max_duration)) if max_duration and 'repository' in checks_subset else () ) + make_check_name_flags(checks_subset, 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 + (('--progress',) if check_arguments.progress else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) execute_command( full_command, # The Borg repair option triggers an interactive prompt, which won't work when output is # captured. And progress messes with the terminal directly. output_file=( DO_NOT_CAPTURE if check_arguments.repair or check_arguments.progress else None ), environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/compact.py000066400000000000000000000036231476361726000177450ustar00rootroot00000000000000import logging import borgmatic.config.paths from borgmatic.borg import environment, flags from borgmatic.execute import execute_command logger = logging.getLogger(__name__) def compact_segments( dry_run, repository_path, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, progress=False, cleanup_commits=False, threshold=None, ): ''' Given dry-run flag, a local or remote repository path, a configuration dict, and the local Borg version, compact the segments in a repository. ''' umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) extra_borg_options = config.get('extra_borg_options', {}).get('compact', '') full_command = ( (local_path, 'compact') + (('--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 ()) + (('--progress',) if progress else ()) + (('--cleanup-commits',) if cleanup_commits else ()) + (('--threshold', str(threshold)) if threshold else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) if dry_run: logging.info('Skipping compact (dry run)') return execute_command( full_command, output_log_level=logging.INFO, 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'), ) borgmatic/borgmatic/borg/create.py000066400000000000000000000416131476361726000175630ustar00rootroot00000000000000import itertools import logging import os import pathlib import stat import tempfile import textwrap import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.logger from borgmatic.borg import environment, feature, flags from borgmatic.execute import ( DO_NOT_CAPTURE, execute_command, execute_command_and_capture_output, execute_command_with_processes, ) logger = logging.getLogger(__name__) def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None): ''' Given a sequence of patterns as borgmatic.borg.pattern.Pattern instances, write them to a named temporary file in the given borgmatic runtime directory and return the file object so it can continue to exist on disk as long as the caller needs it. If an optional open pattern file is given, append to it instead of making a new temporary file. Return None if no patterns are provided. ''' if not patterns: return None if patterns_file is None: patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory) operation_name = 'Writing' else: patterns_file.write('\n') operation_name = 'Appending' patterns_output = '\n'.join( f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}' for pattern in patterns ) logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}') patterns_file.write(patterns_output) patterns_file.flush() return patterns_file def make_exclude_flags(config): ''' Given a configuration dict with various exclude options, return the corresponding Borg flags as a tuple. ''' caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else () if_present_flags = tuple( itertools.chain.from_iterable( ('--exclude-if-present', if_present) for if_present in config.get('exclude_if_present', ()) ) ) keep_exclude_tags_flags = ('--keep-exclude-tags',) if config.get('keep_exclude_tags') else () exclude_nodump_flags = ('--exclude-nodump',) if config.get('exclude_nodump') else () return caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags def make_list_filter_flags(local_borg_version, dry_run): ''' Given the local Borg version and whether this is a dry run, return the corresponding flags for passing to "--list --filter". The general idea is that excludes are shown for a dry run or when the verbosity is debug. ''' base_flags = 'AME' show_excludes = logger.isEnabledFor(logging.DEBUG) if feature.available(feature.Feature.EXCLUDED_FILES_MINUS, local_borg_version): if show_excludes or dry_run: return f'{base_flags}+-' else: return base_flags if show_excludes: return f'{base_flags}x-' else: return f'{base_flags}-' def special_file(path, working_directory=None): ''' Return whether the given path is a special file (character device, block device, or named pipe / FIFO). If a working directory is given, take it into account when making the full path to check. ''' try: mode = os.stat(os.path.join(working_directory or '', path)).st_mode except (FileNotFoundError, OSError): return False return stat.S_ISCHR(mode) or stat.S_ISBLK(mode) or stat.S_ISFIFO(mode) def any_parent_directories(path, candidate_parents): ''' Return whether any of the given candidate parent directories are an actual parent of the given path. This includes grandparents, etc. ''' for parent in candidate_parents: if pathlib.PurePosixPath(parent) in pathlib.PurePath(path).parents: return True return False def collect_special_file_paths( dry_run, create_command, config, local_path, working_directory, borgmatic_runtime_directory, ): ''' Given a dry-run flag, a Borg create command as a tuple, a configuration dict, a local Borg path, a working directory, 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 we don't want those omitted. Additionally, if the borgmatic runtime directory is not contained somewhere in the files Borg plans to backup, that means the user must have excluded the runtime directory (e.g. via "exclude_patterns" or similar). Therefore, raise, because this means Borg won't be able to consume any database dumps and therefore borgmatic will hang when it tries to do so. ''' # Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open # files including any named pipe we've created. And omit "--filter" because that can break the # paths output parsing below such that path lines no longer start with th expected "- ". paths_output = execute_command_and_capture_output( flags.omit_flag_and_value(flags.omit_flag(create_command, '--exclude-nodump'), '--filter') + ('--dry-run', '--list'), capture_stderr=True, working_directory=working_directory, environment=environment.make_environment(config), borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) # These are all the individual files that Borg is planning to backup as determined by the Borg # create dry run above. paths = tuple( path_line.split(' ', 1)[1] for path_line in paths_output.split('\n') if path_line and path_line.startswith('- ') or path_line.startswith('+ ') ) # These are the subset of those files that contain the borgmatic runtime directory. paths_containing_runtime_directory = {} if os.path.exists(borgmatic_runtime_directory): paths_containing_runtime_directory = { path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,)) } # If no paths to backup contain the runtime directory, it must've been excluded. if not paths_containing_runtime_directory and not dry_run: raise ValueError( f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.' ) return tuple( path for path in paths if special_file(path, working_directory) if path not in paths_containing_runtime_directory ) def check_all_root_patterns_exist(patterns): ''' Given a sequence of borgmatic.borg.pattern.Pattern instances, check that all root pattern paths exist. If any don't, raise an exception. ''' missing_paths = [ pattern.path for pattern in patterns if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT if not os.path.exists(pattern.path) ] if missing_paths: raise ValueError( f"Source directories / root pattern paths do not exist: {', '.join(missing_paths)}" ) MAX_SPECIAL_FILE_PATHS_LENGTH = 1000 def make_base_create_command( dry_run, repository_path, config, patterns, local_borg_version, global_arguments, borgmatic_runtime_directory, local_path='borg', remote_path=None, progress=False, json=False, list_files=False, stream_processes=None, ): ''' Given verbosity/dry-run flags, a local or remote repository path, a configuration dict, a sequence of patterns as borgmatic.borg.pattern.Pattern instances, the local Borg version, global arguments as an argparse.Namespace instance, and a sequence of borgmatic source directories, return a tuple of (base Borg create command flags, Borg create command positional arguments, open pattern file handle). ''' if config.get('source_directories_must_exist', False): check_all_root_patterns_exist(patterns) patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory) checkpoint_interval = config.get('checkpoint_interval', None) checkpoint_volume = config.get('checkpoint_volume', None) 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.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): atime_flags = ('--atime',) if config.get('atime') is True else () else: atime_flags = ('--noatime',) if config.get('atime') is False else () if feature.available(feature.Feature.NOFLAGS, local_borg_version): noflags_flags = ('--noflags',) if config.get('flags') is False else () else: noflags_flags = ('--nobsdflags',) if config.get('flags') is False else () if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version): numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else () else: numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else () if feature.available(feature.Feature.UPLOAD_RATELIMIT, local_borg_version): upload_ratelimit_flags = ( ('--upload-ratelimit', str(upload_rate_limit)) if upload_rate_limit else () ) else: upload_ratelimit_flags = ( ('--remote-ratelimit', str(upload_rate_limit)) if upload_rate_limit else () ) create_flags = ( tuple(local_path.split(' ')) + ('create',) + (('--patterns-from', patterns_file.name) if patterns_file else ()) + make_exclude_flags(config) + (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ()) + (('--checkpoint-volume', str(checkpoint_volume)) if checkpoint_volume else ()) + (('--chunker-params', chunker_params) if chunker_params else ()) + (('--compression', compression) if compression else ()) + upload_ratelimit_flags + (('--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 ()) + (('--nobirthtime',) if config.get('birthtime') is False else ()) + (('--read-special',) if config.get('read_special') or stream_processes else ()) + noflags_flags + (('--files-cache', files_cache) if files_cache else ()) + (('--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 ()) + ( ('--list', '--filter', list_filter_flags) if list_files and not json and not progress else () ) + (('--dry-run',) if dry_run else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) ) create_positional_arguments = flags.make_repository_archive_flags( repository_path, archive_name_format, local_borg_version ) # 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. if stream_processes and not config.get('read_special'): logger.warning( 'Ignoring configured "read_special" value of false, as true is needed for database hooks.' ) working_directory = borgmatic.config.paths.get_working_directory(config) logger.debug('Collecting special file paths') special_file_paths = collect_special_file_paths( dry_run, create_flags + create_positional_arguments, config, local_path, working_directory, 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'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}' ) patterns_file = write_patterns_file( tuple( borgmatic.borg.pattern.Pattern( special_file_path, borgmatic.borg.pattern.Pattern_type.NO_RECURSE, borgmatic.borg.pattern.Pattern_style.FNMATCH, source=borgmatic.borg.pattern.Pattern_source.INTERNAL, ) for special_file_path in special_file_paths ), borgmatic_runtime_directory, patterns_file=patterns_file, ) if '--patterns-from' not in create_flags: create_flags += ('--patterns-from', patterns_file.name) return (create_flags, create_positional_arguments, patterns_file) def create_archive( dry_run, repository_path, config, patterns, local_borg_version, global_arguments, borgmatic_runtime_directory, local_path='borg', remote_path=None, progress=False, stats=False, json=False, list_files=False, stream_processes=None, ): ''' Given verbosity/dry-run flags, a local or remote repository path, a configuration dict, a sequence of loaded configuration paths, the local Borg version, and global arguments as an argparse.Namespace instance, create a Borg archive and return Borg's JSON output (if any). If a sequence of stream processes is given (instances of subprocess.Popen), then execute the create command while also triggering the given processes to produce output. ''' borgmatic.logger.add_custom_log_levels() working_directory = borgmatic.config.paths.get_working_directory(config) (create_flags, create_positional_arguments, patterns_file) = make_base_create_command( dry_run, repository_path, config, patterns, local_borg_version, global_arguments, borgmatic_runtime_directory, local_path, remote_path, progress, json, list_files, stream_processes, ) if json: output_log_level = None elif list_files or (stats and not dry_run): output_log_level = logging.ANSWER else: output_log_level = logging.INFO # The progress output isn't compatible with captured and logged output, as progress messes with # the terminal directly. output_file = DO_NOT_CAPTURE if progress else None create_flags += ( (('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ()) + (('--stats',) if stats and not json and not dry_run else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ()) + (('--progress',) if progress else ()) + (('--json',) if json else ()) ) borg_exit_codes = config.get('borg_exit_codes') if stream_processes: return execute_command_with_processes( create_flags + create_positional_arguments, stream_processes, output_log_level, output_file, working_directory=working_directory, environment=environment.make_environment(config), borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) elif output_log_level is None: return execute_command_and_capture_output( create_flags + create_positional_arguments, working_directory=working_directory, environment=environment.make_environment(config), borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) else: execute_command( create_flags + create_positional_arguments, output_log_level, output_file, working_directory=working_directory, environment=environment.make_environment(config), borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/delete.py000066400000000000000000000107601476361726000175610ustar00rootroot00000000000000import 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, 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'), ) borgmatic/borgmatic/borg/environment.py000066400000000000000000000102551476361726000206620ustar00rootroot00000000000000import os import borgmatic.borg.passcommand import borgmatic.hooks.credential.parse OPTION_TO_ENVIRONMENT_VARIABLE = { 'borg_base_directory': 'BORG_BASE_DIR', 'borg_config_directory': 'BORG_CONFIG_DIR', 'borg_cache_directory': 'BORG_CACHE_DIR', 'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL', 'borg_security_directory': 'BORG_SECURITY_DIR', 'borg_keys_directory': 'BORG_KEYS_DIR', 'ssh_command': 'BORG_RSH', 'temporary_directory': 'TMPDIR', } DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = { 'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK', 'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK', } DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = { 'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING', } def make_environment(config): ''' Given a borgmatic configuration dict, convert it to a Borg environment variable dict, merge it with a copy of the current environment variables, and return the result. Do not reuse this environment across multiple Borg invocations, because it can include references to resources like anonymous pipes for passphrases—which can only be consumed once. Here's how native Borg precedence works for a few of the environment variables: 1. BORG_PASSPHRASE, if set, is used first. 2. BORG_PASSCOMMAND is used only if BORG_PASSPHRASE isn't set. 3. BORG_PASSPHRASE_FD is used only if neither of the above are set. In borgmatic, we want to simulate this precedence order, but there are some additional complications. First, values can come from either configuration or from environment variables set outside borgmatic; configured options should take precedence. Second, when borgmatic gets a passphrase—directly from configuration or indirectly via a credential hook or a passcommand—we want to pass that passphrase to Borg via an anonymous pipe (+ BORG_PASSPHRASE_FD), since that's more secure than using an environment variable (BORG_PASSPHRASE). ''' environment = dict(os.environ) for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items(): value = config.get(option_name) if value is not None: environment[environment_variable_name] = str(value) if 'encryption_passphrase' in config: environment.pop('BORG_PASSPHRASE', None) environment.pop('BORG_PASSCOMMAND', None) if 'encryption_passcommand' in config: environment.pop('BORG_PASSCOMMAND', None) passphrase = borgmatic.hooks.credential.parse.resolve_credential( config.get('encryption_passphrase'), config ) if passphrase is None: passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config) # If there's a passphrase (from configuration, from a configured credential, or from a # configured passcommand), send it to Borg via an anonymous pipe. if passphrase is not None: read_file_descriptor, write_file_descriptor = os.pipe() os.write(write_file_descriptor, passphrase.encode('utf-8')) os.close(write_file_descriptor) # This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the Borg # child process to inherit the file descriptor. os.set_inheritable(read_file_descriptor, True) environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor) for ( option_name, environment_variable_name, ) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items(): if os.environ.get(environment_variable_name) is None: value = config.get(option_name) environment[environment_variable_name] = 'yes' if value else 'no' for ( option_name, environment_variable_name, ) in DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE.items(): value = config.get(option_name) if value is not None: environment[environment_variable_name] = 'YES' if value else 'NO' # On Borg 1.4.0a1+, take advantage of more specific exit codes. No effect on # older versions of Borg. environment['BORG_EXIT_CODES'] = 'modern' return environment borgmatic/borgmatic/borg/export_key.py000066400000000000000000000050421476361726000205050ustar00rootroot00000000000000import 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 logger = logging.getLogger(__name__) def export_key( repository_path, config, local_borg_version, export_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' 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. Raise FileExistsError if a path is given but it already exists on disk. ''' 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(os.path.join(working_directory or '', export_arguments.path)): raise FileExistsError( f'Destination path {export_arguments.path} already exists. Aborting.' ) output_file = None else: output_file = DO_NOT_CAPTURE full_command = ( (local_path, 'key', 'export') + (('--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_flags('paper', export_arguments.paper) + flags.make_flags('qr-html', export_arguments.qr_html) + flags.make_repository_flags( repository_path, local_borg_version, ) + ((export_arguments.path,) if output_file is None else ()) ) if global_arguments.dry_run: logger.info('Skipping key export (dry run)') return execute_command( full_command, output_file=output_file, output_log_level=logging.ANSWER, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) borgmatic/borgmatic/borg/export_tar.py000066400000000000000000000052301476361726000205020ustar00rootroot00000000000000import logging import borgmatic.config.paths import borgmatic.logger from borgmatic.borg import environment, flags from borgmatic.execute import DO_NOT_CAPTURE, execute_command logger = logging.getLogger(__name__) def export_tar_archive( dry_run, repository_path, archive, paths, destination_path, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, tar_filter=None, list_files=False, strip_components=None, ): ''' Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to export from the archive, a destination path to export to, a configuration dict, the local Borg version, optional local and remote Borg paths, an optional filter program, whether to include per-file details, and an optional number of path components to strip, export the archive into the given destination path as a tar-formatted file. If the destination path is "-", then stream the output to stdout instead of to a file. ''' borgmatic.logger.add_custom_log_levels() umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) full_command = ( (local_path, 'export-tar') + (('--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 ()) + (('--list',) if list_files else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (('--tar-filter', tar_filter) if tar_filter else ()) + (('--strip-components', str(strip_components)) if strip_components else ()) + flags.make_repository_archive_flags( repository_path, archive, local_borg_version, ) + (destination_path,) + (tuple(paths) if paths else ()) ) if list_files: output_log_level = logging.ANSWER else: output_log_level = logging.INFO if dry_run: logging.info('Skipping export to tar file (dry run)') return execute_command( full_command, output_file=DO_NOT_CAPTURE if destination_path == '-' else None, output_log_level=output_log_level, 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'), ) borgmatic/borgmatic/borg/extract.py000066400000000000000000000150721476361726000177720ustar00rootroot00000000000000import logging import os import subprocess import borgmatic.config.paths import borgmatic.config.validate from borgmatic.borg import environment, feature, flags, repo_list from borgmatic.execute import DO_NOT_CAPTURE, execute_command logger = logging.getLogger(__name__) def extract_last_archive_dry_run( config, local_borg_version, global_arguments, repository_path, lock_wait=None, local_path='borg', remote_path=None, ): ''' Perform an extraction dry-run of the most recent archive. If there are no archives, skip the dry-run. ''' verbosity_flags = () if logger.isEnabledFor(logging.DEBUG): verbosity_flags = ('--debug', '--show-rc') elif logger.isEnabledFor(logging.INFO): verbosity_flags = ('--info',) try: last_archive_name = repo_list.resolve_archive_name( repository_path, 'latest', config, local_borg_version, global_arguments, local_path, remote_path, ) except ValueError: logger.warning('No archives found. Skipping extract consistency check.') return list_flag = ('--list',) if logger.isEnabledFor(logging.DEBUG) else () full_extract_command = ( (local_path, 'extract', '--dry-run') + (('--remote-path', remote_path) if remote_path else ()) + (('--log-json',) if global_arguments.log_json else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + verbosity_flags + list_flag + flags.make_repository_archive_flags( repository_path, last_archive_name, local_borg_version ) ) execute_command( full_extract_command, 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'), ) def extract_archive( dry_run, repository, archive, paths, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, destination_path=None, strip_components=None, progress=False, extract_to_stdout=False, ): ''' Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to restore from the archive, the local Borg version string, an argparse.Namespace of global arguments, a configuration dict, optional local and remote Borg paths, and an optional destination path to extract to, extract the archive into the current directory. If extract to stdout is True, then start the extraction streaming to stdout, and return that extract process as an instance of subprocess.Popen. ''' umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) if progress and extract_to_stdout: raise ValueError('progress and extract_to_stdout cannot both be set') if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version): numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else () else: numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else () if strip_components == 'all': 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. "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') + (('--remote-path', remote_path) if remote_path else ()) + numeric_ids_flags + (('--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', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (('--strip-components', str(strip_components)) if strip_components else ()) + (('--progress',) if progress else ()) + (('--stdout',) if extract_to_stdout else ()) + flags.make_repository_archive_flags( # 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, ) + (tuple(paths) if paths else ()) ) 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. if progress: return execute_command( full_command, output_file=DO_NOT_CAPTURE, environment=environment.make_environment(config), working_directory=full_destination_path, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) return None if extract_to_stdout: return execute_command( full_command, output_file=subprocess.PIPE, run_to_completion=False, environment=environment.make_environment(config), working_directory=full_destination_path, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command( full_command, environment=environment.make_environment(config), working_directory=full_destination_path, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/feature.py000066400000000000000000000031031476361726000177430ustar00rootroot00000000000000from enum import Enum from packaging.version import parse class Feature(Enum): COMPACT = 1 ATIME = 2 NOFLAGS = 3 NUMERIC_IDS = 4 UPLOAD_RATELIMIT = 5 SEPARATE_REPOSITORY_ARCHIVE = 6 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 = { Feature.COMPACT: parse('1.2.0a2'), # borg compact Feature.ATIME: parse('1.2.0a7'), # borg create --atime Feature.NOFLAGS: parse('1.2.0a8'), # borg create --noflags 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.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 } def available(feature, borg_version): ''' Given a Borg Feature constant and a Borg version string, return whether that feature is available in that version of Borg. ''' return FEATURE_TO_MINIMUM_BORG_VERSION[feature] <= parse(borg_version) borgmatic/borgmatic/borg/flags.py000066400000000000000000000152331476361726000174130ustar00rootroot00000000000000import itertools import json import logging import re from borgmatic.borg import feature logger = logging.getLogger(__name__) def make_flags(name, value): ''' Given a flag name and its value, return it formatted as Borg-compatible flags. ''' if not value: return () flag = f"--{name.replace('_', '-')}" if value is True: return (flag,) return (flag, str(value)) def make_flags_from_arguments(arguments, excludes=()): ''' Given borgmatic command-line arguments as an instance of argparse.Namespace, and optionally a list of named arguments to exclude, generate and return the corresponding Borg command-line flags as a tuple. ''' return tuple( itertools.chain.from_iterable( make_flags(name, value=getattr(arguments, name)) for name in sorted(vars(arguments)) if name not in excludes and not name.startswith('_') ) ) def make_repository_flags(repository_path, local_borg_version): ''' Given the path of a Borg repository and the local Borg version, return Borg-version-appropriate command-line flags (as a tuple) for selecting that repository. ''' return ( ('--repo',) if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version) else () ) + (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, return Borg-version-appropriate command-line flags (as a tuple) for selecting that repository and archive. ''' return ( ( '--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_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}' # noqa: FS003 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 format. This is done by replacing certain archive name format placeholders for ephemeral data (like "{now}") with globs. ''' if match_archives: if match_archives in {'*', 're:.*', 'sh:*'}: 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 or get_default_archive_name_format(local_borg_version), ) if derived_match_archives == '*': return () if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version): return ('--match-archives', f'sh:{derived_match_archives}') else: return ('--glob-archives', f'{derived_match_archives}') def warn_for_aggressive_archive_flags(json_command, json_output): ''' Given a JSON archives command and the resulting JSON string output from running it, parse the JSON and warn if the command used an archive flag but the output indicates zero archives were found. ''' archive_flags_used = {'--glob-archives', '--match-archives'}.intersection(set(json_command)) if not archive_flags_used: return try: if len(json.loads(json_output)['archives']) == 0: logger.warning('An archive filter was applied, but no matching archives were found.') logger.warning( 'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.' ) except json.JSONDecodeError as error: logger.debug(f'Cannot parse JSON output from archive command: {error}') except (TypeError, KeyError): logger.debug('Cannot parse JSON output from archive command: No "archives" key found') def omit_flag(arguments, flag): ''' Given a sequence of Borg command-line arguments, return them with the given (valueless) flag omitted. For instance, if the flag is "--flag" and arguments is: ('borg', 'create', '--flag', '--other-flag') ... then return: ('borg', 'create', '--other-flag') ''' return tuple(argument for argument in arguments if argument != flag) def omit_flag_and_value(arguments, flag): ''' Given a sequence of Borg command-line arguments, return them with the given flag and its corresponding value omitted. For instance, if the flag is "--flag" and arguments is: ('borg', 'create', '--flag', 'value', '--other-flag') ... or: ('borg', 'create', '--flag=value', '--other-flag') ... then return: ('borg', 'create', '--other-flag') ''' # This works by zipping together a list of overlapping pairwise arguments. E.g., ('one', 'two', # 'three', 'four') becomes ((None, 'one'), ('one, 'two'), ('two', 'three'), ('three', 'four')). # This makes it easy to "look back" at the previous arguments so we can exclude both a flag and # its value. return tuple( argument for (previous_argument, argument) in zip((None,) + arguments, arguments) if flag not in (previous_argument, argument) if not argument.startswith(f'{flag}=') ) borgmatic/borgmatic/borg/info.py000066400000000000000000000076051476361726000172560ustar00rootroot00000000000000import 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 logger = logging.getLogger(__name__) def make_info_command( repository_path, config, local_borg_version, info_arguments, global_arguments, local_path, remote_path, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, the arguments to the info action as an argparse.Namespace, and global arguments, return a command as a tuple to display summary information for archives in the repository. ''' return ( (local_path, 'info') + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not info_arguments.json else () ) + ( ('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not 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', config.get('lock_wait')) + ( ( flags.make_flags('match-archives', f'sh:{info_arguments.prefix}*') if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else flags.make_flags('glob-archives', f'{info_arguments.prefix}*') ) if info_arguments.prefix else ( flags.make_match_archives_flags( info_arguments.match_archives or info_arguments.archive or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) + flags.make_flags_from_arguments( info_arguments, excludes=('repository', 'archive', 'prefix', 'match_archives') ) + flags.make_repository_flags(repository_path, local_borg_version) ) def display_archives_info( repository_path, config, local_borg_version, 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 info action as an argparse.Namespace, and global arguments, display summary information for Borg archives in the repository or return JSON summary information. ''' borgmatic.logger.add_custom_log_levels() main_command = make_info_command( repository_path, config, local_borg_version, info_arguments, global_arguments, local_path, remote_path, ) json_command = make_info_command( repository_path, config, local_borg_version, argparse.Namespace(**dict(info_arguments.__dict__, json=True)), global_arguments, local_path, 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, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) if info_arguments.json: return json_info flags.warn_for_aggressive_archive_flags(json_command, json_info) execute_command( main_command, output_log_level=logging.ANSWER, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/list.py000066400000000000000000000216351476361726000172750ustar00rootroot00000000000000import argparse import copy import logging import re import borgmatic.config.paths import borgmatic.logger 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_REPO_LIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last') MAKE_FLAGS_EXCLUDES = ( 'repository', 'archive', 'paths', 'find_paths', ) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST def make_list_command( repository_path, config, local_borg_version, list_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the arguments to the list action, and local and remote Borg paths, return a command as a tuple to list archives or paths within an archive. ''' return ( (local_path, 'list') + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not list_arguments.json else () ) + ( ('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not 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_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES) + ( flags.make_repository_archive_flags( repository_path, list_arguments.archive, local_borg_version ) if list_arguments.archive else flags.make_repository_flags(repository_path, local_borg_version) ) + (tuple(list_arguments.paths) if list_arguments.paths else ()) ) def make_find_paths(find_paths): ''' Given a sequence of path fragments or patterns as passed to `--find`, transform all path fragments into glob patterns. Pass through existing patterns untouched. For example, given find_paths of: ['foo.txt', 'pp:root/somedir'] ... transform that into: ['sh:**/*foo.txt*/**', 'pp:root/somedir'] ''' if not find_paths: return () return tuple( ( find_path if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path) else f'sh:**/*{find_path}*/**' ) for find_path in find_paths ) def capture_archive_listing( repository_path, archive, config, local_borg_version, global_arguments, list_paths=None, path_format=None, local_path='borg', 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 (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. ''' return tuple( execute_command_and_capture_output( make_list_command( repository_path, config, local_borg_version, argparse.Namespace( repository=repository_path, archive=archive, paths=[path for path in list_paths] if list_paths else None, find_paths=None, json=None, format=path_format or '{path}{NUL}', # noqa: FS003 ), global_arguments, local_path, remote_path, ), 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'), ) .strip('\0') .split('\0') ) def list_archive( repository_path, config, local_borg_version, 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 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.REPO_LIST, local_borg_version): logger.warning( 'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.' ) repo_list_arguments = argparse.Namespace( repository=repository_path, short=list_arguments.short, format=list_arguments.format, json=list_arguments.json, prefix=list_arguments.prefix, match_archives=list_arguments.match_archives, sort_by=list_arguments.sort_by, first=list_arguments.first, last=list_arguments.last, ) return repo_list.list_repository( repository_path, config, local_borg_version, repo_list_arguments, global_arguments, local_path, remote_path, ) if list_arguments.archive: 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." ) if list_arguments.json: raise ValueError( 'The --json flag on the list action is not supported when using the --archive/--find flags.' ) borg_exit_codes = config.get('borg_exit_codes') # 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: repo_list_arguments = argparse.Namespace( repository=repository_path, short=True, format=None, json=None, prefix=list_arguments.prefix, match_archives=list_arguments.match_archives, sort_by=list_arguments.sort_by, first=list_arguments.first, last=list_arguments.last, ) # Ask Borg to list archives. Capture its output for use below. archive_lines = tuple( execute_command_and_capture_output( repo_list.make_repo_list_command( repository_path, config, local_borg_version, repo_list_arguments, global_arguments, local_path, remote_path, ), environment=environment.make_environment(config), working_directory=borgmatic.config.paths.get_working_directory(config), borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) .strip('\n') .splitlines() ) else: archive_lines = (list_arguments.archive,) # For each archive listed by Borg, run list on the contents of that archive. for archive in archive_lines: logger.answer(f'Listing archive {archive}') archive_arguments = copy.copy(list_arguments) archive_arguments.archive = 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_REPO_LIST: setattr(archive_arguments, name, None) main_command = make_list_command( repository_path, config, local_borg_version, archive_arguments, global_arguments, local_path, remote_path, ) + make_find_paths(list_arguments.find_paths) execute_command( main_command, output_log_level=logging.ANSWER, environment=environment.make_environment(config), working_directory=borgmatic.config.paths.get_working_directory(config), borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/mount.py000066400000000000000000000061351476361726000174620ustar00rootroot00000000000000import logging import borgmatic.config.paths from borgmatic.borg import environment, feature, flags from borgmatic.execute import DO_NOT_CAPTURE, execute_command logger = logging.getLogger(__name__) def mount_archive( repository_path, archive, mount_arguments, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, an optional archive name, a filesystem mount point, zero or more paths to mount from the archive, extra Borg mount options, a storage configuration dict, the local Borg version, global arguments as an argparse.Namespace instance, and optional local and remote Borg paths, mount the archive onto the mount point. ''' umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) full_command = ( (local_path, 'mount') + (('--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_flags_from_arguments( mount_arguments, excludes=('repository', 'archive', 'mount_point', 'paths', 'options'), ) + (('-o', mount_arguments.options) if mount_arguments.options else ()) + ( ( flags.make_repository_flags(repository_path, local_borg_version) + ( ('--match-archives', archive) if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else ('--glob-archives', archive) ) ) if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version) else ( flags.make_repository_archive_flags(repository_path, archive, local_borg_version) if archive else flags.make_repository_flags(repository_path, local_borg_version) ) ) + (mount_arguments.mount_point,) + (tuple(mount_arguments.paths) if mount_arguments.paths else ()) ) 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: execute_command( full_command, output_file=DO_NOT_CAPTURE, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) return execute_command( full_command, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) borgmatic/borgmatic/borg/passcommand.py000066400000000000000000000024231476361726000206210ustar00rootroot00000000000000import functools import logging import shlex import borgmatic.config.paths import borgmatic.execute logger = logging.getLogger(__name__) @functools.cache def run_passcommand(passcommand, working_directory): ''' Run the given passcommand using the given working directory and return the passphrase produced by the command. Cache the results so that the passcommand only needs to run—and potentially prompt the user—once per borgmatic invocation. ''' return borgmatic.execute.execute_command_and_capture_output( shlex.split(passcommand), working_directory=working_directory, ) def get_passphrase_from_passcommand(config): ''' Given the configuration dict, call the configured passcommand to produce and return an encryption passphrase. In effect, we're doing an end-run around Borg by invoking its passcommand ourselves. This allows us to pass the resulting passphrase to multiple different Borg invocations without the user having to be prompted multiple times. If no passcommand is configured, then return None. ''' passcommand = config.get('encryption_passcommand') if not passcommand: return None return run_passcommand(passcommand, borgmatic.config.paths.get_working_directory(config)) borgmatic/borgmatic/borg/pattern.py000066400000000000000000000025211476361726000177700ustar00rootroot00000000000000import collections import enum # See https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns class Pattern_type(enum.Enum): ROOT = 'R' # A ROOT pattern always has a NONE pattern style. PATTERN_STYLE = 'P' EXCLUDE = '-' NO_RECURSE = '!' INCLUDE = '+' class Pattern_style(enum.Enum): NONE = '' FNMATCH = 'fm' SHELL = 'sh' REGULAR_EXPRESSION = 're' PATH_PREFIX = 'pp' PATH_FULL_MATCH = 'pf' class Pattern_source(enum.Enum): ''' Where the pattern came from within borgmatic. This is important because certain use cases (like filesystem snapshotting) only want to consider patterns that the user actually put in a configuration file and not patterns from other sources. ''' # The pattern is from a borgmatic configuration option, e.g. listed in "source_directories". CONFIG = 'config' # The pattern is generated internally within borgmatic, e.g. for special file excludes. INTERNAL = 'internal' # The pattern originates from within a borgmatic hook, e.g. a database hook that adds its dump # directory. HOOK = 'hook' Pattern = collections.namedtuple( 'Pattern', ('path', 'type', 'style', 'device', 'source'), defaults=( Pattern_type.ROOT, Pattern_style.NONE, None, Pattern_source.HOOK, ), ) borgmatic/borgmatic/borg/prune.py000066400000000000000000000070741476361726000174540ustar00rootroot00000000000000import logging import borgmatic.config.paths import borgmatic.logger from borgmatic.borg import environment, feature, flags from borgmatic.execute import execute_command logger = logging.getLogger(__name__) def make_prune_flags(config, prune_arguments, local_borg_version): ''' 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: {'keep_weekly': 4, 'keep_monthly': 6} This will be returned as an iterable of: ( ('--keep-weekly', '4'), ('--keep-monthly', '6'), ) ''' flag_pairs = ( ('--' + option_name.replace('_', '-'), str(value)) for option_name, value in config.items() if option_name.startswith('keep_') and option_name != 'keep_exclude_tags' ) prefix = config.get('prefix') return tuple(element for pair in flag_pairs for element in pair) + ( ( ('--match-archives', f'sh:{prefix}*') if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else ('--glob-archives', f'{prefix}*') ) if prefix else ( flags.make_match_archives_flags( prune_arguments.match_archives or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) def prune_archives( dry_run, repository_path, config, local_borg_version, prune_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' Given dry-run flag, a local or remote repository path, and a configuration dict, prune Borg archives according to the retention policy specified in that configuration. ''' borgmatic.logger.add_custom_log_levels() umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) extra_borg_options = config.get('extra_borg_options', {}).get('prune', '') full_command = ( (local_path, 'prune') + 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 ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + (('--stats',) if prune_arguments.stats and not dry_run else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + flags.make_flags_from_arguments( prune_arguments, excludes=('repository', 'match_archives', 'stats', 'list_archives'), ) + (('--list',) if prune_arguments.list_archives else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) if prune_arguments.stats or prune_arguments.list_archives: output_log_level = logging.ANSWER else: output_log_level = logging.INFO execute_command( full_command, output_log_level=output_log_level, 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'), ) borgmatic/borgmatic/borg/repo_create.py000066400000000000000000000076371476361726000206200ustar00rootroot00000000000000import argparse import json import logging import subprocess 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__) REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13, 15} def create_repository( dry_run, repository_path, config, local_borg_version, global_arguments, encryption_mode, source_repository=None, copy_crypt_key=False, append_only=None, storage_quota=None, make_parent_dirs=False, local_path='borg', remote_path=None, ): ''' Given a dry-run flag, a local or remote repository path, a configuration dict, the local Borg version, a Borg encryption mode, the path to another repo whose key material should be reused, whether the repository should be append-only, and the storage quota to use, create the repository. If the repository already exists, then log and skip creation. Raise ValueError if the requested encryption mode does not match that of the repository. Raise json.decoder.JSONDecodeError if the "borg info" JSON outputcannot be decoded. Raise subprocess.CalledProcessError if "borg info" returns an error exit code. ''' try: info_data = json.loads( repo_info.display_repository_info( repository_path, config, local_borg_version, argparse.Namespace(json=True), global_arguments, local_path, remote_path, ) ) repository_encryption_mode = info_data.get('encryption', {}).get('mode') if repository_encryption_mode != encryption_mode: raise ValueError( f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"' ) logger.info('Repository already exists. Skipping creation.') return except subprocess.CalledProcessError as error: if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES: raise lock_wait = config.get('lock_wait') umask = config.get('umask') extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '') repo_create_command = ( (local_path,) + ( ('repo-create',) if feature.available(feature.Feature.REPO_CREATE, local_borg_version) else ('init',) ) + (('--encryption', encryption_mode) if encryption_mode else ()) + (('--other-repo', source_repository) if source_repository else ()) + (('--copy-crypt-key',) if copy_crypt_key else ()) + (('--append-only',) if append_only else ()) + (('--storage-quota', storage_quota) if storage_quota else ()) + (('--make-parent-dirs',) if make_parent_dirs else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ()) + (('--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) ) if dry_run: logging.info('Skipping repository creation (dry run)') return # Do not capture output here, so as to support interactive prompts. execute_command( repo_create_command, output_file=DO_NOT_CAPTURE, 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'), ) borgmatic/borgmatic/borg/repo_delete.py000066400000000000000000000063771476361726000206170ustar00rootroot00000000000000import 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 ), 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'), ) borgmatic/borgmatic/borg/repo_info.py000066400000000000000000000046301476361726000202760ustar00rootroot00000000000000import 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 logger = logging.getLogger(__name__) def display_repository_info( repository_path, config, local_borg_version, 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 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() lock_wait = config.get('lock_wait', None) full_command = ( (local_path,) + ( ('repo-info',) if feature.available(feature.Feature.REPO_INFO, local_borg_version) else ('info',) ) + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not repo_info_arguments.json else () ) + ( ('--debug', '--show-rc') 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 repo_info_arguments.json else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) working_directory = borgmatic.config.paths.get_working_directory(config) borg_exit_codes = config.get('borg_exit_codes') if repo_info_arguments.json: return execute_command_and_capture_output( full_command, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) else: execute_command( full_command, output_log_level=logging.ANSWER, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/repo_list.py000066400000000000000000000137011476361726000203150ustar00rootroot00000000000000import 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 logger = logging.getLogger(__name__) def resolve_archive_name( repository_path, archive, config, local_borg_version, global_arguments, local_path='borg', 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, a local Borg path, and a remote Borg path, return the archive name. But if the archive name is "latest", then instead introspect the repository for the latest archive and return its name. Raise ValueError if "latest" is given but there are no archives in the repository. ''' if archive != 'latest': return archive full_command = ( ( local_path, ( '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) + ('--short',) + flags.make_repository_flags(repository_path, local_borg_version) ) output = execute_command_and_capture_output( full_command, 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'), ) try: latest_archive = output.strip().splitlines()[-1] except IndexError: raise ValueError('No archives found in the repository') logger.debug(f'Latest archive is {latest_archive}') return latest_archive MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives') def make_repo_list_command( repository_path, config, local_borg_version, 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 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, ( 'repo-list' if feature.available(feature.Feature.REPO_LIST, local_borg_version) else 'list' ), ) + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not repo_list_arguments.json else () ) + ( ('--debug', '--show-rc') 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:{repo_list_arguments.prefix}*') if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else flags.make_flags('glob-archives', f'{repo_list_arguments.prefix}*') ) if repo_list_arguments.prefix else ( flags.make_match_archives_flags( repo_list_arguments.match_archives or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) + flags.make_flags_from_arguments(repo_list_arguments, excludes=MAKE_FLAGS_EXCLUDES) + flags.make_repository_flags(repository_path, local_borg_version) ) def list_repository( repository_path, config, local_borg_version, 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 list action, global arguments as an argparse.Namespace instance, and local and remote Borg paths, display the output of listing Borg archives in the given repository (or return JSON output). ''' borgmatic.logger.add_custom_log_levels() main_command = make_repo_list_command( repository_path, config, local_borg_version, repo_list_arguments, global_arguments, local_path, remote_path, ) json_command = make_repo_list_command( repository_path, config, local_borg_version, 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, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) if repo_list_arguments.json: return json_listing flags.warn_for_aggressive_archive_flags(json_command, json_listing) execute_command( main_command, output_log_level=logging.ANSWER, environment=environment.make_environment(config), working_directory=working_directory, borg_local_path=local_path, borg_exit_codes=borg_exit_codes, ) borgmatic/borgmatic/borg/state.py000066400000000000000000000000641476361726000174330ustar00rootroot00000000000000DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic' borgmatic/borgmatic/borg/transfer.py000066400000000000000000000044601476361726000201430ustar00rootroot00000000000000import logging import borgmatic.config.paths import borgmatic.logger from borgmatic.borg import environment, flags from borgmatic.execute import DO_NOT_CAPTURE, execute_command logger = logging.getLogger(__name__) def transfer_archives( dry_run, repository_path, config, local_borg_version, transfer_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' Given a dry-run flag, a local or remote repository path, a configuration dict, the local Borg version, the arguments to the transfer action, and global arguments as an argparse.Namespace instance, transfer archives to the given repository. ''' borgmatic.logger.add_custom_log_levels() full_command = ( (local_path, 'transfer') + (('--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)) + ( flags.make_flags_from_arguments( transfer_arguments, excludes=('repository', 'source_repository', 'archive', 'match_archives'), ) or ( flags.make_match_archives_flags( transfer_arguments.match_archives or transfer_arguments.archive or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) + flags.make_repository_flags(repository_path, local_borg_version) + flags.make_flags('other-repo', transfer_arguments.source_repository) + flags.make_flags('dry-run', dry_run) ) return execute_command( full_command, output_log_level=logging.ANSWER, output_file=DO_NOT_CAPTURE if transfer_arguments.progress else None, 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'), ) borgmatic/borgmatic/borg/umount.py000066400000000000000000000014401476361726000176410ustar00rootroot00000000000000import logging import borgmatic.config.paths from borgmatic.execute import execute_command logger = logging.getLogger(__name__) def unmount_archive(config, mount_point, local_path='borg'): ''' Given a mounted filesystem mount point, and an optional local Borg paths, umount the filesystem from the mount point. ''' full_command = ( (local_path, 'umount') + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (mount_point,) ) execute_command( full_command, working_directory=borgmatic.config.paths.get_working_directory(config), borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes'), ) borgmatic/borgmatic/borg/version.py000066400000000000000000000022021476361726000177740ustar00rootroot00000000000000import logging import borgmatic.config.paths from borgmatic.borg import environment from borgmatic.execute import execute_command_and_capture_output logger = logging.getLogger(__name__) def local_borg_version(config, local_path='borg'): ''' 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. ''' full_command = ( (local_path, '--version') + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) ) output = execute_command_and_capture_output( full_command, 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'), ) try: return output.split(' ')[1].strip() except IndexError: raise ValueError('Could not parse Borg version string') borgmatic/borgmatic/commands/000077500000000000000000000000001476361726000166115ustar00rootroot00000000000000borgmatic/borgmatic/commands/__init__.py000066400000000000000000000000001476361726000207100ustar00rootroot00000000000000borgmatic/borgmatic/commands/arguments.py000066400000000000000000001756251476361726000212100ustar00rootroot00000000000000import collections import itertools import sys from argparse import ArgumentParser from borgmatic.config import collect ACTION_ALIASES = { 'repo-create': ['rcreate', 'init', '-I'], 'prune': ['-p'], 'compact': [], 'create': ['-C'], 'check': ['-k'], 'config': [], 'delete': [], 'extract': ['-x'], 'export-tar': [], 'mount': ['-m'], 'umount': ['-u'], 'restore': ['-r'], 'repo-delete': ['rdelete'], 'repo-list': ['rlist'], 'list': ['-l'], 'repo-info': ['rinfo'], 'info': ['-i'], 'transfer': [], 'break-lock': [], 'key': [], 'borg': [], } def get_subaction_parsers(action_parser): ''' Given an argparse.ArgumentParser instance, lookup the subactions in it and return a dict from subaction name to subaction parser. ''' if not action_parser._subparsers: return {} return { subaction_name: subaction_parser for group_action in action_parser._subparsers._group_actions for subaction_name, subaction_parser in group_action.choices.items() } def get_subactions_for_actions(action_parsers): ''' Given a dict from action name to an argparse.ArgumentParser instance, make a map from action name to the names of contained sub-actions. ''' return { action: tuple( subaction_name for group_action in action_parser._subparsers._group_actions for subaction_name in group_action.choices.keys() ) for action, action_parser in action_parsers.items() if action_parser._subparsers } def omit_values_colliding_with_action_names(unparsed_arguments, parsed_arguments): ''' Given a sequence of string arguments and a dict from action name to parsed argparse.Namespace arguments, return the string arguments with any values omitted that happen to be the same as the name of a borgmatic action. This prevents, for instance, "check --only extract" from triggering the "extract" action. ''' remaining_arguments = list(unparsed_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() and value in remaining_arguments: remaining_arguments.remove(value) elif isinstance(value, list): for item in value: if item in ACTION_ALIASES.keys() and item in remaining_arguments: remaining_arguments.remove(item) return tuple(remaining_arguments) def parse_and_record_action_arguments( unparsed_arguments, parsed_arguments, action_parser, action_name, canonical_name=None ): ''' Given unparsed arguments as a sequence of strings, parsed arguments as a dict from action name to parsed argparse.Namespace, a parser to parse with, an action name, and an optional canonical action name (in case this the action name is an alias), parse the arguments and return a list of any remaining string arguments that were not parsed. Also record the parsed argparse.Namespace by setting it into the given parsed arguments. Return None if no parsing occurs because the given action doesn't apply to the given unparsed arguments. ''' filtered_arguments = omit_values_colliding_with_action_names( unparsed_arguments, parsed_arguments ) if action_name not in filtered_arguments: return tuple(unparsed_arguments) parsed, remaining = action_parser.parse_known_args(filtered_arguments) parsed_arguments[canonical_name or action_name] = parsed # Special case: If this is a "borg" action, greedily consume all arguments after (+1) the "borg" # argument. if action_name == 'borg': borg_options_index = remaining.index('borg') + 1 parsed_arguments['borg'].options = remaining[borg_options_index:] remaining = remaining[:borg_options_index] 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 remaining arguments that no action parsers have consumed. ''' 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( 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 ) ) ) def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parser): ''' Given a sequence of arguments, a dict from action name to argparse.ArgumentParser instance, and the global parser as a argparse.ArgumentParser instance, give each requested action's parser a shot at parsing all arguments. This allows common arguments like "--repository" to be shared across multiple action parsers. Return the result as a tuple of: (a dict mapping from action name to an argparse.Namespace of parsed arguments, a tuple of argument tuples where each is the remaining arguments not claimed by any action parser). ''' arguments = collections.OrderedDict() help_requested = bool('--help' in unparsed_arguments or '-h' in unparsed_arguments) remaining_action_arguments = [] alias_to_action_name = { alias: action_name for action_name, aliases in ACTION_ALIASES.items() for alias in aliases } # If the "borg" action is used, skip all other action parsers. This avoids confusion like # "borg list" triggering borgmatic's own list action. if 'borg' in unparsed_arguments: action_parsers = {'borg': action_parsers['borg']} # Ask each action parser, one by one, to parse arguments. for argument in unparsed_arguments: action_name = argument canonical_name = alias_to_action_name.get(action_name, action_name) action_parser = action_parsers.get(action_name) if not action_parser: continue subaction_parsers = get_subaction_parsers(action_parser) # But first parse with subaction parsers, if any. if subaction_parsers: subactions_parsed = False for subaction_name, subaction_parser in subaction_parsers.items(): remaining_action_arguments.append( tuple( argument for argument in parse_and_record_action_arguments( unparsed_arguments, arguments, subaction_parser, subaction_name, ) if argument != action_name ) ) if subaction_name in arguments: subactions_parsed = True if not subactions_parsed: if help_requested: action_parser.print_help() sys.exit(0) else: raise ValueError( f"Missing sub-action after {action_name} action. Expected one of: {', '.join(get_subactions_for_actions(action_parsers)[action_name])}" ) # Otherwise, parse with the main action parser. else: remaining_action_arguments.append( parse_and_record_action_arguments( unparsed_arguments, arguments, action_parser, action_name, canonical_name ) ) # If no actions were explicitly requested, assume defaults. if not arguments and not help_requested: for default_action_name in ('create', 'prune', 'compact', 'check'): default_action_parser = action_parsers[default_action_name] remaining_action_arguments.append( parse_and_record_action_arguments( tuple(unparsed_arguments) + (default_action_name,), arguments, default_action_parser, default_action_name, ) ) arguments['global'], remaining = global_parser.parse_known_args(unparsed_arguments) remaining_action_arguments.append(remaining) return ( arguments, tuple(remaining_action_arguments) if arguments else unparsed_arguments, ) def make_parsers(): ''' Build a global arguments parser, individual action parsers, and a combined parser containing both. Return them as a tuple. The global parser is useful for parsing just global arguments while ignoring actions, and the combined parser is handy for displaying help that includes everything: global flags, a list of actions, etc. ''' config_paths = collect.get_default_config_paths(expand_home=True) unexpanded_config_paths = collect.get_default_config_paths(expand_home=False) global_parser = ArgumentParser(add_help=False) global_group = global_parser.add_argument_group('global arguments') global_group.add_argument( '-c', '--config', dest='config_paths', action='append', help=f"Configuration filename or directory, can specify flag multiple times, defaults to: -c {' -c '.join(unexpanded_config_paths)}", ) global_group.add_argument( '-n', '--dry-run', dest='dry_run', action='store_true', help='Go through the motions, but do not actually write to any repositories', ) global_group.add_argument( '-nc', '--no-color', dest='no_color', action='store_true', help='Disable colored output' ) global_group.add_argument( '-v', '--verbosity', type=int, choices=range(-2, 3), default=0, help='Display verbose progress to the console: -2 (disabled), -1 (errors only), 0 (responses to actions, the default), 1 (info about steps borgmatic is taking), or 2 (debug)', ) global_group.add_argument( '--syslog-verbosity', type=int, choices=range(-2, 3), default=-2, help='Log verbose progress to syslog: -2 (disabled, the default), -1 (errors only), 0 (responses to actions), 1 (info about steps borgmatic is taking), or 2 (debug)', ) global_group.add_argument( '--log-file-verbosity', type=int, choices=range(-2, 3), default=1, help='When --log-file is given, log verbose progress to file: -2 (disabled), -1 (errors only), 0 (responses to actions), 1 (info about steps borgmatic is taking, the default), or 2 (debug)', ) global_group.add_argument( '--monitoring-verbosity', type=int, choices=range(-2, 3), default=1, help='When a monitoring integration supporting logging is configured, log verbose progress to it: -2 (disabled), -1 (errors only), responses to actions (0), 1 (info about steps borgmatic is taking, the default), or 2 (debug)', ) global_group.add_argument( '--log-file', type=str, help='Write log messages to this file instead of syslog', ) global_group.add_argument( '--log-file-format', type=str, help='Python format string used for log messages written to the log file', ) global_group.add_argument( '--log-json', action='store_true', help='Write Borg log messages and console output as one JSON object per log line instead of formatted text', ) global_group.add_argument( '--override', metavar='OPTION.SUBOPTION=VALUE', dest='overrides', action='append', help='Configuration file option to override with specified value, see documentation for overriding list or key/value options, can specify flag multiple times', ) global_group.add_argument( '--no-environment-interpolation', dest='resolve_env', action='store_false', help='Do not resolve environment variables in configuration file', ) global_group.add_argument( '--bash-completion', default=False, action='store_true', help='Show bash completion script and exit', ) global_group.add_argument( '--fish-completion', default=False, action='store_true', help='Show fish completion script and exit', ) global_group.add_argument( '--version', dest='version', default=False, action='store_true', help='Display installed version number of borgmatic and exit', ) global_plus_action_parser = ArgumentParser( description=''' Simple, configuration-driven backup software for servers and workstations. If no actions are given, then borgmatic defaults to: create, prune, compact, and check. ''', parents=[global_parser], ) action_parsers = global_plus_action_parser.add_subparsers( title='actions', metavar='', help='Specify zero or more actions. Defaults to create, prune, compact, and check. Use --help with action for details:', ) 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, ) 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, ) 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]', ) 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, quoted globs supported', ) 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]', ) repo_create_group.add_argument( '--append-only', action='store_true', help='Create an append-only repository', ) repo_create_group.add_argument( '--storage-quota', help='Create a repository with a fixed storage quota', ) repo_create_group.add_argument( '--make-parent-dirs', action='store_true', help='Create any missing parent directories of the repository directory', ) repo_create_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) transfer_parser = action_parsers.add_parser( 'transfer', aliases=ACTION_ALIASES['transfer'], help='Transfer archives from one repository to another, optionally upgrading the transferred data [Borg 2.0+ only]', description='Transfer archives from one repository to another, optionally upgrading the transferred data [Borg 2.0+ only]', add_help=False, ) 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, quoted globs supported', ) transfer_group.add_argument( '--source-repository', help='Path of existing source repository to transfer archives from', required=True, ) transfer_group.add_argument( '--archive', help='Name or hash of a single archive to transfer (or "latest"), defaults to transferring all archives', ) transfer_group.add_argument( '--upgrader', help='Upgrader type used to convert the transferred data, e.g. "From12To20" to upgrade data from Borg 1.2 to 2.0 format, defaults to no conversion', ) transfer_group.add_argument( '--progress', default=False, action='store_true', help='Display progress as each archive is transferred', ) transfer_group.add_argument( '-a', '--match-archives', '--glob-archives', metavar='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' ) transfer_group.add_argument( '--first', metavar='N', help='Only transfer first N archives after other filters are applied', ) transfer_group.add_argument( '--last', metavar='N', help='Only transfer last N archives after other filters are applied' ) transfer_group.add_argument( '--oldest', metavar='TIMESPAN', help='Transfer archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]', ) transfer_group.add_argument( '--newest', metavar='TIMESPAN', help='Transfer 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]', ) transfer_group.add_argument( '--older', metavar='TIMESPAN', help='Transfer archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) transfer_group.add_argument( '--newer', metavar='TIMESPAN', help='Transfer archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) transfer_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) prune_parser = action_parsers.add_parser( 'prune', aliases=ACTION_ALIASES['prune'], help='Prune archives according to the retention policy (with Borg 1.2+, you must run compact afterwards to actually free space)', description='Prune archives according to the retention policy (with Borg 1.2+, you must run compact afterwards to actually free space)', add_help=False, ) 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), 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 the pruned archive', ) prune_group.add_argument( '--list', dest='list_archives', action='store_true', help='List archives kept/pruned' ) prune_group.add_argument( '--oldest', metavar='TIMESPAN', help='Prune archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]', ) prune_group.add_argument( '--newest', metavar='TIMESPAN', help='Prune 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]', ) prune_group.add_argument( '--older', metavar='TIMESPAN', help='Prune archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) prune_group.add_argument( '--newer', metavar='TIMESPAN', help='Prune archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') compact_parser = action_parsers.add_parser( 'compact', aliases=ACTION_ALIASES['compact'], help='Compact segments to free space [Borg 1.2+, borgmatic 1.5.23+ only]', description='Compact segments to free space [Borg 1.2+, borgmatic 1.5.23+ only]', add_help=False, ) 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), quoted globs supported', ) compact_group.add_argument( '--progress', dest='progress', default=False, action='store_true', help='Display progress as each segment is compacted', ) compact_group.add_argument( '--cleanup-commits', dest='cleanup_commits', default=False, action='store_true', help='Cleanup commit-only 17-byte segment files left behind by Borg 1.1 [flag in Borg 1.2 only]', ) compact_group.add_argument( '--threshold', type=int, dest='threshold', help='Minimum saved space percentage threshold for compacting a segment, defaults to 10', ) compact_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) create_parser = action_parsers.add_parser( 'create', aliases=ACTION_ALIASES['create'], help='Create an archive (actually perform a backup)', description='Create an archive (actually perform a backup)', add_help=False, ) 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), quoted globs supported', ) create_group.add_argument( '--progress', dest='progress', default=False, action='store_true', help='Display progress for each file as it is backed up', ) create_group.add_argument( '--stats', dest='stats', default=False, action='store_true', help='Display statistics of archive', ) create_group.add_argument( '--list', '--files', dest='list_files', action='store_true', help='Show per-file details' ) create_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) create_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') check_parser = action_parsers.add_parser( 'check', aliases=ACTION_ALIASES['check'], help='Check archives for consistency', description='Check archives for consistency', add_help=False, ) 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), quoted globs supported', ) check_group.add_argument( '--progress', dest='progress', default=False, action='store_true', help='Display progress for each file as it is checked', ) check_group.add_argument( '--repair', dest='repair', default=False, 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, hashes, or series matching this pattern', ) check_group.add_argument( '--only', metavar='CHECK', choices=('repository', 'archives', 'data', 'extract', 'spot'), dest='only_checks', action='append', help='Run a particular consistency check (repository, archives, data, extract, or spot) instead of configured checks (subject to configured frequency, can specify flag multiple times)', ) check_group.add_argument( '--force', default=False, action='store_true', help='Ignore configured check frequencies and run checks unconditionally', ) 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'], help='Extract files from a named archive to the current directory', description='Extract a named archive to the current directory', add_help=False, ) 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, quoted globs supported', ) extract_group.add_argument( '--archive', help='Name or hash of a single archive to extract (or "latest")', required=True ) extract_group.add_argument( '--path', '--restore-path', metavar='PATH', dest='paths', action='append', help='Path to extract from archive, can specify flag multiple times, defaults to the entire archive', ) extract_group.add_argument( '--destination', metavar='PATH', dest='destination', help='Directory to extract files into, defaults to the current directory', ) extract_group.add_argument( '--strip-components', type=lambda number: number if number == 'all' else int(number), metavar='NUMBER', help='Number of leading path components to remove from each extracted path or "all" to strip all leading path components. Skip paths with fewer elements', ) extract_group.add_argument( '--progress', dest='progress', default=False, action='store_true', help='Display progress for each file as it is extracted', ) extract_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) config_parser = action_parsers.add_parser( 'config', aliases=ACTION_ALIASES['config'], help='Perform configuration file related operations', description='Perform configuration file related operations', add_help=False, ) config_group = config_parser.add_argument_group('config arguments') config_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') config_parsers = config_parser.add_subparsers( title='config sub-actions', ) config_bootstrap_parser = config_parsers.add_parser( 'bootstrap', help='Extract the borgmatic configuration files from a named archive', description='Extract the borgmatic configuration files from a named archive', add_help=False, ) config_bootstrap_group = config_bootstrap_parser.add_argument_group( 'config bootstrap arguments' ) config_bootstrap_group.add_argument( '--repository', 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='Deprecated. Path formerly used for temporary runtime data like bootstrap metadata. Defaults to ~/.borgmatic', ) config_bootstrap_group.add_argument( '--archive', help='Name or hash of a single archive to extract config files from, defaults to "latest"', default='latest', ) config_bootstrap_group.add_argument( '--destination', metavar='PATH', dest='destination', help='Directory to extract config files into, defaults to /', default='/', ) config_bootstrap_group.add_argument( '--strip-components', type=lambda number: number if number == 'all' else int(number), metavar='NUMBER', help='Number of leading path components to remove from each extracted path or "all" to strip all leading path components. Skip paths with fewer elements', ) config_bootstrap_group.add_argument( '--progress', dest='progress', default=False, action='store_true', help='Display progress for each file as it is extracted', ) config_bootstrap_group.add_argument( '--ssh-command', metavar='COMMAND', help='Command to use instead of "ssh"', ) config_bootstrap_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) config_generate_parser = config_parsers.add_parser( 'generate', help='Generate a sample borgmatic configuration file', description='Generate a sample borgmatic configuration file', add_help=False, ) config_generate_group = config_generate_parser.add_argument_group('config generate arguments') config_generate_group.add_argument( '-s', '--source', dest='source_filename', help='Optional configuration file to merge into the generated configuration, useful for upgrading your configuration', ) config_generate_group.add_argument( '-d', '--destination', dest='destination_filename', default=config_paths[0], help=f'Destination configuration file, default: {unexpanded_config_paths[0]}', ) config_generate_group.add_argument( '--overwrite', default=False, action='store_true', help='Whether to overwrite any existing destination file, defaults to false', ) config_generate_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) config_validate_parser = config_parsers.add_parser( 'validate', help='Validate borgmatic configuration files specified with --config (see borgmatic --help)', description='Validate borgmatic configuration files specified with --config (see borgmatic --help)', add_help=False, ) config_validate_group = config_validate_parser.add_argument_group('config validate arguments') config_validate_group.add_argument( '-s', '--show', action='store_true', help='Show the validated configuration after all include merging has occurred', ) config_validate_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) export_tar_parser = action_parsers.add_parser( 'export-tar', aliases=ACTION_ALIASES['export-tar'], help='Export an archive to a tar-formatted file or stream', description='Export an archive to a tar-formatted file or stream', add_help=False, ) 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, quoted globs supported', ) export_tar_group.add_argument( '--archive', help='Name or hash of a single archive to export (or "latest")', required=True ) export_tar_group.add_argument( '--path', metavar='PATH', dest='paths', action='append', help='Path to export from archive, can specify flag multiple times, defaults to the entire archive', ) export_tar_group.add_argument( '--destination', metavar='PATH', dest='destination', help='Path to destination export tar file, or "-" for stdout (but be careful about dirtying output with --verbosity or --list)', required=True, ) export_tar_group.add_argument( '--tar-filter', help='Name of filter program to pipe data through' ) export_tar_group.add_argument( '--list', '--files', dest='list_files', action='store_true', help='Show per-file details' ) export_tar_group.add_argument( '--strip-components', type=int, metavar='NUMBER', dest='strip_components', help='Number of leading path components to remove from each exported path. Skip paths with fewer elements', ) export_tar_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) mount_parser = action_parsers.add_parser( 'mount', aliases=ACTION_ALIASES['mount'], help='Mount files from a named archive as a FUSE filesystem', description='Mount a named archive as a FUSE filesystem', add_help=False, ) 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, quoted globs supported', ) mount_group.add_argument( '--archive', help='Name or hash of a single archive to mount (or "latest")' ) mount_group.add_argument( '--mount-point', metavar='PATH', dest='mount_point', help='Path where filesystem is to be mounted', required=True, ) mount_group.add_argument( '--path', metavar='PATH', dest='paths', action='append', help='Path to mount from archive, can specify multiple times, defaults to the entire archive', ) mount_group.add_argument( '--foreground', dest='foreground', default=False, action='store_true', help='Stay in foreground until ctrl-C is pressed', ) mount_group.add_argument( '--first', metavar='N', help='Mount first N archives after other filters are applied', ) mount_group.add_argument( '--last', metavar='N', help='Mount last N archives after other filters are applied' ) mount_group.add_argument( '--oldest', metavar='TIMESPAN', help='Mount archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]', ) mount_group.add_argument( '--newest', metavar='TIMESPAN', help='Mount 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]', ) mount_group.add_argument( '--older', metavar='TIMESPAN', help='Mount archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) mount_group.add_argument( '--newer', metavar='TIMESPAN', help='Mount archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) mount_group.add_argument('--options', dest='options', help='Extra Borg mount options') mount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') umount_parser = action_parsers.add_parser( 'umount', aliases=ACTION_ALIASES['umount'], help='Unmount a FUSE filesystem that was mounted with "borgmatic mount"', description='Unmount a mounted FUSE filesystem', add_help=False, ) umount_group = umount_parser.add_argument_group('umount arguments') umount_group.add_argument( '--mount-point', metavar='PATH', dest='mount_point', help='Path of filesystem to unmount', required=True, ) 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'], help='Restore data source (e.g. database) dumps from a named archive', description='Restore data source (e.g. database) dumps from a named archive. (To extract files instead, use "borgmatic extract".)', add_help=False, ) 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, quoted globs supported', ) restore_group.add_argument( '--archive', help='Name or hash of a single archive to restore from (or "latest")', required=True, ) restore_group.add_argument( '--data-source', '--database', metavar='NAME', dest='data_sources', action='append', 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', metavar='NAME', dest='schemas', action='append', help='Name of schema to restore from the data source, can specify flag multiple times, defaults to all schemas. Schemas are only supported for PostgreSQL and MongoDB databases', ) restore_group.add_argument( '--hostname', help='Database hostname to restore to. Defaults to the "restore_hostname" option in borgmatic\'s configuration', ) restore_group.add_argument( '--port', help='Database port to restore to. Defaults to the "restore_port" option in borgmatic\'s configuration', ) restore_group.add_argument( '--username', help='Username with which to connect to the database. Defaults to the "restore_username" option in borgmatic\'s configuration', ) restore_group.add_argument( '--password', help='Password with which to connect to the restore database. Defaults to the "restore_password" option in borgmatic\'s configuration', ) restore_group.add_argument( '--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' ) 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, ) 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, quoted globs supported', ) repo_list_group.add_argument( '--short', default=False, action='store_true', help='Output only archive names' ) 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' ) repo_list_group.add_argument( '-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix' ) repo_list_group.add_argument( '-a', '--match-archives', '--glob-archives', metavar='PATTERN', help='Only list archive names, hashes, or series matching this pattern', ) repo_list_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' ) repo_list_group.add_argument( '--first', metavar='N', help='List first N archives after other filters are applied' ) repo_list_group.add_argument( '--last', metavar='N', help='List last N archives after other filters are applied' ) 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]', ) 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]', ) 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]', ) 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]', ) 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', aliases=ACTION_ALIASES['list'], help='List archive', description='List the files in an archive or search for a file across archives', add_help=False, ) 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, quoted globs supported', ) list_group.add_argument( '--archive', help='Name or hash of a single archive to list (or "latest")' ) list_group.add_argument( '--path', metavar='PATH', dest='paths', action='append', help='Path or pattern to list from a single selected archive (via "--archive"), can specify flag multiple times, defaults to listing the entire archive', ) list_group.add_argument( '--find', metavar='PATH', dest='find_paths', action='append', help='Partial path or pattern to search for and list across multiple archives, can specify flag multiple times', ) list_group.add_argument( '--short', default=False, action='store_true', help='Output only path names' ) list_group.add_argument('--format', help='Format for file listing') list_group.add_argument( '--json', default=False, action='store_true', help='Output results as JSON' ) list_group.add_argument( '-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix' ) list_group.add_argument( '-a', '--match-archives', '--glob-archives', metavar='PATTERN', help='Only list archive names matching this pattern', ) list_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' ) list_group.add_argument( '--first', metavar='N', help='List first N archives after other filters are applied' ) list_group.add_argument( '--last', metavar='N', help='List last N archives after other filters are applied' ) list_group.add_argument( '-e', '--exclude', metavar='PATTERN', help='Exclude paths matching the pattern' ) list_group.add_argument( '--exclude-from', metavar='FILENAME', help='Exclude paths from exclude file, one per line' ) list_group.add_argument('--pattern', help='Include or exclude paths matching a pattern') list_group.add_argument( '--patterns-from', metavar='FILENAME', help='Include or exclude paths matching patterns from pattern file, one per line', ) list_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') 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, ) 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, quoted globs supported', ) repo_info_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) repo_info_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) info_parser = action_parsers.add_parser( 'info', aliases=ACTION_ALIASES['info'], help='Show archive summary information such as disk space used', description='Show archive summary information such as disk space used', add_help=False, ) 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, quoted globs supported', ) info_group.add_argument( '--archive', help='Archive name, hash, or series to show info for (or "latest")' ) info_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) info_group.add_argument( '-P', '--prefix', help='Deprecated. Only show info for archive names starting with this prefix', ) info_group.add_argument( '-a', '--match-archives', '--glob-archives', metavar='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' ) info_group.add_argument( '--first', metavar='N', help='Show info for first N archives after other filters are applied', ) info_group.add_argument( '--last', metavar='N', help='Show info for last N archives after other filters are applied' ) info_group.add_argument( '--oldest', metavar='TIMESPAN', help='Show info for archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]', ) info_group.add_argument( '--newest', metavar='TIMESPAN', help='Show info for 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]', ) info_group.add_argument( '--older', metavar='TIMESPAN', help='Show info for archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) info_group.add_argument( '--newer', metavar='TIMESPAN', help='Show info for archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) info_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') break_lock_parser = action_parsers.add_parser( 'break-lock', aliases=ACTION_ALIASES['break-lock'], help='Break the repository and cache locks left behind by Borg aborting', description='Break Borg repository and cache locks left behind by Borg aborting', add_help=False, ) 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, quoted globs supported', ) break_lock_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' ) key_parser = action_parsers.add_parser( 'key', aliases=ACTION_ALIASES['key'], help='Perform repository key related operations', description='Perform repository key related operations', add_help=False, ) key_group = key_parser.add_argument_group('key arguments') key_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') key_parsers = key_parser.add_subparsers( title='key sub-actions', ) key_export_parser = key_parsers.add_parser( 'export', help='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged', description='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged', add_help=False, ) key_export_group = key_export_parser.add_argument_group('key export arguments') key_export_group.add_argument( '--paper', action='store_true', help='Export the key in a text format suitable for printing and later manual entry', ) key_export_group.add_argument( '--qr-html', action='store_true', help='Export the key in an HTML format suitable for printing and later manual entry or QR code scanning', ) 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, quoted globs supported', ) key_export_group.add_argument( '--path', metavar='PATH', help='Path to export the key to, defaults to stdout (but be careful about dirtying the output with --verbosity)', ) key_export_group.add_argument( '-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'], help='Run an arbitrary Borg command', description="Run an arbitrary Borg command based on borgmatic's configuration", add_help=False, ) 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, quoted globs supported', ) borg_group.add_argument( '--archive', help='Archive name, hash, or series to pass to Borg (or "latest")' ) borg_group.add_argument( '--', metavar='OPTION', dest='options', nargs='+', help='Options to pass to Borg, command first ("create", "list", etc). "--" is optional. To specify the repository or the archive, you must use --repository or --archive instead of providing them here.', ) borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') return global_parser, action_parsers, global_plus_action_parser def parse_arguments(*unparsed_arguments): ''' Given command-line arguments with which this script was invoked, parse the arguments and return them as a dict mapping from action name (or "global") to an argparse.Namespace instance. Raise ValueError if the arguments cannot be parsed. Raise SystemExit with an error code of 0 if "--help" was requested. ''' global_parser, action_parsers, global_plus_action_parser = make_parsers() arguments, remaining_action_arguments = parse_arguments_for_actions( unparsed_arguments, action_parsers.choices, global_parser ) if not arguments['global'].config_paths: arguments['global'].config_paths = collect.get_default_config_paths(expand_home=True) for action_name in ('bootstrap', 'generate', 'validate'): if ( action_name in arguments.keys() and len(arguments.keys()) > 2 ): # 2 = 1 for 'global' + 1 for the action raise ValueError( f'The {action_name} action cannot be combined with other actions. Please run it separately.' ) unknown_arguments = get_unparsable_arguments(remaining_action_arguments) if unknown_arguments: if '--help' in unknown_arguments or '-h' in unknown_arguments: global_plus_action_parser.print_help() sys.exit(0) global_plus_action_parser.print_usage() raise ValueError( f"Unrecognized argument{'s' if len(unknown_arguments) > 1 else ''}: {' '.join(unknown_arguments)}" ) if 'create' in arguments and arguments['create'].list_files and arguments['create'].progress: raise ValueError( 'With the create action, only one of --list (--files) and --progress flags can be used.' ) if 'create' in arguments and arguments['create'].list_files and arguments['create'].json: raise ValueError( 'With the create action, only one of --list (--files) and --json flags can be used.' ) if ( ('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 ('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.') if ( 'transfer' in arguments and arguments['transfer'].archive and arguments['transfer'].match_archives ): raise ValueError( 'With the transfer action, only one of --archive and --match-archives flags can be used.' ) if 'list' in arguments and (arguments['list'].prefix and arguments['list'].match_archives): raise ValueError( 'With the list action, only one of --prefix or --match-archives flags can be used.' ) if 'repo-list' in arguments and ( arguments['repo-list'].prefix and arguments['repo-list'].match_archives ): raise ValueError( 'With the repo-list action, only one of --prefix or --match-archives flags can be used.' ) if 'info' in arguments and ( (arguments['info'].archive and arguments['info'].prefix) or (arguments['info'].archive and arguments['info'].match_archives) or (arguments['info'].prefix and arguments['info'].match_archives) ): raise ValueError( 'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.' ) if 'borg' in arguments and arguments['global'].dry_run: raise ValueError('With the borg action, --dry-run is not supported.') return arguments borgmatic/borgmatic/commands/borgmatic.py000066400000000000000000001051151476361726000211350ustar00rootroot00000000000000import collections import importlib.metadata import json import logging import os import sys import time from queue import Queue from subprocess import CalledProcessError 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 import borgmatic.actions.info import borgmatic.actions.list import borgmatic.actions.mount import borgmatic.actions.prune 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.transfer import borgmatic.commands.completion.bash import borgmatic.commands.completion.fish 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 from borgmatic.hooks.monitoring import monitor from borgmatic.logger import ( DISABLED, Log_prefix, add_custom_log_levels, configure_delayed_logging, configure_logging, should_do_markup, ) from borgmatic.signals import configure_signals from borgmatic.verbosity import verbosity_to_log_level logger = logging.getLogger(__name__) def get_skip_actions(config, arguments): ''' Given a configuration dict and command-line arguments as an argparse.Namespace, return a list of the configured action names to skip. Omit "check" from this list though if "check --force" is part of the command-like arguments. ''' skip_actions = config.get('skip_actions', []) if 'check' in arguments and arguments['check'].force: return [action for action in skip_actions if action != 'check'] return skip_actions def run_configuration(config_filename, config, config_paths, arguments): ''' Given a config filename, the corresponding parsed config dict, a sequence of loaded configuration paths, and command-line arguments as a dict from subparser name to a namespace of parsed arguments, execute the defined create, prune, compact, check, and/or other actions. Yield a combination of: * JSON output strings from successfully executing any actions that produce JSON * logging.LogRecord instances containing errors from any actions or backup hooks that fail ''' global_arguments = arguments['global'] local_path = config.get('local_path', 'borg') remote_path = config.get('remote_path') retries = config.get('retries', 0) retry_wait = config.get('retry_wait', 0) encountered_error = None error_repository = '' using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments) monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity) monitoring_hooks_are_activated = using_primary_action and monitoring_log_level != DISABLED skip_actions = get_skip_actions(config, arguments) if skip_actions: logger.debug( f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions" ) try: local_borg_version = borg_version.local_borg_version(config, local_path) logger.debug(f'Borg {local_borg_version}') except (OSError, CalledProcessError, ValueError) as error: yield from log_error_records(f'{config_filename}: Error getting local Borg version', error) return try: if monitoring_hooks_are_activated: dispatch.call_hooks( 'initialize_monitor', config, dispatch.Hook_type.MONITORING, config_filename, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'ping_monitor', config, dispatch.Hook_type.MONITORING, config_filename, monitor.State.START, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(error): return encountered_error = error yield from log_error_records(f'{config_filename}: Error pinging monitor', error) if not encountered_error: repo_queue = Queue() for repo in config['repositories']: repo_queue.put( (repo, 0), ) while not repo_queue.empty(): repository, retry_num = repo_queue.get() with Log_prefix(repository.get('label', repository['path'])): logger.debug('Running actions for repository') timeout = retry_num * retry_wait if timeout: logger.warning(f'Sleeping {timeout}s before next retry') time.sleep(timeout) try: yield from run_actions( arguments=arguments, config_filename=config_filename, config=config, config_paths=config_paths, local_path=local_path, remote_path=remote_path, local_borg_version=local_borg_version, repository=repository, ) except (OSError, CalledProcessError, ValueError) as error: if retry_num < retries: repo_queue.put( (repository, retry_num + 1), ) tuple( # Consume the generator so as to trigger logging. log_error_records( 'Error running actions for repository', error, levelno=logging.WARNING, log_command_error_output=True, ) ) logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}') continue if command.considered_soft_failure(error): continue yield from log_error_records( 'Error running actions for repository', error, ) encountered_error = error error_repository = repository['path'] try: if monitoring_hooks_are_activated: # Send logs irrespective of error. dispatch.call_hooks( 'ping_monitor', config, dispatch.Hook_type.MONITORING, config_filename, monitor.State.LOG, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if not command.considered_soft_failure(error): encountered_error = error yield from log_error_records('Error pinging monitor', error) if not encountered_error: try: if monitoring_hooks_are_activated: dispatch.call_hooks( 'ping_monitor', config, dispatch.Hook_type.MONITORING, config_filename, monitor.State.FINISH, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'destroy_monitor', config, dispatch.Hook_type.MONITORING, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(error): return encountered_error = error yield from log_error_records(f'{config_filename}: Error pinging monitor', error) if encountered_error and using_primary_action: try: command.execute_hook( config.get('on_error'), config.get('umask'), config_filename, 'on-error', global_arguments.dry_run, repository=error_repository, error=encountered_error, output=getattr(encountered_error, 'output', ''), ) dispatch.call_hooks( 'ping_monitor', config, dispatch.Hook_type.MONITORING, config_filename, monitor.State.FAIL, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'destroy_monitor', config, dispatch.Hook_type.MONITORING, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(error): return yield from log_error_records(f'{config_filename}: Error running on-error hook', error) def run_actions( *, arguments, config_filename, config, config_paths, local_path, remote_path, local_borg_version, repository, ): ''' Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration filename, a configuration dict, a sequence of loaded configuration paths, local and remote paths to Borg, a local Borg version string, and a repository name, run all actions from the command-line arguments on the given repository. Yield JSON output strings from executing any actions that produce JSON. Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an action or a hook. Raise ValueError if the arguments or configuration passed to action are invalid. ''' add_custom_log_levels() repository_path = os.path.expanduser(repository['path']) global_arguments = arguments['global'] dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else '' hook_context = { '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']]), 'repository': repository_path, } skip_actions = set(get_skip_actions(config, arguments)) command.execute_hook( config.get('before_actions'), config.get('umask'), config_filename, 'pre-actions', global_arguments.dry_run, **hook_context, ) for action_name, action_arguments in arguments.items(): if action_name == 'repo-create' and action_name not in skip_actions: borgmatic.actions.repo_create.run_repo_create( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'transfer' and action_name not in skip_actions: borgmatic.actions.transfer.run_transfer( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'create' and action_name not in skip_actions: yield from borgmatic.actions.create.run_create( config_filename, repository, config, config_paths, hook_context, local_borg_version, action_arguments, global_arguments, dry_run_label, local_path, remote_path, ) elif action_name == 'prune' and action_name not in skip_actions: borgmatic.actions.prune.run_prune( config_filename, repository, config, hook_context, local_borg_version, action_arguments, global_arguments, dry_run_label, local_path, remote_path, ) elif action_name == 'compact' and action_name not in skip_actions: borgmatic.actions.compact.run_compact( config_filename, repository, config, hook_context, local_borg_version, action_arguments, global_arguments, dry_run_label, local_path, remote_path, ) elif action_name == 'check' and action_name not in skip_actions: if checks.repository_enabled_for_checks(repository, config): borgmatic.actions.check.run_check( config_filename, repository, config, hook_context, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'extract' and action_name not in skip_actions: borgmatic.actions.extract.run_extract( config_filename, repository, config, hook_context, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'export-tar' and action_name not in skip_actions: borgmatic.actions.export_tar.run_export_tar( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'mount' and action_name not in skip_actions: borgmatic.actions.mount.run_mount( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'restore' and action_name not in skip_actions: borgmatic.actions.restore.run_restore( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) 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, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'list' and action_name not in skip_actions: yield from borgmatic.actions.list.run_list( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) 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, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'info' and action_name not in skip_actions: yield from borgmatic.actions.info.run_info( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'break-lock' and action_name not in skip_actions: borgmatic.actions.break_lock.run_break_lock( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'export' and action_name not in skip_actions: borgmatic.actions.export_key.run_export_key( repository, config, local_borg_version, action_arguments, global_arguments, 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, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) command.execute_hook( config.get('after_actions'), config.get('umask'), config_filename, 'post-actions', global_arguments.dry_run, **hook_context, ) def load_configurations(config_filenames, overrides=None, resolve_env=True): ''' Given a sequence of configuration filenames, a sequence of configuration file override strings in the form of "option.suboption=value", and whether to resolve environment variables, load and validate each configuration file. Return the results as a tuple of: dict of configuration filename to corresponding parsed configuration, a sequence of paths for all loaded configuration files (including includes), and a sequence of logging.LogRecord instances containing any parse errors. Log records are returned here instead of being logged directly because logging isn't yet initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this approach could change.) ''' # Dict mapping from config filename to corresponding parsed config dict. configs = collections.OrderedDict() config_paths = set() logs = [] # Parse and load each configuration file. for config_filename in config_filenames: logs.extend( [ logging.makeLogRecord( dict( levelno=logging.DEBUG, levelname='DEBUG', msg=f'{config_filename}: Loading configuration file', ) ), ] ) try: configs[config_filename], paths, parse_logs = validate.parse_configuration( config_filename, validate.schema_filename(), overrides, resolve_env, ) config_paths.update(paths) logs.extend(parse_logs) except PermissionError: logs.extend( [ logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: Insufficient permissions to read configuration file', ) ), ] ) except (ValueError, OSError, validate.Validation_error) as error: logs.extend( [ logging.makeLogRecord( dict( levelno=logging.CRITICAL, levelname='CRITICAL', msg=f'{config_filename}: Error parsing configuration file', ) ), logging.makeLogRecord( dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error)) ), ] ) return (configs, sorted(config_paths), logs) def log_record(suppress_log=False, **kwargs): ''' Create a log record based on the given makeLogRecord() arguments, one of which must be named "levelno". Log the record (unless suppress log is set) and return it. ''' record = logging.makeLogRecord(kwargs) if suppress_log: return record logger.handle(record) return record BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62 def log_error_records( message, error=None, levelno=logging.CRITICAL, log_command_error_output=False ): ''' Given error message text, an optional exception object, an optional log level, and whether to log the error output of a CalledProcessError (if any), log error summary information and also yield it as a series of logging.LogRecord instances. Note that because the logs are yielded as a generator, logs won't get logged unless you consume the generator output. ''' level_name = logging._levelToName[levelno] if not error: 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=str(message)) if error.output: try: output = error.output.decode('utf-8') except (UnicodeDecodeError, AttributeError): output = error.output # Suppress these logs for now and save the error output for the log summary at the end. # Log a separate record per line, as some errors can be really verbose and overflow the # per-record size limits imposed by some logging backends. for output_line in output.splitlines(): yield log_record( levelno=levelno, levelname=level_name, msg=output_line, suppress_log=True, ) 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=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. pass def get_local_path(configs): ''' Arbitrarily return the local path from the first configuration dict. Default to "borg" if not set. ''' return next(iter(configs.values())).get('local_path', 'borg') def collect_highlander_action_summary_logs(configs, arguments, configuration_parse_errors): ''' Given a dict of configuration filename to corresponding parsed configuration, parsed command-line arguments as a dict from subparser name to a parsed namespace of arguments, and whether any configuration files encountered errors during parsing, run a highlander action specified in the arguments, if any, and yield a series of logging.LogRecord instances containing summary information. A highlander action is an action that cannot coexist with other actions on the borgmatic command-line, and borgmatic exits after processing such an action. ''' add_custom_log_levels() if 'bootstrap' in arguments: try: # No configuration file is needed for bootstrap. 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 try: borgmatic.actions.config.bootstrap.run_bootstrap( arguments['bootstrap'], arguments['global'], local_borg_version ) yield logging.makeLogRecord( dict( levelno=logging.ANSWER, levelname='ANSWER', msg='Bootstrap successful', ) ) except ( CalledProcessError, ValueError, OSError, ) as error: yield from log_error_records(error) return if 'generate' in arguments: try: borgmatic.actions.config.generate.run_generate( arguments['generate'], arguments['global'] ) yield logging.makeLogRecord( dict( levelno=logging.ANSWER, levelname='ANSWER', msg='Generate successful', ) ) except ( CalledProcessError, ValueError, OSError, ) as error: yield from log_error_records(error) return if 'validate' in arguments: if configuration_parse_errors: yield logging.makeLogRecord( dict( levelno=logging.CRITICAL, levelname='CRITICAL', msg='Configuration validation failed', ) ) return try: borgmatic.actions.config.validate.run_validate(arguments['validate'], configs) yield logging.makeLogRecord( dict( levelno=logging.ANSWER, levelname='ANSWER', msg='All configuration files are valid', ) ) except ( CalledProcessError, ValueError, OSError, ) as error: yield from log_error_records(error) return def collect_configuration_run_summary_logs(configs, config_paths, arguments): ''' Given a dict of configuration filename to corresponding parsed configuration, a sequence of loaded configuration paths, and parsed command-line arguments as a dict from subparser name to a parsed namespace of arguments, run each configuration file and yield a series of logging.LogRecord instances containing summary information about each run. As a side effect of running through these configuration files, output their JSON results, if any, to stdout. ''' # Run cross-file validation checks. repository = None for action_name, action_arguments in arguments.items(): if hasattr(action_arguments, 'repository'): repository = getattr(action_arguments, 'repository') break try: validate.guard_configuration_contains_repository(repository, configs) except ValueError as error: yield from log_error_records(str(error)) return if not configs: yield from log_error_records( f"{' '.join(arguments['global'].config_paths)}: No valid configuration files found", ) return if 'create' in arguments: try: for config_filename, config in configs.items(): command.execute_hook( config.get('before_everything'), config.get('umask'), config_filename, 'pre-everything', arguments['global'].dry_run, ) except (CalledProcessError, ValueError, OSError) as error: yield from log_error_records('Error running pre-everything hook', error) return # Execute the actions corresponding to each configuration file. json_results = [] for config_filename, config in configs.items(): with Log_prefix(config_filename): results = list(run_configuration(config_filename, config, config_paths, arguments)) error_logs = tuple( result for result in results if isinstance(result, logging.LogRecord) ) if error_logs: yield from log_error_records('An error occurred') yield from error_logs else: yield logging.makeLogRecord( dict( levelno=logging.INFO, levelname='INFO', msg='Successfully ran configuration file', ) ) if results: json_results.extend(results) if 'umount' in arguments: logger.info(f"Unmounting mount point {arguments['umount'].mount_point}") try: borg_umount.unmount_archive( config, mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs), ) except (CalledProcessError, OSError) as error: yield from log_error_records('Error unmounting mount point', error) if json_results: sys.stdout.write(json.dumps(json_results)) if 'create' in arguments: try: for config_filename, config in configs.items(): command.execute_hook( config.get('after_everything'), config.get('umask'), config_filename, 'post-everything', arguments['global'].dry_run, ) except (CalledProcessError, ValueError, OSError) as error: yield from log_error_records('Error running post-everything hook', error) def exit_with_help_link(): # pragma: no cover ''' Display a link to get help and exit with an error code. ''' logger.critical('') logger.critical('Need some help? https://torsion.org/borgmatic/#issues') sys.exit(1) def main(extra_summary_logs=[]): # pragma: no cover configure_signals() configure_delayed_logging() try: arguments = parse_arguments(*sys.argv[1:]) except ValueError as error: configure_logging(logging.CRITICAL) logger.critical(error) exit_with_help_link() except SystemExit as error: if error.code == 0: raise error configure_logging(logging.CRITICAL) logger.critical(f"Error parsing arguments: {' '.join(sys.argv)}") exit_with_help_link() global_arguments = arguments['global'] if global_arguments.version: print(importlib.metadata.version('borgmatic')) sys.exit(0) if global_arguments.bash_completion: print(borgmatic.commands.completion.bash.bash_completion()) sys.exit(0) if global_arguments.fish_completion: print(borgmatic.commands.completion.fish.fish_completion()) sys.exit(0) validate = bool('validate' in arguments) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) configs, config_paths, parse_logs = load_configurations( config_filenames, global_arguments.overrides, resolve_env=global_arguments.resolve_env and not validate, ) configuration_parse_errors = ( (max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False ) any_json_flags = any( 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) try: configure_logging( verbosity_to_log_level(global_arguments.verbosity), verbosity_to_log_level(global_arguments.syslog_verbosity), verbosity_to_log_level(global_arguments.log_file_verbosity), verbosity_to_log_level(global_arguments.monitoring_verbosity), global_arguments.log_file, global_arguments.log_file_format, color_enabled=color_enabled, ) except (FileNotFoundError, PermissionError) as error: configure_logging(logging.CRITICAL) logger.critical(f'Error configuring logging: {error}') exit_with_help_link() summary_logs = ( extra_summary_logs + parse_logs + ( list( collect_highlander_action_summary_logs( configs, arguments, configuration_parse_errors ) ) or list(collect_configuration_run_summary_logs(configs, config_paths, arguments)) ) ) summary_logs_max_level = max(log.levelno for log in summary_logs) for message in ('', 'summary:'): log_record( levelno=summary_logs_max_level, levelname=logging.getLevelName(summary_logs_max_level), msg=message, ) for log in summary_logs: logger.handle(log) if summary_logs_max_level >= logging.CRITICAL: exit_with_help_link() borgmatic/borgmatic/commands/completion/000077500000000000000000000000001476361726000207625ustar00rootroot00000000000000borgmatic/borgmatic/commands/completion/__init__.py000066400000000000000000000000001476361726000230610ustar00rootroot00000000000000borgmatic/borgmatic/commands/completion/actions.py000066400000000000000000000025461476361726000230030ustar00rootroot00000000000000import borgmatic.commands.arguments def upgrade_message(language: str, upgrade_command: str, completion_file: str): return f''' Your {language} completions script is from a different version of borgmatic than is currently installed. Please upgrade your script so your completions match the command-line flags in your installed borgmatic! Try this to upgrade: {upgrade_command} source {completion_file} ''' def available_actions(subparsers, current_action=None): ''' Given subparsers as an argparse._SubParsersAction instance and a current action name (if any), return the actions names that can follow the current action on a command-line. This takes into account which sub-actions that the current action supports. For instance, if "bootstrap" is a sub-action for "config", then "bootstrap" should be able to follow a current action of "config" but not "list". ''' action_to_subactions = borgmatic.commands.arguments.get_subactions_for_actions( subparsers.choices ) current_subactions = action_to_subactions.get(current_action) if current_subactions: return current_subactions all_subactions = set( subaction for subactions in action_to_subactions.values() for subaction in subactions ) return tuple(action for action in subparsers.choices.keys() if action not in all_subactions) borgmatic/borgmatic/commands/completion/bash.py000066400000000000000000000045261476361726000222600ustar00rootroot00000000000000import borgmatic.commands.arguments import borgmatic.commands.completion.actions def parser_flags(parser): ''' Given an argparse.ArgumentParser instance, return its argument flags in a space-separated string. ''' return ' '.join(option for action in parser._actions for option in action.option_strings) def bash_completion(): ''' Return a bash completion script for the borgmatic command. Produce this by introspecting borgmatic's command-line argument parsers. ''' ( unused_global_parser, action_parsers, global_plus_action_parser, ) = borgmatic.commands.arguments.make_parsers() global_flags = parser_flags(global_plus_action_parser) # Avert your eyes. return '\n'.join( ( 'check_version() {', ' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"', ' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"', ' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];' f''' then cat << EOF\n{borgmatic.commands.completion.actions.upgrade_message( 'bash', 'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"', '$BASH_SOURCE', )}\nEOF''', ' fi', '}', 'complete_borgmatic() {', ) + tuple( ''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}")) return 0 fi''' % ( action, parser_flags(action_parser), ' '.join( borgmatic.commands.completion.actions.available_actions(action_parsers, action) ), global_flags, ) for action, action_parser in reversed(action_parsers.choices.items()) ) + ( ' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003 % ( ' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)), global_flags, ), ' (check_version &)', '}', '\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic', ) ) borgmatic/borgmatic/commands/completion/fish.py000066400000000000000000000152011476361726000222640ustar00rootroot00000000000000import shlex from argparse import Action from textwrap import dedent import borgmatic.commands.arguments import borgmatic.commands.completion.actions def has_file_options(action: Action): ''' Given an argparse.Action instance, return True if it takes a file argument. ''' return action.metavar in ( 'FILENAME', 'PATH', ) or action.dest in ('config_paths',) def has_choice_options(action: Action): ''' Given an argparse.Action instance, return True if it takes one of a predefined set of arguments. ''' return action.choices is not None def has_unknown_required_param_options(action: Action): ''' A catch-all for options that take a required parameter, but we don't know what the parameter is. This should be used last. These are actions that take something like a glob, a list of numbers, or a string. Actions that match this pattern should not show the normal arguments, because those are unlikely to be valid. ''' return ( action.required is True or action.nargs in ( '+', '*', ) or action.metavar in ('PATTERN', 'KEYS', 'N') or (action.type is not None and action.default is None) ) def has_exact_options(action: Action): return ( has_file_options(action) or has_choice_options(action) or has_unknown_required_param_options(action) ) def exact_options_completion(action: Action): ''' Given an argparse.Action instance, return a completion invocation that forces file completions, options completion, or just that some value follow the action, if the action takes such an argument and was the last action on the command line prior to the cursor. Otherwise, return an empty string. ''' if not has_exact_options(action): return '' args = ' '.join(action.option_strings) if has_file_options(action): return f'''\ncomplete -c borgmatic -Fr -n "__borgmatic_current_arg {args}"''' if has_choice_options(action): return f'''\ncomplete -c borgmatic -f -a '{' '.join(map(str, action.choices))}' -n "__borgmatic_current_arg {args}"''' if has_unknown_required_param_options(action): return f'''\ncomplete -c borgmatic -x -n "__borgmatic_current_arg {args}"''' raise ValueError( f'Unexpected action: {action} passes has_exact_options but has no choices produced' ) def dedent_strip_as_tuple(string: str): ''' Dedent a string, then strip it to avoid requiring your first line to have content, then return a tuple of the string. Makes it easier to write multiline strings for completions when you join them with a tuple. ''' return (dedent(string).strip('\n'),) def fish_completion(): ''' Return a fish completion script for the borgmatic command. Produce this by introspecting borgmatic's command-line argument parsers. ''' ( unused_global_parser, action_parsers, global_plus_action_parser, ) = borgmatic.commands.arguments.make_parsers() all_action_parsers = ' '.join(action for action in action_parsers.choices.keys()) exact_option_args = tuple( ' '.join(action.option_strings) for action_parser in action_parsers.choices.values() for action in action_parser._actions if has_exact_options(action) ) + tuple( ' '.join(action.option_strings) for action in global_plus_action_parser._actions if len(action.option_strings) > 0 if has_exact_options(action) ) # Avert your eyes. return '\n'.join( dedent_strip_as_tuple( f''' function __borgmatic_check_version set -fx this_filename (status current-filename) fish -c ' if test -f "$this_filename" set this_script (cat $this_filename 2> /dev/null) set installed_script (borgmatic --fish-completion 2> /dev/null) if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ] echo "{borgmatic.commands.completion.actions.upgrade_message( 'fish', 'borgmatic --fish-completion | sudo tee $this_filename', '$this_filename', )}" end end ' & end __borgmatic_check_version function __borgmatic_current_arg --description 'Check if any of the given arguments are the last on the command line before the cursor' set -l all_args (commandline -poc) # premature optimization to avoid iterating all args if there aren't enough # to have a last arg beyond borgmatic if [ (count $all_args) -lt 2 ] return 1 end for arg in $argv if [ "$arg" = "$all_args[-1]" ] return 0 end end return 1 end set --local action_parser_condition "not __fish_seen_subcommand_from {all_action_parsers}" set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}" ''' ) + ('\n# action_parser completions',) + tuple( f'''complete -c borgmatic -f -n "$action_parser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(action_parser.description)}''' for action_name, action_parser in action_parsers.choices.items() ) + ('\n# global flags',) + tuple( # -n is checked in order, so put faster / more likely to be true checks first f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}''' for action in global_plus_action_parser._actions # ignore the noargs action, as this is an impossible completion for fish if len(action.option_strings) > 0 if 'Deprecated' not in action.help ) + ('\n# action_parser flags',) + tuple( f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}''' for action_name, action_parser in action_parsers.choices.items() for action in action_parser._actions if 'Deprecated' not in (action.help or ()) ) ) borgmatic/borgmatic/commands/generate_config.py000066400000000000000000000007501476361726000223040ustar00rootroot00000000000000import logging import sys import borgmatic.commands.borgmatic def main(): warning_log = logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg='generate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config generate" instead.', ) ) sys.argv = ['borgmatic', 'config', 'generate'] + sys.argv[1:] borgmatic.commands.borgmatic.main([warning_log]) borgmatic/borgmatic/commands/validate_config.py000066400000000000000000000007501476361726000223030ustar00rootroot00000000000000import logging import sys import borgmatic.commands.borgmatic def main(): warning_log = logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg='validate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config validate" instead.', ) ) sys.argv = ['borgmatic', 'config', 'validate'] + sys.argv[1:] borgmatic.commands.borgmatic.main([warning_log]) borgmatic/borgmatic/config/000077500000000000000000000000001476361726000162555ustar00rootroot00000000000000borgmatic/borgmatic/config/__init__.py000066400000000000000000000000001476361726000203540ustar00rootroot00000000000000borgmatic/borgmatic/config/checks.py000066400000000000000000000005021476361726000200640ustar00rootroot00000000000000def repository_enabled_for_checks(repository, config): ''' Given a repository name and a configuration dict, return whether the repository is enabled to have consistency checks run. ''' if not config.get('check_repositories'): return True return repository in config['check_repositories'] borgmatic/borgmatic/config/collect.py000066400000000000000000000042421476361726000202560ustar00rootroot00000000000000import os def get_default_config_paths(expand_home=True): ''' Based on the value of the XDG_CONFIG_HOME and HOME environment variables, return a list of default configuration paths. This includes both system-wide configuration and configuration in the current user's home directory. Don't expand the home directory ($HOME) if the expand home flag is False. ''' user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.join('$HOME', '.config') if expand_home: user_config_directory = os.path.expandvars(user_config_directory) return [ '/etc/borgmatic/config.yaml', '/etc/borgmatic.d', os.path.join(user_config_directory, 'borgmatic/config.yaml'), os.path.join(user_config_directory, 'borgmatic.d'), ] def collect_config_filenames(config_paths): ''' Given a sequence of config paths, both filenames and directories, resolve that to an iterable of absolute files. Accomplish this by listing any given directories looking for contained config files (ending with the ".yaml" or ".yml" extension). This is non-recursive, so any directories within the given directories are ignored. Return paths even if they don't exist on disk, so the user can find out about missing configuration paths. However, skip a default config path if it's missing, so the user doesn't have to create a default config path unless they need it. ''' real_default_config_paths = set(map(os.path.realpath, get_default_config_paths())) for path in config_paths: exists = os.path.exists(path) if os.path.realpath(path) in real_default_config_paths and not exists: continue if not os.path.isdir(path) or not exists: yield os.path.abspath(path) continue if not os.access(path, os.R_OK): continue for filename in sorted(os.listdir(path)): full_filename = os.path.join(path, filename) matching_filetype = full_filename.endswith('.yaml') or full_filename.endswith('.yml') if matching_filetype and not os.path.isdir(full_filename): yield os.path.abspath(full_filename) borgmatic/borgmatic/config/constants.py000066400000000000000000000042621476361726000206470ustar00rootroot00000000000000import shlex def coerce_scalar(value): ''' Given a configuration value, coerce it to an integer or a boolean as appropriate and return the result. ''' try: return int(value) except (TypeError, ValueError): pass if value == 'true' or value == 'True': return True if value == 'false' or value == 'False': return False return value def apply_constants(value, constants, shell_escape=False): ''' Given a configuration value (bool, dict, int, list, or string) and a dict of named constants, replace any configuration string values of the form "{constant}" (or containing it) with the value of the correspondingly named key from the constants. Recurse as necessary into nested configuration to find values to replace. For instance, if a configuration value contains "{foo}", replace it with the value of the "foo" key found within the configuration's "constants". If shell escape is True, then escape the constant's value before applying it. Return the configuration value and modify the original. ''' if not value or not constants: return value if isinstance(value, str): for constant_name, constant_value in constants.items(): value = value.replace( '{' + constant_name + '}', shlex.quote(str(constant_value)) if shell_escape else str(constant_value), ) # Support constants within non-string scalars by coercing the value to its appropriate type. value = coerce_scalar(value) elif isinstance(value, list): for index, list_value in enumerate(value): value[index] = apply_constants(list_value, constants, shell_escape) elif isinstance(value, dict): for option_name, option_value in value.items(): 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' ), ) return value borgmatic/borgmatic/config/environment.py000066400000000000000000000030451476361726000211750ustar00rootroot00000000000000import os import re VARIABLE_PATTERN = re.compile( r'(?P\\)?(?P\$\{(?P[A-Za-z0-9_]+)((:?-)(?P[^}]+))?\})' ) def resolve_string(matcher): ''' Given a matcher containing a name and an optional default value, get the value from environment. Raise ValueError if the variable is not defined in environment and no default value is provided. ''' if matcher.group('escape') is not None: # In the case of an escaped environment variable, unescape it. return matcher.group('variable') # Resolve the environment variable. name, default = matcher.group('name'), matcher.group('default') out = os.getenv(name, default=default) if out is None: raise ValueError(f'Cannot find variable {name} in environment') return out def resolve_env_variables(item): ''' Resolves variables like or ${FOO} from given configuration with values from process environment. Supported formats: * ${FOO} will return FOO env variable * ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar" Raise if any variable is missing in environment and no default value is provided. ''' if isinstance(item, str): return VARIABLE_PATTERN.sub(resolve_string, item) if isinstance(item, list): for index, subitem in enumerate(item): item[index] = resolve_env_variables(subitem) if isinstance(item, dict): for key, value in item.items(): item[key] = resolve_env_variables(value) return item borgmatic/borgmatic/config/generate.py000066400000000000000000000264051476361726000204300ustar00rootroot00000000000000import collections import io import os import re import ruamel.yaml from borgmatic.config import load, normalize INDENT = 4 SEQUENCE_INDENT = 2 def insert_newline_before_comment(config, field_name): ''' Using some ruamel.yaml black magic, insert a blank line in the config right before the given field and its comments. ''' config.ca.items[field_name][1].insert( 0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None) ) def get_properties(schema): ''' Given a schema dict, return its properties. But if it's got sub-schemas with multiple different potential properties, returned their merged properties instead. ''' if 'oneOf' in schema: return dict( collections.ChainMap(*[sub_schema['properties'] for sub_schema in schema['oneOf']]) ) return schema['properties'] def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False): ''' Given a loaded configuration schema, generate and return sample config for it. Include comments for each option based on the schema "description". ''' schema_type = schema.get('type') example = schema.get('example') if example is not None: return example 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' 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)) for field_name, sub_schema in get_properties(schema).items() ] ) indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0) add_comments_to_configuration_object( config, schema, indent=indent, skip_first=parent_is_sequence ) else: raise ValueError(f'Schema at level {level} is unsupported: {schema}') return config def comment_out_line(line): # If it's already is commented out (or empty), there's nothing further to do! stripped_line = line.lstrip() if not stripped_line or stripped_line.startswith('#'): return line # Comment out the names of optional options, inserting the '#' after any indent for aesthetics. matches = re.match(r'(\s*)', line) indent_spaces = matches.group(0) if matches else '' count_indent_spaces = len(indent_spaces) return '# '.join((indent_spaces, line[count_indent_spaces:])) def comment_out_optional_configuration(rendered_config): ''' Post-process a rendered configuration string to comment out optional key/values, as determined by a sentinel in the comment before each key. The idea is that the pre-commented configuration prevents the user from having to comment out a bunch of configuration they don't care about to get to a minimal viable configuration file. Ideally ruamel.yaml would support commenting out keys during configuration generation, but it's not terribly easy to accomplish that way. ''' lines = [] optional = False for line in rendered_config.split('\n'): # Upon encountering an optional configuration option, comment out lines until the next blank # line. if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'): optional = True continue # Hit a blank line, so reset commenting. if not line.strip(): optional = False lines.append(comment_out_line(line) if optional else line) return '\n'.join(lines) def render_configuration(config): ''' Given a config data structure of nested OrderedDicts, render the config as YAML and return it. ''' dumper = ruamel.yaml.YAML(typ='rt') dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT) rendered = io.StringIO() dumper.dump(config, rendered) return rendered.getvalue() def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=False): ''' Given a target config filename and rendered config YAML, write it out to file. Create any containing directories as needed. But if the file already exists and overwrite is False, abort before writing anything. ''' if not overwrite and os.path.exists(config_filename): raise FileExistsError( f'{config_filename} already exists. Aborting. Use --overwrite to replace the file.' ) try: os.makedirs(os.path.dirname(config_filename), mode=0o700) except (FileExistsError, FileNotFoundError): pass with open(config_filename, 'w') as config_file: config_file.write(rendered_config) os.chmod(config_filename, mode) def add_comments_to_configuration_sequence(config, schema, indent=0): ''' If the given config sequence's items are object, then mine the schema for the description of the object's first item, and slap that atop the sequence. Indent the comment the given number of characters. Doing this for sequences of maps results in nice comments that look like: ``` things: # First key description. Added by this function. - key: foo # Second key description. Added by add_comments_to_configuration_object(). other: bar ``` ''' if schema['items'].get('type') != 'object': return for field_name in config[0].keys(): field_schema = get_properties(schema['items']).get(field_name, {}) description = field_schema.get('description') # No description to use? Skip it. if not field_schema or not description: return config[0].yaml_set_start_comment(description, indent=indent) # We only want the first key's description here, as the rest of the keys get commented by # add_comments_to_configuration_object(). return REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'} COMMENTED_OUT_SENTINEL = 'COMMENT_OUT' def add_comments_to_configuration_object(config, schema, indent=0, skip_first=False): ''' Using descriptions from a schema as a source, add those descriptions as comments to the given config mapping, before each field. Indent the comment the given number of characters. ''' for index, field_name in enumerate(config.keys()): if skip_first and index == 0: continue field_schema = get_properties(schema).get(field_name, {}) description = field_schema.get('description', '').strip() # If this is an optional key, add an indicator to the comment flagging it to be commented # out from the sample configuration. This sentinel is consumed by downstream processing that # does the actual commenting out. if field_name not in REQUIRED_KEYS: description = ( '\n'.join((description, COMMENTED_OUT_SENTINEL)) if description else COMMENTED_OUT_SENTINEL ) # No description to use? Skip it. if not field_schema or not description: # pragma: no cover continue config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent) if index > 0: insert_newline_before_comment(config, field_name) RUAMEL_YAML_COMMENTS_INDEX = 1 def remove_commented_out_sentinel(config, field_name): ''' Given a configuration CommentedMap and a top-level field name in it, remove any "commented out" sentinel found at the end of its YAML comments. This prevents the given field name from getting commented out by downstream processing that consumes the sentinel. ''' try: last_comment_value = config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX][-1].value except KeyError: return if last_comment_value == f'# {COMMENTED_OUT_SENTINEL}\n': config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX].pop() def merge_source_configuration_into_destination(destination_config, source_config): ''' Deep merge the given source configuration dict into the destination configuration CommentedMap, favoring values from the source when there are collisions. The purpose of this is to upgrade configuration files from old versions of borgmatic by adding new configuration keys and comments. ''' if not source_config: return destination_config if not destination_config or not isinstance(source_config, collections.abc.Mapping): return source_config for field_name, source_value in source_config.items(): # Since this key/value is from the source configuration, leave it uncommented and remove any # sentinel that would cause it to get commented out. remove_commented_out_sentinel( ruamel.yaml.comments.CommentedMap(destination_config), field_name ) # This is a mapping. Recurse for this key/value. if isinstance(source_value, collections.abc.Mapping): destination_config[field_name] = merge_source_configuration_into_destination( destination_config[field_name], source_value ) continue # This is a sequence. Recurse for each item in it. if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str): destination_value = destination_config[field_name] destination_config[field_name] = ruamel.yaml.comments.CommentedSeq( [ merge_source_configuration_into_destination( destination_value[index] if index < len(destination_value) else None, source_item, ) for index, source_item in enumerate(source_value) ] ) continue # This is some sort of scalar. Set it into the destination. destination_config[field_name] = source_config[field_name] return destination_config def generate_sample_configuration( dry_run, source_filename, destination_filename, schema_filename, overwrite=False ): ''' Given an optional source configuration filename, and a required destination configuration filename, the path to a schema filename in a YAML rendition of the JSON Schema format, and whether to overwrite a destination file, write out a sample configuration file based on that schema. If a source filename is provided, merge the parsed contents of that configuration into the generated configuration. ''' schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename)) source_config = None if source_filename: source_config = load.load_configuration(source_filename) normalize.normalize(source_filename, source_config) destination_config = merge_source_configuration_into_destination( schema_to_sample_configuration(schema), source_config ) if dry_run: return write_configuration( destination_filename, comment_out_optional_configuration(render_configuration(destination_config)), overwrite=overwrite, ) borgmatic/borgmatic/config/load.py000066400000000000000000000363621476361726000175600ustar00rootroot00000000000000import functools import itertools import logging import operator import os import ruamel.yaml logger = logging.getLogger(__name__) def probe_and_include_file(filename, include_directories, config_paths): ''' Given a filename to include, a list of include directories to search for matching files, and a set of configuration paths, probe for the file, load it, and return the loaded configuration as a data structure of nested dicts, lists, etc. Add the filename to the given configuration paths. Raise FileNotFoundError if the included file was not found. ''' expanded_filename = os.path.expanduser(filename) if os.path.isabs(expanded_filename): return load_configuration(expanded_filename, config_paths) candidate_filenames = { os.path.join(directory, expanded_filename) for directory in include_directories } for candidate_filename in candidate_filenames: if os.path.exists(candidate_filename): return load_configuration(candidate_filename, config_paths) raise FileNotFoundError( f'Could not find include {filename} at {" or ".join(candidate_filenames)}' ) def include_configuration(loader, filename_node, include_directory, config_paths): ''' Given a ruamel.yaml.loader.Loader, a ruamel.yaml.nodes.ScalarNode containing the included filename (or a list containing multiple such filenames), an include directory path to search for matching files, and a set of configuration paths, load the given YAML filenames (ignoring the given loader so we can use our own) and return their contents as data structure of nested dicts, lists, etc. Add the names of included files to the given configuration paths. If the given filename node's value is a scalar string, then the return value will be a single value. But if the given node value is a list, then the return value will be a list of values, one per loaded configuration file. If a filename is relative, probe for it within: 1. the current working directory and 2. the given include directory. Raise FileNotFoundError if an included file was not found. ''' include_directories = [os.getcwd(), os.path.abspath(include_directory)] if isinstance(filename_node.value, str): return probe_and_include_file(filename_node.value, include_directories, config_paths) if ( isinstance(filename_node.value, list) and len(filename_node.value) and isinstance(filename_node.value[0], ruamel.yaml.nodes.ScalarNode) ): # Reversing the values ensures the correct ordering if these includes are subsequently # merged together. return [ probe_and_include_file(node.value, include_directories, config_paths) for node in reversed(filename_node.value) ] raise ValueError( 'The value given for the !include tag is invalid; use a single filename or a list of filenames instead' ) def raise_retain_node_error(loader, node): ''' Given a ruamel.yaml.loader.Loader and a YAML node, raise an error about "!retain" usage. Raise ValueError if a mapping or sequence node is given, as that indicates that "!retain" was used in a configuration file without a merge. In configuration files with a merge, mapping and sequence nodes with "!retain" tags are handled by deep_merge_nodes() below. Also raise ValueError if a scalar node is given, as "!retain" is not supported on scalar nodes. ''' if isinstance(node, (ruamel.yaml.nodes.MappingNode, ruamel.yaml.nodes.SequenceNode)): raise ValueError( 'The !retain tag may only be used within a configuration file containing a merged !include tag.' ) raise ValueError('The !retain tag may only be used on a mapping or list.') def raise_omit_node_error(loader, node): ''' Given a ruamel.yaml.loader.Loader and a YAML node, raise an error about "!omit" usage. Raise ValueError unconditionally, as an "!omit" node here indicates it was used in a configuration file without a merge. In configuration files with a merge, nodes with "!omit" tags are handled by deep_merge_nodes() below. ''' raise ValueError( 'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.' ) class Include_constructor(ruamel.yaml.SafeConstructor): ''' A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including separate YAML configuration files. Example syntax: `option: !include common.yaml` ''' def __init__( self, preserve_quotes=None, loader=None, include_directory=None, config_paths=None ): super(Include_constructor, self).__init__(preserve_quotes, loader) self.add_constructor( '!include', functools.partial( include_configuration, include_directory=include_directory, config_paths=config_paths, ), ) # These are catch-all error handlers for tags that don't get applied and removed by # deep_merge_nodes() below. self.add_constructor('!retain', raise_retain_node_error) self.add_constructor('!omit', raise_omit_node_error) def flatten_mapping(self, node): ''' Support the special case of deep merging included configuration into an existing mapping using the YAML '<<' merge key. Example syntax: ``` option: sub_option: 1 <<: !include common.yaml ``` These includes are deep merged into the current configuration file. For instance, in this example, any "option" with sub-options in common.yaml will get merged into the corresponding "option" with sub-options in the example configuration file. ''' representer = ruamel.yaml.representer.SafeRepresenter() for index, (key_node, value_node) in enumerate(node.value): if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == '!include': # Replace the merge include with a sequence of included configuration nodes ready # for merging. The construct_object() call here triggers include_configuration() # among other constructors. node.value[index] = ( key_node, representer.represent_data(self.construct_object(value_node)), ) # This super().flatten_mapping() call actually performs "<<" merges. super(Include_constructor, self).flatten_mapping(node) node.value = deep_merge_nodes(node.value) def load_configuration(filename, config_paths=None): ''' Load the given configuration file and return its contents as a data structure of nested dicts and lists. Add the filename to the given configuration paths set, and also add any included configuration filenames. Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError if there are too many recursive includes. ''' if config_paths is None: config_paths = set() # Use an embedded derived class for the include constructor so as to capture the include # directory and configuration paths values. (functools.partial doesn't work for this use case # because yaml.Constructor has to be an actual class.) class Include_constructor_with_extras(Include_constructor): def __init__(self, preserve_quotes=None, loader=None): super(Include_constructor_with_extras, self).__init__( preserve_quotes, loader, include_directory=os.path.dirname(filename), config_paths=config_paths, ) yaml = ruamel.yaml.YAML(typ='safe') yaml.Constructor = Include_constructor_with_extras config_paths.add(filename) with open(filename) as file: return yaml.load(file.read()) def filter_omitted_nodes(nodes, values): ''' Given a nested borgmatic configuration data structure as a list of tuples in the form of: [ ( ruamel.yaml.nodes.ScalarNode as a key, ruamel.yaml.nodes.MappingNode or other Node as a value, ), ... ] ... and a combined list of all values for those nodes, return a filtered list of the values, omitting any that have an "!omit" tag (or with a value matching such nodes). But if only a single node is given, bail and return the given values unfiltered, as "!omit" only applies when there are merge includes (and therefore multiple nodes). ''' if len(nodes) <= 1: return values omitted_values = tuple(node.value for node in values if node.tag == '!omit') return [node for node in values if node.value not in omitted_values] def merge_values(nodes): ''' Given a nested borgmatic configuration data structure as a list of tuples in the form of: [ ( ruamel.yaml.nodes.ScalarNode as a key, ruamel.yaml.nodes.MappingNode or other Node as a value, ), ... ] ... merge its sequence or mapping node values and return the result. For sequence nodes, this means appending together its contained lists. For mapping nodes, it means merging its contained dicts. ''' return functools.reduce(operator.add, (value.value for key, value in nodes)) def deep_merge_nodes(nodes): ''' Given a nested borgmatic configuration data structure as a list of tuples in the form of: [ ( ruamel.yaml.nodes.ScalarNode as a key, ruamel.yaml.nodes.MappingNode or other Node as a value, ), ... ] ... deep merge any node values corresponding to duplicate keys and return the result. The purpose of merging like this is to support, for instance, merging one borgmatic configuration file into another for reuse, such that a configuration option with sub-options does not completely replace the corresponding option in a merged file. If there are colliding keys with scalar values (e.g., integers or strings), the last of the values wins. For instance, given node values of: [ ( ScalarNode(tag='tag:yaml.org,2002:str', value='option'), MappingNode(tag='tag:yaml.org,2002:map', value=[ ( ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'), ScalarNode(tag='tag:yaml.org,2002:int', value='1') ), ( ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'), ScalarNode(tag='tag:yaml.org,2002:int', value='2') ), ]), ), ( ScalarNode(tag='tag:yaml.org,2002:str', value='option'), MappingNode(tag='tag:yaml.org,2002:map', value=[ ( ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'), ScalarNode(tag='tag:yaml.org,2002:int', value='5') ), ]), ), ] ... the returned result would be: [ ( ScalarNode(tag='tag:yaml.org,2002:str', value='option'), MappingNode(tag='tag:yaml.org,2002:map', value=[ ( ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'), ScalarNode(tag='tag:yaml.org,2002:int', value='1') ), ( ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'), ScalarNode(tag='tag:yaml.org,2002:int', value='5') ), ]), ), ] This function supports multi-way merging, meaning that if the same option name exists three or more times (at the same scope level), all of those instances get merged together. If a mapping or sequence node has a YAML "!retain" tag, then that node is not merged. Raise ValueError if a merge is implied using multiple incompatible types. ''' merged_nodes = [] def get_node_key_name(node): return node[0].value # Bucket the nodes by their keys. Then merge all of the values sharing the same key. for key_name, grouped_nodes in itertools.groupby( sorted(nodes, key=get_node_key_name), get_node_key_name ): grouped_nodes = list(grouped_nodes) # The merged node inherits its attributes from the final node in the group. (last_node_key, last_node_value) = grouped_nodes[-1] value_types = set(type(value) for (_, value) in grouped_nodes) if len(value_types) > 1: raise ValueError( f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}' ) # If we're dealing with MappingNodes, recurse and merge its values as well. if ruamel.yaml.nodes.MappingNode in value_types: # A "!retain" tag says to skip deep merging for this node. Replace the tag so # downstream schema validation doesn't break on our application-specific tag. if last_node_value.tag == '!retain' and len(grouped_nodes) > 1: last_node_value.tag = 'tag:yaml.org,2002:map' merged_nodes.append((last_node_key, last_node_value)) else: merged_nodes.append( ( last_node_key, ruamel.yaml.nodes.MappingNode( tag=last_node_value.tag, value=deep_merge_nodes(merge_values(grouped_nodes)), start_mark=last_node_value.start_mark, end_mark=last_node_value.end_mark, flow_style=last_node_value.flow_style, comment=last_node_value.comment, anchor=last_node_value.anchor, ), ) ) continue # If we're dealing with SequenceNodes, merge by appending sequences together. if ruamel.yaml.nodes.SequenceNode in value_types: if last_node_value.tag == '!retain' and len(grouped_nodes) > 1: last_node_value.tag = 'tag:yaml.org,2002:seq' merged_nodes.append((last_node_key, last_node_value)) else: merged_nodes.append( ( last_node_key, ruamel.yaml.nodes.SequenceNode( tag=last_node_value.tag, value=filter_omitted_nodes(grouped_nodes, merge_values(grouped_nodes)), start_mark=last_node_value.start_mark, end_mark=last_node_value.end_mark, flow_style=last_node_value.flow_style, comment=last_node_value.comment, anchor=last_node_value.anchor, ), ) ) continue merged_nodes.append((last_node_key, last_node_value)) return merged_nodes borgmatic/borgmatic/config/normalize.py000066400000000000000000000311531476361726000206320ustar00rootroot00000000000000import logging import os def normalize_sections(config_filename, config): ''' Given a configuration filename and a configuration dict of its loaded contents, airlift any options out of sections ("location:", etc.) to the global scope and delete those sections. Return any log message warnings produced based on the normalization performed. Raise ValueError if the "prefix" option is set in both "location" and "consistency" sections. ''' try: location = config.get('location') or {} except AttributeError: raise ValueError('Configuration does not contain any options') storage = config.get('storage') or {} consistency = config.get('consistency') or {} hooks = config.get('hooks') or {} if ( location.get('prefix') and consistency.get('prefix') and location.get('prefix') != consistency.get('prefix') ): raise ValueError( 'The retention prefix and the consistency prefix cannot have different values (unless one is not set).' ) if storage.get('umask') and hooks.get('umask') and storage.get('umask') != hooks.get('umask'): raise ValueError( 'The storage umask and the hooks umask cannot have different values (unless one is not set).' ) any_section_upgraded = False # Move any options from deprecated sections into the global scope. for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'): section_config = config.get(section_name) if section_config is not None: any_section_upgraded = True del config[section_name] config.update(section_config) if any_section_upgraded: return [ logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: Configuration sections (like location:, storage:, retention:, consistency:, and hooks:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.', ) ) ] return [] def normalize(config_filename, config): ''' Given a configuration filename and a configuration dict of its loaded contents, apply particular hard-coded rules to normalize the configuration to adhere to the current schema. Return any log message warnings produced based on the normalization performed. Raise ValueError the configuration cannot be normalized. ''' 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): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The exclude_if_present option now expects a list value. String values for this option are deprecated and support will be removed from a future release.', ) ) ) 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): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The healthchecks hook now expects a key/value pair with "ping_url" as a key. String values for this option are deprecated and support will be removed from a future release.', ) ) ) config['healthchecks'] = {'ping_url': healthchecks} cronitor = config.get('cronitor') if isinstance(cronitor, str): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.', ) ) ) config['cronitor'] = {'ping_url': cronitor} pagerduty = config.get('pagerduty') if isinstance(pagerduty, str): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.', ) ) ) config['pagerduty'] = {'integration_key': pagerduty} cronhub = config.get('cronhub') if isinstance(cronhub, str): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.', ) ) ) config['cronhub'] = {'ping_url': cronhub} # Upgrade consistency checks from a list of strings to a list of dicts. checks = config.get('checks') if isinstance(checks, list) and len(checks) and isinstance(checks[0], str): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The checks option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.', ) ) ) config['checks'] = [{'name': check_type} for check_type in checks] # Rename various configuration options. numeric_owner = config.pop('numeric_owner', None) if numeric_owner is not None: logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The numeric_owner option has been renamed to numeric_ids. numeric_owner is deprecated and support will be removed from a future release.', ) ) ) config['numeric_ids'] = numeric_owner bsd_flags = config.pop('bsd_flags', None) if bsd_flags is not None: logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The bsd_flags option has been renamed to flags. bsd_flags is deprecated and support will be removed from a future release.', ) ) ) config['flags'] = bsd_flags remote_rate_limit = config.pop('remote_rate_limit', None) if remote_rate_limit is not None: logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The remote_rate_limit option has been renamed to upload_rate_limit. remote_rate_limit is deprecated and support will be removed from a future release.', ) ) ) config['upload_rate_limit'] = remote_rate_limit # Upgrade remote repositories to ssh:// syntax, required in Borg 2. repositories = config.get('repositories') if repositories: if any(isinstance(repository, str) for repository in repositories): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The repositories option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.', ) ) ) config['repositories'] = [ {'path': repository} if isinstance(repository, str) else repository for repository in repositories ] repositories = config['repositories'] config['repositories'] = [] for repository_dict in repositories: repository_path = repository_dict['path'] if '~' in repository_path: logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and support will be removed from a future release.', ) ) ) if ':' in repository_path: if repository_path.startswith('file://'): updated_repository_path = os.path.abspath( repository_path.partition('file://')[-1] ) config['repositories'].append( dict( repository_dict, path=updated_repository_path, ) ) 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(':', '/./')}" logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', 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}"', ) ) ) config['repositories'].append( dict( repository_dict, path=rewritten_repository_path, ) ) else: config['repositories'].append(repository_dict) if config.get('prefix'): logs.append( logging.makeLogRecord( dict( levelno=logging.WARNING, levelname='WARNING', msg=f'{config_filename}: The prefix option is deprecated and support will be removed from a future release. Use archive_name_format or match_archives instead.', ) ) ) return logs borgmatic/borgmatic/config/override.py000066400000000000000000000106061476361726000204510ustar00rootroot00000000000000import io import ruamel.yaml def set_values(config, keys, value): ''' Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value, descend into the hierarchy based on the keys to set the value into the right place. ''' if not keys: return first_key = keys[0] if len(keys) == 1: if isinstance(config, list): raise ValueError( 'When overriding a list option, the value must use list syntax (e.g., "[foo, bar]" or "[{key: value}]" as appropriate)' ) config[first_key] = value return if first_key not in config: config[first_key] = {} set_values(config[first_key], keys[1:], value) def convert_value_type(value, option_type): ''' Given a string value and its schema type as a string, determine its logical type (string, boolean, integer, etc.), and return it converted to that type. If the option type is a string, leave the value as a string so that special characters in it don't get interpreted as YAML during conversion. Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML. ''' if option_type == 'string': return value return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value)) LEGACY_SECTION_NAMES = {'location', 'storage', 'retention', 'consistency', 'output', 'hooks'} def strip_section_names(parsed_override_key): ''' Given a parsed override key as a tuple of option and suboption names, strip out any initial legacy section names, since configuration file normalization also strips them out. ''' if parsed_override_key[0] in LEGACY_SECTION_NAMES: return parsed_override_key[1:] return parsed_override_key def type_for_option(schema, option_keys): ''' Given a configuration schema and a sequence of keys identifying an option, e.g. ('extra_borg_options', 'init'), return the schema type of that option as a string. Return None if the option or its type cannot be found in the schema. ''' option_schema = schema for key in option_keys: try: option_schema = option_schema['properties'][key] except KeyError: return None try: return option_schema['type'] except KeyError: return None def parse_overrides(raw_overrides, schema): ''' Given a sequence of configuration file override strings in the form of "option.suboption=value" and a configuration schema dict, parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For instance, given the following raw overrides: ['my_option.suboption=value1', 'other_option=value2'] ... return this: ( (('my_option', 'suboption'), 'value1'), (('other_option'), 'value2'), ) Raise ValueError if an override can't be parsed. ''' if not raw_overrides: return () parsed_overrides = [] for raw_override in raw_overrides: try: raw_keys, value = raw_override.split('=', 1) keys = tuple(raw_keys.split('.')) option_type = type_for_option(schema, keys) parsed_overrides.append( ( keys, convert_value_type(value, option_type), ) ) except ValueError: raise ValueError( f"Invalid override '{raw_override}'. Make sure you use the form: OPTION=VALUE or OPTION.SUBOPTION=VALUE" ) except ruamel.yaml.error.YAMLError as error: raise ValueError(f"Invalid override '{raw_override}': {error.problem}") return tuple(parsed_overrides) def apply_overrides(config, schema, raw_overrides): ''' Given a configuration dict, a corresponding configuration schema dict, and a sequence of configuration file override strings in the form of "option.suboption=value", parse each override and set it into the configuration dict. Set the overrides into the configuration both with and without deprecated section names (if used), so that the overrides work regardless of whether the configuration is also using deprecated section names. ''' overrides = parse_overrides(raw_overrides, schema) for keys, value in overrides: set_values(config, keys, value) set_values(config, strip_section_names(keys), value) borgmatic/borgmatic/config/paths.py000066400000000000000000000146751476361726000177630ustar00rootroot00000000000000import 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): ''' Given a configuration dict 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'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', ) ) borgmatic/borgmatic/config/schema.yaml000066400000000000000000003262741476361726000204170ustar00rootroot00000000000000type: object required: - repositories additionalProperties: false properties: constants: type: object description: | Constants to use in the configuration file. Within option values, all occurrences of the constant name in curly braces will be replaced with the constant value. For example, if you have a constant named "app_name" with the value "myapp", then the string "{app_name}" will be replaced with "myapp" in the configuration file. example: app_name: myapp user: myuser source_directories: type: array items: type: string description: | List of source directories and files to back up. Globs and tildes are expanded. Do not backslash spaces in path names. example: - /home - /etc - /var/log/syslog* - /home/user/path with spaces repositories: type: array items: type: object required: - path properties: path: type: string example: ssh://user@backupserver/./{fqdn} label: type: string example: backupserver description: | A required list of local or remote repositories with paths and optional labels (which can be used with the --repository flag to select a repository). Tildes are expanded. Multiple repositories are backed up to in sequence. Borg placeholders can be used. See the output of "borg help placeholders" for details. See ssh_command for SSH options like identity file or port. If systemd service is used, then add local repository paths in the systemd service file to the ReadWritePaths list. Prior to borgmatic 1.7.10, repositories was a list of plain path strings. example: - path: ssh://user@backupserver/./sourcehostname.borg label: backupserver - path: /mnt/backup label: local working_directory: type: string description: | 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. example: true numeric_ids: type: boolean description: | Only store/extract numeric user and group identifiers. Defaults to false. example: true atime: type: boolean description: | Store atime into archive. Defaults to true in Borg < 1.2, false in Borg 1.2+. example: false ctime: type: boolean description: Store ctime into archive. Defaults to true. example: false birthtime: type: boolean description: | Store birthtime (creation date) into archive. Defaults to true. example: false read_special: type: boolean description: | Use Borg's --read-special flag to allow backup of block and other special devices. Use with caution, as it will lead to problems if used when backing up special devices such as /dev/zero. Defaults to false. But when a database hook is used, the setting here is ignored and read_special is considered true. example: false flags: type: boolean description: | Record filesystem flags (e.g. NODUMP, IMMUTABLE) in archive. Defaults to true. example: true files_cache: type: string description: | Mode in which to operate the files cache. See http://borgbackup.readthedocs.io/en/stable/usage/create.html for details. Defaults to "ctime,size,inode". example: ctime,size,inode local_path: type: string description: | Alternate Borg local executable. Defaults to "borg". example: borg1 remote_path: type: string description: | Alternate Borg remote executable. Defaults to "borg". example: borg1 patterns: type: array items: type: string description: | Any paths matching these patterns are included/excluded from backups. Globs are expanded. (Tildes are not.) See the output of "borg help patterns" for more details. Quote any value if it contains leading punctuation, so it parses correctly. example: - 'R /' - '- /home/*/.cache' - '+ /home/susan' - '- /home/*' patterns_from: type: array items: type: string description: | Read include/exclude patterns from one or more separate named files, one pattern per line. See the output of "borg help patterns" for more details. example: - /etc/borgmatic/patterns exclude_patterns: type: array items: type: string description: | Any paths matching these patterns are excluded from backups. Globs and tildes are expanded. Note that a glob pattern must either start with a glob or be an absolute path. Do not backslash spaces in path names. See the output of "borg help patterns" for more details. example: - '*.pyc' - /home/*/.cache - '*/.vim*.tmp' - /etc/ssl - /home/user/path with spaces exclude_from: type: array items: type: string description: | Read exclude patterns from one or more separate named files, one pattern per line. See the output of "borg help patterns" for more details. example: - /etc/borgmatic/excludes exclude_caches: type: boolean description: | Exclude directories that contain a CACHEDIR.TAG file. See http://www.brynosaurus.com/cachedir/spec.html for details. Defaults to false. example: true exclude_if_present: type: array items: type: string description: | Exclude directories that contain a file with the given filenames. Defaults to not set. example: - .nobackup keep_exclude_tags: type: boolean description: | If true, the exclude_if_present filename is included in backups. Defaults to false, meaning that the exclude_if_present filename is omitted from backups. example: true exclude_nodump: type: boolean description: | Exclude files with the NODUMP flag. Defaults to false. example: true borgmatic_source_directory: type: string description: | Deprecated. Only used for locating database dumps and bootstrap metadata within backup archives created prior to deprecation. Replaced by user_runtime_directory and user_state_directory. Defaults to ~/.borgmatic example: /tmp/borgmatic user_runtime_directory: type: string description: | 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: | If true, then source directories (and root pattern paths) must exist. If they don't, an error is raised. Defaults to false. example: true encryption_passcommand: type: string description: | The standard output of this command is used to unlock the encryption 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. 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 description: | Passphrase to unlock the encryption key with. Only use on repositories that were initialized with passphrase/repokey/keyfile encryption. Quote the value if it contains punctuation, so it parses correctly. And backslash any quote or backslash literals as well. Defaults to not set. Supports the "{credential ...}" syntax. example: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" checkpoint_interval: type: integer description: | Number of seconds between each checkpoint during a long-running backup. See https://borgbackup.readthedocs.io/en/stable/faq.html for details. Defaults to checkpoints every 1800 seconds (30 minutes). example: 1800 checkpoint_volume: type: integer description: | Number of backed up bytes between each checkpoint during a long-running backup. Only supported with Borg 2+. See https://borgbackup.readthedocs.io/en/stable/faq.html for details. Defaults to only time-based checkpointing (see "checkpoint_interval") instead of volume-based checkpointing. example: 1048576 chunker_params: type: string description: | Specify the parameters passed to the chunker (CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS, HASH_WINDOW_SIZE). See https://borgbackup.readthedocs.io/en/stable/internals.html for details. Defaults to "19,23,21,4095". example: 19,23,21,4095 compression: type: string description: | 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 upload_rate_limit: type: integer description: | 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: | Number of times to retry a failing backup before giving up. Defaults to 0 (i.e., does not attempt retry). example: 3 retry_wait: type: integer description: | Wait time between retries (in seconds) to allow transient issues to pass. Increases after each retry by that same wait time as a form of backoff. Defaults to 0 (no wait). example: 10 temporary_directory: type: string description: | Directory where temporary Borg files are stored. Defaults to $TMPDIR. See "Resource Usage" at https://borgbackup.readthedocs.io/en/stable/usage/general.html for details. example: /path/to/tmpdir ssh_command: type: string description: | Command to use instead of "ssh". This can be used to specify ssh options. Defaults to not set. example: ssh -i /path/to/private/key borg_base_directory: type: string description: | Base path used for various Borg directories. Defaults to $HOME, ~$USER, or ~. example: /path/to/base borg_config_directory: type: string description: | Path for Borg configuration files. Defaults to $borg_base_directory/.config/borg example: /path/to/base/config borg_cache_directory: type: string description: | Path for Borg cache files. Defaults to $borg_base_directory/.cache/borg example: /path/to/base/cache borg_files_cache_ttl: type: integer description: | Maximum time to live (ttl) for entries in the Borg files cache. example: 20 borg_security_directory: type: string description: | Path for Borg security and encryption nonce files. Defaults to $borg_base_directory/.config/borg/security example: /path/to/base/config/security borg_keys_directory: type: string description: | Path for Borg encryption key files. Defaults to $borg_base_directory/.config/borg/keys example: /path/to/base/config/keys borg_exit_codes: type: array items: type: object required: ['code', 'treat_as'] additionalProperties: false properties: code: type: integer not: {enum: [0]} description: | The exit code for an existing Borg warning or error. example: 100 treat_as: type: string enum: ['error', 'warning'] description: | Whether to consider the exit code as an error or as a warning in borgmatic. example: error description: | A list of Borg exit codes that should be elevated to errors or squashed to warnings as indicated. By default, Borg error exit codes (2 to 99) are treated as errors while warning exit codes (1 and 100+) are treated as warnings. Exit codes other than 1 and 2 are only present in Borg 1.4.0+. example: - code: 13 treat_as: warning - code: 100 treat_as: error umask: type: integer description: | Umask used for when executing Borg or calling hooks. Defaults to 0077 for Borg or the umask that borgmatic is run with for hooks. example: 0077 lock_wait: type: integer description: | Maximum seconds to wait for acquiring a repository/cache lock. Defaults to 1. example: 5 archive_name_format: type: string description: | 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: type: string description: | A Borg pattern for filtering down the archives used by borgmatic actions that operate on multiple archives. For Borg 1.x, use a shell pattern here and see the output of "borg help placeholders" for details. For Borg 2.x, see the output of "borg help match-archives". If match_archives is not specified, borgmatic defaults to deriving the match_archives value from archive_name_format. example: "sh:{hostname}-*" relocated_repo_access_is_ok: type: boolean description: | Bypass Borg error about a repository that has been moved. Defaults to not bypassing. example: true unknown_unencrypted_repo_access_is_ok: type: boolean description: | Bypass Borg error about a previously unknown unencrypted repository. Defaults to not bypassing. example: true check_i_know_what_i_am_doing: type: boolean description: | Bypass Borg confirmation about check with repair option. Defaults to an interactive prompt from Borg. example: true extra_borg_options: type: object additionalProperties: false properties: init: type: string description: | Extra command-line options to pass to "borg init". example: "--extra-option" create: type: string description: | Extra command-line options to pass to "borg create". example: "--extra-option" prune: type: string description: | Extra command-line options to pass to "borg prune". example: "--extra-option" compact: type: string description: | Extra command-line options to pass to "borg compact". example: "--extra-option" check: type: string description: | Extra command-line options to pass to "borg check". example: "--extra-option" description: | Additional options to pass directly to particular Borg commands, handy for Borg options that borgmatic does not yet support natively. Note that borgmatic does not perform any validation on these options. Running borgmatic with "--verbosity 2" shows the exact Borg command-line invocation. keep_within: type: string description: | Keep all archives within this time interval. See "skip_actions" for disabling pruning altogether. example: 3H keep_secondly: type: integer description: Number of secondly archives to keep. example: 60 keep_minutely: type: integer description: Number of minutely archives to keep. example: 60 keep_hourly: type: integer description: Number of hourly archives to keep. example: 24 keep_daily: type: integer description: Number of daily archives to keep. example: 7 keep_weekly: type: integer description: Number of weekly archives to keep. example: 4 keep_monthly: type: integer description: Number of monthly archives to keep. example: 6 keep_yearly: type: integer description: Number of yearly archives to keep. example: 1 prefix: type: string description: | Deprecated. When pruning or checking archives, only consider archive names starting with this prefix. Borg placeholders can be used. See the output of "borg help placeholders" for details. If a prefix is not specified, borgmatic defaults to matching archives based on the archive_name_format (see above). example: sourcehostname checks: type: array items: type: object oneOf: - required: [name] additionalProperties: false properties: name: type: string enum: - archives - data - extract - disabled 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 - 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 the "--repair" flag. example: 3600 - required: - name - count_tolerance_percentage - data_sample_percentage - data_tolerance_percentage additionalProperties: false properties: name: type: string enum: - spot 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: repository 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 count_tolerance_percentage: type: number description: | The percentage delta between the source directories file count and the most recent backup archive file count that is allowed before the entire consistency check fails. This can catch problems like incorrect excludes, inadvertent deletes, etc. Required (and only valid) for the "spot" check. example: 10 data_sample_percentage: type: number description: | The percentage of total files in the source directories to randomly sample and compare to their corresponding files in the most recent backup archive. Required (and only valid) for the "spot" check. example: 1 data_tolerance_percentage: type: number description: | The percentage of total files in the source directories that can fail a spot check comparison without failing the entire consistency check. This can catch problems like source files that have 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". Required (and only valid) for the "spot" check. example: 0.5 xxh64sum_command: type: string description: | Command to use instead of "xxh64sum" to hash 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 valid for the "spot" check. example: /usr/local/bin/xxh64sum description: | List of one or more consistency checks to run on a periodic basis (if "frequency" is set) or every time borgmatic runs checks (if "frequency" is omitted). check_repositories: type: array items: type: string description: | Paths or labels for a subset of the configured "repositories" (see above) on which to run consistency checks. Handy in case some of your repositories are very large, and so running consistency checks on them would take too long. Defaults to running consistency checks on all configured repositories. example: - user@backupserver:sourcehostname.borg check_last: type: integer description: | Restrict the number of checked archives to the last n. Applies only to the "archives" check. Defaults to checking all archives. example: 3 color: type: boolean description: | Apply color to console output. Can be overridden with --no-color command-line flag. Defaults to true. example: false skip_actions: type: array items: type: string enum: - repo-create - transfer - prune - compact - create - check - delete - extract - config - export-tar - mount - umount - repo-delete - restore - repo-list - list - repo-info - info - break-lock - key - borg description: | List of one or more actions to skip running for this configuration file, even if specified on the command-line (explicitly or implicitly). This is handy for append-only configurations where you never want to run "compact" or checkless configuration where you want to skip "check". Defaults to not skipping any actions. example: - compact before_actions: type: array items: type: string description: | List of one or more shell commands or scripts to execute before all the actions for each repository. example: - "echo Starting actions." before_backup: type: array items: type: string description: | List of one or more shell commands or scripts to execute before creating a backup, run once per repository. example: - "echo Starting a backup." before_prune: type: array items: type: string description: | List of one or more shell commands or scripts to execute before pruning, run once per repository. example: - "echo Starting pruning." before_compact: type: array items: type: string description: | List of one or more shell commands or scripts to execute before compaction, run once per repository. example: - "echo Starting compaction." before_check: type: array items: type: string description: | List of one or more shell commands or scripts to execute before consistency checks, run once per repository. example: - "echo Starting checks." before_extract: type: array items: type: string description: | List of one or more shell commands or scripts to execute before extracting a backup, run once per repository. example: - "echo Starting extracting." after_backup: type: array items: type: string description: | List of one or more shell commands or scripts to execute after creating a backup, run once per repository. example: - "echo Finished a backup." after_compact: type: array items: type: string description: | List of one or more shell commands or scripts to execute after compaction, run once per repository. example: - "echo Finished compaction." after_prune: type: array items: type: string description: | List of one or more shell commands or scripts to execute after pruning, run once per repository. example: - "echo Finished pruning." after_check: type: array items: type: string description: | List of one or more shell commands or scripts to execute after consistency checks, run once per repository. example: - "echo Finished checks." after_extract: type: array items: type: string description: | List of one or more shell commands or scripts to execute after extracting a backup, run once per repository. example: - "echo Finished extracting." after_actions: type: array items: type: string description: | List of one or more shell commands or scripts to execute after all actions for each repository. example: - "echo Finished actions." on_error: type: array items: type: string description: | List of one or more shell commands or scripts to execute when an exception occurs during a "create", "prune", "compact", or "check" action or an associated before/after hook. example: - "echo Error during create/prune/compact/check." before_everything: type: array items: type: string description: | List of one or more shell commands or scripts to execute before running all actions (if one of them is "create"). These are collected from all configuration files and then run once before all of them (prior to all actions). example: - "echo Starting actions." after_everything: type: array items: type: string description: | List of one or more shell commands or scripts to execute after running all actions (if one of them is "create"). These are collected from all configuration files and then run once after all 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: type: object required: ['name'] additionalProperties: false properties: name: type: string description: | Database name (required if using this hook). Or "all" to 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 read_special (see above) to support dump and restore streaming. example: users hostname: type: string description: | Database hostname to connect to. Defaults to connecting via local Unix socket. example: database.example.org restore_hostname: type: string description: | Database hostname to restore to. Defaults to the "hostname" option. example: database.example.org port: type: integer description: Port to connect to. Defaults to 5432. example: 5433 restore_port: type: integer description: | Port to restore to. Defaults to the "port" option. example: 5433 username: type: string description: | Username with which to connect to the database. Defaults to the username of the current user. You probably want to specify the "postgres" superuser here when the database name is "all". Supports the "{credential ...}" syntax. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. Supports the "{credential ...}" syntax. example: dbuser password: type: string description: | Password with which to connect to the database. Omitting a password will only work if PostgreSQL is configured to trust the configured username without a password or you create a ~/.pgpass file. Supports the "{credential ...}" syntax. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. Supports the "{credential ...}" syntax. example: trustsome1 no_owner: type: boolean description: | Do not output commands to set ownership of objects to match the original database. By default, pg_dump and pg_restore issue ALTER OWNER or SET SESSION AUTHORIZATION statements to set ownership of created schema elements. These statements will fail unless the initial connection to the database is made by a superuser. example: true format: type: string enum: ['plain', 'custom', 'directory', 'tar'] description: | Database dump output format. One of "plain", "custom", "directory", or "tar". Defaults to "custom" (unlike raw pg_dump) for a single database. Or, when database name is "all" and format is blank, dumps all databases to a single file. But if a format is specified with an "all" database name, dumps each database to a separate file of that format, allowing more convenient restores of individual databases. See the pg_dump documentation for more about formats. example: directory compression: type: ["string", "integer"] description: | Database dump compression level (integer) or method ("gzip", "lz4", "zstd", or "none") and optional colon-separated detail. Defaults to moderate "gzip" for "custom" and "directory" formats and no compression for the "plain" format. Compression is not supported for the "tar" format. Be aware that Borg does its own compression as well, so you may not need it in both places. example: none ssl_mode: type: string enum: ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full'] description: | SSL mode to use to connect to the database server. One of "disable", "allow", "prefer", "require", "verify-ca" or "verify-full". Defaults to "disable". example: require ssl_cert: type: string description: | Path to a client certificate. example: "/root/.postgresql/postgresql.crt" ssl_key: type: string description: | Path to a private client key. example: "/root/.postgresql/postgresql.key" ssl_root_cert: type: string description: | Path to a root certificate containing a list of trusted certificate authorities. example: "/root/.postgresql/root.crt" ssl_crl: type: string description: | Path to a certificate revocation list. example: "/root/.postgresql/root.crl" pg_dump_command: type: string 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). 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 description: | Command to use instead of "pg_restore". This can be used to run a specific pg_restore version (e.g., one inside a running container). Defaults to "pg_restore". example: docker exec my_pg_container pg_restore psql_command: type: string description: | Command to use instead of "psql". This can be used to run a specific psql version (e.g., one inside a running container). Defaults to "psql". example: docker exec my_pg_container psql options: type: string description: | Additional pg_dump/pg_dumpall options to pass directly to the dump command, without performing any validation on them. See pg_dump documentation for details. example: --role=someone list_options: type: string description: | Additional psql options to pass directly to the psql command that lists available databases, without performing any validation on them. See psql documentation for details. example: --role=someone restore_options: type: string description: | Additional pg_restore/psql options to pass directly to the restore command, without performing any validation on them. See pg_restore/psql documentation for details. example: --role=someone analyze_options: type: string description: | Additional psql options to pass directly to the analyze command run after a restore, without performing any validation on them. See psql documentation for details. example: --role=someone description: | List of one or more PostgreSQL databases to dump before creating a backup, run once per configuration file. The database dumps are added to your source directories at runtime and streamed directly to Borg. Requires pg_dump/pg_dumpall/pg_restore commands. See https://www.postgresql.org/docs/current/app-pgdump.html and https://www.postgresql.org/docs/current/libpq-ssl.html for details. mariadb_databases: type: array items: type: object required: ['name'] additionalProperties: false properties: name: type: string 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 read_special (see above) to support dump and restore streaming. example: users hostname: type: string description: | Database hostname to connect to. Defaults to connecting via local Unix socket. example: database.example.org restore_hostname: type: string description: | Database hostname to restore to. Defaults to the "hostname" option. example: database.example.org port: type: integer description: Port to connect to. Defaults to 3306. example: 3307 restore_port: type: integer description: | Port to restore to. Defaults to the "port" option. example: 5433 username: type: string description: | Username with which to connect to the database. Defaults to the username of the current user. Supports the "{credential ...}" syntax. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. Supports the "{credential ...}" syntax. example: dbuser password: type: string description: | Password with which to connect to the database. Omitting a password will only work if MariaDB is configured to trust the configured username without a password. Supports the "{credential ...}" syntax. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. Supports the "{credential ...}" syntax. example: trustsome1 tls: type: boolean description: | Whether to TLS-encrypt data transmitted between the client and server. The default varies based on the MariaDB version. example: false restore_tls: type: boolean description: | Whether to TLS-encrypt data transmitted between the client and restore server. The default varies based on the MariaDB version. example: false mariadb_dump_command: type: string 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). 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 description: | Command to run instead of "mariadb". This can be used to run a specific mariadb version (e.g., one inside a running container). Defaults to "mariadb". example: docker exec mariadb_container mariadb format: type: string enum: ['sql'] description: | Database dump output format. Currently only "sql" is supported. Defaults to "sql" for a single database. Or, when database name is "all" and format is blank, dumps all databases to a single file. But if a format is specified with an "all" database name, dumps each database to a separate file of that format, allowing more convenient restores of individual databases. example: directory add_drop_database: type: boolean description: | Use the "--add-drop-database" flag with mariadb-dump, causing the database to be dropped right before restore. Defaults to true. example: false options: type: string description: | Additional mariadb-dump options to pass directly to the dump command, without performing any validation on them. See mariadb-dump documentation for details. example: --skip-comments list_options: type: string description: | Additional options to pass directly to the mariadb command that lists available databases, without performing any validation on them. See mariadb command documentation for details. example: --defaults-extra-file=mariadb.cnf restore_options: type: string description: | Additional options to pass directly to the mariadb command that restores database dumps, without performing any validation on them. See mariadb command documentation for details. example: --defaults-extra-file=mariadb.cnf description: | List of one or more MariaDB databases to dump before creating a backup, run once per configuration file. The database dumps are added to your source directories at runtime and streamed directly to Borg. Requires mariadb-dump/mariadb commands. See https://mariadb.com/kb/en/library/mysqldump/ for details. mysql_databases: type: array items: type: object required: ['name'] additionalProperties: false properties: name: type: string 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 read_special (see above) to support dump and restore streaming. example: users hostname: type: string description: | Database hostname to connect to. Defaults to connecting via local Unix socket. example: database.example.org restore_hostname: type: string description: | Database hostname to restore to. Defaults to the "hostname" option. example: database.example.org port: type: integer description: Port to connect to. Defaults to 3306. example: 3307 restore_port: type: integer description: | Port to restore to. Defaults to the "port" option. example: 5433 username: type: string description: | Username with which to connect to the database. Defaults to the username of the current user. Supports the "{credential ...}" syntax. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. Supports the "{credential ...}" syntax. example: dbuser password: type: string description: | Password with which to connect to the database. Omitting a password will only work if MySQL is configured to trust the configured username without a password. Supports the "{credential ...}" syntax. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. Supports the "{credential ...}" syntax. example: trustsome1 tls: type: boolean description: | Whether to TLS-encrypt data transmitted between the client and server. The default varies based on the MySQL installation. example: false restore_tls: type: boolean description: | Whether to TLS-encrypt data transmitted between the client and restore server. The default varies based on the MySQL installation. example: false 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). 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 description: | Command to run instead of "mysql". This can be used to run a specific mysql version (e.g., one inside a running container). Defaults to "mysql". example: docker exec mysql_container mysql format: type: string enum: ['sql'] description: | Database dump output format. Currently only "sql" is supported. Defaults to "sql" for a single database. Or, when database name is "all" and format is blank, dumps all databases to a single file. But if a format is specified with an "all" database name, dumps each database to a separate file of that format, allowing more convenient restores of individual databases. example: directory add_drop_database: type: boolean description: | Use the "--add-drop-database" flag with mysqldump, causing the database to be dropped right before restore. Defaults to true. example: false options: type: string description: | Additional mysqldump options to pass directly to the dump command, without performing any validation on them. See mysqldump documentation for details. example: --skip-comments list_options: type: string description: | Additional options to pass directly to the mysql command that lists available databases, without performing any validation on them. See mysql command documentation for details. example: --defaults-extra-file=my.cnf restore_options: type: string description: | Additional options to pass directly to the mysql command that restores database dumps, without performing any validation on them. See mysql command documentation for details. example: --defaults-extra-file=my.cnf description: | List of one or more MySQL databases to dump before creating a backup, run once per configuration file. The database dumps are added to your source directories at runtime and streamed directly to Borg. Requires mysqldump/mysql commands. See https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html for details. sqlite_databases: type: array items: type: object required: ['path','name'] additionalProperties: false properties: name: type: string description: | This is used to tag the database dump file with a name. It is not the path to the database file itself. The name "all" has no special meaning for SQLite databases. example: users path: type: string 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 read_special (see above) to support dump and restore streaming. example: /var/lib/sqlite/users.db restore_path: type: string description: | Path to the SQLite database file to restore to. Defaults to the "path" option. example: /var/lib/sqlite/users.db mongodb_databases: type: array items: type: object required: ['name'] additionalProperties: false properties: name: type: string 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 read_special (see above) to support dump and restore streaming. example: users hostname: type: string description: | Database hostname to connect to. Defaults to connecting to localhost. example: database.example.org restore_hostname: type: string description: | Database hostname to restore to. Defaults to the "hostname" option. example: database.example.org port: type: integer description: Port to connect to. Defaults to 27017. example: 27018 restore_port: type: integer description: | Port to restore to. Defaults to the "port" option. example: 5433 username: type: string description: | Username with which to connect to the database. Skip it if no authentication is needed. Supports the "{credential ...}" syntax. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. Supports the "{credential ...}" syntax. example: dbuser password: type: string description: | Password with which to connect to the database. Skip it if no authentication is needed. Supports the "{credential ...}" syntax. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. Supports the "{credential ...}" syntax. example: trustsome1 authentication_database: type: string description: | Authentication database where the specified username exists. If no authentication database is specified, the database provided in "name" is used. If "name" is "all", the "admin" database is used. example: admin format: type: string enum: ['archive', 'directory'] description: | Database dump output format. One of "archive", or "directory". Defaults to "archive". See mongodump documentation for details. Note that format is ignored when the database name is "all". example: directory options: type: string description: | Additional mongodump options to pass directly to the dump command, without performing any validation on them. See mongodump documentation for details. example: --dumpDbUsersAndRoles restore_options: type: string description: | Additional mongorestore options to pass directly to the dump command, without performing any validation on them. See mongorestore documentation for details. example: --restoreDbUsersAndRoles description: | List of one or more MongoDB databases to dump before creating a backup, run once per configuration file. The database dumps are added to your source directories at runtime and streamed directly to Borg. Requires mongodump/mongorestore commands. See https://docs.mongodb.com/database-tools/mongodump/ and https://docs.mongodb.com/database-tools/mongorestore/ for details. ntfy: type: object required: ['topic'] additionalProperties: false properties: topic: type: string description: | The topic to publish to. See https://ntfy.sh/docs/publish/ for details. example: topic server: type: string description: | The address of your self-hosted ntfy.sh instance. example: https://ntfy.your-domain.com username: type: string description: | The username used for authentication. Supports the "{credential ...}" syntax. example: testuser password: type: string description: | The password used for authentication. Supports the "{credential ...}" syntax. example: fakepassword access_token: type: string description: | An ntfy access token to authenticate with instead of username/password. Supports the "{credential ...}" syntax. example: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 start: type: object properties: title: type: string description: | The title of the message. example: Ping! message: type: string description: | The message body to publish. example: Your backups have failed. priority: type: string description: | The priority to set. example: urgent tags: type: string description: | Tags to attach to the message. example: incoming_envelope finish: type: object properties: title: type: string description: | The title of the message. example: Ping! message: type: string description: | The message body to publish. example: Your backups have failed. priority: type: string description: | The priority to set. example: urgent tags: type: string description: | Tags to attach to the message. example: incoming_envelope fail: type: object properties: title: type: string description: | The title of the message. example: Ping! message: type: string description: | The message body to publish. example: Your backups have failed. priority: type: string description: | The priority to set. example: urgent tags: type: string description: | Tags to attach to the message. example: incoming_envelope 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 pushover: type: object required: ['token', 'user'] additionalProperties: false properties: token: type: string description: | Your application's API token. Supports the "{credential ...}" syntax. 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. Supports the "{credential ...}" syntax. 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 required: - server 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 API endpoint URL of your Zabbix instance, usually ending with "/api_jsonrpc.php". Required. example: https://zabbix.your-domain.com username: type: string description: | The username used for authentication. Not needed if using an API key. Supports the "{credential ...}" syntax. example: testuser password: type: string description: | The password used for authentication. Not needed if using an API key. Supports the "{credential ...}" syntax. example: fakepassword api_key: type: string description: | The API key used for authentication. Not needed if using an username/password. Supports the "{credential ...}" syntax. 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'] additionalProperties: false properties: services: type: array items: type: object required: - url - label properties: url: type: string example: "gotify://hostname/token" label: type: string example: gotify description: | A list of Apprise services to publish to with URLs and labels. The labels are used for logging. A full list of services and their configuration can be found at https://github.com/caronc/apprise/wiki. example: - url: "kodi://user@hostname" label: kodi - url: "line://Token@User" label: line send_logs: type: boolean description: | Send borgmatic logs to Apprise services as part the "finish", "fail", and "log" states. Defaults to true. example: false logs_size_limit: type: integer description: | Number of bytes of borgmatic logs to send to Apprise services. Set to 0 to send all logs and disable this truncation. Defaults to 1500. example: 100000 start: type: object required: ['body'] properties: title: type: string description: | Specify the message title. If left unspecified, no title is sent. example: Ping! body: type: string description: | Specify the message body. example: Starting backup process. finish: type: object required: ['body'] properties: title: type: string description: | Specify the message title. If left unspecified, no title is sent. example: Ping! body: type: string description: | Specify the message body. example: Backups successfully made. fail: type: object required: ['body'] properties: title: type: string description: | Specify the message title. If left unspecified, no title is sent. example: Ping! body: type: string description: | Specify the message body. example: Your backups have failed. log: type: object required: ['body'] properties: title: type: string description: | Specify the message title. If left unspecified, no title is sent. example: Ping! body: type: string description: | Specify the message body. example: Here is some info about your backups. states: type: array items: type: string enum: - start - finish - fail - log uniqueItems: true description: | List of one or more monitoring states to ping for: "start", "finish", "fail", and/or "log". Defaults to pinging for failure only. For each selected state, corresponding configuration for the message title and body should be given. If any is left unspecified, a generic message is emitted instead. example: - start - finish healthchecks: type: object required: ['ping_url'] additionalProperties: false properties: ping_url: type: string description: | Healthchecks ping URL or UUID to notify when a backup begins, ends, errors, or to send only logs. example: https://hc-ping.com/your-uuid-here verify_tls: type: boolean description: | Verify the TLS certificate of the ping URL host. Defaults to true. example: false send_logs: type: boolean description: | Send borgmatic logs to Healthchecks as part the "finish", "fail", and "log" states. Defaults to true. example: false ping_body_limit: type: integer description: | Number of bytes of borgmatic logs to send to Healthchecks, ideally the same as PING_BODY_LIMIT configured on the Healthchecks server. Set to 0 to send all logs and disable this truncation. Defaults to 100000. example: 200000 states: type: array items: type: string enum: - start - finish - fail - log uniqueItems: true description: | List of one or more monitoring states to ping for: "start", "finish", "fail", and/or "log". Defaults to pinging for all 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// as opposed to https://hc-ping.com/). 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 verify_tls: type: boolean description: | Verify the TLS certificate of the push URL host. Defaults to true. example: false 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'] additionalProperties: false properties: ping_url: type: string description: | Cronitor ping URL to notify when a backup begins, ends, or errors. example: https://cronitor.link/d3x0c1 description: | Configuration for a monitoring integration with Cronitor. Create an account at https://cronitor.io if you'd like to use this service. See borgmatic monitoring documentation for details. pagerduty: type: object required: ['integration_key'] additionalProperties: false properties: integration_key: type: string description: | PagerDuty integration key used to notify PagerDuty when a backup errors. Supports the "{credential ...}" syntax. example: a177cad45bd374409f78906a810a3074 send_logs: type: boolean description: | Send borgmatic logs to PagerDuty when a backup errors. Defaults to true. example: false description: | Configuration for a monitoring integration with PagerDuty. Create an account at https://www.pagerduty.com if you'd like to use this service. See borgmatic monitoring documentation for details. cronhub: type: object required: ['ping_url'] additionalProperties: false properties: ping_url: type: string description: | Cronhub ping URL to notify when a backup begins, ends, or errors. example: https://cronhub.io/ping/1f5e3410-254c-5587 description: | Configuration for a monitoring integration with Cronhub. Create an account at https://cronhub.io if you'd like to use this service. See borgmatic monitoring documentation for details. loki: type: object required: ['url', 'labels'] additionalProperties: false properties: url: type: string description: | Grafana loki log URL to notify when a backup begins, ends, or fails. example: "http://localhost:3100/loki/api/v1/push" labels: type: object additionalProperties: type: string description: | 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 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 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. sentry: type: object required: ['data_source_name_url', 'monitor_slug'] additionalProperties: false properties: data_source_name_url: type: string description: | Sentry Data Source Name (DSN) URL, associated with a particular Sentry project. Used to construct a cron URL, notified when a backup begins, ends, or errors. example: https://5f80ec@o294220.ingest.us.sentry.io/203069 monitor_slug: type: string description: | Sentry monitor slug, associated with a particular Sentry project monitor. Used along with the data source name URL to construct a cron URL. example: mymonitor 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 all states. example: - start - finish description: | Configuration for a monitoring integration with Sentry. You can use a self-hosted instance via https://develop.sentry.dev/self-hosted/ or create a cloud-hosted account at https://sentry.io. 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). container: type: object additionalProperties: false properties: secrets_directory: type: string description: | Secrets directory to use instead of "/run/secrets". example: /path/to/secrets description: | Configuration for integration with Docker or Podman secrets. keepassxc: type: object additionalProperties: false properties: keepassxc_cli_command: type: string description: | Command to use instead of "keepassxc-cli". example: /usr/local/bin/keepassxc-cli description: | Configuration for integration with the KeePassXC password manager. borgmatic/borgmatic/config/validate.py000066400000000000000000000156331476361726000204300ustar00rootroot00000000000000import fnmatch import os import jsonschema import ruamel.yaml import borgmatic.config from borgmatic.config import constants, environment, load, normalize, override def schema_filename(): ''' Path to the installed YAML configuration schema file, used to validate and parse the configuration. Raise FileNotFoundError when the schema path does not exist. ''' schema_path = os.path.join(os.path.dirname(borgmatic.config.__file__), 'schema.yaml') with open(schema_path): return schema_path def format_json_error_path_element(path_element): ''' Given a path element into a JSON data structure, format it for display as a string. ''' if isinstance(path_element, int): return str(f'[{path_element}]') return str(f'.{path_element}') def format_json_error(error): ''' Given an instance of jsonschema.exceptions.ValidationError, format it for display as a string. ''' if not error.path: return f'At the top level: {error.message}' formatted_path = ''.join(format_json_error_path_element(element) for element in error.path) return f"At '{formatted_path.lstrip('.')}': {error.message}" class Validation_error(ValueError): ''' A collection of error messages generated when attempting to validate a particular configuration file. ''' def __init__(self, config_filename, errors): ''' Given a configuration filename path and a sequence of string error messages, create a Validation_error. ''' self.config_filename = config_filename self.errors = errors def __str__(self): ''' Render a validation error as a user-facing string. ''' return ( f'An error occurred while parsing a configuration file at {self.config_filename}:\n' + '\n'.join(error for error in self.errors) ) def apply_logical_validation(config_filename, parsed_configuration): ''' Given a parsed and schematically valid configuration as a data structure of nested dicts (see below), run through any additional logical validation checks. If there are any such validation problems, raise a Validation_error. ''' repositories = parsed_configuration.get('repositories') check_repositories = parsed_configuration.get('check_repositories', []) for repository in check_repositories: if not any( repositories_match(repository, config_repository) for config_repository in repositories ): raise Validation_error( config_filename, (f'Unknown repository in "check_repositories": {repository}',), ) def parse_configuration(config_filename, schema_filename, overrides=None, resolve_env=True): ''' Given the path to a config filename in YAML format, the path to a schema filename in a YAML rendition of JSON Schema format, a sequence of configuration file override strings in the form of "option.suboption=value", and whether to resolve environment variables, return the parsed configuration as a data structure of nested dicts and lists corresponding to the schema. Example return value: { 'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg', 'keep_daily': 7, 'checks': ['repository', 'archives'], } Also return a set of loaded configuration paths and a sequence of logging.LogRecord instances containing any warnings about the configuration. Raise FileNotFoundError if the file does not exist, PermissionError if the user does not have permissions to read the file, or Validation_error if the config does not match the schema. ''' config_paths = set() try: config = load.load_configuration(config_filename, config_paths) schema = load.load_configuration(schema_filename) except (ruamel.yaml.error.YAMLError, RecursionError) as error: raise Validation_error(config_filename, (str(error),)) override.apply_overrides(config, schema, overrides) constants.apply_constants(config, config.get('constants') if config else {}) if resolve_env: environment.resolve_env_variables(config) logs = normalize.normalize(config_filename, config) try: validator = jsonschema.Draft7Validator(schema) except AttributeError: # pragma: no cover validator = jsonschema.Draft4Validator(schema) validation_errors = tuple(validator.iter_errors(config)) if validation_errors: raise Validation_error( config_filename, tuple(format_json_error(error) for error in validation_errors) ) apply_logical_validation(config_filename, config) return config, config_paths, logs def normalize_repository_path(repository): ''' Given a repository path, return the absolute path of it (for local repositories). ''' # A colon in the repository could mean that it's either a file:// URL or a remote repository. # If it's a remote repository, we don't want to normalize it. If it's a file:// URL, we do. if ':' not in repository: return os.path.abspath(repository) elif repository.startswith('file://'): return os.path.abspath(repository.partition('file://')[-1]) else: 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", 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 glob_match(first.get('label'), second.get('label')) or glob_match( normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path')) ) def guard_configuration_contains_repository(repository, configurations): ''' Given a repository path and a dict mapping from config filename to corresponding parsed config dict, ensure that the repository is declared at least once in all of the configurations. If no repository is given, skip this check. Raise ValueError if the repository is not found in any configurations. ''' if not repository: return count = len( tuple( config_repository for config in configurations.values() for config_repository in config['repositories'] if repositories_match(config_repository, repository) ) ) if count == 0: raise ValueError(f'Repository "{repository}" not found in configuration files') borgmatic/borgmatic/execute.py000066400000000000000000000431421476361726000170300ustar00rootroot00000000000000import collections import enum import logging import select import subprocess import textwrap import borgmatic.logger logger = logging.getLogger(__name__) ERROR_OUTPUT_MAX_LINE_COUNT = 25 BORG_ERROR_EXIT_CODE_START = 2 BORG_ERROR_EXIT_CODE_END = 99 class Exit_status(enum.Enum): STILL_RUNNING = 1 SUCCESS = 2 WARNING = 3 ERROR = 4 def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_codes=None): ''' Return an Exit_status value (e.g. SUCCESS, ERROR, or WARNING) based on interpreting the given exit code. If a Borg local path is given and matches the process' command, then interpret the exit code based on Borg's documented exit code semantics. And if Borg exit codes are given as a sequence of exit code configuration dicts, then take those configured preferences into account. ''' if exit_code is None: return Exit_status.STILL_RUNNING if exit_code == 0: return Exit_status.SUCCESS if borg_local_path and command[0] == borg_local_path: # First try looking for the exit code in the borg_exit_codes configuration. for entry in borg_exit_codes or (): if entry.get('code') == exit_code: treat_as = entry.get('treat_as') if treat_as == 'error': logger.error( f'Treating exit code {exit_code} as an error, as per configuration' ) return Exit_status.ERROR elif treat_as == 'warning': logger.warning( f'Treating exit code {exit_code} as a warning, as per configuration' ) return Exit_status.WARNING # If the exit code doesn't have explicit configuration, then fall back to the default Borg # behavior. return ( Exit_status.ERROR if ( exit_code < 0 or ( exit_code >= BORG_ERROR_EXIT_CODE_START and exit_code <= BORG_ERROR_EXIT_CODE_END ) ) else Exit_status.WARNING ) return Exit_status.ERROR def command_for_process(process): ''' Given a process as an instance of subprocess.Popen, return the command string that was used to invoke it. ''' return process.args if isinstance(process.args, str) else ' '.join(process.args) def output_buffer_for_process(process, exclude_stdouts): ''' Given a process as an instance of subprocess.Popen and a sequence of stdouts to exclude, return either the process's stdout or stderr. The idea is that if stdout is excluded for a process, we still have stderr to log. ''' return process.stderr if process.stdout in exclude_stdouts else process.stdout def append_last_lines(last_lines, captured_output, line, output_log_level): ''' Given a rolling list of last lines, a list of captured output, a line to append, and an output log level, append the line to the last lines and (if necessary) the captured output. Then log the line at the requested output log level. ''' last_lines.append(line) if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT: last_lines.pop(0) if output_log_level is None: captured_output.append(line) else: logger.log(output_log_level, line) def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, borg_exit_codes): ''' Given a sequence of subprocess.Popen() instances for multiple processes, log the output for each process with the requested log level. Additionally, raise a CalledProcessError if a process exits with an error (or a warning for exit code 1, if that process does not match the Borg local path). If output log level is None, then instead of logging, capture output for each process and return it as a dict from the process to its output. Use the given Borg local path and exit code configuration to decide what's an error and what's a warning. For simplicity, it's assumed that the output buffer for each process is its stdout. But if any stdouts are given to exclude, then for any matching processes, log from their stderr instead. Note that stdout for a process can be None if output is intentionally not captured. In which case it won't be logged. ''' # Map from output buffer to sequence of last lines. buffer_last_lines = collections.defaultdict(list) process_for_output_buffer = { output_buffer_for_process(process, exclude_stdouts): process for process in processes if process.stdout or process.stderr } output_buffers = list(process_for_output_buffer.keys()) captured_outputs = collections.defaultdict(list) still_running = True # Log output for each process until they all exit. while True: if output_buffers: (ready_buffers, _, _) = select.select(output_buffers, [], []) for ready_buffer in ready_buffers: ready_process = process_for_output_buffer.get(ready_buffer) # The "ready" process has exited, but it might be a pipe destination with other # processes (pipe sources) waiting to be read from. So as a measure to prevent # hangs, vent all processes when one exits. if ready_process and ready_process.poll() is not None: for other_process in processes: if ( other_process.poll() is None and other_process.stdout and other_process.stdout not in output_buffers ): # Add the process's output to output_buffers to ensure it'll get read. output_buffers.append(other_process.stdout) while True: line = ready_buffer.readline().rstrip().decode() if not line or not ready_process: break # Keep the last few lines of output in case the process errors, and we need the output for # the exception below. append_last_lines( buffer_last_lines[ready_buffer], captured_outputs[ready_process], line, output_log_level, ) if not still_running: break still_running = False for process in processes: exit_code = process.poll() if output_buffers else process.wait() if exit_code is None: still_running = True command = process.args.split(' ') if isinstance(process.args, str) else process.args continue command = process.args.split(' ') if isinstance(process.args, str) else process.args exit_status = interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes) if exit_status in (Exit_status.ERROR, Exit_status.WARNING): # If an error occurs, include its output in the raised exception so that we don't # inadvertently hide error output. output_buffer = output_buffer_for_process(process, exclude_stdouts) last_lines = buffer_last_lines[output_buffer] if output_buffer else [] # Collect any straggling output lines that came in since we last gathered output. while output_buffer: # pragma: no cover line = output_buffer.readline().rstrip().decode() if not line: break append_last_lines( last_lines, captured_outputs[process], line, output_log_level=logging.ERROR ) if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT: last_lines.insert(0, '...') # Something has gone wrong. So vent each process' output buffer to prevent it from # hanging. And then kill the process. for other_process in processes: if other_process.poll() is None: other_process.stdout.read(0) other_process.kill() if exit_status == Exit_status.ERROR: raise subprocess.CalledProcessError( exit_code, command_for_process(process), '\n'.join(last_lines) ) still_running = False break if captured_outputs: return { process: '\n'.join(output_lines) for process, output_lines in captured_outputs.items() } SECRET_COMMAND_FLAG_NAMES = {'--password'} def mask_command_secrets(full_command): ''' Given a command as a sequence, mask secret values for flags like "--password" in preparation for logging. ''' masked_command = [] previous_piece = None for piece in full_command: masked_command.append('***' if previous_piece in SECRET_COMMAND_FLAG_NAMES else piece) previous_piece = piece return tuple(masked_command) MAX_LOGGED_COMMAND_LENGTH = 1000 PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG = ('BORG_', 'PG', 'MARIADB_', 'MYSQL_') def log_command(full_command, input_file=None, output_file=None, environment=None): ''' Log the given command (a sequence of command/argument strings), along with its input/output file paths and extra environment variables (with omitted values in case they contain passwords). ''' logger.debug( textwrap.shorten( ' '.join( tuple( f'{key}=***' for key in (environment or {}).keys() if any( key.startswith(prefix) for prefix in PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG ) ) + mask_command_secrets(full_command) ), width=MAX_LOGGED_COMMAND_LENGTH, placeholder=' ...', ) + (f" < {getattr(input_file, 'name', input_file)}" if input_file else '') + (f" > {getattr(output_file, 'name', output_file)}" if output_file else '') ) # A sentinel passed as an output file to execute_command() to indicate that the command's output # should be allowed to flow through to stdout without being captured for logging. Useful for # commands with interactive prompts or those that mess directly with the console. DO_NOT_CAPTURE = object() def execute_command( full_command, output_log_level=logging.INFO, output_file=None, input_file=None, shell=False, environment=None, working_directory=None, borg_local_path=None, borg_exit_codes=None, run_to_completion=True, ): ''' Execute the given command (a sequence of command/argument strings) and log its output at the given log level. If an open output file object is given, then write stdout to the file and only log stderr. If an open input file object is given, then read stdin from the file. If shell is True, execute the command within a shell. If an environment variables dict is given, then pass it into the command. If a working directory is given, use that as the present working directory when running the command. If a Borg local path is given, and the command matches it (regardless of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a sequence of exit code configuration dicts, then use that configuration to decide what's an error and what's a warning. If run to completion is False, then return the process for the command without executing it to completion. Raise subprocesses.CalledProcessError if an error occurs while running the command. ''' log_command(full_command, input_file, output_file, environment) do_not_capture = bool(output_file is DO_NOT_CAPTURE) command = ' '.join(full_command) if shell else full_command process = subprocess.Popen( command, stdin=input_file, stdout=None if do_not_capture else (output_file or subprocess.PIPE), stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT), shell=shell, env=environment, cwd=working_directory, # Necessary for passing credentials via anonymous pipe. close_fds=False, ) if not run_to_completion: return process with borgmatic.logger.Log_prefix(None): # Log command output without any prefix. log_outputs( (process,), (input_file, output_file), output_log_level, borg_local_path, borg_exit_codes, ) def execute_command_and_capture_output( full_command, input_file=None, capture_stderr=False, shell=False, environment=None, working_directory=None, borg_local_path=None, borg_exit_codes=None, ): ''' Execute the given command (a sequence of command/argument strings), capturing and returning its output (stdout). If an input file descriptor is given, then pipe it to the command's stdin. If capture stderr is True, then capture and return stderr in addition to stdout. If shell is True, execute the command within a shell. If an environment variables dict is given, then pass it into the command. If a working directory is given, use that as the present working directory when running the command. If a Borg local path is given, and the command matches it (regardless of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a sequence of exit code configuration dicts, then use that configuration to decide what's an error and what's a warning. Raise subprocesses.CalledProcessError if an error occurs while running the command. ''' log_command(full_command, input_file, environment=environment) command = ' '.join(full_command) if shell else full_command try: output = subprocess.check_output( command, stdin=input_file, stderr=subprocess.STDOUT if capture_stderr else None, shell=shell, env=environment, cwd=working_directory, # Necessary for passing credentials via anonymous pipe. close_fds=False, ) except subprocess.CalledProcessError as error: if ( interpret_exit_code(command, error.returncode, borg_local_path, borg_exit_codes) == Exit_status.ERROR ): raise output = error.output return output.decode() if output is not None else None def execute_command_with_processes( full_command, processes, output_log_level=logging.INFO, output_file=None, input_file=None, shell=False, environment=None, working_directory=None, borg_local_path=None, borg_exit_codes=None, ): ''' Execute the given command (a sequence of command/argument strings) and log its output at the given log level. Simultaneously, continue to poll one or more active processes so that they run as well. This is useful, for instance, for processes that are streaming output to a named pipe that the given command is consuming from. If an open output file object is given, then write stdout to the file and only log stderr. But if output log level is None, instead suppress logging and return the captured output for (only) the given command. If an open input file object is given, then read stdin from the file. If shell is True, execute the command within a shell. If an environment variables dict is given, then pass it into the command. If a working directory is given, use that as the present working directory when running the command. If a Borg local path is given, then for any matching command or process (regardless of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a sequence of exit code configuration dicts, then use that configuration to decide what's an error and what's a warning. Raise subprocesses.CalledProcessError if an error occurs while running the command or in the upstream process. ''' log_command(full_command, input_file, output_file, environment) do_not_capture = bool(output_file is DO_NOT_CAPTURE) command = ' '.join(full_command) if shell else full_command try: command_process = subprocess.Popen( command, stdin=input_file, stdout=None if do_not_capture else (output_file or subprocess.PIPE), stderr=( None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT) ), shell=shell, env=environment, cwd=working_directory, # Necessary for passing credentials via anonymous pipe. close_fds=False, ) except (subprocess.CalledProcessError, OSError): # Something has gone wrong. So vent each process' output buffer to prevent it from hanging. # And then kill the process. for process in processes: if process.poll() is None: process.stdout.read(0) process.kill() raise with borgmatic.logger.Log_prefix(None): # Log command output without any prefix. captured_outputs = log_outputs( tuple(processes) + (command_process,), (input_file, output_file), output_log_level, borg_local_path, borg_exit_codes, ) if output_log_level is None: return captured_outputs.get(command_process) borgmatic/borgmatic/hooks/000077500000000000000000000000001476361726000161335ustar00rootroot00000000000000borgmatic/borgmatic/hooks/__init__.py000066400000000000000000000000001476361726000202320ustar00rootroot00000000000000borgmatic/borgmatic/hooks/command.py000066400000000000000000000074741476361726000201370ustar00rootroot00000000000000import logging import os import re import shlex import sys import borgmatic.execute logger = logging.getLogger(__name__) SOFT_FAIL_EXIT_CODE = 75 def interpolate_context(hook_description, command, context): ''' Given a config filename, a hook description, a single hook command, and a dict of context names/values, interpolate the values by "{name}" into the command and return the result. ''' for name, value in context.items(): command = command.replace(f'{{{name}}}', shlex.quote(str(value))) for unsupported_variable in re.findall(r'{\w+}', command): logger.warning( f"Variable '{unsupported_variable}' is not supported in {hook_description} hook" ) 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 a copy of it, augmented with any extra environment variables that should be used when running command hooks. ''' environment = dict(current_environment) # 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'): environment['LD_LIBRARY_PATH'] = environment.get('LD_LIBRARY_PATH_ORIG', '') return environment 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, a hook description, and whether this is a dry run, run the given commands. Or, don't run them if this is a dry run. The context contains optional values interpolated by name into the hook commands. Raise ValueError if the umask cannot be parsed. Raise subprocesses.CalledProcessError if an error occurs in a hook. ''' if not commands: logger.debug(f'No commands to run for {description} hook') return dry_run_label = ' (dry run; not actually running hooks)' if dry_run else '' context['configuration_filename'] = config_filename commands = [interpolate_context(description, command, context) for command in commands] if len(commands) == 1: logger.info(f'Running command for {description} hook{dry_run_label}') else: logger.info( f'Running {len(commands)} commands for {description} hook{dry_run_label}', ) if umask: parsed_umask = int(str(umask), 8) logger.debug(f'Set hook umask to {oct(parsed_umask)}') original_umask = os.umask(parsed_umask) else: original_umask = None try: for command in commands: if dry_run: continue borgmatic.execute.execute_command( [command], output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING), shell=True, environment=make_environment(os.environ), ) finally: if original_umask: os.umask(original_umask) def considered_soft_failure(error): ''' Given a configuration filename and an exception object, return whether the exception object represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so, that indicates that the error is a "soft failure", and should not result in an error. ''' exit_code = getattr(error, 'returncode', None) if exit_code is None: return False if exit_code == SOFT_FAIL_EXIT_CODE: logger.info( f'Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions', ) return True return False borgmatic/borgmatic/hooks/credential/000077500000000000000000000000001476361726000202455ustar00rootroot00000000000000borgmatic/borgmatic/hooks/credential/__init__.py000066400000000000000000000000001476361726000223440ustar00rootroot00000000000000borgmatic/borgmatic/hooks/credential/container.py000066400000000000000000000026161476361726000226060ustar00rootroot00000000000000import logging import os import re logger = logging.getLogger(__name__) SECRET_NAME_PATTERN = re.compile(r'^\w+$') DEFAULT_SECRETS_DIRECTORY = '/run/secrets' def load_credential(hook_config, config, credential_parameters): ''' Given the hook configuration dict, the configuration dict, and a credential parameters tuple containing a secret name to load, read the secret from the corresponding container secrets file and return it. Raise ValueError if the credential parameters is not one element, the secret name is invalid, or the secret file cannot be read. ''' try: (secret_name,) = credential_parameters except ValueError: name = ' '.join(credential_parameters) raise ValueError(f'Cannot load invalid secret name: "{name}"') if not SECRET_NAME_PATTERN.match(secret_name): raise ValueError(f'Cannot load invalid secret name: "{secret_name}"') try: with open( os.path.join( config.get('working_directory', ''), (hook_config or {}).get('secrets_directory', DEFAULT_SECRETS_DIRECTORY), secret_name, ) ) as secret_file: return secret_file.read().rstrip(os.linesep) except (FileNotFoundError, OSError) as error: logger.warning(error) raise ValueError(f'Cannot load secret "{secret_name}" from file: {error.filename}') borgmatic/borgmatic/hooks/credential/file.py000066400000000000000000000017431476361726000215430ustar00rootroot00000000000000import logging import os logger = logging.getLogger(__name__) def load_credential(hook_config, config, credential_parameters): ''' Given the hook configuration dict, the configuration dict, and a credential parameters tuple containing a credential path to load, load the credential from file and return it. Raise ValueError if the credential parameters is not one element or the secret file cannot be read. ''' try: (credential_path,) = credential_parameters except ValueError: name = ' '.join(credential_parameters) raise ValueError(f'Cannot load invalid credential: "{name}"') try: with open( os.path.join(config.get('working_directory', ''), credential_path) ) as credential_file: return credential_file.read().rstrip(os.linesep) except (FileNotFoundError, OSError) as error: logger.warning(error) raise ValueError(f'Cannot load credential file: {error.filename}') borgmatic/borgmatic/hooks/credential/keepassxc.py000066400000000000000000000025161476361726000226110ustar00rootroot00000000000000import logging import os import shlex import borgmatic.execute logger = logging.getLogger(__name__) def load_credential(hook_config, config, credential_parameters): ''' Given the hook configuration dict, the configuration dict, and a credential parameters tuple containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch the corresponidng KeePassXC credential and return it. Raise ValueError if keepassxc-cli can't retrieve the credential. ''' try: (database_path, attribute_name) = credential_parameters except ValueError: path_and_name = ' '.join(credential_parameters) raise ValueError( f'Cannot load credential with invalid KeePassXC database path and attribute name: "{path_and_name}"' ) if not os.path.exists(database_path): raise ValueError( f'Cannot load credential because KeePassXC database path does not exist: {database_path}' ) return borgmatic.execute.execute_command_and_capture_output( tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli'))) + ( 'show', '--show-protected', '--attributes', 'Password', database_path, attribute_name, ) ).rstrip(os.linesep) borgmatic/borgmatic/hooks/credential/parse.py000066400000000000000000000101071476361726000217300ustar00rootroot00000000000000import functools import re import shlex import borgmatic.hooks.dispatch IS_A_HOOK = False class Hash_adapter: ''' A Hash_adapter instance wraps an unhashable object and pretends it's hashable. This is intended for passing to a @functools.cache-decorated function to prevent it from complaining that an argument is unhashable. It should only be used for arguments that you don't want to actually impact the cache hashing, because Hash_adapter doesn't actually hash the object's contents. Example usage: @functools.cache def func(a, b): print(a, b.actual_value) return a func(5, Hash_adapter({1: 2, 3: 4})) # Calls func(), prints, and returns. func(5, Hash_adapter({1: 2, 3: 4})) # Hits the cache and just returns the value. func(5, Hash_adapter({5: 6, 7: 8})) # Also uses cache, since the Hash_adapter is ignored. In the above function, the "b" value is one that has been wrapped with Hash_adappter, and therefore "b.actual_value" is necessary to access the original value. ''' def __init__(self, actual_value): self.actual_value = actual_value def __eq__(self, other): return True def __hash__(self): return 0 UNHASHABLE_TYPES = (dict, list, set) def cache_ignoring_unhashable_arguments(function): ''' A function decorator that caches calls to the decorated function but ignores any unhashable arguments when performing cache lookups. This is intended to be a drop-in replacement for functools.cache. Example usage: @cache_ignoring_unhashable_arguments def func(a, b): print(a, b) return a func(5, {1: 2, 3: 4}) # Calls func(), prints, and returns. func(5, {1: 2, 3: 4}) # Hits the cache and just returns the value. func(5, {5: 6, 7: 8}) # Also uses cache, since the unhashable value (the dict) is ignored. ''' @functools.cache def cached_function(*args, **kwargs): return function( *(arg.actual_value if isinstance(arg, Hash_adapter) else arg for arg in args), **{ key: value.actual_value if isinstance(value, Hash_adapter) else value for (key, value) in kwargs.items() }, ) @functools.wraps(function) def wrapper_function(*args, **kwargs): return cached_function( *(Hash_adapter(arg) if isinstance(arg, UNHASHABLE_TYPES) else arg for arg in args), **{ key: Hash_adapter(value) if isinstance(value, UNHASHABLE_TYPES) else value for (key, value) in kwargs.items() }, ) wrapper_function.cache_clear = cached_function.cache_clear return wrapper_function CREDENTIAL_PATTERN = re.compile(r'\{credential( +(?P.*))?\}') @cache_ignoring_unhashable_arguments def resolve_credential(value, config): ''' Given a configuration value containing a string like "{credential hookname credentialname}" and a configuration dict, resolve the credential by calling the relevant hook to get the actual credential value. If the given value does not actually contain a credential tag, then return it unchanged. Cache the value (ignoring the config for purposes of caching), so repeated calls to this function don't need to load the credential repeatedly. Raise ValueError if the config could not be parsed or the credential could not be loaded. ''' if value is None: return value matcher = CREDENTIAL_PATTERN.match(value) if not matcher: return value hook_and_parameters = matcher.group('hook_and_parameters') if not hook_and_parameters: raise ValueError(f'Cannot load credential with invalid syntax "{value}"') (hook_name, *credential_parameters) = shlex.split(hook_and_parameters) if not credential_parameters: raise ValueError(f'Cannot load credential with invalid syntax "{value}"') return borgmatic.hooks.dispatch.call_hook( 'load_credential', config, hook_name, tuple(credential_parameters) ) borgmatic/borgmatic/hooks/credential/systemd.py000066400000000000000000000030341476361726000223070ustar00rootroot00000000000000import logging import os import re logger = logging.getLogger(__name__) CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$') def load_credential(hook_config, config, credential_parameters): ''' Given the hook configuration dict, the configuration dict, and a credential parameters tuple containing a credential name to load, read the credential from the corresponding systemd credential file and return it. Raise ValueError if the systemd CREDENTIALS_DIRECTORY environment variable is not set, the credential name is invalid, or the credential file cannot be read. ''' try: (credential_name,) = credential_parameters except ValueError: name = ' '.join(credential_parameters) raise ValueError(f'Cannot load invalid credential name: "{name}"') credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY') if not credentials_directory: raise ValueError( f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set' ) if not CREDENTIAL_NAME_PATTERN.match(credential_name): raise ValueError(f'Cannot load invalid credential name "{credential_name}"') try: with open(os.path.join(credentials_directory, credential_name)) as credential_file: return credential_file.read().rstrip(os.linesep) except (FileNotFoundError, OSError) as error: logger.warning(error) raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}') borgmatic/borgmatic/hooks/data_source/000077500000000000000000000000001476361726000204245ustar00rootroot00000000000000borgmatic/borgmatic/hooks/data_source/__init__.py000066400000000000000000000000001476361726000225230ustar00rootroot00000000000000borgmatic/borgmatic/hooks/data_source/bootstrap.py000066400000000000000000000074251476361726000230230ustar00rootroot00000000000000import glob import importlib import json import logging import os import borgmatic.borg.pattern import borgmatic.config.paths logger = logging.getLogger(__name__) def use_streaming(hook_config, config): # 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, config_paths, borgmatic_runtime_directory, patterns, dry_run, ): ''' Given a bootstrap configuration dict, a configuration dict, the borgmatic configuration file paths, the borgmatic runtime directory, the configured patterns, 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, ) patterns.extend( borgmatic.borg.pattern.Pattern( config_path, source=borgmatic.borg.pattern.Pattern_source.HOOK ) for config_path in config_paths ) patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'bootstrap'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return [] def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run): ''' Given a bootstrap configuration dict, a configuration dict, 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'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'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, 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, 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() borgmatic/borgmatic/hooks/data_source/btrfs.py000066400000000000000000000327131476361726000221240ustar00rootroot00000000000000import collections import glob import json import logging import os import shutil import subprocess import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.execute import borgmatic.hooks.data_source.snapshot logger = logging.getLogger(__name__) def use_streaming(hook_config, config): # pragma: no cover ''' Return whether dump streaming is used for this hook. (Spoiler: It isn't.) ''' return False def get_subvolume_mount_points(findmnt_command): ''' Given a findmnt command to run, get all sorted Btrfs subvolume 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( sorted(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}"') Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),)) def get_subvolume_property(btrfs_command, subvolume_path, property_name): output = borgmatic.execute.execute_command_and_capture_output( tuple(btrfs_command.split(' ')) + ( 'property', 'get', '-t', # Type. 'subvol', subvolume_path, property_name, ), ) try: value = output.strip().split('=')[1] except IndexError: raise ValueError(f'Invalid {btrfs_command} property output') return { 'true': True, 'false': False, }.get(value, value) def omit_read_only_subvolume_mount_points(btrfs_command, subvolume_paths): ''' Given a Btrfs command to run and a sequence of Btrfs subvolume mount points, filter them down to just those that are read-write. The idea is that Btrfs can't actually snapshot a read-only subvolume, so we should just ignore them. ''' retained_subvolume_paths = [] for subvolume_path in subvolume_paths: if get_subvolume_property(btrfs_command, subvolume_path, 'ro'): logger.debug(f'Ignoring Btrfs subvolume {subvolume_path} because it is read-only') else: retained_subvolume_paths.append(subvolume_path) return tuple(retained_subvolume_paths) def get_subvolumes(btrfs_command, findmnt_command, patterns=None): ''' Given a Btrfs command to run and a sequence of configured patterns, find the intersection between the current Btrfs filesystem and subvolume mount points and the paths of any patterns. The idea is that these pattern paths represent the requested subvolumes to snapshot. Only include subvolumes that contain at least one root pattern sourced from borgmatic configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, then return all subvolumes instead, sorted by path. Return the result as a sequence of matching subvolume mount points. ''' candidate_patterns = set(patterns or ()) subvolumes = [] # For each subvolume mount point, match it against the given patterns to find the subvolumes to # backup. Sort the subvolumes from longest to shortest mount points, so longer mount points get # a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during # this process, so no two subvolumes end up with the same contained patterns.) for mount_point in reversed( omit_read_only_subvolume_mount_points( btrfs_command, get_subvolume_mount_points(findmnt_command) ) ): subvolumes.extend( Subvolume(mount_point, contained_patterns) for contained_patterns in ( borgmatic.hooks.data_source.snapshot.get_contained_patterns( mount_point, candidate_patterns ), ) if patterns is None or any( pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG for pattern in contained_patterns ) ) return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path)) BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-' def make_snapshot_path(subvolume_path): ''' 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.rstrip(os.path.sep) def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover ''' Given the path to a subvolume, make a corresponding exclude pattern 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 an exclude pattern to exclude that path. ''' snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}' return borgmatic.borg.pattern.Pattern( os.path.join( subvolume_path, snapshot_directory, subvolume_path.lstrip(os.path.sep), snapshot_directory, ), borgmatic.borg.pattern.Pattern_type.NO_RECURSE, borgmatic.borg.pattern.Pattern_style.FNMATCH, source=borgmatic.borg.pattern.Pattern_source.HOOK, ) def make_borg_snapshot_pattern(subvolume_path, pattern): ''' Given the path to a subvolume and a pattern as a borgmatic.borg.pattern.Pattern instance whose path is inside the subvolume, return a new Pattern with its path rewritten to be in a snapshot path intended for giving to Borg. Move any initial caret in a regular expression pattern path to the beginning, so as not to break the regular expression. ''' initial_caret = ( '^' if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION and pattern.path.startswith('^') else '' ) rewritten_path = initial_caret + 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. pattern.path.lstrip('^').lstrip(os.path.sep), ) return borgmatic.borg.pattern.Pattern( rewritten_path, pattern.type, pattern.style, pattern.device, source=borgmatic.borg.pattern.Pattern_source.HOOK, ) 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, config_paths, borgmatic_runtime_directory, patterns, dry_run, ): ''' Given a Btrfs configuration dict, a configuration dict, the borgmatic configuration file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed in the given patterns. Also update those patterns, replacing subvolume mount points with corresponding snapshot directories so they get stored in the Borg archive instead. 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'Snapshotting Btrfs subvolumes{dry_run_label}') # Based on the configured patterns, determine Btrfs subvolumes to backup. Only consider those # patterns that came from actual user configuration (as opposed to, say, other hooks). btrfs_command = hook_config.get('btrfs_command', 'btrfs') findmnt_command = hook_config.get('findmnt_command', 'findmnt') subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns) if not subvolumes: logger.warning(f'No Btrfs subvolumes found to snapshot{dry_run_label}') # Snapshot each subvolume, rewriting patterns to use their snapshot paths. for subvolume in subvolumes: logger.debug(f'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 pattern in subvolume.contained_patterns: snapshot_pattern = make_borg_snapshot_pattern(subvolume.path, pattern) # Attempt to update the pattern in place, since pattern order matters to Borg. try: patterns[patterns.index(pattern)] = snapshot_pattern except ValueError: patterns.append(snapshot_pattern) patterns.append(make_snapshot_exclude_pattern(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, borgmatic_runtime_directory, dry_run): ''' Given a Btrfs configuration dict, a configuration dict, the borgmatic runtime directory, and whether this is a dry run, delete any Btrfs snapshots created by borgmatic. 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'Could not find "{error.filename}" command') return except subprocess.CalledProcessError as error: logger.debug(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'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'Deleting Btrfs snapshot {snapshot_path}{dry_run_label}') if dry_run: continue try: delete_snapshot(btrfs_command, snapshot_path) except FileNotFoundError: logger.debug(f'Could not find "{btrfs_command}" command') return except subprocess.CalledProcessError as error: logger.debug(error) return # Remove the snapshot parent directory if it still exists. (It might not exist if the # snapshot was for "/".) snapshot_parent_dir = snapshot_path.rsplit(subvolume.path, 1)[0] if os.path.isdir(snapshot_parent_dir): shutil.rmtree(snapshot_parent_dir) def make_data_source_dump_patterns( hook_config, config, 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, 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() borgmatic/borgmatic/hooks/data_source/dump.py000066400000000000000000000050201476361726000217400ustar00rootroot00000000000000import 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, 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'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 ) borgmatic/borgmatic/hooks/data_source/lvm.py000066400000000000000000000402101476361726000215710ustar00rootroot00000000000000import collections import glob import hashlib import json import logging import os import shutil import subprocess import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.execute import borgmatic.hooks.data_source.snapshot logger = logging.getLogger(__name__) def use_streaming(hook_config, config): # 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_patterns') ) def get_logical_volumes(lsblk_command, patterns=None): ''' Given an lsblk command to run and a sequence of configured patterns, find the intersection between the current LVM logical volume mount points and the paths of any patterns. The idea is that these pattern paths represent the requested logical volumes to snapshot. Only include logical volumes that contain at least one root pattern sourced from borgmatic configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, include all logical volume mounts points instead, not just those in patterns. 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_patterns = set(patterns or ()) try: # Sort from longest to shortest mount points, so longer mount points get a whack at the # candidate pattern piñata before their parents do. (Patterns are consumed below, so no two # logical volumes end up with the same contained patterns.) return tuple( Logical_volume(device['name'], device['path'], device['mountpoint'], contained_patterns) for device in sorted( devices_info['blockdevices'], key=lambda device: device['mountpoint'] or '', reverse=True, ) if device['mountpoint'] and device['type'] == 'lvm' for contained_patterns in ( borgmatic.hooks.data_source.snapshot.get_contained_patterns( device['mountpoint'], candidate_patterns ), ) if not patterns or any( pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG for pattern in contained_patterns ) ) 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, ) MOUNT_POINT_HASH_LENGTH = 10 def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_directory): ''' Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and a Logical_volume containing it, return a new Pattern with its path rewritten to be in a snapshot directory based on both the given runtime directory and the given Logical_volume's mount point. Move any initial caret in a regular expression pattern path to the beginning, so as not to break the regular expression. ''' initial_caret = ( '^' if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION and pattern.path.startswith('^') else '' ) rewritten_path = initial_caret + os.path.join( normalized_runtime_directory, 'lvm_snapshots', # Including this hash prevents conflicts between snapshot patterns for different logical # volumes. For instance, without this, snapshotting a logical volume at /var and another at # /var/spool would result in overlapping snapshot patterns and therefore colliding mount # attempts. hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest( MOUNT_POINT_HASH_LENGTH ), '.', # Borg 1.4+ "slashdot" hack. # Included so that the source directory ends up in the Borg archive at its "original" path. pattern.path.lstrip('^').lstrip(os.path.sep), ) return borgmatic.borg.pattern.Pattern( rewritten_path, pattern.type, pattern.style, pattern.device, source=borgmatic.borg.pattern.Pattern_source.HOOK, ) DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN' def dump_data_sources( hook_config, config, config_paths, borgmatic_runtime_directory, patterns, dry_run, ): ''' Given an LVM configuration dict, a configuration dict, the borgmatic configuration file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry run, auto-detect and snapshot any LVM logical volume mount points listed in the given patterns. Also update those patterns, replacing logical volume mount points with corresponding snapshot directories so they get stored in the Borg archive instead. 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'Snapshotting LVM logical volumes{dry_run_label}') # List logical volumes to get their mount points, but only consider those patterns that came # from actual user configuration (as opposed to, say, other hooks). lsblk_command = hook_config.get('lsblk_command', 'lsblk') requested_logical_volumes = get_logical_volumes(lsblk_command, patterns) # 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'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'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', hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest( MOUNT_POINT_HASH_LENGTH ), logical_volume.mount_point.lstrip(os.path.sep), ) logger.debug( f'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 ) for pattern in logical_volume.contained_patterns: snapshot_pattern = make_borg_snapshot_pattern( pattern, logical_volume, normalized_runtime_directory ) # Attempt to update the pattern in place, since pattern order matters to Borg. try: patterns[patterns.index(pattern)] = snapshot_pattern except ValueError: patterns.append(snapshot_pattern) 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, borgmatic_runtime_directory, dry_run): ''' Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, and whether this is a dry run, unmount and delete any LVM snapshots created by borgmatic. 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'Could not find "{error.filename}" command') return except subprocess.CalledProcessError as error: logger.debug(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'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 the snapshot mount path is empty, this is probably just a "shadow" of a nested # logical volume and therefore there's nothing to unmount. if not os.path.isdir(snapshot_mount_path) or not os.listdir(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'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'Could not find "{umount_command}" command') return except subprocess.CalledProcessError as error: logger.debug(error) continue 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'Could not find "{error.filename}" command') return except subprocess.CalledProcessError as error: logger.debug(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'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, 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, 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() borgmatic/borgmatic/hooks/data_source/mariadb.py000066400000000000000000000352761476361726000224120ustar00rootroot00000000000000import copy import logging import os import re import shlex import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.hooks.credential.parse from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks.data_source import dump logger = logging.getLogger(__name__) def make_dump_path(base_directory): # pragma: no cover ''' Given a base directory, make the corresponding dump path. ''' return dump.make_data_source_dump_path(base_directory, 'mariadb_databases') DEFAULTS_EXTRA_FILE_FLAG_PATTERN = re.compile('^--defaults-extra-file=(?P.*)$') def parse_extra_options(extra_options): ''' Given an extra options string, split the options into a tuple and return it. Additionally, if the first option is "--defaults-extra-file=...", then remove it from the options and return the filename. So the return value is a tuple of: (parsed options, defaults extra filename). The intent is to support downstream merging of multiple "--defaults-extra-file"s, as MariaDB/MySQL only allows one at a time. ''' split_extra_options = tuple(shlex.split(extra_options)) if extra_options else () if not split_extra_options: return ((), None) match = DEFAULTS_EXTRA_FILE_FLAG_PATTERN.match(split_extra_options[0]) if not match: return (split_extra_options, None) return (split_extra_options[1:], match.group('filename')) def make_defaults_file_options(username=None, password=None, defaults_extra_filename=None): ''' Given a database username and/or password, write it to an anonymous pipe and return the flags for passing that file descriptor to an executed command. The idea is that this is a more secure way to transmit credentials to a database client than using an environment variable. If no username or password are given, then return the options for the given defaults extra filename (if any). But if there is a username and/or password and a defaults extra filename is given, then "!include" it from the generated file, effectively allowing multiple defaults extra files. Do not use the returned value for multiple different command invocations. That will not work because each pipe is "used up" once read. ''' escaped_password = None if password is None else password.replace('\\', '\\\\') values = '\n'.join( ( (f'user={username}' if username is not None else ''), (f'password="{escaped_password}"' if escaped_password is not None else ''), ) ).strip() if not values: if defaults_extra_filename: return (f'--defaults-extra-file={defaults_extra_filename}',) return () fields_message = ' and '.join( field_name for field_name in ( (f'username ({username})' if username is not None else None), ('password' if password is not None else None), ) if field_name is not None ) include_message = f' (including {defaults_extra_filename})' if defaults_extra_filename else '' logger.debug(f'Writing database {fields_message} to defaults extra file pipe{include_message}') include = f'!include {defaults_extra_filename}\n' if defaults_extra_filename else '' read_file_descriptor, write_file_descriptor = os.pipe() os.write(write_file_descriptor, f'{include}[client]\n{values}'.encode('utf-8')) os.close(write_file_descriptor) # This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the database # client child process to inherit the file descriptor. os.set_inheritable(read_file_descriptor, True) return (f'--defaults-extra-file=/dev/fd/{read_file_descriptor}',) def database_names_to_dump(database, config, username, password, environment, dry_run): ''' Given a requested database config, a configuration dict, a database username and password, an environment dict, and whether this is a dry run, return the corresponding sequence of database names to dump. In the case of "all", query for the names of databases on the configured host and return them, excluding any system databases that will cause problems during restore. ''' if database['name'] != 'all': return (database['name'],) if dry_run: return () mariadb_show_command = tuple( shlex.quote(part) for part in shlex.split(database.get('mariadb_command') or 'mariadb') ) extra_options, defaults_extra_filename = parse_extra_options(database.get('list_options')) show_command = ( mariadb_show_command + make_defaults_file_options(username, password, defaults_extra_filename) + extra_options + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--ssl',) if database.get('tls') is True else ()) + (('--skip-ssl',) if database.get('tls') is False else ()) + ('--skip-column-names', '--batch') + ('--execute', 'show schemas') ) logger.debug('Querying for "all" MariaDB databases to dump') show_output = execute_command_and_capture_output(show_command, environment=environment) return tuple( show_name for show_name in show_output.strip().splitlines() if show_name not in SYSTEM_DATABASE_NAMES ) SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys') def execute_dump_command( database, config, username, password, dump_path, database_names, environment, dry_run, dry_run_label, ): ''' Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named pipe constructed from the given dump path and database name. Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if this is a dry run, then don't actually dump anything and return None. ''' database_name = database['name'] dump_filename = dump.make_data_source_dump_filename( dump_path, database['name'], database.get('hostname'), database.get('port'), ) if os.path.exists(dump_filename): logger.warning( f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}' ) return None mariadb_dump_command = tuple( shlex.quote(part) for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump') ) extra_options, defaults_extra_filename = parse_extra_options(database.get('options')) dump_command = ( mariadb_dump_command + make_defaults_file_options(username, password, defaults_extra_filename) + extra_options + (('--add-drop-database',) if database.get('add_drop_database', True) else ()) + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--ssl',) if database.get('tls') is True else ()) + (('--skip-ssl',) if database.get('tls') is False else ()) + ('--databases',) + database_names + ('--result-file', dump_filename) ) logger.debug(f'Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}') if dry_run: return None dump.create_named_pipe_for_dump(dump_filename) return execute_command( dump_command, environment=environment, run_to_completion=False, ) def get_default_port(databases, config): # pragma: no cover return 3306 def use_streaming(databases, config): ''' Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), return whether streaming will be using during dumps. ''' return any(databases) def dump_data_sources( databases, config, config_paths, borgmatic_runtime_directory, patterns, 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 borgmatic runtime directory to construct the destination path. 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 the parent directory of the database dumps to the given patterns list, so the dumps actually get backed up. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'Dumping MariaDB databases{dry_run_label}') for database in databases: dump_path = make_dump_path(borgmatic_runtime_directory) username = borgmatic.hooks.credential.parse.resolve_credential( database.get('username'), config ) password = borgmatic.hooks.credential.parse.resolve_credential( database.get('password'), config ) environment = dict(os.environ) dump_database_names = database_names_to_dump( database, config, username, password, environment, dry_run ) if not dump_database_names: if dry_run: continue raise ValueError('Cannot find any MariaDB databases to dump.') if database['name'] == 'all' and database.get('format'): for dump_name in dump_database_names: renamed_database = copy.copy(database) renamed_database['name'] = dump_name processes.append( execute_dump_command( renamed_database, config, username, password, dump_path, (dump_name,), environment, dry_run, dry_run_label, ) ) else: processes.append( execute_dump_command( database, config, username, password, dump_path, dump_database_names, environment, dry_run, dry_run_label, ) ) if not dry_run: patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'mariadb_databases'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return [process for process in processes if process] def remove_data_source_dumps( databases, config, borgmatic_runtime_directory, dry_run ): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MariaDB', dry_run) def make_data_source_dump_patterns( databases, config, borgmatic_runtime_directory, name=None ): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' 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, 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. 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( 'restore_hostname', data_source.get('hostname') ) port = str( connection_params['port'] or data_source.get('restore_port', data_source.get('port', '')) ) tls = data_source.get('restore_tls', data_source.get('tls')) username = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['username'] or data_source.get('restore_username', data_source.get('username')) ), config, ) password = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['password'] or data_source.get('restore_password', data_source.get('password')) ), config, ) mariadb_restore_command = tuple( shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb') ) extra_options, defaults_extra_filename = parse_extra_options(data_source.get('restore_options')) restore_command = ( mariadb_restore_command + make_defaults_file_options(username, password, defaults_extra_filename) + extra_options + ('--batch',) + (('--host', hostname) if hostname else ()) + (('--port', str(port)) if port else ()) + (('--protocol', 'tcp') if hostname or port else ()) + (('--ssl',) if tls is True else ()) + (('--skip-ssl',) if tls is False else ()) ) environment = dict(os.environ) logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}") if dry_run: return # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command_with_processes( restore_command, [extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment=environment, ) borgmatic/borgmatic/hooks/data_source/mongodb.py000066400000000000000000000236611476361726000224330ustar00rootroot00000000000000import logging import os import shlex import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.hooks.credential.parse from borgmatic.execute import execute_command, execute_command_with_processes from borgmatic.hooks.data_source import dump logger = logging.getLogger(__name__) def make_dump_path(base_directory): # pragma: no cover ''' Given a base directory, make the corresponding dump path. ''' return dump.make_data_source_dump_path(base_directory, 'mongodb_databases') def get_default_port(databases, config): # pragma: no cover return 27017 def use_streaming(databases, config): ''' Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), return whether streaming will be using during dumps. ''' return any(database.get('format') != 'directory' for database in databases) def dump_data_sources( databases, config, config_paths, borgmatic_runtime_directory, patterns, 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 borgmatic runtime directory to construct the destination path (used for the directory format. 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 the parent directory of the database dumps to the given patterns list, so the dumps actually get backed up. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' logger.info(f'Dumping MongoDB databases{dry_run_label}') processes = [] for database in databases: name = database['name'] dump_filename = dump.make_data_source_dump_filename( make_dump_path(borgmatic_runtime_directory), name, database.get('hostname'), database.get('port'), ) dump_format = database.get('format', 'archive') logger.debug( f'Dumping MongoDB database {name} to {dump_filename}{dry_run_label}', ) if dry_run: continue command = build_dump_command(database, config, dump_filename, dump_format) if dump_format == 'directory': dump.create_parent_directory_for_dump(dump_filename) execute_command(command, shell=True) else: dump.create_named_pipe_for_dump(dump_filename) processes.append(execute_command(command, shell=True, run_to_completion=False)) if not dry_run: patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'mongodb_databases'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return processes def make_password_config_file(password): ''' Given a database password, write it as a MongoDB configuration file to an anonymous pipe and return its filename. The idea is that this is a more secure way to transmit a password to MongoDB than providing it directly on the command-line. Do not use the returned value for multiple different command invocations. That will not work because each pipe is "used up" once read. ''' logger.debug('Writing MongoDB password to configuration file pipe') read_file_descriptor, write_file_descriptor = os.pipe() os.write(write_file_descriptor, f'password: {password}'.encode('utf-8')) os.close(write_file_descriptor) # This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the database # client child process to inherit the file descriptor. os.set_inheritable(read_file_descriptor, True) return f'/dev/fd/{read_file_descriptor}' def build_dump_command(database, config, dump_filename, dump_format): ''' Return the mongodump command from a single database configuration. ''' all_databases = database['name'] == 'all' password = borgmatic.hooks.credential.parse.resolve_credential(database.get('password'), config) return ( ('mongodump',) + (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ()) + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ()) + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ()) + ( ( '--username', shlex.quote( borgmatic.hooks.credential.parse.resolve_credential( database['username'], config ) ), ) if 'username' in database else () ) + (('--config', make_password_config_file(password)) if password else ()) + ( ('--authenticationDatabase', shlex.quote(database['authentication_database'])) if 'authentication_database' in database else () ) + (('--db', shlex.quote(database['name'])) if not all_databases else ()) + ( tuple(shlex.quote(option) for option in database['options'].split(' ')) if 'options' in database else () ) + (('--archive', '>', shlex.quote(dump_filename)) if dump_format != 'directory' else ()) ) def remove_data_source_dumps( databases, config, borgmatic_runtime_directory, dry_run ): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MongoDB', dry_run) def make_data_source_dump_patterns( databases, config, borgmatic_runtime_directory, name=None ): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' 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, 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. 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. ''' dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else '' dump_filename = dump.make_data_source_dump_filename( make_dump_path(borgmatic_runtime_directory), data_source['name'], data_source.get('hostname'), ) restore_command = build_restore_command( extract_process, data_source, config, dump_filename, connection_params ) logger.debug(f"Restoring MongoDB database {data_source['name']}{dry_run_label}") if dry_run: return # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command_with_processes( restore_command, [extract_process] if extract_process else [], output_log_level=logging.DEBUG, input_file=extract_process.stdout if extract_process else None, ) def build_restore_command(extract_process, database, config, dump_filename, connection_params): ''' Return the mongorestore command from a single database configuration. ''' hostname = connection_params['hostname'] or database.get( 'restore_hostname', database.get('hostname') ) port = str(connection_params['port'] or database.get('restore_port', database.get('port', ''))) username = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['username'] or database.get('restore_username', database.get('username')) ), config, ) password = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['password'] or database.get('restore_password', database.get('password')) ), config, ) command = ['mongorestore'] if extract_process: command.append('--archive') else: command.extend(('--dir', dump_filename)) if database['name'] != 'all': command.extend(('--drop',)) if hostname: command.extend(('--host', hostname)) if port: command.extend(('--port', str(port))) if username: command.extend(('--username', username)) if password: command.extend(('--config', make_password_config_file(password))) if 'authentication_database' in database: command.extend(('--authenticationDatabase', database['authentication_database'])) if 'restore_options' in database: command.extend(database['restore_options'].split(' ')) if database.get('schemas'): for schema in database['schemas']: command.extend(('--nsInclude', schema)) return command borgmatic/borgmatic/hooks/data_source/mysql.py000066400000000000000000000275351476361726000221570ustar00rootroot00000000000000import copy import logging import os import shlex import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.hooks.credential.parse import borgmatic.hooks.data_source.mariadb from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks.data_source import dump logger = logging.getLogger(__name__) def make_dump_path(base_directory): # pragma: no cover ''' Given a base directory, make the corresponding dump path. ''' return dump.make_data_source_dump_path(base_directory, 'mysql_databases') SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys') def database_names_to_dump(database, config, username, password, environment, dry_run): ''' Given a requested database config, a configuration dict, a database username and password, an environment dict, and whether this is a dry run, return the corresponding sequence of database names to dump. In the case of "all", query for the names of databases on the configured host and return them, excluding any system databases that will cause problems during restore. ''' if database['name'] != 'all': return (database['name'],) if dry_run: return () mysql_show_command = tuple( shlex.quote(part) for part in shlex.split(database.get('mysql_command') or 'mysql') ) extra_options, defaults_extra_filename = ( borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('list_options')) ) show_command = ( mysql_show_command + borgmatic.hooks.data_source.mariadb.make_defaults_file_options( username, password, defaults_extra_filename ) + extra_options + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--ssl',) if database.get('tls') is True else ()) + (('--skip-ssl',) if database.get('tls') is False else ()) + ('--skip-column-names', '--batch') + ('--execute', 'show schemas') ) logger.debug('Querying for "all" MySQL databases to dump') show_output = execute_command_and_capture_output(show_command, environment=environment) return tuple( show_name for show_name in show_output.strip().splitlines() if show_name not in SYSTEM_DATABASE_NAMES ) def execute_dump_command( database, config, username, password, dump_path, database_names, environment, dry_run, dry_run_label, ): ''' Kick off a dump for the given MySQL/MariaDB database (provided as a configuration dict) to a named pipe constructed from the given dump path and database name. Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if this is a dry run, then don't actually dump anything and return None. ''' database_name = database['name'] dump_filename = dump.make_data_source_dump_filename( dump_path, database['name'], database.get('hostname'), database.get('port'), ) if os.path.exists(dump_filename): logger.warning( f'Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}' ) return None mysql_dump_command = tuple( shlex.quote(part) for part in shlex.split(database.get('mysql_dump_command') or 'mysqldump') ) extra_options, defaults_extra_filename = ( borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('options')) ) dump_command = ( mysql_dump_command + borgmatic.hooks.data_source.mariadb.make_defaults_file_options( username, password, defaults_extra_filename ) + extra_options + (('--add-drop-database',) if database.get('add_drop_database', True) else ()) + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--ssl',) if database.get('tls') is True else ()) + (('--skip-ssl',) if database.get('tls') is False else ()) + ('--databases',) + database_names + ('--result-file', dump_filename) ) logger.debug(f'Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}') if dry_run: return None dump.create_named_pipe_for_dump(dump_filename) return execute_command( dump_command, environment=environment, run_to_completion=False, ) def get_default_port(databases, config): # pragma: no cover return 3306 def use_streaming(databases, config): ''' Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), return whether streaming will be using during dumps. ''' return any(databases) def dump_data_sources( databases, config, config_paths, borgmatic_runtime_directory, patterns, 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 borgmatic runtime directory to construct the destination path. 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 the parent directory of the database dumps to the given patterns list, so the dumps actually get backed up. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'Dumping MySQL databases{dry_run_label}') for database in databases: dump_path = make_dump_path(borgmatic_runtime_directory) username = borgmatic.hooks.credential.parse.resolve_credential( database.get('username'), config ) password = borgmatic.hooks.credential.parse.resolve_credential( database.get('password'), config ) environment = dict(os.environ) dump_database_names = database_names_to_dump( database, config, username, password, environment, dry_run ) if not dump_database_names: if dry_run: continue raise ValueError('Cannot find any MySQL databases to dump.') if database['name'] == 'all' and database.get('format'): for dump_name in dump_database_names: renamed_database = copy.copy(database) renamed_database['name'] = dump_name processes.append( execute_dump_command( renamed_database, config, username, password, dump_path, (dump_name,), environment, dry_run, dry_run_label, ) ) else: processes.append( execute_dump_command( database, config, username, password, dump_path, dump_database_names, environment, dry_run, dry_run_label, ) ) if not dry_run: patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'mysql_databases'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return [process for process in processes if process] def remove_data_source_dumps( databases, config, borgmatic_runtime_directory, dry_run ): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the borgmatic runtime directory to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MySQL', dry_run) def make_data_source_dump_patterns( databases, config, borgmatic_runtime_directory, name=None ): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' 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, 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. 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( 'restore_hostname', data_source.get('hostname') ) port = str( connection_params['port'] or data_source.get('restore_port', data_source.get('port', '')) ) tls = data_source.get('restore_tls', data_source.get('tls')) username = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['username'] or data_source.get('restore_username', data_source.get('username')) ), config, ) password = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['password'] or data_source.get('restore_password', data_source.get('password')) ), config, ) mysql_restore_command = tuple( shlex.quote(part) for part in shlex.split(data_source.get('mysql_command') or 'mysql') ) extra_options, defaults_extra_filename = ( borgmatic.hooks.data_source.mariadb.parse_extra_options(data_source.get('restore_options')) ) restore_command = ( mysql_restore_command + borgmatic.hooks.data_source.mariadb.make_defaults_file_options( username, password, defaults_extra_filename ) + extra_options + ('--batch',) + (('--host', hostname) if hostname else ()) + (('--port', str(port)) if port else ()) + (('--protocol', 'tcp') if hostname or port else ()) + (('--ssl',) if tls is True else ()) + (('--skip-ssl',) if tls is False else ()) ) environment = dict(os.environ) logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}") if dry_run: return # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command_with_processes( restore_command, [extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment=environment, ) borgmatic/borgmatic/hooks/data_source/postgresql.py000066400000000000000000000350721476361726000232100ustar00rootroot00000000000000import csv import itertools import logging import os import pathlib import shlex import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.hooks.credential.parse from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks.data_source import dump logger = logging.getLogger(__name__) def make_dump_path(base_directory): # pragma: no cover ''' Given a base directory, make the corresponding dump path. ''' return dump.make_data_source_dump_path(base_directory, 'postgresql_databases') def make_environment(database, config, restore_connection_params=None): ''' Make an environment dict from the current environment variables and the given database configuration. If restore connection params are given, this is for a restore operation. ''' environment = dict(os.environ) try: if restore_connection_params: environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential( ( restore_connection_params.get('password') or database.get('restore_password', database['password']) ), config, ) else: environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential( database['password'], config ) except (AttributeError, KeyError): pass if 'ssl_mode' in database: environment['PGSSLMODE'] = database['ssl_mode'] if 'ssl_cert' in database: environment['PGSSLCERT'] = database['ssl_cert'] if 'ssl_key' in database: environment['PGSSLKEY'] = database['ssl_key'] if 'ssl_root_cert' in database: environment['PGSSLROOTCERT'] = database['ssl_root_cert'] if 'ssl_crl' in database: environment['PGSSLCRL'] = database['ssl_crl'] return environment EXCLUDED_DATABASE_NAMES = ('template0', 'template1') def database_names_to_dump(database, config, environment, dry_run): ''' Given a requested database config and a configuration dict, return the corresponding sequence of database names to dump. In the case of "all" when a database format is given, query for the names of databases on the configured host and return them. For "all" without a database format, just return a sequence containing "all". ''' requested_name = database['name'] if requested_name != 'all': return (requested_name,) if not database.get('format'): return ('all',) if dry_run: return () psql_command = tuple( shlex.quote(part) for part in shlex.split(database.get('psql_command') or 'psql') ) list_command = ( psql_command + ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only') + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + ( ( '--username', borgmatic.hooks.credential.parse.resolve_credential(database['username'], config), ) if 'username' in database else () ) + (tuple(database['list_options'].split(' ')) if 'list_options' in database else ()) ) logger.debug('Querying for "all" PostgreSQL databases to dump') list_output = execute_command_and_capture_output(list_command, environment=environment) return tuple( row[0] for row in csv.reader(list_output.splitlines(), delimiter=',', quotechar='"') if row[0] not in EXCLUDED_DATABASE_NAMES ) def get_default_port(databases, config): # pragma: no cover return 5432 def use_streaming(databases, config): ''' Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored), return whether streaming will be using during dumps. ''' return any(database.get('format') != 'directory' for database in databases) def dump_data_sources( databases, config, config_paths, borgmatic_runtime_directory, patterns, 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 borgmatic runtime directory to construct the destination path. 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 the parent directory of the database dumps to the given patterns list, so the dumps actually get backed up. Raise ValueError if the databases to dump cannot be determined. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'Dumping PostgreSQL databases{dry_run_label}') for database in databases: environment = make_environment(database, config) dump_path = make_dump_path(borgmatic_runtime_directory) dump_database_names = database_names_to_dump(database, config, environment, dry_run) if not dump_database_names: if dry_run: continue raise ValueError('Cannot find any PostgreSQL databases to dump.') for database_name in dump_database_names: dump_format = database.get('format', None if database_name == 'all' else 'custom') compression = database.get('compression') default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump' dump_command = tuple( shlex.quote(part) 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'), database.get('port'), ) if os.path.exists(dump_filename): logger.warning( f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}' ) continue command = ( dump_command + ( '--no-password', '--clean', '--if-exists', ) + (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ()) + (('--port', shlex.quote(str(database['port']))) if 'port' in database else ()) + ( ( '--username', shlex.quote( borgmatic.hooks.credential.parse.resolve_credential( database['username'], config ) ), ) if 'username' in database else () ) + (('--no-owner',) if database.get('no_owner', False) else ()) + (('--format', shlex.quote(dump_format)) if dump_format else ()) + (('--compress', shlex.quote(str(compression))) if compression is not None else ()) + (('--file', shlex.quote(dump_filename)) if dump_format == 'directory' else ()) + ( tuple(shlex.quote(option) for option in database['options'].split(' ')) if 'options' in database else () ) + (() if database_name == 'all' else (shlex.quote(database_name),)) # Use shell redirection rather than the --file flag to sidestep synchronization issues # when pg_dump/pg_dumpall tries to write to a named pipe. But for the directory dump # format in a particular, a named destination is required, and redirection doesn't work. + (('>', shlex.quote(dump_filename)) if dump_format != 'directory' else ()) ) logger.debug( f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}' ) if dry_run: continue if dump_format == 'directory': dump.create_parent_directory_for_dump(dump_filename) execute_command( command, shell=True, environment=environment, ) else: dump.create_named_pipe_for_dump(dump_filename) processes.append( execute_command( command, shell=True, environment=environment, run_to_completion=False, ) ) if not dry_run: patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'postgresql_databases'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return processes def remove_data_source_dumps( databases, config, borgmatic_runtime_directory, dry_run ): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the borgmatic runtime directory to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps( make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', dry_run ) def make_data_source_dump_patterns( databases, config, borgmatic_runtime_directory, name=None ): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' 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, 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 borgmatic runtime directory is used to construct the destination path (used for the directory format). 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. Use the given connection parameters to connect to the database. The connection parameters are hostname, port, username, and password. ''' dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else '' hostname = connection_params['hostname'] or data_source.get( 'restore_hostname', data_source.get('hostname') ) port = str( connection_params['port'] or data_source.get('restore_port', data_source.get('port', '')) ) username = borgmatic.hooks.credential.parse.resolve_credential( ( connection_params['username'] or data_source.get('restore_username', data_source.get('username')) ), config, ) all_databases = bool(data_source['name'] == 'all') dump_filename = dump.make_data_source_dump_filename( 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') ) analyze_command = ( psql_command + ('--no-password', '--no-psqlrc', '--quiet') + (('--host', hostname) if hostname else ()) + (('--port', port) if port else ()) + (('--username', username) if username else ()) + (('--dbname', data_source['name']) if not all_databases else ()) + ( tuple(data_source['analyze_options'].split(' ')) if 'analyze_options' in data_source else () ) + ('--command', 'ANALYZE') ) use_psql_command = all_databases or data_source.get('format') == 'plain' pg_restore_command = tuple( shlex.quote(part) for part in shlex.split(data_source.get('pg_restore_command') or 'pg_restore') ) restore_command = ( (psql_command if use_psql_command else pg_restore_command) + ('--no-password',) + (('--no-psqlrc',) if use_psql_command else ('--if-exists', '--exit-on-error', '--clean')) + (('--dbname', data_source['name']) if not all_databases else ()) + (('--host', hostname) if hostname else ()) + (('--port', port) if port else ()) + (('--username', username) if username else ()) + (('--no-owner',) if data_source.get('no_owner', False) else ()) + ( tuple(data_source['restore_options'].split(' ')) if 'restore_options' in data_source else () ) + (() 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') else () ) ) environment = make_environment(data_source, config, restore_connection_params=connection_params) logger.debug(f"Restoring PostgreSQL database {data_source['name']}{dry_run_label}") if dry_run: return # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command_with_processes( restore_command, [extract_process] if extract_process else [], output_log_level=logging.DEBUG, input_file=extract_process.stdout if extract_process else None, environment=environment, ) execute_command(analyze_command, environment=environment) borgmatic/borgmatic/hooks/data_source/snapshot.py000066400000000000000000000035011476361726000226340ustar00rootroot00000000000000import pathlib IS_A_HOOK = False def get_contained_patterns(parent_directory, candidate_patterns): ''' Given a parent directory and a set of candidate patterns potentially inside it, get the subset of contained patterns 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 pattern paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then /var is what we want to snapshot. For this function to work, a candidate pattern path can't have any globs or other non-literal characters in the initial portion of the path that matches the parent directory. For instance, a parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a pattern path like /v*/log/*/data. The one exception is that if a regular expression pattern path starts with "^", that will get stripped off for purposes of matching against a parent directory. As part of this, also mutate the given set of candidate patterns to remove any actually contained patterns from it. That way, this function can be called multiple times, successively processing candidate patterns until none are left—and avoiding assigning any candidate pattern to more than one parent directory. ''' if not candidate_patterns: return () contained_patterns = tuple( candidate for candidate in candidate_patterns for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),) if ( pathlib.PurePath(parent_directory) == candidate_path or pathlib.PurePath(parent_directory) in candidate_path.parents ) ) candidate_patterns -= set(contained_patterns) return contained_patterns borgmatic/borgmatic/hooks/data_source/sqlite.py000066400000000000000000000134611476361726000223040ustar00rootroot00000000000000import logging import os import shlex import borgmatic.borg.pattern import borgmatic.config.paths from borgmatic.execute import execute_command, execute_command_with_processes from borgmatic.hooks.data_source import dump logger = logging.getLogger(__name__) def make_dump_path(base_directory): # pragma: no cover ''' Given a base directory, make the corresponding dump path. ''' return dump.make_data_source_dump_path(base_directory, 'sqlite_databases') def get_default_port(databases, config): # pragma: no cover return None # SQLite doesn't use a port. def use_streaming(databases, config): ''' Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), return whether streaming will be using during dumps. ''' return any(databases) def dump_data_sources( databases, config, config_paths, borgmatic_runtime_directory, patterns, 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 borgmatic runtime directory to construct the destination path. 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 the parent directory of the database dumps to the given patterns list, so the dumps actually get backed up. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'Dumping SQLite databases{dry_run_label}') for database in databases: database_path = database['path'] if database['name'] == 'all': logger.warning('The "all" database name has no meaning for SQLite databases') if not os.path.exists(database_path): logger.warning( f'No SQLite database at {database_path}; an empty database will be created and dumped' ) 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): logger.warning( f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}' ) continue command = ( 'sqlite3', shlex.quote(database_path), '.dump', '>', shlex.quote(dump_filename), ) logger.debug( f'Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}' ) if dry_run: continue dump.create_named_pipe_for_dump(dump_filename) processes.append(execute_command(command, shell=True, run_to_completion=False)) if not dry_run: patterns.append( borgmatic.borg.pattern.Pattern( os.path.join(borgmatic_runtime_directory, 'sqlite_databases'), source=borgmatic.borg.pattern.Pattern_source.HOOK, ) ) return processes def remove_data_source_dumps( databases, config, borgmatic_runtime_directory, dry_run ): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the borgmatic runtime directory to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'SQLite', dry_run) def make_data_source_dump_patterns( databases, config, borgmatic_runtime_directory, name=None ): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' 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, 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. 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( 'restore_path', data_source.get('path') ) logger.debug(f'Restoring SQLite database at {database_path}{dry_run_label}') if dry_run: return try: os.remove(database_path) logger.warning(f'Removed existing SQLite database at {database_path}') except FileNotFoundError: # pragma: no cover pass restore_command = ( 'sqlite3', database_path, ) # Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning # if the restore paths don't exist in the archive. execute_command_with_processes( restore_command, [extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ) borgmatic/borgmatic/hooks/data_source/zfs.py000066400000000000000000000376411476361726000216130ustar00rootroot00000000000000import collections import glob import hashlib import logging import os import shutil import subprocess import borgmatic.borg.pattern import borgmatic.config.paths import borgmatic.execute import borgmatic.hooks.data_source.snapshot logger = logging.getLogger(__name__) def use_streaming(hook_config, config): # 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_patterns'), defaults=(False, ()), ) def get_datasets_to_backup(zfs_command, patterns): ''' Given a ZFS command to run and a sequence of configured patterns, find the intersection between the current ZFS dataset mount points and the paths of any patterns. The idea is that these pattern paths represent the requested datasets to snapshot. But also include any datasets tagged with a borgmatic-specific user property, whether or not they appear in the patterns. Only include datasets that contain at least one root pattern sourced from borgmatic configuration (as opposed to generated elsewhere in borgmatic). 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,canmount,{BORGMATIC_USER_PROPERTY}', ) ) try: # Sort from longest to shortest mount points, so longer mount points get a whack at the # candidate pattern piñata before their parents do. (Patterns are consumed during the second # loop below, so no two datasets end up with the same contained patterns.) datasets = sorted( ( Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ()) for line in list_output.splitlines() for (dataset_name, mount_point, can_mount, user_property_value) in ( line.rstrip().split('\t'), ) # Skip datasets that are marked "canmount=off", because mounting their snapshots will # result in completely empty mount points—thereby preventing us from backing them up. if can_mount == 'on' ), key=lambda dataset: dataset.mount_point, reverse=True, ) except ValueError: raise ValueError(f'Invalid {zfs_command} list output') candidate_patterns = set(patterns) return tuple( sorted( ( Dataset( dataset.name, dataset.mount_point, dataset.auto_backup, contained_patterns, ) for dataset in datasets for contained_patterns in ( ( ( ( borgmatic.borg.pattern.Pattern( dataset.mount_point, source=borgmatic.borg.pattern.Pattern_source.HOOK, ), ) if dataset.auto_backup else () ) + borgmatic.hooks.data_source.snapshot.get_contained_patterns( dataset.mount_point, candidate_patterns ) ), ) if dataset.auto_backup or any( pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG for pattern in contained_patterns ) ), 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( { mount_point for line in list_output.splitlines() for mount_point in (line.rstrip(),) if mount_point != 'none' } ) ) 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, ) MOUNT_POINT_HASH_LENGTH = 10 def make_borg_snapshot_pattern(pattern, dataset, normalized_runtime_directory): ''' Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and the Dataset containing it, return a new Pattern with its path rewritten to be in a snapshot directory based on both the given runtime directory and the given Dataset's mount point. Move any initial caret in a regular expression pattern path to the beginning, so as not to break the regular expression. ''' initial_caret = ( '^' if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION and pattern.path.startswith('^') else '' ) rewritten_path = initial_caret + os.path.join( normalized_runtime_directory, 'zfs_snapshots', # Including this hash prevents conflicts between snapshot patterns for different datasets. # For instance, without this, snapshotting a dataset at /var and another at /var/spool would # result in overlapping snapshot patterns and therefore colliding mount attempts. hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(MOUNT_POINT_HASH_LENGTH), '.', # Borg 1.4+ "slashdot" hack. # Included so that the source directory ends up in the Borg archive at its "original" path. pattern.path.lstrip('^').lstrip(os.path.sep), ) return borgmatic.borg.pattern.Pattern( rewritten_path, pattern.type, pattern.style, pattern.device, source=borgmatic.borg.pattern.Pattern_source.HOOK, ) def dump_data_sources( hook_config, config, config_paths, borgmatic_runtime_directory, patterns, dry_run, ): ''' Given a ZFS configuration dict, a configuration dict, the borgmatic configuration file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry run, auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any dataset with a borgmatic-specific user property. Also update those patterns, replacing dataset mount points with corresponding snapshot directories so they get stored in the Borg archive instead. 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'Snapshotting ZFS datasets{dry_run_label}') # List ZFS datasets to get their mount points, but only consider those patterns that came from # actual user configuration (as opposed to, say, other hooks). zfs_command = hook_config.get('zfs_command', 'zfs') requested_datasets = get_datasets_to_backup(zfs_command, patterns) # Snapshot each dataset, rewriting patterns 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'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'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', hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest( MOUNT_POINT_HASH_LENGTH ), dataset.mount_point.lstrip(os.path.sep), ) logger.debug( f'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 pattern in dataset.contained_patterns: snapshot_pattern = make_borg_snapshot_pattern( pattern, dataset, normalized_runtime_directory ) # Attempt to update the pattern in place, since pattern order matters to Borg. try: patterns[patterns.index(pattern)] = snapshot_pattern except ValueError: patterns.append(snapshot_pattern) 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, borgmatic_runtime_directory, dry_run): ''' Given a ZFS configuration dict, a configuration dict, the borgmatic runtime directory, and whether this is a dry run, unmount and destroy any ZFS snapshots created by borgmatic. 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'Could not find "{zfs_command}" command') return except subprocess.CalledProcessError as error: logger.debug(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'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 the snapshot mount path is empty, this is probably just a "shadow" of a nested # dataset and therefore there's nothing to unmount. if not os.path.isdir(snapshot_mount_path) or not os.listdir(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'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'Could not find "{umount_command}" command') return except subprocess.CalledProcessError as error: logger.debug(error) continue if not dry_run: shutil.rmtree(snapshot_mount_path, ignore_errors=True) # 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'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, 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, 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() borgmatic/borgmatic/hooks/dispatch.py000066400000000000000000000104201476361726000203010ustar00rootroot00000000000000import enum import importlib import logging import pkgutil import borgmatic.hooks.credential import borgmatic.hooks.data_source import borgmatic.hooks.monitoring logger = logging.getLogger(__name__) class Hook_type(enum.Enum): CREDENTIAL = 'credential' 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, hook_name, *args, **kwargs): ''' Given a configuration dict, 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) 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. ''' 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 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.credential, 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'Calling {hook_name} hook function {function_name}') return getattr(module, function_name)(hook_config, config, *args, **kwargs) def call_hooks(function_name, config, hook_type, *args, **kwargs): ''' Given a configuration dict, call the requested function of the Python module corresponding to each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each call with the configuration for that hook, 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. Raise ValueError if the hook name is unknown. 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, hook_name, *args, **kwargs) 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, hook_type, *args, **kwargs): ''' Given a configuration dict, call the requested function of the Python module corresponding to each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each call with the configuration for that hook 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, hook_name, *args, **kwargs) for hook_name in get_submodule_names( importlib.import_module(f'borgmatic.hooks.{hook_type.value}') ) } borgmatic/borgmatic/hooks/monitoring/000077500000000000000000000000001476361726000203205ustar00rootroot00000000000000borgmatic/borgmatic/hooks/monitoring/__init__.py000066400000000000000000000000001476361726000224170ustar00rootroot00000000000000borgmatic/borgmatic/hooks/monitoring/apprise.py000066400000000000000000000070271476361726000223430ustar00rootroot00000000000000import logging import operator import borgmatic.hooks.monitoring.logs import borgmatic.hooks.monitoring.monitor logger = logging.getLogger(__name__) DEFAULT_LOGS_SIZE_LIMIT_BYTES = 100000 HANDLER_IDENTIFIER = 'apprise' def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run): ''' Add a handler to the root logger that stores in memory the most recent logs emitted. That way, we can send them all to an Apprise notification service upon a finish or failure state. But skip this if the "send_logs" option is false. ''' if hook_config.get('send_logs') is False: return logs_size_limit = max( hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES) - len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR), 0, ) borgmatic.hooks.monitoring.logs.add_handler( borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler( HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level ) ) def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' Ping the configured Apprise service URLs. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' try: import apprise from apprise import NotifyFormat, NotifyType except ImportError: # pragma: no cover logger.warning('Unable to import Apprise in monitoring hook') return state_to_notify_type = { 'start': NotifyType.INFO, 'finish': NotifyType.SUCCESS, 'fail': NotifyType.FAILURE, 'log': NotifyType.INFO, } run_states = hook_config.get('states', ['fail']) if state.name.lower() not in run_states: return state_config = hook_config.get( state.name.lower(), { 'title': f'A borgmatic {state.name} event happened', 'body': f'A borgmatic {state.name} event happened', }, ) if not hook_config.get('services'): logger.info('No Apprise services to ping') return dry_run_string = ' (dry run; not actually pinging)' if dry_run else '' labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services'))) logger.info(f'Pinging Apprise services: {labels_string}{dry_run_string}') apprise_object = apprise.Apprise() apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services')))) if dry_run: return body = state_config.get('body') if state in ( borgmatic.hooks.monitoring.monitor.State.FINISH, borgmatic.hooks.monitoring.monitor.State.FAIL, borgmatic.hooks.monitoring.monitor.State.LOG, ): formatted_logs = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload( HANDLER_IDENTIFIER ) if formatted_logs: body += f'\n\n{formatted_logs}' result = apprise_object.notify( title=state_config.get('title', ''), body=body, body_format=NotifyFormat.TEXT, notify_type=state_to_notify_type[state.name.lower()], ) if result is False: logger.warning('Error sending some Apprise notifications') def destroy_monitor(hook_config, config, monitoring_log_level, dry_run): ''' 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.monitoring.logs.remove_handler(HANDLER_IDENTIFIER) borgmatic/borgmatic/hooks/monitoring/cronhub.py000066400000000000000000000034331476361726000223350ustar00rootroot00000000000000import logging import requests from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) MONITOR_STATE_TO_CRONHUB = { monitor.State.START: 'start', monitor.State.FINISH: 'finish', monitor.State.FAIL: 'fail', } 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): ''' Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' if state not in MONITOR_STATE_TO_CRONHUB: logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook') return dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' formatted_state = f'/{MONITOR_STATE_TO_CRONHUB[state]}/' ping_url = ( hook_config['ping_url'] .replace('/start/', formatted_state) .replace('/ping/', formatted_state) ) logger.info(f'Pinging Cronhub {state.name.lower()}{dry_run_label}') logger.debug(f'Using Cronhub ping URL {ping_url}') if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.get(ping_url) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'Cronhub error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/cronitor.py000066400000000000000000000032441476361726000225340ustar00rootroot00000000000000import logging import requests from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) MONITOR_STATE_TO_CRONITOR = { monitor.State.START: 'run', monitor.State.FINISH: 'complete', monitor.State.FAIL: 'fail', } 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): ''' Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' if state not in MONITOR_STATE_TO_CRONITOR: logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook') return dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' ping_url = f"{hook_config['ping_url']}/{MONITOR_STATE_TO_CRONITOR[state]}" logger.info(f'Pinging Cronitor {state.name.lower()}{dry_run_label}') logger.debug(f'Using Cronitor ping URL {ping_url}') if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.get(ping_url) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'Cronitor error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/healthchecks.py000066400000000000000000000071201476361726000233200ustar00rootroot00000000000000import logging import re import requests import borgmatic.hooks.monitoring.logs from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) MONITOR_STATE_TO_HEALTHCHECKS = { monitor.State.START: 'start', monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state. monitor.State.FAIL: 'fail', monitor.State.LOG: 'log', } DEFAULT_PING_BODY_LIMIT_BYTES = 100000 HANDLER_IDENTIFIER = 'healthchecks' def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run): ''' Add a handler to the root logger that stores in memory the most recent logs emitted. That way, we can send them all to Healthchecks upon a finish or failure state. But skip this if the "send_logs" option is false. ''' if hook_config.get('send_logs') is False: return ping_body_limit = max( hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES) - len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR), 0, ) borgmatic.hooks.monitoring.logs.add_handler( borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler( HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level ) ) def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given configuration filename in any log entries, and log to Healthchecks with the giving log level. If this is a dry run, then don't actually ping anything. ''' ping_url = ( hook_config['ping_url'] if hook_config['ping_url'].startswith('http') else f"https://hc-ping.com/{hook_config['ping_url']}" ) dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' if 'states' in hook_config and state.name.lower() not in hook_config['states']: logger.info(f'Skipping Healthchecks {state.name.lower()} ping due to configured states') 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('Healthchecks UUIDs do not support auto provisionning; ignoring') else: ping_url = f'{ping_url}?create=1' logger.info(f'Pinging Healthchecks {state.name.lower()}{dry_run_label}') logger.debug(f'Using Healthchecks ping URL {ping_url}') if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG): payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload( HANDLER_IDENTIFIER ) else: payload = '' if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.post( ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True) ) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'Healthchecks error: {error}') def destroy_monitor(hook_config, config, monitoring_log_level, dry_run): ''' 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.monitoring.logs.remove_handler(HANDLER_IDENTIFIER) borgmatic/borgmatic/hooks/monitoring/logs.py000066400000000000000000000051661476361726000216460ustar00rootroot00000000000000import logging IS_A_HOOK = False PAYLOAD_TRUNCATION_INDICATOR = '...\n' class Forgetful_buffering_handler(logging.Handler): ''' A buffering log handler that stores log messages in memory, and throws away messages (oldest first) once a particular capacity in bytes is reached. But if the given byte capacity is zero, don't throw away any messages. The given identifier is used to distinguish the instance of this handler used for one monitoring hook from those instances used for other monitoring hooks. ''' def __init__(self, identifier, byte_capacity, log_level): super().__init__() self.identifier = identifier self.byte_capacity = byte_capacity self.byte_count = 0 self.buffer = [] self.forgot = False self.setLevel(log_level) def emit(self, record): message = record.getMessage() + '\n' self.byte_count += len(message) self.buffer.append(message) if not self.byte_capacity: return while self.byte_count > self.byte_capacity and self.buffer: self.byte_count -= len(self.buffer[0]) self.buffer.pop(0) self.forgot = True def add_handler(handler): # pragma: no cover ''' Add the given handler to the global logger. ''' logging.getLogger().addHandler(handler) def get_handler(identifier): ''' Given the identifier for an existing Forgetful_buffering_handler instance, return the handler. Raise ValueError if the handler isn't found. ''' try: return next( handler for handler in logging.getLogger().handlers if isinstance(handler, Forgetful_buffering_handler) and handler.identifier == identifier ) except StopIteration: raise ValueError(f'A buffering handler for {identifier} was not found') def format_buffered_logs_for_payload(identifier): ''' Get the handler previously added to the root logger, and slurp buffered logs out of it to send to the monitoring service. ''' try: buffering_handler = get_handler(identifier) except ValueError: # No handler means no payload. return '' payload = ''.join(message for message in buffering_handler.buffer) if buffering_handler.forgot: return PAYLOAD_TRUNCATION_INDICATOR + payload return payload def remove_handler(identifier): ''' Given the identifier for an existing Forgetful_buffering_handler instance, remove it. ''' logger = logging.getLogger() try: logger.removeHandler(get_handler(identifier)) except ValueError: pass borgmatic/borgmatic/hooks/monitoring/loki.py000066400000000000000000000104341476361726000216320ustar00rootroot00000000000000import json import logging import os import platform import time import requests from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) MONITOR_STATE_TO_LOKI = { monitor.State.START: 'Started', monitor.State.FINISH: 'Finished', monitor.State.FAIL: 'Failed', } # Threshold at which logs get flushed to loki MAX_BUFFER_LINES = 100 class Loki_log_buffer: ''' A log buffer that allows to output the logs as loki requests in json. Allows adding labels to the log stream and takes care of communication with loki. ''' def __init__(self, url, dry_run): self.url = url self.dry_run = dry_run self.root = {'streams': [{'stream': {}, 'values': []}]} def add_value(self, value): ''' Add a log entry to the stream. ''' timestamp = str(time.time_ns()) self.root['streams'][0]['values'].append((timestamp, value)) def add_label(self, label, value): ''' Add a label to the logging stream. ''' self.root['streams'][0]['stream'][label] = value def to_request(self): return json.dumps(self.root) def __len__(self): ''' Gets the number of lines currently in the buffer. ''' return len(self.root['streams'][0]['values']) def flush(self): if self.dry_run: # Just empty the buffer and skip self.root['streams'][0]['values'] = [] logger.info('Skipped uploading logs to loki due to dry run') return if len(self) == 0: # Skip as there are not logs to send yet return request_body = self.to_request() self.root['streams'][0]['values'] = [] request_header = {'Content-Type': 'application/json'} try: result = requests.post(self.url, headers=request_header, data=request_body, timeout=5) result.raise_for_status() except requests.RequestException: logger.warning('Failed to upload logs to loki') class Loki_log_handler(logging.Handler): ''' A log handler that sends logs to loki. ''' def __init__(self, url, dry_run): super().__init__() self.buffer = Loki_log_buffer(url, dry_run) def emit(self, record): ''' Add a log record from the logging module to the stream. ''' self.raw(record.getMessage()) def add_label(self, key, value): ''' Add a label to the logging stream. ''' self.buffer.add_label(key, value) def raw(self, msg): ''' Add an arbitrary string as a log entry to the stream. ''' self.buffer.add_value(msg) if len(self.buffer) > MAX_BUFFER_LINES: self.buffer.flush() def flush(self): ''' Send the logs to loki and empty the buffer. ''' self.buffer.flush() def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run): ''' Add a handler to the root logger to regularly send the logs to loki. ''' url = hook_config.get('url') loki = Loki_log_handler(url, dry_run) for key, value in hook_config.get('labels').items(): if value == '__hostname': loki.add_label(key, platform.node()) elif value == '__config': loki.add_label(key, os.path.basename(config_filename)) elif value == '__config_path': loki.add_label(key, config_filename) else: loki.add_label(key, value) logging.getLogger().addHandler(loki) def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' Add an entry to the loki logger with the current state. ''' for handler in tuple(logging.getLogger().handlers): if isinstance(handler, Loki_log_handler): if state in MONITOR_STATE_TO_LOKI.keys(): handler.raw(f'{MONITOR_STATE_TO_LOKI[state]} backup') def destroy_monitor(hook_config, config, monitoring_log_level, dry_run): ''' Remove the monitor handler that was added to the root logger. ''' logger = logging.getLogger() for handler in tuple(logger.handlers): if isinstance(handler, Loki_log_handler): handler.flush() logger.removeHandler(handler) borgmatic/borgmatic/hooks/monitoring/monitor.py000066400000000000000000000001571476361726000223640ustar00rootroot00000000000000import enum IS_A_HOOK = False class State(enum.Enum): START = 1 FINISH = 2 FAIL = 3 LOG = 4 borgmatic/borgmatic/hooks/monitoring/ntfy.py000066400000000000000000000065771476361726000216710ustar00rootroot00000000000000import logging import requests import borgmatic.hooks.credential.parse 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): ''' Ping the configured Ntfy topic. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' run_states = hook_config.get('states', ['fail']) if state.name.lower() in run_states: dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' state_config = hook_config.get( state.name.lower(), { 'title': f'A borgmatic {state.name} event happened', 'message': f'A borgmatic {state.name} event happened', 'priority': 'default', 'tags': 'borgmatic', }, ) base_url = hook_config.get('server', 'https://ntfy.sh') topic = hook_config.get('topic') logger.info(f'Pinging ntfy topic {topic}{dry_run_label}') logger.debug(f'Using Ntfy ping URL {base_url}/{topic}') headers = { 'X-Title': state_config.get('title'), 'X-Message': state_config.get('message'), 'X-Priority': state_config.get('priority'), 'X-Tags': state_config.get('tags'), } try: username = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('username'), config ) password = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('password'), config ) access_token = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('access_token'), config ) except ValueError as error: logger.warning(f'Ntfy credential error: {error}') return auth = None if access_token is not None: if username or password: logger.warning( 'ntfy access_token is set but so is username/password, only using access_token' ) auth = requests.auth.HTTPBasicAuth('', access_token) elif (username and password) is not None: auth = requests.auth.HTTPBasicAuth(username, password) logger.info(f'Using basic auth with user {username} for ntfy') elif username is not None: logger.warning('Password missing for ntfy authentication, defaulting to no auth') elif password is not None: logger.warning('Username missing for ntfy authentication, defaulting to no auth') if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.post(f'{base_url}/{topic}', headers=headers, auth=auth) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'ntfy error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/pagerduty.py000066400000000000000000000071671476361726000227110ustar00rootroot00000000000000import datetime import json import logging import platform import requests import borgmatic.hooks.credential.parse import borgmatic.hooks.monitoring.logs from borgmatic.hooks.monitoring import monitor logger = logging.getLogger(__name__) EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue' DEFAULT_LOGS_PAYLOAD_LIMIT_BYTES = 10000 HANDLER_IDENTIFIER = 'pagerduty' def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run): ''' Add a handler to the root logger that stores in memory the most recent logs emitted. That way, we can send them all to PagerDuty upon a failure state. But skip this if the "send_logs" option is false. ''' if hook_config.get('send_logs') is False: return ping_body_limit = max( DEFAULT_LOGS_PAYLOAD_LIMIT_BYTES - len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR), 0, ) borgmatic.hooks.monitoring.logs.add_handler( borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler( HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level ) ) def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' If this is an error state, create a PagerDuty event with the configured integration key. Use the given configuration filename in any log entries. If this is a dry run, then don't actually create an event. ''' if state != monitor.State.FAIL: logger.debug( f'Ignoring unsupported monitoring {state.name.lower()} in PagerDuty hook', ) return dry_run_label = ' (dry run; not actually sending)' if dry_run else '' logger.info(f'Sending failure event to PagerDuty {dry_run_label}') try: integration_key = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('integration_key'), config ) except ValueError as error: logger.warning(f'PagerDuty credential error: {error}') return logs_payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload( HANDLER_IDENTIFIER ) hostname = platform.node() local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat() payload = json.dumps( { 'routing_key': integration_key, 'event_action': 'trigger', 'payload': { 'summary': f'backup failed on {hostname}', 'severity': 'error', 'source': hostname, 'timestamp': local_timestamp, 'component': 'borgmatic', 'group': 'backups', 'class': 'backup failure', 'custom_details': { 'hostname': hostname, 'configuration filename': config_filename, 'server time': local_timestamp, 'logs': logs_payload, }, }, } ) if dry_run: return logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.post(EVENTS_API_URL, data=payload.encode('utf-8')) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'PagerDuty error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' 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.monitoring.logs.remove_handler(HANDLER_IDENTIFIER) borgmatic/borgmatic/hooks/monitoring/pushover.py000066400000000000000000000055011476361726000225460ustar00rootroot00000000000000import logging import requests import borgmatic.hooks.credential.parse 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(), {}) try: token = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('token'), config ) user = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('user'), config) except ValueError as error: logger.warning(f'Pushover credential error: {error}') return logger.info(f'Updating Pushover{dry_run_label}') if state_config.get('priority') == EMERGENCY_PRIORITY: if 'expire' not in state_config: logger.info('Setting expire to default (10 min)') state_config['expire'] = 600 if 'retry' not in state_config: logger.info('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'Pushover error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/sentry.py000066400000000000000000000043061476361726000222210ustar00rootroot00000000000000import logging import re 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 DATA_SOURCE_NAME_URL_PATTERN = re.compile( '^(?P.+)://(?P.+)@(?P.+)/(?P.+)$' ) def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' Construct and ping a Sentry cron URL, based on the configured DSN URL and monitor slug. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. ''' run_states = hook_config.get('states', ['start', 'finish', 'fail']) if not state.name.lower() in run_states: return dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' data_source_name_url = hook_config.get('data_source_name_url') monitor_slug = hook_config.get('monitor_slug') match = DATA_SOURCE_NAME_URL_PATTERN.match(data_source_name_url) if not match: logger.warning(f'Invalid Sentry data source name URL: {data_source_name_url}') return cron_url = f'{match.group("protocol")}://{match.group("hostname")}/api/{match.group("project_id")}/cron/{monitor_slug}/{match.group("username")}/' logger.info(f'Pinging Sentry {state.name.lower()}{dry_run_label}') logger.debug(f'Using Sentry cron URL {cron_url}') status = { 'start': 'in_progress', 'finish': 'ok', 'fail': 'error', }.get(state.name.lower()) if not status: logger.warning('Invalid Sentry state') return if dry_run: return logging.getLogger('urllib3').setLevel(logging.ERROR) try: response = requests.post(f'{cron_url}?status={status}') if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'Sentry error: {error}') def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/uptime_kuma.py000066400000000000000000000032221476361726000232110ustar00rootroot00000000000000import 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'Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}') logger.debug(f'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}', verify=hook_config.get('verify_tls', True)) if not response.ok: response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'Uptime Kuma error: {error}') def destroy_monitor(push_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/hooks/monitoring/zabbix.py000066400000000000000000000123051476361726000221520ustar00rootroot00000000000000import logging import requests import borgmatic.hooks.credential.parse 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 send_zabbix_request(server, headers, data): ''' Given a Zabbix server URL, HTTP headers as a dict, and valid Zabbix JSON payload data as a dict, send a request to the Zabbix server via API. Return the response "result" value or None. ''' logging.getLogger('urllib3').setLevel(logging.ERROR) logger.debug(f'Sending a "{data["method"]}" request to the Zabbix server') 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'Zabbix error: {error}') return None try: result = response.json().get('result') error_message = result['data'][0]['error'] except requests.exceptions.JSONDecodeError: logger.warning('Zabbix error: Cannot parse API response') return None except (TypeError, KeyError, IndexError): return result else: logger.warning(f'Zabbix error: {error_message}') return None 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(), }, ) try: username = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('username'), config ) password = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('password'), config ) api_key = borgmatic.hooks.credential.parse.resolve_credential( hook_config.get('api_key'), config ) except ValueError as error: logger.warning(f'Zabbix credential error: {error}') return server = hook_config.get('server') 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'Pinging Zabbix{dry_run_label}') logger.debug(f'Using Zabbix URL: {server}') # Determine the Zabbix method used to store the value: itemid or host/key if itemid is not None: logger.info(f'Updating {itemid} on Zabbix') data = { 'jsonrpc': '2.0', 'method': 'history.push', 'params': {'itemid': itemid, 'value': value}, 'id': 1, } elif host is not None and key is not None: logger.info(f'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('Key missing for Zabbix') return elif key is not None: logger.warning('Host missing for Zabbix') return else: logger.warning('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('Using API key auth for Zabbix') headers['Authorization'] = f'Bearer {api_key}' elif username is not None and password is not None: logger.info(f'Using user/pass auth with user {username} for Zabbix') login_data = { 'jsonrpc': '2.0', 'method': 'user.login', 'params': {'username': username, 'password': password}, 'id': 1, } if not dry_run: result = send_zabbix_request(server, headers, login_data) if not result: return headers['Authorization'] = f'Bearer {result}' elif username is not None: logger.warning('Password missing for Zabbix authentication') return elif password is not None: logger.warning('Username missing for Zabbix authentication') return else: logger.warning('Authentication data missing for Zabbix') return if not dry_run: send_zabbix_request(server, headers, data) if username is not None and password is not None: logout_data = { 'jsonrpc': '2.0', 'method': 'user.logout', 'params': [], 'id': 1, } if not dry_run: send_zabbix_request(server, headers, logout_data) def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic/borgmatic/logger.py000066400000000000000000000311231476361726000166410ustar00rootroot00000000000000import enum import logging import logging.handlers import os import sys def to_bool(arg): ''' Return a boolean value based on `arg`. ''' if arg is None or isinstance(arg, bool): return arg if isinstance(arg, str): arg = arg.lower() if arg in ('yes', 'on', '1', 'true', 1): return True return False def interactive_console(): ''' Return whether the current console is "interactive". Meaning: Capable of user input and not just something like a cron job. ''' return sys.stderr.isatty() and os.environ.get('TERM') != 'dumb' 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 color marking up. ''' if no_color: return False if any(config.get('color', True) is False for config in configs.values()): return False if os.environ.get('NO_COLOR', None): return False py_colors = os.environ.get('PY_COLORS', None) if py_colors is not None: return to_bool(py_colors) return interactive_console() class Multi_stream_handler(logging.Handler): ''' A logging handler that dispatches each log record to one of multiple stream handlers depending on the record's log level. ''' def __init__(self, log_level_to_stream_handler): super(Multi_stream_handler, self).__init__() self.log_level_to_handler = log_level_to_stream_handler self.handlers = set(self.log_level_to_handler.values()) def flush(self): # pragma: no cover super(Multi_stream_handler, self).flush() for handler in self.handlers: handler.flush() def emit(self, record): ''' Dispatch the log record to the appropriate stream handler for the record's log level. ''' self.log_level_to_handler[record.levelno].emit(record) def setFormatter(self, formatter): # pragma: no cover super(Multi_stream_handler, self).setFormatter(formatter) for handler in self.handlers: handler.setFormatter(formatter) def setLevel(self, level): # pragma: no cover super(Multi_stream_handler, self).setLevel(level) for handler in self.handlers: handler.setLevel(level) class Log_prefix_formatter(logging.Formatter): def __init__(self, fmt='{prefix}{message}', style='{', *args, **kwargs): # pragma: no cover self.prefix = None super(Log_prefix_formatter, self).__init__(fmt=fmt, style=style, *args, **kwargs) def format(self, record): # pragma: no cover record.prefix = f'{self.prefix}: ' if self.prefix else '' return super(Log_prefix_formatter, self).format(record) class Color(enum.Enum): RESET = 0 RED = 31 GREEN = 32 YELLOW = 33 MAGENTA = 35 CYAN = 36 class Console_color_formatter(logging.Formatter): def __init__(self, *args, **kwargs): self.prefix = None super(Console_color_formatter, self).__init__( '{prefix}{message}', style='{', *args, **kwargs ) def format(self, record): add_custom_log_levels() 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 ) record.prefix = f'{self.prefix}: ' if self.prefix else '' return color_text(color, super(Console_color_formatter, self).format(record)) 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): ''' Given a color value and a message, return the message colored with ANSI escape codes. ''' if not color: return message return f'{ansi_escape_code(color)}{message}{ansi_escape_code(Color.RESET.value)}' def add_logging_level(level_name, level_number): ''' Globally add a custom logging level based on the given (all uppercase) level name and number. Do this idempotently. Inspired by https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 ''' method_name = level_name.lower() if not hasattr(logging, level_name): logging.addLevelName(level_number, level_name) setattr(logging, level_name, level_number) if not hasattr(logging, method_name): def log_for_level(self, message, *args, **kwargs): # pragma: no cover if self.isEnabledFor(level_number): self._log(level_number, message, args, **kwargs) setattr(logging.getLoggerClass(), method_name, log_for_level) if not hasattr(logging.getLoggerClass(), method_name): def log_to_root(message, *args, **kwargs): # pragma: no cover logging.log(level_number, message, *args, **kwargs) setattr(logging, method_name, log_to_root) ANSWER = logging.WARN - 5 DISABLED = logging.CRITICAL + 10 def add_custom_log_levels(): # pragma: no cover ''' Add a custom log level between WARN and INFO for user-requested answers. ''' add_logging_level('ANSWER', ANSWER) add_logging_level('DISABLED', DISABLED) def get_log_prefix(): ''' Return the current log prefix set by set_log_prefix(). Return None if no such prefix exists. It would be a whole lot easier to use logger.Formatter(defaults=...) instead, but that argument doesn't exist until Python 3.10+. ''' try: formatter = next( handler.formatter for handler in logging.getLogger().handlers if handler.formatter if hasattr(handler.formatter, 'prefix') ) except StopIteration: return None return formatter.prefix def set_log_prefix(prefix): ''' Given a log prefix as a string, set it into the each handler's formatter so that it can inject the prefix into each logged record. ''' for handler in logging.getLogger().handlers: if handler.formatter and hasattr(handler.formatter, 'prefix'): handler.formatter.prefix = prefix class Log_prefix: ''' A Python context manager for setting a log prefix so that it shows up in every subsequent logging message for the duration of the context manager. For this to work, it relies on each logging formatter to be initialized with "{prefix}" somewhere in its logging format. Example use as a context manager: with borgmatic.logger.Log_prefix('myprefix'): do_something_that_logs() For the scope of that "with" statement, any logs created are prefixed with "myprefix: ". Afterwards, the prefix gets restored to whatever it was prior to the context manager. ''' def __init__(self, prefix): ''' Given the desired log prefix, save it for use below. Set prefix to None to disable any prefix from getting logged. ''' self.prefix = prefix self.original_prefix = None def __enter__(self): ''' Set the prefix onto the formatter defaults for every logging handler so that the prefix ends up in every log message. But first, save off any original prefix so that it can be restored below. ''' self.original_prefix = get_log_prefix() set_log_prefix(self.prefix) def __exit__(self, exception, value, traceback): ''' Restore any original prefix. ''' set_log_prefix(self.original_prefix) class Delayed_logging_handler(logging.handlers.BufferingHandler): ''' A logging handler that buffers logs and doesn't flush them until explicitly flushed (after target handlers are actually set). It's useful for holding onto messages logged before logging is configured, ensuring those records eventually make their way to the relevant logging handlers. When flushing, don't forward log records to a target handler if the record's log level is below that of the handler. This recreates the standard logging behavior of, say, logging.DEBUG records getting suppressed if a handler's level is only set to logging.INFO. ''' def __init__(self): super(Delayed_logging_handler, self).__init__(capacity=0) self.targets = None def shouldFlush(self, record): return self.targets is not None def flush(self): self.acquire() try: if not self.targets: return for record in self.buffer: for target in self.targets: if record.levelno >= target.level: target.handle(record) self.buffer.clear() finally: self.release() def configure_delayed_logging(): # pragma: no cover ''' Configure a delayed logging handler to buffer anything that gets logged until we're ready to deal with it. ''' logging.basicConfig( level=logging.DEBUG, handlers=[Delayed_logging_handler()], ) def flush_delayed_logging(target_handlers): ''' Flush any previously buffered logs to our "real" logging handlers. ''' root_logger = logging.getLogger() if root_logger.handlers and isinstance(root_logger.handlers[0], Delayed_logging_handler): delayed_handler = root_logger.handlers[0] delayed_handler.targets = target_handlers delayed_handler.flush() root_logger.removeHandler(delayed_handler) def configure_logging( console_log_level, syslog_log_level=None, log_file_log_level=None, monitoring_log_level=None, log_file=None, log_file_format=None, color_enabled=True, ): ''' Configure logging to go to both the console and (syslog or log file). Use the given log levels, respectively. If color is enabled, set up log formatting accordingly. Raise FileNotFoundError or PermissionError if the log file could not be opened for writing. ''' add_custom_log_levels() if syslog_log_level is None: syslog_log_level = logging.DISABLED if log_file_log_level is None: log_file_log_level = console_log_level if monitoring_log_level is None: monitoring_log_level = console_log_level # Log certain log levels to console stderr and others to stdout. This supports use cases like # grepping (non-error) output. console_disabled = logging.NullHandler() console_error_handler = logging.StreamHandler(sys.stderr) console_standard_handler = logging.StreamHandler(sys.stdout) console_handler = Multi_stream_handler( { logging.DISABLED: console_disabled, logging.CRITICAL: console_error_handler, logging.ERROR: console_error_handler, logging.WARN: console_error_handler, logging.ANSWER: console_standard_handler, logging.INFO: console_standard_handler, logging.DEBUG: console_standard_handler, } ) if color_enabled: console_handler.setFormatter(Console_color_formatter()) else: console_handler.setFormatter(Log_prefix_formatter()) console_handler.setLevel(console_log_level) handlers = [console_handler] if syslog_log_level != logging.DISABLED: syslog_path = None if os.path.exists('/dev/log'): syslog_path = '/dev/log' elif os.path.exists('/var/run/syslog'): syslog_path = '/var/run/syslog' elif os.path.exists('/var/run/log'): syslog_path = '/var/run/log' if syslog_path: syslog_handler = logging.handlers.SysLogHandler(address=syslog_path) syslog_handler.setFormatter( Log_prefix_formatter( 'borgmatic: {levelname} {prefix}{message}', # noqa: FS003 ) ) syslog_handler.setLevel(syslog_log_level) handlers.append(syslog_handler) if log_file and log_file_log_level != logging.DISABLED: file_handler = logging.handlers.WatchedFileHandler(log_file) file_handler.setFormatter( Log_prefix_formatter( log_file_format or '[{asctime}] {levelname}: {prefix}{message}', # noqa: FS003 ) ) file_handler.setLevel(log_file_log_level) handlers.append(file_handler) flush_delayed_logging(handlers) logging.basicConfig( level=min(handler.level for handler in handlers), handlers=handlers, ) borgmatic/borgmatic/signals.py000066400000000000000000000023441476361726000170250ustar00rootroot00000000000000import logging import os import signal import sys logger = logging.getLogger(__name__) EXIT_CODE_FROM_SIGNAL = 128 def handle_signal(signal_number, frame): ''' Send the signal to all processes in borgmatic's process group, which includes child processes. ''' # Prevent infinite signal handler recursion. If the parent frame is this very same handler # function, we know we're recursing. if frame.f_back.f_code.co_name == handle_signal.__name__: return os.killpg(os.getpgrp(), signal_number) if signal_number == signal.SIGTERM: logger.critical('Exiting due to TERM signal') sys.exit(EXIT_CODE_FROM_SIGNAL + signal.SIGTERM) elif signal_number == signal.SIGINT: # Borg doesn't always exit on a SIGINT, so give it a little encouragement. os.killpg(os.getpgrp(), signal.SIGTERM) raise KeyboardInterrupt() def configure_signals(): ''' Configure borgmatic's signal handlers to pass relevant signals through to any child processes like Borg. ''' for signal_number in ( signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2, ): signal.signal(signal_number, handle_signal) borgmatic/borgmatic/verbosity.py000066400000000000000000000011121476361726000174030ustar00rootroot00000000000000import logging import borgmatic.logger VERBOSITY_DISABLED = -2 VERBOSITY_ERROR = -1 VERBOSITY_ANSWER = 0 VERBOSITY_SOME = 1 VERBOSITY_LOTS = 2 def verbosity_to_log_level(verbosity): ''' Given a borgmatic verbosity value, return the corresponding Python log level. ''' borgmatic.logger.add_custom_log_levels() return { VERBOSITY_DISABLED: logging.DISABLED, VERBOSITY_ERROR: logging.ERROR, VERBOSITY_ANSWER: logging.ANSWER, VERBOSITY_SOME: logging.INFO, VERBOSITY_LOTS: logging.DEBUG, }.get(verbosity, logging.WARNING) borgmatic/docs/000077500000000000000000000000001476361726000137715ustar00rootroot00000000000000borgmatic/docs/Dockerfile000066400000000000000000000033671476361726000157740ustar00rootroot00000000000000FROM 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 --break-system-packages --no-cache /app && borgmatic config generate && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ && 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:22.4.0-alpine AS html ARG ENVIRONMENT=production WORKDIR /source RUN npm install @11ty/eleventy \ @11ty/eleventy-plugin-syntaxhighlight \ @11ty/eleventy-plugin-inclusive-language \ @11ty/eleventy-navigation \ eleventy-plugin-code-clipboard \ markdown-it \ markdown-it-anchor \ 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.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 borgmatic/docs/README.md000077700000000000000000000000001476361726000167352../README.mdustar00rootroot00000000000000borgmatic/docs/SECURITY.md000066400000000000000000000012401476361726000155570ustar00rootroot00000000000000--- title: Security policy permalink: security-policy/index.html --- ## Supported versions While we want to hear about security vulnerabilities in all versions of borgmatic, security fixes will only be made to the most recently released version. It's not practical for our small volunteer effort to maintain multiple different release branches and put out separate security patches for each. ## Reporting a vulnerability If you find a security vulnerability, please [file a ticket](https://torsion.org/borgmatic/#issues) or [send email directly](mailto:witten@torsion.org) as appropriate. You should expect to hear back within a few days at most and generally sooner. borgmatic/docs/_data/000077500000000000000000000000001476361726000150415ustar00rootroot00000000000000borgmatic/docs/_data/borgmatic.js000066400000000000000000000001601476361726000173430ustar00rootroot00000000000000module.exports = function() { return { environment: process.env.NODE_ENV || "development" }; }; borgmatic/docs/_data/layout.json000066400000000000000000000000231476361726000172440ustar00rootroot00000000000000"layouts/main.njk" borgmatic/docs/_includes/000077500000000000000000000000001476361726000157365ustar00rootroot00000000000000borgmatic/docs/_includes/asciinema.css000066400000000000000000000000641476361726000204010ustar00rootroot00000000000000.asciicast > iframe { width: 100% !important; } borgmatic/docs/_includes/components/000077500000000000000000000000001476361726000201235ustar00rootroot00000000000000borgmatic/docs/_includes/components/external-links.css000066400000000000000000000007541476361726000236030ustar00rootroot00000000000000/* External links */ a[href^="http://"]:not(.minilink):not(.elv-externalexempt), a[href^="https://"]:not(.minilink):not(.elv-externalexempt), a[href^="//"]:not(.minilink):not(.elv-externalexempt) { text-decoration-color: inherit; } /* External link hovers */ a[href^="http://"]:not(.minilink):not(.elv-externalexempt):hover, a[href^="https://"]:not(.minilink):not(.elv-externalexempt):hover, a[href^="//"]:not(.minilink):not(.elv-externalexempt):hover { text-decoration-color: #00bcd4; } borgmatic/docs/_includes/components/info-blocks.css000066400000000000000000000011741476361726000230460ustar00rootroot00000000000000/* Warning */ .elv-info { line-height: 1.5; padding: 0.8125em 1em 0.75em; /* 13px 16px 12px /16 */ margin-left: -1rem; margin-right: -1rem; margin-bottom: 2em; background-color: #dff7ff; } .elv-info:before { content: "ℹ️ "; } .elv-info-warn { background-color: #ffa; } .elv-info-warn:before { content: "⚠️ "; } .elv-info:first-child { margin-top: 0; } body > .elv-info { margin-left: 0; margin-right: 0; padding: .5rem 1rem; } @media (min-width: 37.5em) and (min-height: 25em) { /* 600px / 400px */ body > .elv-info-sticky { position: sticky; top: 0; z-index: 2; box-shadow: 0 3px 0 0 rgba(0,0,0,.08); } }borgmatic/docs/_includes/components/lists.css000066400000000000000000000120051476361726000217710ustar00rootroot00000000000000/* Buzzwords */ @keyframes rainbow { 0% { background-position: 0% 50%; } 100% { background-position: 100% 50%; } } .buzzword-list, .inlinelist { padding: 0; } .inlinelist:first-child:last-child { margin: 0; } .buzzword, .buzzword-list li, .inlinelist .inlinelist-item { display: inline; -webkit-box-decoration-break: clone; box-decoration-break: clone; font-family: Georgia, serif; font-size: 116%; white-space: normal; line-height: 1.85; padding: .2em .5em; margin: 4px 4px 4px 0; transition: .15s linear outline; } .inlinelist .inlinelist-item.active { background-color: #222; color: #fff; font-weight: inherit; } .inlinelist .inlinelist-item.active :link, .inlinelist .inlinelist-item.active :visited { color: #fff; } .inlinelist .inlinelist-item code { background-color: transparent; font-size: 80%; margin-left: 6px; padding-left: 6px; display: inline-block; position: relative; } @media (max-width: 26.8125em) { /* 429px */ .inlinelist .inlinelist-item { overflow: hidden; } .inlinelist .inlinelist-item code { float: right; line-height: 1.75; } } @media (min-width: 26.875em) { /* 430px */ .inlinelist .inlinelist-item code { float: none; } .inlinelist .inlinelist-item code:before { content: " "; border-left: 1px solid rgba(255,255,255,.8); position: absolute; left: -2px; top: -2px; bottom: 2px; } } a.buzzword { text-decoration: underline; } .buzzword-list a, .inlinelist a { text-decoration: none; } .inlinelist .inlinelist-item { font-size: 100%; line-height: 2; } @supports not(-webkit-box-decoration-break: clone) { .buzzword, .buzzword-list li, .inlinelist .inlinelist-item { display: inline-block; } } .buzzword-list li, .buzzword { background-color: #f7f7f7; } .inlinelist .inlinelist-item { background-color: #e9e9e9; } .inlinelist .inlinelist-item:hover, .inlinelist .inlinelist-item:focus, .buzzword-list li:hover, .buzzword-list li:focus, .buzzword:hover, .buzzword:focus, .rainbow-active:hover, .rainbow-active:focus { position: relative; background-image: linear-gradient(238deg, #ff0000, #ff8000, #ffff00, #80ff00, #00ff00, #00ff80, #00ffff, #0080ff, #0000ff, #8000ff, #ff0080); background-size: 1200% 1200%; background-position: 2% 80%; color: #fff; text-shadow: 0 0 2px rgba(0,0,0,.9); animation: rainbow 4s ease-out alternate infinite; } .rainbow-active-noanim { animation: none !important; } .inlinelist .inlinelist-item:hover a, .inlinelist .inlinelist-item:focus a, .buzzword-list li:hover a, .buzzword-list li:focus a, a.buzzword:hover, a.buzzword:focus, a.rainbow-active:hover, a.rainbow-active:focus { color: #fff; text-decoration: none; } @media (prefers-reduced-motion: reduce) { .inlinelist .inlinelist-item:hover, .inlinelist .inlinelist-item:focus, .buzzword-list li:hover, .buzzword-list li:focus, .buzzword:hover, .buzzword:focus, .rainbow-active:hover, .rainbow-active:focus { animation: none; } } .buzzword-list li:hover:after, .buzzword-list li:focus:after, .buzzword:hover:after, .buzzword:focus:after { font-family: system-ui, -apple-system, sans-serif; content: "Buzzword alert!!!"; position: absolute; left: 0; top: 0; max-width: 8em; color: #f00; font-weight: 700; text-transform: uppercase; transform: rotate(-10deg) translate(-25%, -125%); text-shadow: 1px 1px 5px rgba(0,0,0,.6); line-height: 1.2; pointer-events: none; } main h2 .buzzword, main h3 .buzzword, main p .buzzword { padding: 0px 7px; font-size: 1em; /* 18px /18 */ margin: 0; line-height: 1.444444444444; /* 26px /18 */ font-family: inherit; } main h2 a.buzzword, main h3 a.buzzword, main p a.buzzword { text-decoration: underline; } /* Small viewport */ @media (max-width: 26.8125em) { /* 429px */ .inlinelist .inlinelist-item { display: block; width: auto; padding: 0; line-height: 1.4; } .inlinelist .inlinelist-item > a { display: block; padding: .2em .5em; } } @media (min-width: 26.875em) { /* 430px */ .inlinelist .inlinelist-item > a { display: inline-block; white-space: nowrap; } } .numberflag { display: inline-flex; align-items: center; justify-content: center; background-color: #dff7ff; border-radius: 50%; width: 1.75em; height: 1.75em; font-weight: 600; } h1 .numberflag, h2 .numberflag, h3 .numberflag, h4 .numberflag, h5 .numberflag { width: 1.25em; height: 1.25em; } h2 .numberflag { position: relative; margin-right: 0.25em; /* 10px /40 */ } h2 .numberflag:after { content: " "; position: absolute; bottom: -1px; left: 0; height: 1px; background-color: #fff; width: calc(100% + 0.4em); /* 16px /40 */ } /* Super featured list on home page */ .list-superfeatured .avatar { width: calc(30px + 5vw); height: calc(30px + 5vw); max-width: 60px; max-height: 60px; margin-left: 0; } @media (max-width: 26.8125em) { /* 429px */ .list-superfeatured .inlinelist-item > a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } @media (min-width: 26.875em) { /* 430px */ .list-superfeatured .inlinelist-item { font-size: 110%; } } /* Only top level */ .inlinelist-no-nest ul, .inlinelist-no-nest ol { display: none; } borgmatic/docs/_includes/components/minilink.css000066400000000000000000000033741476361726000224560ustar00rootroot00000000000000/* Mini link */ .minilink { display: inline-block; padding: .125em .375em; text-transform: uppercase; font-size: 0.875rem; /* 14px /16 */ text-decoration: none; background-color: #ddd; border-radius: 0.1875em; /* 3px /16 */ font-weight: 500; margin: 0 0.4285714285714em 0.07142857142857em 0; /* 0 6px 1px 0 /14 */ line-height: 1.285714285714; /* 18px /14 */ font-family: system-ui, -apple-system, sans-serif; } table .minilink { margin-top: 6px; } .minilink[href] { box-shadow: 0 1px 1px 0 rgba(0,0,0,.5); } .minilink[href]:hover, .minilink[href]:focus { background-color: #bbb; } pre + .minilink { color: #fff; border-radius: 0 0 0.2857142857143em 0.2857142857143em; /* 4px /14 */ float: right; background-color: #444; color: #fff; } pre[class*=language-] + .minilink { position: relative; top: -0.7142857142857em; /* -10px /14 */ } p.minilink { float: right; margin-left: 2em; margin-bottom: 2em; } h1 .minilink, h2 .minilink, h3 .minilink, h4 .minilink { font-size: 0.9375rem; /* 15px /16 */ vertical-align: middle; margin-left: 1em; } h3 .minilink, h4 .minilink { font-size: 0.8125rem; /* 13px /16 */ } .minilink + pre[class*=language-] { clear: both; } .minilink-addedin { text-transform: none; box-shadow: 0 0 0 1px rgba(0,0,0,0.3); } .minilink-addedin:not(:first-child) { margin-left: .5em; } .minilink-addedin.minilink-inline { margin: 0 4px; background-color: #fff; } .minilink-lower { text-transform: none; background-color: transparent; } .minilink-lower[href] { box-shadow: 0 0 0 1px rgba(0,0,0,0.5); } .minilink-lower[href]:hover, .minilink-lower[href]:focus { background-color: #eee; } .minilink > .minilink { margin: -.125em .375em -.125em -.375em; box-shadow: none; border-top-right-radius: 0; border-bottom-right-radius: 0; } borgmatic/docs/_includes/components/suggestion-link.html000066400000000000000000000003331476361726000241320ustar00rootroot00000000000000

Improve this documentation

Have an idea on how to make this documentation even better? Use our issue tracker to send your feedback!

borgmatic/docs/_includes/components/toc.css000066400000000000000000000034711476361726000214270ustar00rootroot00000000000000.elv-toc { font-size: 1rem; /* Reset */ } .elv-toc details { --details-force-closed: (max-width: 79.9375em); /* 1023px */ } .elv-toc details > summary { font-size: 1.375rem; /* 22px /16 */ margin-bottom: .5em; } @media (min-width: 80em) { .elv-toc { position: absolute; left: 3rem; width: 16rem; z-index: 1; } .elv-toc details > summary { margin-top: 0; } .js .elv-toc details > summary { display: none; } } .elv-toc-list { display: flex; flex-wrap: wrap; justify-content: space-between; padding-left: 0; padding-right: 0; margin: 0 0 2.5em; list-style: none; } .elv-toc-list li { font-size: 0.9375em; /* 15px /16 */ line-height: 1.466666666667; /* 22px /15 */ } /* Nested lists */ .elv-toc-list ul { padding: 0 0 .75em 0; margin: 0; list-style: none; } /* Menus nested 2 or more deep */ .elv-toc-list ul ul { padding-bottom: 0; padding-left: 0.625rem; /* 10px /16 */ } /* Hide inactive menus 3 or more deep */ .elv-toc-list ul ul > li:not(.elv-toc-active) > ul > li:not(.elv-toc-active) { display: none; } /* List items */ .elv-toc summary, .elv-toc-list a { padding: .15em .25em; } .elv-toc-list a { display: block; } .elv-toc-list a:not(:hover) { text-decoration: none; } .elv-toc-list li { margin: 0; padding: 0; } .elv-toc-list > li { flex-grow: 1; flex-basis: 14.375rem; /* 230px /16 */ } /* Top level links */ .elv-toc-list > li > a { color: #222; font-weight: 600; border-bottom: 1px solid #ddd; margin-bottom: 0.25em; /* 4px /16 */ } /* Active links */ .elv-toc-list li.elv-toc-active > a { background-color: #dff7ff; } .elv-toc-list ul .elv-toc-active > a:after { content: ""; } /* Show only active nested lists */ .elv-toc-list ul.elv-toc-active, .elv-toc-list li.elv-toc-active > ul { display: block; } /* Footer category navigation */ .elv-cat-list-active { font-weight: 600; } borgmatic/docs/_includes/header.njk000066400000000000000000000004111476361726000176660ustar00rootroot00000000000000
{% if page.url != '/' %}

borgmatic

{% endif %}

{{ title | safe }}

borgmatic/docs/_includes/index.css000066400000000000000000000633251476361726000175700ustar00rootroot00000000000000@font-face { font-family: BenchNine; src: url("data:font/woff2;charset=utf-8;base64,d09GMgABAAAAADFYABEAAAAAX2gAADD4AADrxwAAAAAAAAAAAAAAAAAAAAAAAAAAGh4bj2AcIAZWAEQILgmSYhEICoGQSIGBDwE2AiQDgwgLgUYABCAFOAcgDIEGG75VFezYC+A8QAq6uT9B9v+3BE1iKKT2IG91MwzFYrVtDyfavY9ii6qSIJybn7qqPfVk4Jv4IPPDqz8vFV7HmV9WXLRjVL2OAjH0oMfYZod2qMIF73BHXHv4/Ifftah4dMb/iIGvGyHJrM+/P9V7H/zP8jeJLYv8BWiW7SR6IVbskBymqWtgzVjAtacj0Zazd+vp3NO5w94M8HPr36JeLfK9tyi2UQvGNgYMYWMMcPRwRA+QkYKkioR4YGEUFkZdGFj9lbvWS734XkRYl/Dw/X07f2+lVbAEmjDQbTdqQoroJxC+7o868/ValnqbIclHIMcB+ohbYIl/N7mjLDv2IYDhYJhKLJl4wDepkfVmxuhZlZp298zsLCLJC1J+J0qOAaR9T5YLcgQVXlcoemjbv6ifY4f5g28eysziQmieNyjHNp5nrNibQZPNkF07pqVYu/Y/ABlN+P9XV27CPZACz/kBsgO0gJrlanZCUlAeqtOUOeqqKy+bNWzJt0YvmdyXrAdVlxMGurtj5p2hWY112P/v175KT2//rqOS4WIDPnM2JKqERIp41f/fpp/tfc/6/vLyLGoDVBH3XxvGMnVOTj9z3xt6b1gyjS1b1nzUJ3u0JHmd8+ePfVYLCFWAKrK1QBwgqPZ3CXfp0m6XMn2blOmIiq7LwzYShGFUDPe+imPMpII1pV5Lqa6ioiJ0ZxB2k6v/TwECgksCksiIbtU+yO33VMUhL1f+AIA+d93tAEoBgBn7e5y62gEmtXYO4skgeMqJgKrkFEOynt7+/0LUi+sZ1r3+XL58KQFt71M8Bv3+E/L56De+l8P3HaXzZza/tiPwR/OxefD5NB57wixv2OH9NS9ceRsPnZk3QxQ2P8sO5Lwep99bsOE4PMpQgaz+afvKp+75zbs+i/szOO+yya+O6M8cKp/lMN9QVaRZSKZe0U0ONkWYdPTvHLFPgG/NVPFldejfNI3/4K/6OPyhgvmqRXw9O5ob23PMjLPkDxHfUbI55aMMDXPUPNg2zh+iHhwDD++jZvfHR/8nGvKF3791Gra1j7In/ONQrYwf+VmtCybZQeiyh/+XdvFAIaw+Kv4S78j5m8yfjwVxaLimiaULz/TsSPzVpRAnuPLyTfftxcSUo6YVQc8gRiwzCysbhwSJPDL4ZPLLV6BIQFCJUmVIypxIAEECFH22m8VWHC7xpL8PkvDQE6RvYmOEi5cvm1ZmCHdLErenYXAEcm0jpm9CmaFWSKoKWV2haClUfYVmSaFbVRjWFKZ1hWWggDYUyKYCO6AgDimowwpHhsKVqfDkK3yFilBAEQsqEiUKplTBlSmEWle0t7+hPLlxee3M1/lVNJiYDR+D2Dy+d3fuLfodyNu/BkBX+vzX1w0oKT+M8WXaASTFfD/GRyBoBonanvEla4WqbztIxKW9/G6U2BxQvEnFt/W3mXT59/G3mLi4kTEN0O/iZ/h/Sj0Rv1VDxV/xcH5QCoMZUCzTGkJj8a6PSj9Q+WeorHL5shj1qY6Jjuhgl8F7THm5HVN/q0Is+oXot8K7GZoqv21Zney0k8UO+EzXYC1Isag7ENmIAd+axJ/wGgFtCOXtWGj2A9o8lnlXVRGWGgmrrNK46A+vxhmdp41Fld9kKhc/v5+J0m9H5HMh9V07iMFiOmo+h0A4zvk0GrUp4JjHrinqxKbDVeHwRQeFJ3mSyzsa0BG4oAOLPIJ5mNb3/dbiCCtWy5M2cqWCLmCEAyVspniAEWsE6bf2ulu7jaLbGda57gBCU3jcpdCGwLxK+O/IA/E7a1zoREndb4uEcqUSMCmbgOAGbBEPBeODX0MJ8w5YPW7EldSEexHqxWt4Q/w63DZoSO+HVlBxfitmU86iMjfj1XVDtZq9nx7xuIUPZ0u8Hqrgc1og07YOCa46qjL5Gh8F7cNfwgyDeSVYDxMTFRhun28WrxU8euEeRAI1Z0Qg+x5cKoJHJfCpDAFVIKQqRFQDQHWA1ABEzXcHbv/QW/MSQiRCkQCjEsRUhoQqkFIVMqpBTnXg1ACAFanbuwQlEkU9g0VuGq3bC6bFZdOYWt5x3F9CTRmUgtKipgJpS3KsWztrcKwXwhfC9fGr8nwCt6ksKluvBZALwuZ/ncTQh42yoYYZjNXFQCBefnnYPoAAOLRssyVW4fOaOrSorclHAOVhqmmgubFY02yZghzdHriFIkT0SI1h2+hGCt/Jb7dsy9ohNN81lbR19TmR9tQ2yivWsjhWfjHJXZMW84BX6FTU1E2hzFPE4qZv3HKzF/bF6Tml2xfpHB69N+FSuULZbpsJRV4PkyoCeBYRTGNDsh2LtghbRyJ5omajWhclKz3paGed4XLTaGrH2AwmFcKIbQuRFNbU3qKHaS7ImXTGA54uVjmrIrNGrOmKYms3qBMZscXZo77es+Gal7SJrFHFsr92kK5QzEHxFBotWuWcSVdGt5ad7Su546MbmUc2dBldmsIsRyzeoYSMazUZLZYpGxIPljCKRuiRzUHYTY9NKavgxdHy5bL+oaxQ5rdv2ALYcoKog9HXXmmoL85elD1r96JAFyENsvbd260OVo+7HTzNOP6yg6ym5ARVOLkdhZyiOn0NdCIXHdBxjisPWMxAsaFsyIUBWbMvDlTh0nYUcpmqv26TJBkuWzctStzZJdHTZJ2eIev0LHHQEbn48B6guC337n0kbtk0rhTB5OKLNusq+xqsIqd0PxnQA2RAD5IEeohcRualc4a8f94knIYwpE+9xEkziJP6yAbNJE6aOKjSlCihjLppAUmkhfgERduRABKgJDGPvKtXozQqM1ZrahVslZ74xkWIsjfhTFzQV06soqIPZiQeKn0TJVQVTFBdMMmaHG+hdrkZ6gpmCBXMUF8wQ8NyCzQWLLCwYIGmgiUIxyRpZsMMRZvfj8r7jQUIcG4uQZZ1v5Png3FgC6d2E6QBRUDpEImlJD+84xLQgAB4aOLVkbT8xhxGmV/zhxeVvmCyjGa90PwPGhEOlzOPyqA7eDAMwQUirlRpNUtRmAuJhPbkKLeOLWYp2REIDBOwkcOxRXJKISVbjCA2tihGn2viEWqxOYKDSflarhW2KExig5YwSri4ks01w1rQJEO1oAVCsqNgWALHwWmxPKke0kJafVw0bo6Pljs5CWpU4eonFHyQUhUUZTykahG0uiXU1PMunvFwVishTcoM2ZejtpzC/c3H0HSS4NZ4jDRBRQChs/WLy6eqxgXvSNmzBze6tOwivM8yW4Hmf56n4BdUOSDgJUSPlGFv8tD3lzLG4VWi8KIGwsWUfPVc1JsuUUHgRWoD5FIgQm+EvIsP9QDgY7Idi1KfFVJ23WzKFNZ3M5/B8T8HH7Vwm6BU309566hlXSl7BGIYNU1fE0QIwddPjF4xO8J3Ie7dxvRV1s2mm8eHl1V9bWyqq8t0YKjh4mUJp76ws6LK51n0/dPrZ0Fsdrn6wfljNNGObAQ7LQ5rQTn7kaUzwOnbtv3ubpLy1TtBHLspOgkIlgJZ5b2APiLPE23eoevTjLaYSdSDeFbxHjIQ45gRVWG/UBt8cFyQgw+TlDLBh6N72+iJYbM7m8GIhQehYKeWR7oCAL7iYieuO59jo7rJWg2GDWEROmofgWWoiaWr8bJ+dv7ilB6fOz8htbBPcyQoeVyJ/TjetxyCix/AxwM/mESoy3j/GgU0wftZEJhr3fC60B7hqjw1CI3G7jcoDFHhYvZ39GteqwXk3nUCQeCogqrEGTyFwiIcuEOdXEOjMLH/wrhipx2JOomIjquAVVKZrstNnaO11Uf9h1slcuqmaDVaWkZGVshl6C+G92F9ursdF6y9XYMpaKRVBa3VuCLab7ViNHf9bB59yKgJG4LFJNFiF8VBQ6QWzNPv9COG1i5tUBAecXHX0QPUzNIHzLRvBFBwfCu23qChiyr1DD66w0yq/N3M3t1R//5uF8131H+YQ+c4htjh8Z74L5h9uVnaTs6dH7tZPabX/Sij467pq2Elp+6Yctld7JLap/f/3Nlpw+v6nEd+MV9ctDV2/wLtQpyVxYShnQ22pxzUrR3y0Nbe79rRTuP+ueWUqUlJ3skl+MAm8aeOSs/4tsVrf2R/cLvslOxHgiAWwJsrRM23Vzs8sNfbi5a/dRWGGae94FMq4+A13XiXWq1pebjZG7WYITNx0GiJGjp0TW7TajyiriHL1Qo0LcMADa6HKCbrRAMIYd1NwpH9P1CYXfcAabmtUDYMgBm9XbXbmx9SLAuPiA6QihxTyDMEIQeTxr7n584UVhYNPIZ7pOTYdIjcG4Q4eXihfaIw0+Vs4xhLkzvYkJ/ggWVFxR8IZmz4RKFMKIVBmtraRw8vI25l9BHM0T1tR22xykJavjbpyuss8NjuOw/o3ZoPoIJiDZ5NzPDuu1Hx0EaJOFoq4psJTrHp9hvXYA6sxTUT4vd2I3bVNsiH7QRKg9YxDkpnlrltbWNdfU1BAHDAfCohBHs77mMI8FrSVln8raK7kad/v9BlZh4unBdNHViTKRkd4ULFBKNXUJ+vu7uoSfyHE41vFYcJhq4xNLJus63K8vR84ZXOSLudibesK4fyyMKtYbfe8ZKlo0V/B3C4lQR4ZlVdm2MR7Y5+nmH+SzbfnxqOg5f0JFQqbqXXEtixMTsvopXpKHz86dvPsrI4MbNYF/XOnWF96+3a6e21g5MzoNwnjejWOlTSQ72xj7P9PhMfMNv21Bjh4lW0Idd8tZV9MbsdtrD1e6leOxPPW9gg2jKn+YVu06mqp7uLNuSVaCGxwCKov/doN7XIg54wB04fVx/vemQHFfderGn2TSjgllx28MRTiZRlVDPG+ROT8X5cuyGjjXdlck7XCOKcodkKGveUTHBJ9sbcMOJJ79dQQ14Gjlo5jVuNC4qjsLRo4+1FjYPDsNCMuZaglIwgkqoJFRrwiNeoVDe91aHjtrtU5+1Zm1MWSle3O8lOTLXaK2QK5iTFSirhhDGNsTkdGc3mKJbkS7z2FperIF/bfF/jDIUigb0b9+xI5Q6D3LiWHK0WutxPErrc8yCpKmur/i4YYCWiVd1g0maww/B0cWbE/uAI8XXddzfOX1VZrCKC0GNwc9V9iQmWIsdOjvewDoz8B9G3GDean4MugfC6MTVUd4t1tKCT6PAVE0aEeGFEFyDGSXJfC4oNWXNR6ADf4dq7/lNPrwDKXn8k2hozARJvfRjl3M6iric+Or37ykGeM0w55ThmQj1uGaIfjH3O+mfQvdfEBxaZmqqTnh7hjc3Mr0wEOwTeob2BdXQvIdzZtoexteOwZbaaQI7ZdRxnKIDzPUsk96j3zvpDIQuJWvTrWC2kGhZ3aUTKxtMXOrRxYCVbXHZVv1OZCFdI00pHuNf5Gwr21u7Gv/UpRKpN0vFStx0s1u8Fa/oK+GERb7uJNFSQxcReA2JrAqacvd7h4OiM3BDHiv6jET7fo5yLxpceR1JLAhjo+Gsmd2blqEH3thW7L0RBCaawLBpuoqGvY7gmZwtUj6vMFn02cYXHLOHqLrvxFs4/PcFAOun1tCCB8Bn58S6IE5iXMjd7HFH5SiarqGKSZYDMFTGhfKBHSOSNiSbqUKoT2hoSjpDoXWuzGhXSI2KgnpjpdyjycaT63KXv6WbcasdObJycG5hkugAZ6667/S6Ix1vG5BHfS+7IYZbycfxLQmliRgfSjqtsKw0UxBh4xkY8tJVl5RgLzZkC33++tRfXbuCBsSCmBfAeHBFHa1HIbit8j+HE0q1m1r9gatLoIHQ06dOkvSj6vedazLhoO352FQt97j7e9ipv26aLwwM7aZRHAtpYZHY2aTGOo/rMsTQ61Mz/tXmp9Pcr/xtjhHUqjliFzMqnrHjxOwJyJnm2jyX9EuqFPiNg8pl+cibfP/KPgOyN4AA+/iVYB94jWFoO1cv7gk/x6jgs4iswBM7isJZD9vJj10xTi6UR7zolcao4UZa0xS2J08aJC2Pm0M20HIgXMzcRO8dDsy3UKXSJzgOhH0MPpgzEgSdr1ch8kNeJiYvv5/TlE0TLxA6Jl/KMryPH7wl9G3c53tG6yVZbQvYgbHcP4+7A+RJUk1CIg/0dk0OMO+3Zu+4nBORd3uOHPltFXZbGFqFpg4dY1YO7eZrEHAFdk1PZBN0ssr088tehqU9XCJa40eBoJuO4gCFFaatBLx7JWMMHpchDWRyDD5ZF65avmdSNRMfUweQa+KVGd5WWnX/1ldJzZaUl5155tfT8D1ELWidXF/5ekVIV/x/w5kfc/KGYhmrHkcUrui77hD5e976Vr5x8aXtdzDqCRaXlwrvJj0U9uDsUAhbV5q/JfsiW07+lyUdl9u/k6FZFUmyKYisq47/ZFDV3niYGQWP7Q3f6ykzcC7z9rGX6g2dfIe9XNrcbPMlDW0mUBin7h3SgcmtHElY5EDDOFbf/w6cY7BOodE1mYKH/oqoORFWF5jbDZNb3hM98BOMZTN99NL+VedTiI75nyYYngcarzjRfYKoNxm+/M5jUpzeHVRny/Un2YXVzz6veejuqsu4/yaPDojEuLyJ7mSNPc8n+wCzCG/lInqPct83AbmXk6yVTgthYAWlPaAtTMCuKGDgZM751cjXMg/nC2d6Xin4wzMkXodxfpW+gC5ln2HO4Cv+PfYa5EH1R+is3k/Op9EW0jzXD/i/576Y59gyrD31D+inH3J1kIfOjeYpqcpmkOpKgxwqdm5OJbjx1W0C22lg0I+niI8/LrfJZ5DkszmrF5P24dSoOX0XETVkJrOI2CyfRyOiRR25kFYgxyOj5x25IdnVDsYeebE+nHHOTHUnpjMABZ7WupNF1QMs4Psp7S7XNssiRnhGVzo5MqlCUVyXvjoDOfh3zPuYpUN7M5ZMKjDWGboZHSReZAB3lwTV1wAPnpVCpzA3llJ5zXfty9/jKy8vTddnsoSuFKjnXz9WunRvXeeMWoMFIl0fXOd+tXupszmzNW5o5mjrDqyazOEP0HF6c2RTLRydvBgZ41HQyxCDH5ZvKLv8NdJeMBQAt94SwRSLVHKZ8P5LXlt+eazwqoDLJNDeDuSZQaF2T3hJZXGLc1Yw9b6kXNV7bGbZP1RQu0t5MBBZcn00Kr0pYVOYffyk/cwu5UhFEo2s66VtZX211RdRGuLaOxiTMS4gZ/cwVYZxE912PORNjYELOQTrFDSzZffLKuHR56fSzZlq2oCTRozdG/hS9cx1qRheRmVR+B5Iv04AXBaTCKPiQOI68Omvx1MaJx593qo8mN1ekNUnyF57Ye6z6tZQx3YWGlQwq3QcIT24LJhRklHns53dii2JthQPejGHlgcZ21cGEUDCpSug6eSBzwRHTsGrVkhSwGi9I0qWWRff/6r7qvAb+X8xkMA/1R6GzuLEjBp+QWEAa6wWCkcZXKt9JvIZtcz/gfT1SX0f2lMX6YL/KkRpbLd5Qudz9/IWC4qujGnMeP9tmLZavLq82Hnw4ZN8/FlZPO6sCSQsE1mvZ2d1TTl7eicU3iqVXhPheNx6/HXVst334/yhaDqVmQ1HhUqu7PPOZF+wLbGnNawZct9e2Kg86mopTq7DkrrUrspOZ+uycrU913cp2xx/dKH8mIdXkTlv39JqEbb3LidTbqUTJ70cPnnpzqTF9Pg6Fb+QSmb7XHRyiSzeShf2tt6YrnyfPftElDOP+1wEejWxgxKgDPtFDkUVE/3yJqvIZlG3gJbDk+HLhG1w+8I94gjfxCaL+2HvM3orLWfO4BrmvADkpj5SfQPYhJyQ5iewVfSAnOfnxN13ewJbwHQwYS9Q1a9dYr/I+FfULnvoY0QBHrhmqFGAqT8QGf1AXh/+giSxMOEeyR/imhs9VVv5fjjK075fXg6u0Krfbm2EeDEyTw83d/sXh7ORXaAIfqB3UTFrS7FWSoHNeUD9OCvzKmJ4fa228gULCf4QH5B3yA8Lw2Z+vGt/cvevB6NTnefrn1hgfh95aP3W7eOnI7YKJdWWvGb5b9fPgj0F3bi0tEM+glSnCnW2hkQPf37+765uhC8kfpTBhaLRGy+SYMGqJ1qNGR8d4+eDKYJ/8pYxjrf0HPU0NR/I6xv2X1K93diheyL7UNXYqf1HvYBJAEnxfjHa379XXfbGg6fDeXU3PFFeWnowqPzkXkc6WcREKKowduvnaqSHkXizFBFGl3MOSJu1/EczfGfbIfxJm4LbLtxk8Oo9updDJFBqjiEIn813r0TekgPbK3kDbZesulWRN0HcsK3nQbz5aUdVnyDuwdaikZvNQZ/FGz15U8r0AefaII621cklgWfYwKtoucP2ExvtZpeqfUtoipsQfT/7PyPft6Ao2ibMqrwBxahFtAGWQZnvOeXimNIEnPXIAe2fLdtE7miXzPH+YfXWkEGkxqQ54caV3emlOUbnhwdf3jI8Lyt0rYFG86Llp5IYU0NnYdaSkZNhbu9csenmS+750p615OqgqBF0x65DrBKCz8RsIytxcy8tT3aIFsGoLq5bCx4w5RoxHDYGvNLC+PtnriLXr7j+wVixlhfFLAvAv5BpWtpr2J7WDFUP0358xlmGO7jqJ2AmBdAOnY1FPZWwWVKibS+7QbRPfXzUp/cS4PastO3mhupjljKoT3gZCdsZSlEnyGsbv8W3zBTnppgHZO1vWCl/XDCbMf1ZvfEHeAa6sj2V1sg6t4BUSnyIInrJSHp8/ic7IAZ0qn0E3ynUIWG2UGMpyFl92E9mMNKqwFGtLjyAkhCJBYSgKotevwwZBih1c5svvnFMIlrITUYwobw5vni/q2w0GKb05B6M5h7nR7+0z3Ld3hZPzQdk6zA7oODu2CbdvfrCVh20RitGq3xYuxFNIq2pRMbKJsDI6u+IJ3bqJtegfapdFKBW5huVj1xHlgMRCI98z0TunP7XMBzefpng8VPDgyfVv7et0kB+a6GasXSIkPaDQlUoKVI8M4WmSvZCASdOQqGTKf3Zx1CQyKwe0w4ZPCb8WWteLEpndNqIPswE6ST6DtIILa8Wb33dIydRZ+SwacXsyI6tK9eSXbxQ/ZdZkZmTXKX754ifVk8wa756iLk1lle2oFPhjC++TqP3OcDAlJPEUdGkrKx2HpP+cuUyQbEcrK7o0MeuweEAnyU4hOqObVijclpihqRVtqOjXHU+qrUoIspMmId6JeEy9uT8yebPACzblL+D1EmnuJ7oGUODlWWSlWdlpQBa1MXJBbdwh4u+Zc0pg82Bd1WL1ocXqUIXzoJJ0/i+7qWkyiGVr9hFjkLr4bm0Wl0HkyjJzApH//HZT9yi3NCszt0L/+Kuzgi/Mg+4Y+zpkRgpoj/wGkj+vBKmMTSjQrqufL2yPTte71HSVeTY2XdxDLqbkSOJ4OwEqB2ZAp2oMR56eItZDIq90BiFbtSNH/xgpa2yphQTHiHis58TVponI8tejpGWpt6g7kOcLuaOWF7w88teFS9/siU8vKzfYXxnwNvmMQ8VJ6lfk7IlPEqnwLdlZDDoBjtbw8ogZ1IbMEHnYMfQ5CDsrv4loPkHtOcwi6WZ7iWKQd2qxwQH/NFH3Inux2s/NUbbtplLOqSm1fAbZwhxrEBi6eWcVy+zB0UJpFkPjH7nGPkxwGVGczTiYCt4Vg2NiVioxwNbzMPQhcXyjkrOR5YaKBRgvkrMCts2j/3GOufh0Sircumkt848XGZRXUyLgTk4kYoSEApa7c8lGlocVOsziHxnq1rkgAsl/Mvzz00EmAd0ktENtaxappH0Iwc76Jv/JR/O5cuQMwd989PAzFsygR87Kn0MXj+WiCs4W6d218auswUp4kKNEUlDPyAkrpm1FlMtL1vzGH9M6QA5Dgxji76b/yYmNhPnP6zA6Sk8mUZi1HZxi7HVEv36ZFAb0/1hSB25ttWKjsCqohEfwxEo77i65A7nR849JNDLOWoOo0SOPKAwJUzY8hMzKl1vxEUhlV0IjeFKrE0+AfkUBfZNJuHuqpDJuN39x0zv/lVblnYfgncY+RCHLFd45/9PRwqXo0sIvj758hTAMDOK9hEnag9/EeghznEVOaDfCjSkBByIFC6EjCqEbESrZWhkUah1aUm4fL/SGYouRVDjD1mJsTom3p/Lcaz5rwhiFjFSsdrC+Z16FwVup3tcdbRUqefcxdRtOVlkoeHl9pW0oI3Bh2M/inWvB6wfqxrtnwH3O1wRkKrtOUodKBSmARpTvQx3Q+8r32KCIyCjJiDcVxHs79W9Ojkfdzupa4CgY7xs9Pe9/CYnZt/3Z96+tBrVH3XiMJVfRwttVO6i6bG/NT8sRml2ZIvMAtZm+JO2mQjbwrXQ+b22oibsBy/m2X2WYZ15HD3Fna2KLQcYcHXgRvyZMoiLfJGPLsGRACz0SmegKviNNnYAvbSsLsq55zrnkJzQ53G0duxTHE6JGj7T/1tG3rteUIyhKc7Zqrw+PaK47WvNTs/ix5M8zsAomBnyWcTBddad0tSkCLRDHdzi/ImMMWbMlbZD2VFzCFO76R8mIh09iLpM4nqEZkT7tCmYrHN5zIWWa1MyaZ4su6S2hU9aJotySfdAEU/mPC6/oeWlZamd9OHvMlZS+ZGEb4wtGKBTIS8uITOti/UoU6hrCTcvGg/kVgeXxLKhI+WxBaTqYMG3UQCbMn7svzIIW/G5Z6tu16eoG/PLGHpBFomzeDBYwhqLSCHPbSyPVV/aseW6Nd/8H1zWBppZtKxo63LGNGUk1ujIkOIrcQt3fi0OY7S82jWKT+8M+u8UX6ayVzQwh8lvwMHJart4ZORaflH9ohJCfhuNXYp6wG1skcYc9Ekb6Ksw+YsP6MNuIHfOrcqFPcJdVotel1Jx2Yx2Y+2QJf1CRnvGnliJ8n+oLWjKKuQDtSsY6seQAmw6Uk9aNc7aIRlkizuSNATsM6t/6uJIjg4ZEc1NHnVQ63XbkwsU6KcmF1Y0HKyvdCRZp/deXHTOPQHcVtcqXIQ32HPepaBolJu7O2mHLNT2FzpBUNlcn6DeOaUqF2xeK0rFuLN0wpW8zWd1rdzAVd6HFeDqxJ7In1mAPiMaC/+4+IS+MwLfkI8mSaJTr5w/iNCrGis95sFbjyNOnVCmODUrkpxF5/NnJuC5qpSWlYN+AXn4LHqxZlrpT4+fu7u/h7tD6d6U8p/Vzp3r7Obu0OS0H3BFpYpY1RxEW7HtZMK0KW/1Mt4iWSksTMa1+ZVgw/bJgnyIcl8NKE8uXB8XeqxliHAO9VCh1Dw2nUgzEot5wuiFT7cmxdEmPNXNWP+m2lvolPiGVjmzzSnbfHew7k/6qM9l3Z+Zs5q01nKA4HvgGodNomK/Yb/2TNRH92vzOEps1va2DWGygUHG6aMsxDBe73lMcgqG7SRm3zp7y3k5ypr58rm/NCHRPUZHD10FX60TjchpVLgsXNdzk4Ji0XjII6r7qsJ1ruKW4BxUoQe9oGpjYVbaw0pmY63QkZ+TaPx22+SRlyT0jO8JDZfWu3oycTJPVHZfg8n9z+6tEv6whdflTG+xNJMOzjwwebCBAEkApmNluwVIggjumFddjtpUEPIxZ7GZsmJGBUBNR/+xrQh8RkRwpq/CCkam8tyOLJBMVWfJbSC98S17fZ6mPT/RNNzui4iLknxvDLgxHSTmidTHMaElDZ2iQL4E9UFEzxtOyl2BrOi9hdFbNpaXSRWw1Mx482Aq7Md23RD/zYDeXqeeGJIPlbwEGKrPynVa8TLys4i6TSmYseKcNz+FoESNjtmeCCBNLgmEGXVQaXk2kJjFxyHB+v56HXXnFCRHguNgSAvRUmq6aosA7WRjiODbhA1nOVz7ToQRrArfUQZF0hqGR3TMBR388IFZlezn8KthjkjG17AGU5x85mUSiQg3HXVQkwNWzZJBmr6DLMnBfCRa82h+RLSF7+JX19N1wF37zm7XfKtOjwQYCWssmhpXbPh39Lm050oghevw8A/ocx5X/CTWcfGDbbmApvnKvhWNZjy3p3gns51ZECP9V4fjnUGGO5zjp/yuBdlkgtQaIcuigGrOsltRBB95QgA9Vh69Z7ZNb4a+UgCVTfgVHPcfyxY2j76T6d5dl+Nj+uJZJyQBQ7vifkhwDUqnYXMP+SushsVlaR2oX9gCbVYeuW5O1/MyKRNcLHEFMJpc9/F5RTOxo6sQ8+9D8/maONCJVyD4Fn1ZFNOiLze36lp6fS8Y7lTupoL41emUnkDPd0Ina+uWrr6Jdev0YdMf/W+NL6+538vDI+eD9zX9jEr+8h3/F5KmfjL6alKZkM8yuhwKJs4pAAJMzCBYUTUvTF3xsphIhkZx284mcrRCwK/3eTDPAEw5y0sIN4GwkO5ipSRHZVFhE3LIRcaLjI915noWyih8Jajr5ox99NXWb4XHptwLEg0QrDZ0IwWXTyMznWuQ+2vGpOVWY4eGG3M59GqROj7E4D/7jJ969NzFxYL8Zbt3dXJ4f2zKPXVPmacIwCL2ga4pjH/dOOi3zcQNK/6ngWK5Mmo6k+YR0eWqFzGX3iEpo7Fh03w0FM74DeUUzDt+tnLZl7LssYSQK3IPj702/P/kjG301HY9w8MDtpOzlRb6AvK5uVcyI6A4opc4Da+3r8alMnOUeaZ/q6Vl90BvYpcl+7zxzDgYv+XefuPdjE69d8hxEIvZsXr5cmaM3T22dj3xkJMpAZtmYZJQQhZArTdxSRGX6gXBsyp0U2jKZ+UC86vjbwJvRsOgds3ouiBgOksZiAl2X66lWnHV7AVM4jir6kj8Xk6+98GZ+l+TsxdvqEqqLD122bf+HwTwhaAxbL4zrF3VnjYtPoClvvyNuXD13f/v+kUNbG1fn+zr1TZkllPghRnw39nuTJYQ8JxKsJIoqsVmIdcQAiZNoAS0K3gHI4UlNDilCGEBOdxtnAeJgOllK4f7kD3N0NuEzUl/EGVAgZaBD2l8UVDmg2FoHMTYaMe9TDpSJtsIgDLdVCLgH6UaYsbtKh8Gn+z0cwlXH0CyOIGDzEqjrhkEWxQtCXrhkF7i43EbXxsq/uojotkuesMDDEpfQ5xJXmCBPTgUR5u7BDGctQuIg0YjLnOHQcwt67NqBfHXbvJL+ACDCcXmjlCf2Zku5R4JlSDjsHbI3DzdFCHT5NLbDZxtpGhoQ8yIeM9f2SAyMo90Qb2++mIPSlTB9R2TTdjEoWhoe24vrK0BBEHchdQc5S3uUsAtmOK3k0VNSlKPUrGBgZSL5jRRY6uVp4GFVrOpg/8sFVXACgm5OHXK6uCKgFL4kscICNXZDmUIRl28yQvwu7QdEc9p9H5zWvNxGzCWyRMlYLw4TBYKUSGvzE9G1RZ5QGHpBW7Ngi1tesDezphUSkmenOvrtq46rJc9iiv0gDA3WHWXx1Xrv7ClqghvN5y/JqKdKSE8zgDKQoOB0EJuDy7Nt9uHnhJHtYj9/a27FVVLU+pIqNEHQrtg3mwAmh6GCmfTKr6R5Ee9bd9FfjO1S6dEjN9NNVoNPHXqJJGfbjuUCCG4IdwQClUzIws7dfN2w3Ffr0zXFZt2rz2jh3kQ1UcWOwgttfCXoExx2ztt0CLpQMBP2iUWm8lrLdzd7bJJxK6LTao/uyFMYeQGqrDqOa6YS5Tc9wp6YjpxUmPQBnolh+Zbriu5b9a5tJp+reNKl7fldqXnZU0UJdNjpXuIUs0ufusrJSEQmGfAwNy1McEzzR3qK0rzwDVe4eloWQEhATikgQ5d5+nooRPhsxvPX+/EpTfnu6t6bxOvG2Wm8ma6nDmzGvgzrFa3hIR2Tr6UPoAw2FCalOlj9zHRq1bUrAmGJYdn0qk51VQvakfCwg6bZv1Zn3theeOhHs9f11lOQV51YBVD0Ivo2ZN4iY4gc1osnI9xgMz+mTcA7IRyy8cXBBONm734jbl4f39m9s3329Mn9w9XzbR76tt6Y7Qg4Hi7zvKXoLvBh3H3BI1/t8ZIllJ1BdgdhyaaK5eLVN2rFVm0xgaFpY54Fp6dwuoLQeYFIv9KmdAXBVFxlBHs3m73bi4zUrDatjcZSNcL8dDyCflZ1H8ShjiTefDxw2jDrEpr2FgLCivR1wu99oWBYBlMzO+155Bn7P4A2eAg7BKzYQX9N2wmB9KxfjWrbQULWLHalTIX5gvpKJxFRXCNRLfOU4ch3C1psjo3dy+FpyqQ4R1EVx1zIf0OvBGFxgkcNryuYJVlWVNisiQ/FUmqiEUDetONHzN7B1TJ1TRo7Xhac5h8tiAEYCSLjnK4yYzbo7tplR2Ku+gVPP3kyVKTFxA5gRjoCS0rTyDILlbW6YMZSOLAmlcpLzWc8Px0lFnUAuBzTu0dkJ7dcrG9yX46xSsmwsjWuJBSERmxArNOsQk6Fyv7gOoEqK3pPR3R6jkI+GMLBtOkmvVk0CIDZsbjrRrW7WhGterrlG4GGRo5ZFc0+T4uQzyiw6wyJ//uQHQZOieK6F6rr3Fy0F3UZV1pibw6P0jdIdlMVV5enbQ3Ony6fIlEBZ7zExWzkGgwuV/QVHvK4R8XgzghuV0tUSjyj2PFqQZ99/iYLyR2uX6Gu9+8ECpzTh/V9tl9dQqXIiOejy67BevgUOJFAoGTboQ4giaWVKyXx1HUVOtIgdDitlcx6K3+yNbxIXnVXWtR6aYvE066xHoUcCBwXFZbPLl6mXGV3ggU6mhLZDs34TeDOplWXT8QlAj0fHbb1ntKYhnk9tINTndMQ5mlDtBP7BU7R6XrpSDGp3f1wypjRGJuwj0wPSHIaDNJjZtLzayDD9FlnK5Qg8QBRhXty1Eug3RGZNAgsXAU9r4h31wSHahksMLfdiG87rZbBt1QR+MjqFRHekOPYzVAHUiEdUcxeHUZzWMvdniayWGBUUlzcr3JtZb8/7tsqjf0QPfY8PsdztOHJdCJ8IRyLYadj6V1yMzQJXqT6syePR6BPv0qQ+l5Vh0Vu9xe5wwBwN7ILjYhmvVnbNWEYhZ6XYaiw4kXwMAMybKPrhjizaa0aRH64OTB/FKUlskL1CVFChXE8FDF0oJRD5ioPV51u3L3vXLVZZLu9qB2uSsxU6ZKjyA+a9eydZ94Zu4T/YNO5JgPCQ2l2RjCJQjWojrndelmfTZUx6eKr5uCetILmEEe9MdLvyrW90B3jZ1a70VZ5gsBuEPfZI5+tj0Pq8jcTJcpqE0qUCwC7wj6jQqraAtJRPiGSzy3dt8o8Tjv+h2V7dOTg1gb9zm6ByE4FqWpgQ2waCmjBbv0OIbtvgl66UkpZFLie2GiwbtvqIctv5ujwKoyHlxkPeSk2ZzOUtyJtOohxNAUjTaP1MbwwWtrqAZTV1WSoiPk8rVnlBzWwyLPhpquU4AAA+o9S/ri2w8y1l++Q62lEbaHiEtoeTC6yz1wG43nSuvEt2elDwONCUiPuzSgoKkF9apjJOUzySRyrtVpPkOU8xfmzhbslGesL8OSma4U6vii3j82MhOkEMzBBTZuXYuzpSxAb6+f7PLaVd7kwCrygKTA8+CtoZKEo2LYC86kmgysv51C5h49uuLvt82jEnOm4ylMEPHPGbbYISfRuxmnMdd4K8zxNAyVq/XWo5DFkSxvKSI2WSkYZylwC4hqzs4nboWFdh7p3ZTztmJnTvTk22Xp/hJnqj+GoipPshh+mR6TSQzhHLSMX0k2Zs4wJkCFb7TocQfK768jcs9VotnbLE4Ki4DtjDUiZf+OVZCDX1k7unrZXETefhxLuluy7qUYuA0MN4GiYjZwnRW4+VIK224MNmYW7o5OYj0dLM3SNmDEYqIiA2KdSN6DYzM7bZJc2g7R2mnWRxSQyQC+kDrFnZREU9KfF6xGaX8IHWUWu9TwZWstjM7wFAwXLfIsgAkCMsfVKx0XzNGVODREUhnSpCEy4PqPHLs3HBvalbfZCUkytTg1wb3tqzXJ0k241+4anTDgn/HnvUN0TkhXHoNuWJWICIz/AMpeg+QugoNsR5qUXJnTnREH9M3Qdj09NfgzBII/wmBYPsts5jVpdMBwFgmZiyX0aRSpOflJijnlxzmgzA1x+KtsoM53SO6GULe51hpOpjyAxLAy4bqdtu05wfOLtoshIDMJPOzDdOnos9AUrw1RZ5xqXxJOX4QHtiDyhOGXaaoKxGDgWOAiTT6TJmO0YwqjcmHKSQp4RcMSQLErZ/H9w5X9WsL8o3AHQAyWKkSEc9JH95ZBQSVV89jzle1ysvkTAfy+t+PUSr/aTf0U6AODPp4b/CQDwt8UdiuFkzv3NT8ghAADhf1kVOG6K/+v4KXv/g307lTShA1/CFfL2vvGYGmdhvD3a06I11zXllgd9wZ2pdGSDsm5QmgFVjRfYUgtL8Pc9o26TsOWCy7Jofk3NlrkCzauzMAGh1Ks5i50yMz/eBKPafhqUzM62Yd2942YfBfvXGo7dhD2lYmd054SeisTNeAZrn+wS1e5WuN1au84nF8nu7y+mRreT7JL6KUVfM9sixfVD5b5mpz499apspoVHt7tzMlucVeR3nem/HjZDwYTyekFBvtKewtC4mtKiKVuw8o9HadF++Zom9G0AbRiBVMyvn7AGGAoq9Q9Xup1jOzl7mLHbpDRdoKjdZOk++zLvnLwXwNXkK0UC3NaP4SUqoSlBMwBfN3UoBEAooGDZRVOA11HBkaXdGrQLaZIrnKiwjbt1aFWP4v63puGTJ/gbhEL9jaeOMBEStHUh7R8zWaJepsoXwv+9JkDcde57g3Yr9v3V3wcmt9tip2aXvYP1PVy8kmI170tVJ/YC5v/lFSiNx0+UlHYsep3wfyXnf8oFSQcAAAA=") format("woff2"); font-weight: 700; font-display: swap; unicode-range: U+20-7E,U+2014,U+2019; } * { box-sizing: border-box; } body { font-family: system-ui, sans-serif; margin: 0; color: #222; } img { border: 0; } a, a:visited, a[href] { color: #222; } strong, b { font-weight: 600; } hr { margin: 3em 0; border: none; border-top: 1px solid #ddd; } p { max-width: 42em; line-height: 1.5; } /* Blockquotes */ blockquote { font-family: Georgia, serif; font-size: 1.1875em; /* 19px /16 */ color: #666; margin: 1.5em 0; padding: 0 1em; max-width: 31.57894736842em; /* 600px /19 */ border-left: 6px solid #ddd; /*text-indent: -0.3684210526316em;*/ /* 7px /19 */ } blockquote + blockquote { margin-top: 2em; } blockquote img { height: 1.3em; width: 1.3em; border-radius: 50%; vertical-align: text-top; margin-left: 2px; margin-right: 6px; } /* Main */ main { font-size: 1.125em; /* 18px /16 */ } main:not(:empty) { padding-bottom: 3em; margin-bottom: 3em; } /* Tables */ table { border-collapse: collapse; margin-bottom: 2em; } table th, table td { text-align: left; border-top: 1px solid #eee; border-bottom: 1px solid #eee; padding: .4em; font-size: 0.8125em; /* 13px /16 */ } table th:first-child, table td:first-child { padding-left: 0; } table th { border-color: #ddd; } h2 + table { margin-top: -0.625em; /* -10px /16 */ } @media (min-width: 37.5em) { /* 600px */ table th, table td { padding: .4em .8em; font-size: 1em; /* 16px /16 */ } } /* Headings */ h1, h2, h3, h4, h5 { font-family: BenchNine, system-ui, sans-serif; } h1 { font-size: 2.666666666667em; /* 48px /18 */ margin: 0 0 .5em; } main .elv-toc + h1 { margin-top: 1em; } main h1:first-child, main .elv-toc + h1 { border-bottom: 2px dotted #666; } @media (min-width: 80em) { main .elv-toc + h1, main .elv-toc + h2 { margin-top: 0; } } h2 { font-size: 2.222222222222em; /* 40px /18 */ border-bottom: 1px solid #ddd; margin: 1em 0 .25em; } h3 { font-size: 1.666666666667em; /* 30px /18 */ margin-bottom: .5em; } h4 { font-size: 1.444444444444em; /* 26px /18 */ margin-bottom: .5em; } h5 { font-size: 1.277777777778em; /* 23px /18 */ margin-bottom: .5em; } main h1, main h2, main h3 { text-transform: uppercase; } h1 code, h2 code, h3 code, h4 code, h5 code { font-family: inherit; text-transform: none; } /* Lists */ ul { padding: 0 1em; } li { padding: .25em 0; } li ul { list-style-type: disc; padding-left: 2em; } li li:last-child { padding-bottom: 0em; } /* Syntax highlighting and Code blocks */ pre { display: block; padding: .5em; margin: 1em -.5em 2em -.5em; overflow-x: auto; background-color: #fafafa; font-size: 0.75em; /* 12px /16 */ } pre, code { font-family: Monaco, monospace; } code { -ms-word-break: break-all; word-break: break-word; -webkit-hyphens: manual; -moz-hyphens: manual; hyphens: manual; background-color: #fafafa; } pre + pre[class*="language-"] { margin-top: 1em; } pre + .note { font-size: 0.6666666666667em; /* 16px /24 */ margin-top: -2.875em; /* 46px /16 */ margin-bottom: 2.5em; /* 40px /16 */ text-align: right; } @media (min-width: 37.5em) { /* 600px */ pre { font-size: 0.75em; /* 16px /16 */ } } #quick-start ~ .language-text { border-top: 2px solid #666; border-bottom: 2px solid #666; } @media (min-width: 42em) { /* 672px */ #quick-start ~ .language-text { border: 2px solid #666; } } #quick-start ~ .language-text, #quick-start ~ .language-text code { background-color: #fafafa; color: #222; } /* Layout */ .elv-layout { padding: 1rem; margin: 0 auto; max-width: 42rem; clear: both; } header.elv-layout { padding: 0 1rem; } footer.elv-layout { margin-bottom: 5em; } .elv-layout-full { max-width: none; } @media (min-width: 80em) { .elv-layout-toc { padding-left: 15rem; max-width: 76rem; margin-right: 1rem; position: relative; } } /*.elv-layout-wider { max-width: 60rem; }*/ /* Header */ .elv-header { position: relative; text-align: center; } .elv-header-default { display: flex; flex-direction: column; justify-content: center; align-items: center; padding-top: 0; } .elv-header-c { width: 100%; } .elv-header-docs .elv-header-c { padding: 1rem 0; } .elv-header-docs:before, .elv-header-docs:after { content: " "; display: table; } .elv-header-docs:after { clear: both; } /* Header Hero */ .elv-hero { background-color: #222; } .elv-hero img, .elv-hero svg { width: 42.95774646vh; height: 60vh; } .elv-hero:hover img, .elv-hero:hover svg { background-color: inherit; } .elv-header-default .elv-hero { display: flex; justify-content: center; width: calc(100% + 2rem); margin-left: -1rem; margin-right: -1rem; } .elv-hero:hover { background-color: #333; } .elv-header-docs .elv-hero { float: left; margin-right: .5em; } .elv-header-default .elv-hero img, .elv-header-default .elv-hero svg { position: relative; background-color: transparent; z-index: 1; } .elv-header-docs .elv-hero img, .elv-header-docs .elv-hero svg { width: auto; height: 3em; } @media (min-width: 43.75em) { /* 700px */ .elv-header-docs .elv-hero { margin-right: 1em; } .elv-header-docs .elv-hero img, .elv-header-docs .elv-hero svg { width: 4.303125em; /* 68.85px /16 */ height: 6em; } } /* Header Possum */ .elv-possum-anchor { display: block; } .elv-possum { position: absolute; right: .5rem; top: 1rem; transition: .3s opacity ease-out; } .elv-header-docs .elv-possum { width: 15vw; max-width: 6.25rem; /* 100px /16 */ } .elv-header-default { overflow: hidden; } .elv-header-default .elv-possum { pointer-events: none; width: auto; height: calc((60vh - 2rem) / 1.6); top: 36%; left: 1vw; right: auto; animation-duration: 180s; animation-name: balloonFloat; } @media (prefers-reduced-motion: reduce) { .elv-header-default .elv-possum { display: none; } } /* Navigation */ .elv-nav { padding: 0; margin: 1em 0 0 0; clear: both; list-style: none; } .elv-nav-item { float: left; padding-left: .25em; padding-right: .25em; font-size: 0.8125rem; /* 13px /16 */ } .elv-nav-item:first-child { padding-left: 0; } .elv-nav-item:last-child { padding-right: 0; } .elv-nav-item a { font-weight: 600; } .elv-nav-item .elv-nav-light { font-weight: 300; } @media (min-width: 20em) { /* 320px */ .elv-nav-item { font-size: 4vw; } } @media (min-width: 25em) { /* 400px */ .elv-nav-item { font-size: 1rem; /* 16px /16 */ padding-left: .45em; padding-right: .45em; } } @media (min-width: 35.625em) { /* 570px */ .elv-nav { clear: none; width: auto; margin-top: 0; } .elv-nav-item { float: left; padding-left: 0; padding-right: 0; } .elv-nav-item a:not(:hover) { text-decoration: none; } .elv-nav-item:not(:first-child):before { content: ""; border-left: 1px solid #ccc; padding: 0 0 0 .75em; margin-left: .75em; } } /* Version */ .latestversion { font-size: 2em; margin-top: 0; } .latestversion code { font-size: 0.75em; /* 24px /32 */ } .latestversion { font-family: BenchNine, system-ui, sans-serif; } .tmpl-docs .latestversion { position: absolute; top: 1rem; right: 1rem; margin: 0; } /* News */ .news { text-align: center; } /* Direct Links / Markdown Headers */ .direct-link { font-family: sans-serif; text-decoration: none; font-style: normal; margin-left: .1em; } a[href].direct-link, a[href].direct-link:visited { color: transparent; } a[href].direct-link:focus, a[href].direct-link:focus:visited, :hover > a[href].direct-link, :hover > a[href].direct-link:visited, :focus > a[href].direct-link, :focus > a[href].direct-link:visited { color: #aaa; } /* don’t use a direct link, should be a link to the page */ main .elv-toc + h1 .direct-link { display: none; } /* Style Guide */ .elv-sg-component { background-color: #f9f9f9; border-top: 1px dotted #ddd; border-bottom: 1px dotted #ddd; margin: 2rem 0; } /* Screen readers only */ .sr-only { position: absolute; height: 1px; width: 1px; overflow: hidden; clip: rect(1px, 1px, 1px, 1px); } /* Language List */ .elv-langlist { font-size: 0.8333333333333em; /* 15px /18 */ background-color: #f7f7f7; padding: .5rem; margin: 2em 0; } .elv-langlist-hed { margin: 0; float: left; border: none; font-size: 1.4em; /* 21px /15 */ } .elv-langlist > .inlinelist { display: inline; margin-left: 1em; } @media (min-width: 37.5em) { /* 600px */ .quicktipstoc { margin: 0 0 3% 3%; float: right; width: 32%; border-radius: .25em; font-size: 0.8125em; /* 13px /16 */ } } /* Breakpoint Overrides */ @media (max-width: 37.4375em) { /* 599px */ .bp-notsm.bp-notsm.bp-notsm.bp-notsm { display: none; } } @media (min-width: 37.5em) { /* 600px */ .bp-sm.bp-sm.bp-sm.bp-sm { display: none ; } } .header-anchor { text-decoration: none; } .header-anchor:hover::after { content: " 🔗"; } .mdi { display: inline-block; width: 1em; height: 1em; background-color: currentColor; -webkit-mask: no-repeat center / 100%; mask: no-repeat center / 100%; -webkit-mask-image: var(--svg); mask-image: var(--svg); } .mdi.mdi-content-copy { --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1Z'/%3E%3C/svg%3E"); } borgmatic/docs/_includes/layouts/000077500000000000000000000000001476361726000174365ustar00rootroot00000000000000borgmatic/docs/_includes/layouts/base.njk000066400000000000000000000017661476361726000210660ustar00rootroot00000000000000 {{ subtitle + ' - ' if subtitle}}{{ title }} {%- set css %} {% include 'index.css' %} {% include 'components/lists.css' %} {% include 'components/external-links.css' %} {% include 'components/minilink.css' %} {% include 'components/toc.css' %} {% include 'components/info-blocks.css' %} {% include 'prism-theme.css' %} {% include 'asciinema.css' %} {% endset %} {% if feedTitle and feedUrl %} {% endif %} {{ content | safe }} {% initClipboardJS %} borgmatic/docs/_includes/layouts/main.njk000066400000000000000000000022401476361726000210640ustar00rootroot00000000000000--- layout: layouts/base.njk templateClass: elv-default headerClass: elv-header-default --- {% include "header.njk" %}
{% set navPages = collections.all | eleventyNavigation %} {% macro renderNavListItem(entry) -%} {{ entry.title }} {%- if entry.children.length -%}
    {%- for child in entry.children %}{{ renderNavListItem(child) }}{% endfor -%}
{%- endif -%} {%- endmacro %}
    {%- for entry in navPages %}{{ renderNavListItem(entry) }}{%- endfor -%}
{{ content | safe }} {% include 'components/suggestion-link.html' %}
borgmatic/docs/_includes/prism-theme.css000066400000000000000000000057371476361726000207160ustar00rootroot00000000000000/** * prism.js default theme for JavaScript, CSS and HTML * Based on dabblet (http://dabblet.com) * @author Lea Verou */ /* * Modified with an approximation of the One Light syntax highlighting theme. */ code[class*="language-"], pre[class*="language-"] { color: #494b53; background: none; font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; text-align: left; white-space: pre; word-spacing: normal; word-break: normal; word-wrap: normal; line-height: 1.5; -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; } pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { text-shadow: none; color: #232324; background: #dbdbdc; } pre[class*="language-"]::selection, pre[class*="language-"] ::selection, code[class*="language-"]::selection, code[class*="language-"] ::selection { text-shadow: none; color: #232324; background: #dbdbdc; } @media print { code[class*="language-"], pre[class*="language-"] { text-shadow: none; } } /* Code blocks */ pre[class*="language-"] { padding: 1em; margin: .5em 0; overflow: auto; } :not(pre) > code[class*="language-"], pre[class*="language-"] { background: #fafafa; } /* Inline code */ :not(pre) > code[class*="language-"] { padding: .1em; border-radius: .3em; white-space: normal; } .token.comment, .token.prolog, .token.doctype, .token.cdata { color: #505157; } .token.punctuation { color: #526fff; } .token.selector, .token.tag { color: none; } .token.property, .token.boolean, .token.number, .token.constant, .token.symbol, .token.attr-name, .token.deleted { color: #986801; } .token.string, .token.char, .token.attr-value, .token.builtin, .token.inserted { color: #50a14f; } .token.operator, .token.entity, .token.url, .language-css .token.string, .style .token.string { color: #526fff; } .token.atrule, .token.keyword { color: #e45649; } .token.function { color: #4078f2; } .token.regex, .token.important, .token.variable { color: #e45649; } .token.important, .token.bold { font-weight: bold; } .token.italic { font-style: italic; } .token.entity { cursor: help; } pre.line-numbers { position: relative; padding-left: 3.8em; counter-reset: linenumber; } pre.line-numbers > code { position: relative; } .line-numbers .line-numbers-rows { position: absolute; pointer-events: none; top: 0; font-size: 100%; left: -3.8em; width: 3em; /* works for line-numbers below 1000 lines */ letter-spacing: -1px; border-right: 0; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .line-numbers-rows > span { pointer-events: none; display: block; counter-increment: linenumber; } .line-numbers-rows > span:before { content: counter(linenumber); color: #5C6370; display: block; padding-right: 0.8em; text-align: right; } borgmatic/docs/docker-compose.yaml000066400000000000000000000006621476361726000175730ustar00rootroot00000000000000services: docs: image: borgmatic-docs container_name: borgmatic-docs ports: - 8080:80 build: dockerfile: docs/Dockerfile context: .. args: ENVIRONMENT: development message: image: alpine container_name: borgmatic-docs-message command: - sh - -c - | echo; echo "You can view dev docs at http://localhost:8080"; echo depends_on: - docs borgmatic/docs/fetch-contributors000077500000000000000000000047011476361726000175450ustar00rootroot00000000000000#!/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')) def list_contributing_issues(url): response = requests.get(url, headers={'Accept': 'application/json', 'Content-Type': 'application/json'}) if not response.ok: response.raise_for_status() return tuple(response.json()) PULLS_API_ENDPOINT_URLS = ( 'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/pulls', 'https://api.github.com/repos/borgmatic-collective/borgmatic/pulls', ) ISSUES_API_ENDPOINT_URL = 'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/issues?state=all' RECENT_CONTRIBUTORS_CUTOFF_DAYS = 365 def get_item_timestamp(item): return item.get('merged_at') or item.get('created_at') 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 PULLS_API_ENDPOINT_URLS)) issues = list_contributing_issues(ISSUES_API_ENDPOINT_URL) seen_user_ids = set() print('

') for item in sorted(pulls + issues, key=get_item_timestamp, reverse=True): timestamp = get_item_timestamp(item) user = item.get('user') if not timestamp 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(timestamp) < datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=RECENT_CONTRIBUTORS_CUTOFF_DAYS): continue seen_user_ids.add(user_id) print( f'''''' ) print('

') if __name__ == '__main__': print_contributors() borgmatic/docs/how-to/000077500000000000000000000000001476361726000152065ustar00rootroot00000000000000borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md000066400000000000000000000124151476361726000261740ustar00rootroot00000000000000--- title: How to add preparation and cleanup steps to backups eleventyNavigation: key: 🧹 Add preparation and cleanup steps parent: How-to guides order: 10 --- ## Preparation and cleanup hooks If you find yourself performing preparation tasks before your backup runs or cleanup work afterwards, borgmatic command hooks may be of interest. These are custom shell commands you can configure borgmatic to execute at various points as it runs. But if you're looking to backup a database, it's probably easier to use the [database backup feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/) instead. You can specify `before_backup` hooks to perform preparation steps before running backups and specify `after_backup` hooks to perform cleanup steps afterwards. Here's an example: ```yaml before_backup: - mount /some/filesystem after_backup: - umount /some/filesystem ``` If your command contains a special YAML character such as a colon, you may need to quote the entire string (or use a [multiline string](https://yaml-multiline.info/)) to avoid an error: ```yaml before_backup: - "echo Backup: start" ``` There are additional hooks that run before/after other actions as well. For instance, `before_prune` runs before a `prune` action for a repository, while `after_prune` runs after it. Prior to version 1.8.0 Put these options in the `hooks:` section of your configuration. New in version 1.7.0 The `before_actions` and `after_actions` hooks run before/after all the actions (like `create`, `prune`, etc.) for each repository. These hooks are a good place to run per-repository steps like mounting/unmounting a remote filesystem. New in version 1.6.0 The `before_backup` and `after_backup` hooks each run once per repository in a configuration file. `before_backup` hooks runs right before the `create` action for a particular repository, and `after_backup` hooks run afterwards, but not if an error occurs in a previous hook or in the backups themselves. (Prior to borgmatic 1.6.0, these hooks instead ran once per configuration file rather than once per repository.) ## Variable interpolation The before and after action hooks support interpolating particular runtime variables into the hook command. Here's an example that assumes you provide a separate shell script: ```yaml after_prune: - record-prune.sh "{configuration_filename}" "{repository}" ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. In this example, when the hook is triggered, borgmatic interpolates runtime values into the hook command: the borgmatic configuration filename and the paths of the current Borg repository. Here's the full set of supported variables you can use here: * `configuration_filename`: borgmatic configuration filename in which the hook was defined * `log_file` New in version 1.7.12: 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` New in version 1.8.12: 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/). ## Global hooks You can also use `before_everything` and `after_everything` hooks to perform global setup or cleanup: ```yaml before_everything: - set-up-stuff-globally after_everything: - clean-up-stuff-globally ``` Prior to version 1.8.0 Put these options in the `hooks:` section of your configuration. `before_everything` hooks collected from all borgmatic configuration files run once before all configuration files (prior to all actions), but only if there is a `create` action. An error encountered during a `before_everything` hook causes borgmatic to exit without creating backups. `after_everything` hooks run once after all configuration files and actions, but only if there is a `create` action. It runs even if an error occurs during a backup or a backup hook, but not if an error occurs during a `before_everything` hook. ## Error hooks borgmatic also runs `on_error` hooks if an error occurs, either when creating a backup or running a backup hook. See the [monitoring and alerting documentation](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/) for more information. ## Hook output Any output produced by your hooks shows up both at the console and in syslog (when enabled). For more information, read about inspecting your backups. ## Security An important security note about hooks: borgmatic executes all hook commands with the user permissions of borgmatic itself. So to prevent potential shell injection or privilege escalation, do not forget to set secure permissions on borgmatic configuration files (`chmod 0600`) and scripts (`chmod 0700`) invoked by hooks. borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md000066400000000000000000000141761476361726000277670ustar00rootroot00000000000000--- 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: 11 --- ## Occasional backups A common situation is backing up to a repository that's only sometimes online. For instance, you might send most of your backups to the cloud, but occasionally you want to plug in an external hard drive or backup to your buddy's sometimes-online server for that extra level of redundancy. But if you run borgmatic and your hard drive isn't plugged in, or your buddy's server is offline, then you'll get an annoying error message and the overall borgmatic run will fail (even if individual repositories still complete). Another variant is when the source machine is only sometimes available for backups, e.g. a laptop where you want to skip backups when the battery falls below a certain level. So what if you want borgmatic to swallow the error of a missing drive or an offline server or a low battery—and exit gracefully? That's where the concept of "soft failure" come in. ## Soft failure command hooks This feature leverages [borgmatic command hooks](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/), so first familiarize yourself with them. The idea is that you write a simple 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 the current repository. Prior to version 1.9.0 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 file](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/), say at `/etc/borgmatic.d/removable.yaml`: ```yaml source_directories: - /home repositories: - path: /mnt/removable/backup.borg ``` Prior to version 1.8.0 Put these options in the `location:` section of your configuration. Prior to version 1.7.10 Omit the `path:` portion of the `repositories` list. Then, write a `before_backup` hook in that same configuration file that uses the external `findmnt` utility to see whether the drive is mounted before proceeding. ```yaml before_backup: - findmnt /mnt/removable > /dev/null || exit 75 ``` Prior to version 1.8.0 Put this 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 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. You can imagine a similar check for the sometimes-online server case: ```yaml source_directories: - /home repositories: - path: ssh://me@buddys-server.org/./backup.borg before_backup: - ping -q -c 1 buddys-server.org > /dev/null || exit 75 ``` Or to only run backups if the battery level is high enough: ```yaml before_backup: - is_battery_percent_at_least.sh 25 ``` (Writing the battery script is left as an exercise to the reader.) New in version 1.7.0 The `before_actions` and `after_actions` hooks run before/after all the actions (like `create`, `prune`, etc.) for each repository. So if you'd like your soft failure command hook to run regardless of action, consider using `before_actions` instead of `before_backup`. ## Caveats and details There are some caveats you should be aware of with this feature. * You'll generally want to put a soft failure command in the `before_backup` hook, so as to gate whether the backup action occurs. While a soft failure is also supported in the `after_backup` hook, returning a soft failure there won't prevent any actions from occurring, because they've already occurred! Similarly, you can return a soft failure from an `on_error` hook, but at that point it's too late to prevent the error. * Returning a soft failure does prevent further commands in the same hook from 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. * 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`. borgmatic/docs/how-to/backup-your-databases.md000066400000000000000000000573471476361726000217360ustar00rootroot00000000000000--- title: How to backup your databases eleventyNavigation: key: 🗄️ Backup your databases parent: How-to guides order: 8 --- ## Database dump hooks If you want to backup a database, it's best practice with most database systems to backup an exported database dump, rather than backing up your database's internal file storage. That's because the internal storage can change while you're reading from it. In contrast, a database dump creates a consistent snapshot that is more suited for backups. Fortunately, borgmatic includes built-in support for creating database dumps prior to running backups. For example, here is everything you need to dump and backup a couple of local PostgreSQL databases and a MySQL database. ```yaml postgresql_databases: - name: users - name: orders mysql_databases: - name: posts ``` Prior to version 1.8.0 Put these and other database options in the `hooks:` section of your configuration. New in version 1.5.22 You can also dump MongoDB databases. For example: ```yaml mongodb_databases: - name: messages ``` New in version 1.7.9 Additionally, you can dump SQLite databases. For example: ```yaml sqlite_databases: - name: mydb path: /var/lib/sqlite3/mydb.sqlite ``` New in version 1.8.2 If you're using MariaDB, use the MariaDB database hook instead of `mysql_databases:` as the MariaDB hook calls native MariaDB commands instead of the deprecated MySQL ones. For instance: ```yaml mariadb_databases: - name: comments ``` As part of each backup, borgmatic streams a database dump for each configured database directly to Borg, so it's included in the backup without consuming additional disk space. (The exceptions are the PostgreSQL/MongoDB `directory` 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.) 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: ```yaml postgresql_databases: - name: users hostname: database1.example.org - name: orders hostname: database2.example.org port: 5433 username: postgres password: trustsome1 format: tar options: "--role=someone" mariadb_databases: - name: photos hostname: database3.example.org port: 3307 username: root password: trustsome1 options: "--skip-comments" mysql_databases: - name: posts hostname: database4.example.org port: 3307 username: root password: trustsome1 options: "--skip-comments" mongodb_databases: - name: messages hostname: database5.example.org port: 27018 username: dbuser password: trustsome1 authentication_database: mongousers options: "--ssl" sqlite_databases: - name: mydb path: /var/lib/sqlite3/mydb.sqlite ``` See your [borgmatic configuration file](https://torsion.org/borgmatic/docs/reference/configuration/) for additional customization of the options passed to database commands (when listing databases, restoring databases, etc.). ### Runtime directory New in version 1.9.0 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. New in version 1.9.2The `RUNTIME_DIRECTORY` environment variable, set by systemd if `RuntimeDirectory=borgmatic` is added to borgmatic's systemd service file. 4. New in version 1.9.1The `TMPDIR` environment variable, set on macOS for a user with a session, among other operating systems. 5. New in version 1.9.1The `TEMP` environment variable, set on various systems. 6. New in version 1.9.2 Hard-coded `/tmp`. Prior to version 1.9.2This was instead hard-coded to `/run/user/$UID`. You can see the runtime directory path that borgmatic selects by running with `--verbosity 2` and looking for "Using runtime directory" in the output. 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. Prior to version 1.9.0 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: ```yaml postgresql_databases: - name: all mariadb_databases: - name: all mysql_databases: - name: all mongodb_databases: - name: all ``` Note that you may need to use a `username` of the `postgres` superuser for this to work with PostgreSQL. The SQLite hook in particular does not consider "all" a special database name. Prior to version 1.8.0 Put these options in the `hooks:` section of your configuration. New in version 1.7.6 With PostgreSQL, MariaDB, and MySQL, you can optionally dump "all" databases to separate files instead of one combined dump file, allowing more convenient restores of individual databases. Enable this by specifying your desired database dump `format`: ```yaml postgresql_databases: - name: all format: custom mariadb_databases: - name: all format: sql mysql_databases: - name: all format: sql ``` ### Containers If your database is running within a container and borgmatic is too, no problem—configure borgmatic to connect to the container's name on its exposed port. For instance: ```yaml postgresql_databases: - name: users hostname: your-database-container-name port: 5433 username: postgres password: trustsome1 ``` Prior to version 1.8.0 Put these options in the `hooks:` section of your configuration. But what if borgmatic is running on the host? You can still connect to a database container if its ports are properly exposed to the host. For instance, when running the database container, you can specify `--publish 127.0.0.1:5433:5432` so that it exposes the container's port 5432 to port 5433 on the host (only reachable on localhost, in this case). Or the same thing with Docker Compose: ```yaml services: your-database-container-name: image: postgres ports: - 127.0.0.1:5433:5432 ``` And then you can connect to the database from borgmatic running on the host: ```yaml hooks: postgresql_databases: - name: users hostname: 127.0.0.1 port: 5433 username: postgres password: trustsome1 ``` Alter the ports in these examples to suit your particular database system. Normally, borgmatic dumps a database by running a database dump command (e.g. `pg_dump`) on the host or wherever borgmatic is running, and this command 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. 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: ```yaml hooks: postgresql_databases: - name: users hostname: 127.0.0.1 port: 5433 username: postgres password: trustsome1 pg_dump_command: docker exec my_pg_container pg_dump ``` ... 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. If you choose to use the `pg_dump` command within the container, and you're using the `directory` format in particular, you'll also need to mount the [runtime directory](#runtime-directory) from your host into the container at the same path. Otherwise, the `directory` format dump will remain locked away inside the database container where Borg can't read it. For example, with Docker Compose and a runtime directory located at `/run/user/1000`: ```yaml services: db: image: postgres volumes: - /run/user/1000:/run/user/1000 ``` Similar command override options are available for (some of) the other supported database types as well. See the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/) for details. ### No source directories New in version 1.7.1 If you would like to backup databases only and not source directories, you can omit `source_directories` entirely. Prior to version 1.7.1 In older versions of borgmatic, instead specify an empty `source_directories` value, as it is a mandatory option there: ```yaml location: source_directories: [] hooks: mysql_databases: - name: all ``` ### External passwords If you don't want to keep your database passwords in your borgmatic configuration file, you can instead pass them in [from external credential sources](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/). ### Configuration backups An important note about this database configuration: You'll need the configuration to be present in order for borgmatic to restore a database. So to prepare for this situation, it's a good idea to include borgmatic's own configuration files as part of your regular backups. That way, you can always bring back any missing configuration files in order to restore a database. New in version 1.7.15 borgmatic automatically includes configuration files in your backup. See [the documentation on the `config bootstrap` action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive) for more information. ## Supported databases As of now, borgmatic supports PostgreSQL, MariaDB, MySQL, MongoDB, and SQLite databases directly. But see below about general-purpose preparation and cleanup hooks as a work-around with other database systems. Also, please [file a ticket](https://torsion.org/borgmatic/#issues) for additional database systems that you'd like supported. ## Database restoration When you want to replace an existing database with its backed-up contents, you can restore it with borgmatic. Note that the database must already exist; 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 `repo-list` action: ```bash borgmatic repo-list ``` (No borgmatic `repo-list` action? Try `rlist` or `list` instead or upgrade borgmatic!) That should yield output looking something like: ```text host-2023-01-01T04:05:06.070809 Tue, 2023-01-01 04:05:06 [...] host-2023-01-02T04:06:07.080910 Wed, 2023-01-02 04:06:07 [...] ``` Assuming that you want to restore all database dumps from the archive with the most up-to-date files and therefore the latest timestamp, run a command like: ```bash borgmatic restore --archive host-2023-01-02T04:06:07.080910 ``` (No borgmatic `restore` action? Upgrade borgmatic!) Or you can simplify this to: ```bash borgmatic restore --archive latest ``` 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 you run it. ### Repository selection If you have a single repository in your borgmatic configuration file(s), no problem: the `restore` action figures out which repository to use. But if you have multiple repositories configured, then you'll need to specify the repository to use via the `--repository` flag. This can be done either with the repository's path or its label as configured in your borgmatic configuration file. ```bash borgmatic restore --repository repo.borg --archive latest ``` ### Restore particular databases If you've backed up multiple databases into an archive, and you'd only like to restore one of them, use the `--database` flag to select one or more databases. For instance: ```bash borgmatic restore --archive latest --database users --database orders ``` New in version 1.7.6 You can 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 New in version 1.9.5 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 latest --database all ``` Or omit the `--database` flag entirely: ```bash borgmatic restore --archive latest ``` New in version 1.7.6 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. Prior to borgmatic version 1.7.6, restoring "all" only restored a combined "all" database dump from the archive. ### Restore particular schemas New in version 1.7.13 With PostgreSQL and MongoDB, you can limit the restore to a single schema found within the database dump: ```bash borgmatic restore --archive latest --database users --schema tentant1 ``` ### Restore to an alternate host New in version 1.7.15 A database dump can be restored to a host other than the one from which it was originally dumped. The connection parameters like the username, password, and port can also be changed. This can be done from the command line: ```bash borgmatic restore --archive latest --database users --hostname database2.example.org --port 5433 --username postgres --password trustsome1 ``` Or from the configuration file: ```yaml postgresql_databases: - name: users hostname: database1.example.org restore_hostname: database1.example.org restore_port: 5433 restore_username: postgres restore_password: trustsome1 ``` ### 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 `borgmatic/` directory within the extraction destination path. For example, if you're extracting to `/tmp`, then the dump will be in `/tmp/borgmatic/`. Prior to version 1.9.0 borgmatic extracted 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. 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. Prior to version 1.7.3Special 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. Prior to version 1.9.5 borgmatic did not support backing up or restoring multiple databases that shared the exact same name on different hosts or with different ports. 3. Prior to version 1.9.0 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. Prior to version 1.9.0 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. Prior to version 1.7.15 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 general-purpose [preparation and cleanup hooks](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/). These hooks allows you to trigger arbitrary commands or scripts before and after backups. So if necessary, you can use these hooks to create database dumps with any database system. ## Troubleshooting ### Authentication errors 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. 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 authenticated. For instance, with PostgreSQL, check your [pg_hba.conf](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) file for that configuration. Additionally, MariaDB or MySQL may be picking up some of your credentials from a defaults file like `~/mariadb.cnf` or `~/.my.cnf`. If that's the case, then it's possible MariaDB or MySQL end up using, say, a username from borgmatic's configuration and a password from `~/mariadb.cnf` or `~/.my.cnf`. This may result in authentication errors if this combination of credentials is not what you intend. ### MariaDB or MySQL table lock errors If you encounter table lock errors during a database dump with MariaDB or MySQL, you may need to [use a transaction](https://mariadb.com/docs/skysql-dbaas/ref/mdb/cli/mariadb-dump/single-transaction/). You can add any additional flags to the `options:` in your database configuration. Here's an example with MariaDB: ```yaml mariadb_databases: - name: posts options: "--single-transaction --quick" ``` ### borgmatic hangs during backup See Limitations above about `read_special`. You may need to exclude certain paths with named pipes, block devices, character devices, or sockets on which borgmatic is hanging. Alternatively, if excluding special files is too onerous, you can create two separate borgmatic configuration files—one for your source files and a separate one for backing up databases. That way, the database `read_special` option will not be active when backing up special files. New in version 1.7.3 See Limitations above about borgmatic's automatic exclusion of special files to prevent Borg hangs. borgmatic/docs/how-to/customize-warnings-and-errors.md000066400000000000000000000054031476361726000234540ustar00rootroot00000000000000--- title: How to customize warnings and errors eleventyNavigation: key: 💥 Customize warnings/errors parent: How-to guides order: 13 --- ## When things go wrong After Borg runs, it indicates whether it succeeded via its exit code, a numeric ID indicating success, warning, or error. borgmatic consumes this exit code to decide how to respond. Normally, a Borg error results in a borgmatic error, while a Borg warning or success doesn't. But if that default behavior isn't sufficient for your needs, you can customize how borgmatic interprets [Borg's exit codes](https://borgbackup.readthedocs.io/en/stable/usage/general.html#return-codes). For instance, to elevate Borg warnings to errors, thereby causing borgmatic to error on them, use the following borgmatic configuration: ```yaml borg_exit_codes: - exit_code: 1 treat_as: error ``` Be aware though that Borg exits with a warning code for a variety of benign situations such as files changing while they're being read, so this example may not meet your needs. Keep reading though for more granular exit code configuration. Here's an example that squashes Borg errors to warnings: ```yaml borg_exit_codes: - exit_code: 2 treat_as: warning ``` Be careful with this example though, because it prevents borgmatic from erroring when Borg errors, which may not be desirable. ### More granular configuration New in Borg version 1.4 Borg 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/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 permission warnings (exit code `105`)—and only those warnings—to errors: ```yaml borg_exit_codes: - exit_code: 105 treat_as: error ``` The following configuration does that *and* elevates backup file not found warnings (exit code `107`) to errors as well: ```yaml borg_exit_codes: - exit_code: 105 treat_as: error - exit_code: 107 treat_as: error ``` If you don't know the exit code for a particular Borg error or warning you're experiencing, you can usually find it in your borgmatic output when `--verbosity 2` is enabled. For instance, here's a snippet of that output when a backup file is not found: ``` /noexist: stat: [Errno 2] No such file or directory: '/noexist' ... terminating with warning status, rc 107 ``` So if you want to configure borgmatic to treat this as an error instead of a warning, the exit status to use is `107`. borgmatic/docs/how-to/deal-with-very-large-backups.md000066400000000000000000000360021476361726000231100ustar00rootroot00000000000000--- title: How to deal with very large backups eleventyNavigation: key: 📏 Deal with very large backups parent: How-to guides order: 4 --- ## Biggish data Borg itself is great for efficiently de-duplicating data across successive backup archives, even when dealing with very large repositories. But you may find that while borgmatic's default actions of `create`, `prune`, `compact`, and `check` works well on small repositories, it's not so great on larger ones. That's because running the default pruning, compact, and consistency checks take a long time on large repositories. Prior to version 1.7.9 The 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 `create`, `prune`, `compact`, or `check` actions separately. For instance, the following optional actions are available (among others): ```bash borgmatic create borgmatic prune borgmatic compact borgmatic check ``` You can run borgmatic with only one of these actions provided, or you can mix and match any number of them in a single borgmatic run. This supports approaches like skipping certain actions while running others. For instance, this skips `prune` and `compact` and only runs `create` and `check`: ```bash borgmatic create check ``` New in version 1.7.9 borgmatic now respects your specified command-line action order, running actions in the order you specify. In previous versions, borgmatic ran your specified actions in a fixed ordering regardless of the order they appeared on the command-line. But instead of running actions together, another option is to run backups with `create` on a frequent schedule (e.g. with `borgmatic create` called from one cron job), while only running expensive consistency checks with `check` on a much less frequent basis (e.g. with `borgmatic check` called from a separate cron job). New in version 1.8.5 Instead of (or in addition to) specifying actions on the command-line, you can configure borgmatic to [skip particular actions](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions). ### Consistency check configuration 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, 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 `consistency` section of borgmatic configuration: ```yaml checks: - name: repository ``` Prior to version 1.8.0 Put this option in the `consistency:` section of your configuration. Prior to version 1.6.2 The `checks` option was a plain list of strings without the `name:` part, and borgmatic ran each configured check every time checks were run. For example: ```yaml checks: - repository ``` Here are the available checks, roughly from fastest to slowest: * `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. 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. See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information. ### Spot check 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 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. New in version 1.8.10 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. 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 efficiently hash files for comparison. Then add something like the following to your borgmatic configuration: ```yaml checks: - name: spot count_tolerance_percentage: 10 data_sample_percentage: 1 data_tolerance_percentage: 0.5 ``` The `count_tolerance_percentage` is the percentage delta between the source directories file count and the latest backup archive file count that is allowed before the entire consistency check fails. For instance, if the spot check runs and finds 100 source files on disk and 105 files in the latest archive, that would be within the configured 10% count tolerance and the check would succeed. But if there were 100 source files and 200 archive files, the check would fail. (100 source files and only 50 archive files would also 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 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 `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 set-it-and-forget-it type of consistency check, at least until you get the tolerances dialed in so there are minimal false positives or negatives. It is recommended you run `borgmatic check` several times after configuring the spot check, tweaking your tolerances as needed. For certain workloads where your source files experience wild swings of file contents or counts, the spot check may not suitable at all. What if you add, delete, or change a bunch of your source files and you don't 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. 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 New in version 1.6.2 You can optionally configure checks to run on a periodic basis rather than every time borgmatic runs checks. For instance: ```yaml checks: - name: repository frequency: 2 weeks - name: archives frequency: 1 month ``` Prior to version 1.8.0 Put this option in the `consistency:` section of your configuration. This tells borgmatic to run the `repository` consistency check at most once every two weeks for a given repository and the `archives` check at most once a month. The `frequency` value is a number followed by a unit of time, e.g. `3 days`, `1 week`, `2 months`, etc. The set of possible time units is as follows (singular or plural): * `second` * `minute` * `hour` * `day` * `week` (7 days) * `month` (30 days) * `year` (365 days) The `frequency` defaults to `always` for a check configured without a `frequency`, which means run this check every time checks run. But if you omit consistency checks from configuration entirely, borgmatic runs full-repository checks (`repository`) and per-archive checks (`archives`) within each 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 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. New in version 1.9.0To 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. New in version 1.9.2The `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. Prior to version 1.9.0 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. New in version 1.8.6 `borgmatic check --force` runs `check` even if it's specified in the `skip_actions` option. ### Check days New in version 1.8.13 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 New in version 1.7.1 If you would like to only run consistency checks without creating backups (for instance with the `check` action on the command-line), you can omit the `source_directories` option entirely. Prior to version 1.7.1 In older versions of borgmatic, instead specify an empty `source_directories` value, as it is a mandatory option there: ```yaml location: source_directories: [] ``` ### Disabling checks If that's still too slow, you can disable consistency checks entirely, either for a single repository or for all repositories. New in version 1.8.5 Disabling all consistency checks looks like this: ```yaml skip_actions: - check ``` Prior to version 1.8.5 Use this configuration instead: ```yaml checks: - name: disabled ``` Prior to version 1.8.0 Put `checks:` in the `consistency:` section of your configuration. Prior to version 1.6.2 `checks:` was a plain list of strings without the `name:` part. For instance: ```yaml checks: - disabled ``` If you have multiple repositories in your borgmatic configuration file, you can keep running consistency checks, but only against a subset of the repositories: ```yaml check_repositories: - path/of/repository_to_check.borg ``` Finally, you can override your configuration file's consistency checks and run particular checks via the command-line. For instance: ```bash borgmatic check --only data --only extract ``` This is useful for running slow consistency checks on an infrequent basis, separate from your regular checks. It is still subject to any configured check frequencies unless the `--force` flag is used. ## Troubleshooting ### Broken pipe with remote repository When running borgmatic on a large remote repository, you may receive errors like the following, particularly while "borg check" is validating backups for consistency: ```text Write failed: Broken pipe borg: Error: Connection closed by remote host ``` This error can be caused by an ssh timeout, which you can rectify by adding the following to the `~/.ssh/config` file on the client: ```text Host * ServerAliveInterval 120 ``` This should make the client keep the connection alive while validating backups. borgmatic/docs/how-to/develop-on-borgmatic.md000066400000000000000000000164721476361726000215570ustar00rootroot00000000000000--- title: How to develop on borgmatic eleventyNavigation: key: 🏗️ Develop on borgmatic parent: How-to guides 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 easier without impacting other Python applications on your system. Then, clone borgmatic via HTTPS or SSH: ```bash git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git ``` Or: ```bash git clone ssh://git@projects.torsion.org:3022/borgmatic-collective/borgmatic.git ``` Finally, install borgmatic "[editable](https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs)" so that you can run borgmatic actions during development to make sure your changes work: ```bash cd borgmatic pipx ensurepath pipx install --editable . ``` Or to work on the [Apprise hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook), change that last line to: ```bash pipx install --editable .[Apprise] ``` To get oriented with the borgmatic source code, have a look at the [source code reference](https://torsion.org/borgmatic/docs/reference/source-code/). ## Automated tests Assuming you've cloned the borgmatic source code as described above and you're in the `borgmatic/` working copy, install tox, which is used for setting up testing environments. You can either install a system package of tox (likely called `tox` or `python-tox`) or you can install tox with pipx: ```bash pipx install tox ``` Finally, to actually run tests, run tox from inside the borgmatic sourcedirectory: ```bash tox ``` ### Code formatting If when running tests, you get an error from the [Black](https://black.readthedocs.io/en/stable/) code formatter about files that would be reformatted, you can ask Black to format them for you via the following: ```bash tox -e black ``` And if you get a complaint from the [isort](https://github.com/timothycrosley/isort) Python import orderer, you can ask isort to order your imports for you: ```bash tox -e isort ``` Similarly, if you get errors about spelling mistakes in source code, you can ask [codespell](https://github.com/codespell-project/codespell) to correct them: ```bash tox -e codespell ``` ### End-to-end tests borgmatic additionally includes some end-to-end tests that integration test with Borg and supported databases for a few representative scenarios. These tests don't run by default when running `tox`, because they're relatively slow 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 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 ``` This script assumes you have permission to run `docker`. If you don't, then you may need to run with `sudo`. #### Podman New in version 1.7.12 borgmatic's end-to-end tests optionally support using [rootless](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) [Podman](https://podman.io/) instead of Docker. Setting up Podman is outside the scope of this documentation, but here are some key points to double-check: * Install Podman and your desired networking support. * Configure `/etc/subuid` and `/etc/subgid` to map users/groups for the non-root user who will run tests. * Create a non-root Podman socket for that user: ```bash systemctl --user enable --now podman.socket systemctl --user start --now podman.socket ``` Then you'll be able to run end-to-end tests as per normal, and the test script will automatically use your non-root Podman socket instead of a Docker socket. ## Code style Start with [PEP 8](https://www.python.org/dev/peps/pep-0008/). But then, apply the following deviations from it: * For strings, prefer single quotes over double quotes. * Limit all lines to a maximum of 100 characters. * Use trailing commas within multiline values or argument lists. * For multiline constructs, put opening and closing delimiters on lines separate from their contents. * Within multiline constructs, use standard four-space indentation. Don't align indentation with an opening delimiter. * 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 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 are enforced when running automated tests. See the Black, Flake8, and isort documentation for more information. ## Continuous integration Each commit to [main](https://projects.torsion.org/borgmatic-collective/borgmatic/branches) triggers [a continuous integration build](https://projects.torsion.org/borgmatic-collective/borgmatic/actions) which runs the test suite and updates [documentation](https://torsion.org/borgmatic/). These builds are also linked from the [commits for the main branch](https://projects.torsion.org/borgmatic-collective/borgmatic/commits/branch/main). ## Documentation development Updates to borgmatic's documentation are welcome. It's formatted in Markdown and located in the `docs/` directory in borgmatic's source, plus the `README.md` file at the root. To build and view a copy of the documentation with your local changes, run the following from the root of borgmatic's source code: ```bash scripts/dev-docs ``` This requires Docker (or Podman; see below) to be installed on your system. This script assumes you have permission to run `docker`. If you don't, then you may need to run with `sudo`. After you run the script, you can point your web browser at http://localhost:8080 to view the documentation with your changes. To close the documentation server, ctrl-C the script. Note that it does not currently auto-reload, so you'll need to stop it and re-run it for any additional documentation changes to take effect. #### Podman New in version 1.7.12 borgmatic's developer build for documentation optionally supports using [rootless](https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md) [Podman](https://podman.io/) instead of Docker. Setting up Podman is outside the scope of this documentation. But once you install and configure Podman, then `scripts/dev-docs` should automatically use Podman instead of Docker. borgmatic/docs/how-to/extract-a-backup.md000066400000000000000000000172521476361726000206720ustar00rootroot00000000000000--- title: How to extract a backup eleventyNavigation: key: 📤 Extract a backup parent: How-to guides order: 7 --- ## Extract 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 `repo-list` action: ```bash borgmatic repo-list ``` (No borgmatic `repo-list` action? Try `rlist` or `list` instead or upgrade borgmatic!) That should yield output looking something like: ```text host-2023-01-01T04:05:06.070809 Tue, 2023-01-01 04:05:06 [...] host-2023-01-02T04:06:07.080910 Wed, 2023-01-02 04:06:07 [...] ``` Assuming that you want to extract the archive with the most up-to-date files and therefore the latest timestamp, run a command like: ```bash borgmatic extract --archive host-2023-01-02T04:06:07.080910 ``` (No borgmatic `extract` action? Upgrade borgmatic!) Or simplify this to: ```bash borgmatic extract --archive latest ``` 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 If you have a single repository in your borgmatic configuration file(s), no problem: the `extract` action figures out which repository to use. But if you have multiple repositories configured, then you'll need to specify the repository to use via the `--repository` flag. This can be done either with the repository's path or its label as configured in your borgmatic configuration file. ```bash borgmatic extract --repository repo.borg --archive host-2023-... ``` ## Extract particular files Sometimes, you want to extract a single deleted file, rather than extracting everything from an archive. To do that, tack on one or more `--path` values. For instance: ```bash borgmatic extract --archive latest --path path/1 --path path/2 ``` Note that the specified restore paths should not have a leading slash. Like a whole-archive extract, this also extracts into the current directory by default. So for example, if you happen to be in the directory `/var` and you run the `extract` command above, borgmatic will extract `/var/path/1` and `/var/path/2`. ### Searching for files If you're not sure which archive contains the files you're looking for, you can [search across archives](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file). ## Extract to a particular destination By default, borgmatic extracts files into the current directory. To instead extract files to a particular destination directory, use the `--destination` flag: ```bash borgmatic extract --archive latest --destination /tmp ``` When using the `--destination` flag, be careful not to overwrite your system's files with extracted files unless that is your intent. ## Database restoration The `borgmatic extract` command only extracts files. To restore a database, please see the [documentation on database backups and restores](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/). borgmatic does not perform database restoration as part of `borgmatic extract` so that you can extract files from your archive without impacting your live databases. ## Mount a filesystem If instead of extracting files, you'd like to explore the files from an archive as a [FUSE](https://en.wikipedia.org/wiki/Filesystem_in_Userspace) filesystem, you can use the `borgmatic mount` action. Here's an example: ```bash borgmatic mount --archive latest --mount-point /mnt ``` This mounts the entire archive on the given mount point `/mnt`, so that you can look in there for your files. Omit the `--archive` flag to mount all archives (lazy-loaded): ```bash borgmatic mount --mount-point /mnt ``` Or use the "latest" value for the archive to mount the latest archive: ```bash borgmatic mount --archive latest --mount-point /mnt ``` New in borgmatic version 1.9.0 with Borg version 2.xYou 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: ```bash borgmatic mount --archive latest --mount-point /mnt --path var/lib ``` When you're all done exploring your files, unmount your mount point. No `--archive` flag is needed: ```bash borgmatic umount --mount-point /mnt ``` ## Extract the configuration files used to create an archive New in version 1.7.15 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: ```bash borgmatic config bootstrap --repository repo.borg --destination /tmp ``` This extracts the configuration file from the latest archive in the repository `repo.borg` to `/tmp/etc/borgmatic/config.yaml`, assuming that the only configuration file used to create this archive was located at `/etc/borgmatic/config.yaml` when the archive was created. Note that to run the `config bootstrap` action, you don't need to have a borgmatic configuration file. You only need to specify the repository to use via the `--repository` flag; borgmatic will figure out the rest. If a destination directory is not specified, the configuration files will be extracted to their original locations, silently *overwriting* any configuration files that may already exist. For example, if a configuration file was located at `/etc/borgmatic/config.yaml` when the archive was created, it will be extracted to `/etc/borgmatic/config.yaml` too. If you want to extract the configuration file from a specific archive, use the `--archive` flag: ```bash borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:06:07.080910 --destination /tmp ``` See the output of `config bootstrap --help` for additional flags you may need for bootstrapping. New in version 1.9.3 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: ```yaml bootstrap: store_config_files: false ``` If you do this though, the `config bootstrap` action will no longer work. In version 1.8.1 through 1.9.2 The `store_config_files` option was at the global scope instead of under the `bootstrap` hook. New in version 1.8.7 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. borgmatic/docs/how-to/index.md000066400000000000000000000001151476361726000166340ustar00rootroot00000000000000--- eleventyNavigation: key: How-to guides order: 1 permalink: false --- borgmatic/docs/how-to/inspect-your-backups.md000066400000000000000000000163661476361726000216330ustar00rootroot00000000000000--- title: How to inspect your backups eleventyNavigation: key: 🔎 Inspect your backups parent: How-to guides order: 5 --- ## Backup progress By default, borgmatic runs proceed silently except in the case of errors. But if you'd like to to get additional information about the progress of the backup as it proceeds, use the verbosity option: ```bash borgmatic --verbosity 1 ``` This lists the files that borgmatic is archiving, which are those that are new or changed since the last backup. Or, for even more progress and debug spew: ```bash borgmatic --verbosity 2 ``` The full set of verbosity levels are: * `-2`: disable output entirely New in borgmatic 1.7.14 * `-1`: only show errors * `0`: default output * `1`: some additional output (informational level) * `2`: lots of additional output (debug level) ## Backup summary If you're less concerned with progress during a backup, and you only want to see the summary of archive statistics at the end, you can use the stats option when performing a backup: ```bash borgmatic --stats ``` ## Existing backups borgmatic provides convenient actions for Borg's [`list`](https://borgbackup.readthedocs.io/en/stable/usage/list.html) and [`info`](https://borgbackup.readthedocs.io/en/stable/usage/info.html) functionality: ```bash borgmatic list borgmatic info ``` You can change the output format of `borgmatic list` by specifying your own with `--format`. Refer to the [borg list --format documentation](https://borgbackup.readthedocs.io/en/stable/usage/list.html#the-format-specifier-syntax) for available values. (No borgmatic `list` or `info` actions? Upgrade borgmatic!) New in version 1.9.0 There are also `repo-list` and `repo-info` actions for displaying repository information with Borg 2.x: ```bash borgmatic repo-list borgmatic repo-info ``` See the [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/) for more information. ### Searching for a file New in version 1.6.3 Let's say you've accidentally deleted a file and want to find the backup archive(s) containing it. `borgmatic list` provides a `--find` flag for exactly this purpose. For instance, if you're looking for a `foo.txt`: ```bash borgmatic list --find foo.txt ``` This will list your archives and indicate those with files matching `*foo.txt*` anywhere in the archive. The `--find` parameter can alternatively be a [Borg pattern](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-patterns). To limit the archives searched, use the standard `list` parameters for filtering archives such as `--last`, `--archive`, `--match-archives`, etc. For example, to search only the last five archives: ```bash borgmatic list --find foo.txt --last 5 ``` ## Listing database dumps If you have enabled borgmatic's [database 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 ``` This gives you a listing of all database dump files contained in the latest archive, complete with file sizes. New in borgmatic version 1.9.0Database 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 `/`.) With Borg version 1.2 and earlierDatabase 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. Prior to borgmatic version 1.9.0Database 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 By default, borgmatic logs to the console. You can enable simultaneous syslog logging and customize its log level with the `--syslog-verbosity` flag, which is independent from the console logging `--verbosity` flag described above. For instance, to enable syslog logging, run: ```bash borgmatic --syslog-verbosity 1 ``` To increase syslog logging further to include debugging information, run: ```bash borgmatic --syslog-verbosity 2 ``` See above for further details about the verbosity levels. Where these logs show up depends on your particular system. If you're using systemd, try running `journalctl -xe`. Otherwise, try viewing `/var/log/syslog` or similar. Prior to version 1.8.3borgmatic logged to syslog by default whenever run at a non-interactive console. ### Rate limiting If you are using rsyslog or systemd's journal, be aware that by default they both throttle the rate at which logging occurs. So you may need to change either [the global rate limit](https://www.rootusers.com/how-to-change-log-rate-limiting-in-linux/) or [the per-service rate limit](https://www.freedesktop.org/software/systemd/man/journald.conf.html#RateLimitIntervalSec=) if you're finding that borgmatic logs are missing. Note that the [sample borgmatic systemd service file](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#systemd) already has this rate limit disabled for systemd's journal. ### Logging to file If you don't want to use syslog, and you'd rather borgmatic log to a plain file, use the `--log-file` flag: ```bash borgmatic --log-file /path/to/file.log ``` Note that if you use the `--log-file` flag, you are responsible for rotating the log file so it doesn't grow too large, for example with [logrotate](https://wiki.archlinux.org/index.php/Logrotate). You can use the `--log-file-verbosity` flag to customize the log file's log level: ```bash borgmatic --log-file /path/to/file.log --log-file-verbosity 2 ``` New in version 1.7.11 Use the `--log-file-format` flag to override the default log message format. This format string can contain a series of named placeholders wrapped in curly brackets. For instance, the default log format is: `[{asctime}] {levelname}: {message}`. This means each log message is recorded as the log time (in square brackets), a logging level name, a colon, and the actual log message. So if you only want each log message to get logged *without* a timestamp or a logging level name: ```bash borgmatic --log-file /path/to/file.log --log-file-format "{message}" ``` Here is a list of available placeholders: * `{asctime}`: time the log message was created * `{levelname}`: level of the log message (`INFO`, `DEBUG`, etc.) * `{lineno}`: line number in the source file where the log message originated * `{message}`: actual log message * `{pathname}`: path of the source file where the log message originated See the [Python logging documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes) for additional placeholders. Note that this `--log-file-format` flag only applies to the specified `--log-file` and not to syslog or other logging. borgmatic/docs/how-to/make-backups-redundant.md000066400000000000000000000041651476361726000220630ustar00rootroot00000000000000--- title: How to make backups redundant eleventyNavigation: key: ☁️ Make backups redundant parent: How-to guides order: 3 --- ## Multiple repositories If you really care about your data, you probably want more than one backup of it. borgmatic supports this in its configuration by specifying multiple backup repositories. Here's an example: ```yaml # List of source directories to backup. source_directories: - /home - /etc # Paths of local or remote repositories to backup to. repositories: - path: ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/./repo - path: /var/lib/backups/local.borg ``` Prior to version 1.8.0 Put these options in the `location:` section of your configuration. Prior to version 1.7.10 Omit the `path:` portion of the `repositories` list. When you run borgmatic with this configuration, it invokes Borg once for each configured repository in sequence. (So, not in parallel.) That means—in each repository—borgmatic creates a single new backup archive containing all of your source directories. Here's a way of visualizing what borgmatic does with the above configuration: 1. Backup `/home` and `/etc` to `k8pDxu32@k8pDxu32.repo.borgbase.com:repo` 2. Backup `/home` and `/etc` to `/var/lib/backups/local.borg` This gives you redundancy of your data across repositories and even potentially across providers. See [Borg repository URLs documentation](https://borgbackup.readthedocs.io/en/stable/usage/general.html#repository-urls) for more information on how to specify local and remote repository paths. ### Different options per repository What if you want borgmatic to backup to multiple repositories—while also setting different options for each one? In that case, you'll need to use [a separate borgmatic configuration file for each repository](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/) instead of the multiple repositories in one configuration file as described above. That's because all of the repositories in a particular configuration file get the same options applied. borgmatic/docs/how-to/make-per-application-backups.md000066400000000000000000000514761476361726000231750ustar00rootroot00000000000000--- title: How to make per-application backups eleventyNavigation: key: 🔀 Make per-application backups parent: How-to guides order: 1 --- ## Multiple backup configurations You may find yourself wanting to create different backup policies for different applications on your system or even for different backup repositories. For instance, you might want one backup configuration for your database data directory and a different configuration for your user home directories. Or one backup configuration for your local backups with a different configuration for your remote repository. The way to accomplish that is pretty simple: Create multiple separate configuration files and place each one in a `/etc/borgmatic.d/` directory. For instance, for applications: ```bash sudo mkdir /etc/borgmatic.d sudo borgmatic config generate --destination /etc/borgmatic.d/app1.yaml sudo borgmatic config generate --destination /etc/borgmatic.d/app2.yaml ``` Or, for repositories: ```bash sudo mkdir /etc/borgmatic.d sudo borgmatic config generate --destination /etc/borgmatic.d/repo1.yaml sudo borgmatic config generate --destination /etc/borgmatic.d/repo2.yaml ``` Prior to version 1.7.15 The command to generate configuration files was `generate-borgmatic-config` instead of `borgmatic config generate`. When you set up multiple configuration files like this, borgmatic will run each one in turn from a single borgmatic invocation. This includes, by default, the traditional `/etc/borgmatic/config.yaml` as well. Each configuration file is interpreted independently, as if you ran borgmatic for each configuration file one at a time. In other words, borgmatic does not perform any merging of configuration files by default. If you'd like borgmatic to merge your configuration files, for instance to avoid duplication of settings, see below about configuration includes. Additionally, the `~/.config/borgmatic.d/` directory works the same way as `/etc/borgmatic.d`. If you need even more customizability, you can specify alternate configuration paths on the command-line with borgmatic's `--config` flag. (See `borgmatic --help` for more information.) For instance, if you want to schedule your various borgmatic backups to run at different times, you'll need multiple entries in your [scheduling software of choice](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot), each entry using borgmatic's `--config` flag instead of relying on `/etc/borgmatic.d`. ## Archive naming If you've got multiple borgmatic configuration files, you might want to create archives with different naming schemes for each one. This is especially handy if each configuration file is backing up to the same Borg repository but you still want to be able to distinguish backup archives for one application from another. borgmatic supports this use case with an `archive_name_format` option. The idea is that you define a string format containing a number of [Borg placeholders](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-placeholders), and borgmatic uses that format to name any new archive it creates. For instance: ```yaml archive_name_format: home-directories-{now} ``` Prior to version 1.8.0 Put 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 with Borg 1 is `{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}`, meaning your system hostname plus a timestamp in a particular format. New in borgmatic version 1.9.0 with Borg version 2.xThe 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 New in version 1.7.11 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 `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 to filter archives when running supported actions. For instance, let's say that you have this in your configuration: ```yaml archive_name_format: {hostname}-user-data-{now} ``` Prior to version 1.8.0 Put 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 `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 created for that application. But this doesn't apply to actions like `compact` that operate on an entire repository. If this behavior isn't quite smart enough for your needs, you can use the `match_archives` option to override the pattern that borgmatic uses for filtering archives. For example: ```yaml archive_name_format: {hostname}-user-data-{now} match_archives: sh:myhost-user-data-* ``` With Borg version 1.xUse 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. With Borg version 2.xSee 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` configuration option. Prior to version 1.7.11 The way to limit the archives used for the `prune` action was a `prefix` option in the `retention` section for matching against the start of archive names. And the option for limiting the archives used for the `check` action was a separate `prefix` in the `consistency` section. Both of these options are deprecated in favor of the auto-matching behavior (or `match_archives`/`--match-archives`) in newer versions of borgmatic. ## Configuration includes Once you have multiple different configuration files, you might want to share common configuration options across these files without having to copy and paste them. To achieve this, you can put fragments of common configuration options into a file and then include or inline that file into one or more borgmatic configuration files. Let's say that you want to include common consistency check configuration across all of your configuration files. You could do that in each configuration file with the following: ```yaml repositories: - path: repo.borg checks: !include /etc/borgmatic/common_checks.yaml ``` Prior to version 1.8.0 These options were organized into sections like `location:` and `consistency:`. The contents of `common_checks.yaml` could be: ```yaml - name: repository frequency: 3 weeks - name: archives frequency: 2 weeks ``` To prevent borgmatic from trying to load these configuration fragments by themselves and complaining that they are not valid configuration files, you should put them in a directory other than `/etc/borgmatic.d/`. (A subdirectory is fine.) When a configuration include is a relative path, borgmatic loads it from either the current working directory or from the directory containing the file doing the including. Note that this form of include must be a value rather than an option name. For example, this will not work: ```yaml repositories: - path: repo.borg # Don't do this. It won't work! !include /etc/borgmatic/common_checks.yaml ``` But if you do want to merge in a option name *and* its values, keep reading! ## Include merging If you need to get even fancier and merge in common configuration options, you can perform a YAML merge of included configuration using the YAML `<<` key. For instance, here's an example of a main configuration file that pulls in retention and consistency checks options via a single include: ```yaml repositories: - path: repo.borg <<: !include /etc/borgmatic/common.yaml ``` This is what `common.yaml` might look like: ```yaml keep_hourly: 24 keep_daily: 7 checks: - name: repository frequency: 3 weeks - name: archives frequency: 2 weeks ``` Prior to version 1.8.0 These options were organized into sections like `retention:` and `consistency:`. Once this include gets merged in, the resulting configuration has all of the options from the original configuration file *and* the options from the include. Note that this `<<` include merging syntax is only for merging in mappings (configuration options and their values). If you'd like to include a single value directly, please see above about standard includes. ### Multiple merge includes borgmatic has a limitation preventing multiple `<<` include merges per file or option value. This means you can do a single `<<` merge at the global level, another `<<` within each nested option value, etc. (This is a YAML limitation.) For instance: ```yaml repositories: - path: repo.borg # This won't work! You can't do multiple merges like this at the same level. <<: !include common1.yaml <<: !include common2.yaml ``` But read on for a way around this. New in version 1.8.1 You can include and merge multiple configuration files all at once. For instance: ```yaml repositories: - path: repo.borg <<: !include [common1.yaml, common2.yaml, common3.yaml] ``` This merges in each included configuration file in turn, such that later files replace the options in earlier ones. Here's another way to do the same thing: ```yaml repositories: - path: repo.borg <<: !include - common1.yaml - common2.yaml - common3.yaml ``` ### Deep merge New in version 1.6.0 borgmatic performs a deep merge of merged include files, meaning that values are merged at all levels in the two configuration files. This allows you to include common configuration—up to full borgmatic configuration files—while overriding only the parts you want to customize. For instance, here's an example of a main configuration file that pulls in options via an include and then overrides one of them locally: ```yaml <<: !include /etc/borgmatic/common.yaml constants: base_directory: /opt repositories: - path: repo.borg ``` This is what `common.yaml` might look like: ```yaml constants: app_name: myapp base_directory: /var/lib ``` Once this include gets merged in, the resulting configuration would have an `app_name` value of `myapp` and an overridden `base_directory` value of `/opt`. When there's an option collision between the local file and the merged include, the local file's option takes precedence. #### List merge New in version 1.6.1 Colliding list values are appended together. New in version 1.7.12 If there is a list value from an include that you *don't* want in your local configuration file, you can omit it with an `!omit` tag. For instance: ```yaml <<: !include /etc/borgmatic/common.yaml source_directories: - !omit /home - /var ``` And `common.yaml` like this: ```yaml source_directories: - /home - /etc ``` Prior to version 1.8.0 Put the `source_directories` option in the `location:` section of your configuration. Once this include gets merged in, the resulting configuration will have a `source_directories` value of `/etc` and `/var`—with `/home` omitted. This feature currently only works on scalar (e.g. string or number) list items and will not work elsewhere in a configuration file. Be sure to put the `!omit` tag *before* the list item (after the dash). Putting `!omit` after the list item will not work, as it gets interpreted as part of the string. Here's an example of some things not to do: ```yaml <<: !include /etc/borgmatic/common.yaml source_directories: # Do not do this! It will not work. "!omit" belongs before "/home". - /home !omit # Do not do this either! "!omit" only works on scalar list items. repositories: !omit # Also do not do this for the same reason! This is a list item, but it's # not a scalar. - !omit path: repo.borg ``` Additionally, the `!omit` tag only works in a configuration file that also performs a merge include with `<<: !include`. It doesn't make sense within, for instance, an included configuration file itself (unless it in turn performs its own merge include). That's because `!omit` only applies to the file doing the include; it doesn't work in reverse or propagate through includes. ### Shallow merge Even though deep merging is generally pretty handy for included files, sometimes you want specific options in the local file to take precedence over included options—without any merging occurring for them. New in version 1.7.12 That's where the `!retain` tag comes in. Whenever you're merging an included file into your configuration file, you can optionally add the `!retain` tag to particular local mappings or lists to retain the local values and ignore included values. For instance, start with this configuration file containing the `!retain` tag on the `retention` mapping: ```yaml <<: !include /etc/borgmatic/common.yaml repositories: - path: repo.borg checks: !retain - name: repository ``` And `common.yaml` like this: ```yaml repositories: - path: common.borg checks: - name: archives ``` Prior to version 1.8.0 These options were organized into sections like `location:` and `consistency:`. Once this include gets merged in, the resulting configuration will have a `checks` value with a name of `repository` and no other values. That's because the `!retain` tag says to retain the local version of `checks` and ignore any values coming in from the include. But because the `repositories` list doesn't have a `!retain` tag, it still gets merged together to contain both `common.borg` and `repo.borg`. The `!retain` tag can only be placed on mappings (keys/values) and lists, and it goes right after the name of the option (and its colon) on the same line. The effects of `!retain` are recursive, meaning that if you place a `!retain` tag on a top-level mapping, even deeply nested values within it will not be merged. Additionally, the `!retain` tag only works in a configuration file that also performs a merge include with `<<: !include`. It doesn't make sense within, for instance, an included configuration file itself (unless it in turn performs its own merge include). That's because `!retain` only applies to the file doing the include; it doesn't work in reverse or propagate through includes. ## Debugging includes New in version 1.7.15 If you'd like to see what the loaded configuration looks like after includes get merged in, run the `validate` action on your configuration file: ```bash sudo borgmatic config validate --show ``` In version 1.7.12 through 1.7.14 Use this command instead: ```bash sudo validate-borgmatic-config --show ``` You'll need to specify your configuration file with `--config` if it's not in a default location. This will output the merged configuration as borgmatic sees it, which can be helpful for understanding how your includes work in practice. ## Configuration overrides In more complex multi-application setups, you may want to override particular borgmatic configuration file options at the time you run borgmatic. For instance, you could reuse a common configuration file for multiple applications, but then set the repository for each application at runtime. Or you might want to try a variant of an option for testing purposes without actually touching your configuration file. Whatever the reason, you can override borgmatic configuration options at the command-line via the `--override` flag. Here's an example: ```bash borgmatic create --override remote_path=/usr/local/bin/borg1 ``` What this does is load your configuration files and for each one, disregard the configured value for the `remote_path` option and use the value of `/usr/local/bin/borg1` instead. You can even override nested values or multiple values at once. For instance: ```bash borgmatic create --override parent_option.option1=value1 --override parent_option.option2=value2 ``` Prior to version 1.8.0 Don't forget to specify the section that an option is in. That looks like a prefix on the option name, e.g. `location.repositories`. Note that each value is parsed as an actual YAML string, so you can set list values by using brackets. For instance: ```bash borgmatic create --override repositories=[test1.borg,test2.borg] ``` Or a single list element: ```bash borgmatic create --override repositories=[/root/test.borg] ``` Or a single list element that is a key/value pair: ```bash borgmatic create --override repositories="[{path: test.borg, label: test}]" ``` If your override value contains characters like colons or spaces, then you'll need to use quotes for it to parse correctly. Another example: ```bash borgmatic create --override repositories="['user@server:test.borg']" ``` There is not currently a way to override a single element of a list without replacing the whole list. Using the `[ ]` list syntax is required when overriding an option of the list type (like `location.repositories`). See the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/) for which options are list types. (YAML list values look like `- this` with an indentation and a leading dash.) An alternate to command-line overrides is passing in your values via [environment variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/). ## Constant interpolation New in version 1.7.10 Another tool is borgmatic's support for defining custom constants. This is similar to the [variable interpolation feature](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation) for command hooks, but the constants feature lets you substitute your own custom values into any option values in the entire configuration file. Here's an example usage: ```yaml constants: user: foo archive_prefix: bar source_directories: - /home/{user}/.config - /home/{user}/.ssh ... archive_name_format: '{archive_prefix}-{now}' ``` Prior to version 1.8.0 Don't forget to specify the section (like `location:` or `storage:`) that any option is in. In this example, when borgmatic runs, all instances of `{user}` get replaced with `foo` and all instances of `{archive_prefix}` get replaced with `bar`. And `{now}` doesn't get replaced with anything, but gets passed directly to Borg, which has its own [placeholders](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-placeholders) using the same syntax as borgmatic constants. So borgmatic options like `archive_name_format` that get passed directly to Borg can use either Borg placeholders or borgmatic constants or both! After substitution, the logical result looks something like this: ```yaml source_directories: - /home/foo/.config - /home/foo/.ssh ... archive_name_format: 'bar-{now}' ``` Note that if you'd like to interpolate a constant into the beginning of a value, you'll need to quote it. For instance, this won't work: ```yaml source_directories: - {my_home_directory}/.config # This will error! ``` Instead, do this: ```yaml source_directories: - "{my_home_directory}/.config" ``` New in version 1.8.5 Constants work across includes, meaning you can define a constant and then include a separate configuration file that uses that constant. An alternate to constants is passing in your values via [environment variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/). borgmatic/docs/how-to/monitor-your-backups.md000066400000000000000000000666711476361726000216610ustar00rootroot00000000000000--- title: How to monitor your backups eleventyNavigation: key: 🚨 Monitor your backups parent: How-to guides order: 6 --- ## Monitoring and alerting Having backups is great, but they won't do you a lot of good unless you have confidence that they're running on a regular basis. That's where monitoring and alerting comes in. There are several different ways you can monitor your backups and find out whether they're succeeding. Which of these you choose to do is up to you and your particular infrastructure. ### Job runner alerts The easiest place to start is with failure alerts from the [scheduled job runner](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot) (cron, systemd, etc.) that's running borgmatic. But note that if the job doesn't even get scheduled (e.g. due to the job runner not running), you probably won't get an alert at all! Still, this is a decent first line of defense, especially when combined with some of the other approaches below. ### Commands run on error The `on_error` hook allows you to run an arbitrary command or script when borgmatic itself encounters an error running your backups. So for instance, you can run a script to send yourself a text message alert. But note that if borgmatic doesn't actually run, this alert won't fire. See [error hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks) below for how to configure this. ### Third-party monitoring services borgmatic integrates with these monitoring services and libraries, pinging them as backups happen: * [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) * [Sentry](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-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). See the documentation links above for configuration information. While these services and libraries offer different features, you probably only need to use one of them at most. ### Third-party monitoring software You can use traditional monitoring software to consume borgmatic JSON output and track when the last successful backup occurred. See [scripting borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#scripting-borgmatic) below for how to configure this. ### Borg hosting providers Most [Borg hosting providers](https://torsion.org/borgmatic/#hosting-providers) include monitoring and alerting as part of their offering. This gives you a dashboard to check on all of your backups, and can alert you if the service doesn't hear from borgmatic for a configured interval. ### Consistency checks While not strictly part of monitoring, if you want confidence that your backups are not only running but are restorable as well, you can configure particular [consistency checks](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#consistency-check-configuration) or even script full [extract tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/). ## Error hooks When an error occurs during a `create`, `prune`, `compact`, or `check` action, borgmatic can run configurable shell commands to fire off custom error notifications or take other actions, so you can get alerted as soon as something goes wrong. Here's a not-so-useful example: ```yaml on_error: - echo "Error while creating a backup or running a backup hook." ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. The `on_error` hook supports interpolating particular runtime variables into the hook command. Here's an example that assumes you provide a separate shell script to handle the alerting: ```yaml on_error: - send-text-message.sh {configuration_filename} {repository} ``` In this example, when the error occurs, borgmatic interpolates runtime values into the hook command: the borgmatic configuration filename and the path of the repository. Here's the full set of supported variables you can use here: * `configuration_filename`: borgmatic configuration filename in which the error occurred * `repository`: path of the repository in which the error occurred (may be blank if the error occurs in a hook) * `error`: the error message itself * `output`: output of the command that failed (may be blank if an error occurred without running a command) Note that borgmatic runs the `on_error` hooks only for `create`, `prune`, `compact`, or `check` actions/hooks in which an error occurs and not other actions. borgmatic does not run `on_error` hooks if an error occurs within a `before_everything` or `after_everything` hook. For more about hooks, see the [borgmatic hooks documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/), especially the security information. New in version 1.8.7 borgmatic automatically escapes these interpolated values to prevent shell injection attacks. One implication of this change is that you shouldn't wrap the interpolated values in your own quotes, as that will interfere with the quoting performed by borgmatic and result in your command receiving incorrect arguments. For instance, this won't work: ```yaml on_error: # Don't do this! It won't work, as the {error} value is already quoted. - send-text-message.sh "Uh oh: {error}" ``` Do this instead: ```yaml on_error: - send-text-message.sh {error} ``` ## Healthchecks hook [Healthchecks](https://healthchecks.io/) is a service that provides "instant alerts when your cron jobs fail silently," and borgmatic has built-in integration with it. Once you create a Healthchecks account and project on their site, all you need to do is configure borgmatic with the unique "Ping URL" for your project. Here's an example: ```yaml healthchecks: ping_url: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. With this configuration, borgmatic pings your Healthchecks project when a backup begins, ends, or errors, but only when any of the `create`, `prune`, `compact`, or `check` actions are run. Then, if the actions complete successfully, borgmatic notifies Healthchecks of the success and includes borgmatic logs in the payload data sent to Healthchecks. This means that borgmatic logs show up in the Healthchecks UI, although be aware that Healthchecks currently has a 100-kilobyte limit for the logs in each ping. If an error occurs during any action or hook, borgmatic notifies Healthchecks, also tacking on logs including the error itself. But the logs are only included for errors that occur when a `create`, `prune`, `compact`, or `check` action is run. You can customize the verbosity of the logs that are sent to Healthchecks with borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags may also be of use. See `borgmatic create --help` for more information. Additionally, see the [borgmatic configuration file](https://torsion.org/borgmatic/docs/reference/configuration/) for additional Healthchecks options. You can configure Healthchecks to notify you by a [variety of mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail or it doesn't hear from borgmatic for a certain period of time. ## Cronitor hook [Cronitor](https://cronitor.io/) provides "Cron monitoring and uptime healthchecks for websites, services and APIs," and borgmatic has built-in integration with it. Once you create a Cronitor account and cron job monitor on their site, all you need to do is configure borgmatic with the unique "Ping API URL" for your monitor. Here's an example: ```yaml cronitor: ping_url: https://cronitor.link/d3x0c1 ``` Prior to version 1.8.0 Put 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 `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 mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail or it doesn't hear from borgmatic for a certain period of time. ## Cronhub hook [Cronhub](https://cronhub.io/) provides "instant alerts when any of your background jobs fail silently or run longer than expected," and borgmatic has built-in integration with it. Once you create a Cronhub account and monitor on their site, all you need to do is configure borgmatic with the unique "Ping URL" for your monitor. Here's an example: ```yaml cronhub: ping_url: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031 ``` Prior to version 1.8.0 Put 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 `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 ping URL, borgmatic substitutes the correct state into the URL when pinging Cronhub ("start", "finish", or "fail"). You can configure Cronhub to notify you by a [variety of mechanisms](https://docs.cronhub.io/integrations.html) when backups fail or it doesn't hear from borgmatic for a certain period of time. ## PagerDuty hook In case you're new here: [borgmatic](https://torsion.org/borgmatic/) is simple, configuration-driven backup software for servers and workstations, powered by [Borg Backup](https://www.borgbackup.org/). [PagerDuty](https://www.pagerduty.com/) provides incident monitoring and alerting. borgmatic has built-in integration that can notify you via PagerDuty as soon as a backup fails, so you can make sure your backups keep working. First, create a PagerDuty account and service on their site. On the service, add an integration and set the Integration Type to "borgmatic". Then, configure borgmatic with the unique "Integration Key" for your service. Here's an example: ```yaml pagerduty: integration_key: a177cad45bd374409f78906a810a3074 ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. With this configuration, borgmatic creates a PagerDuty event for your service whenever backups fail, but only when any of the `create`, `prune`, `compact`, or `check` actions are run. Note that borgmatic does not contact PagerDuty when a backup starts or when it ends without error. You can configure PagerDuty to notify you by a [variety of mechanisms](https://support.pagerduty.com/docs/notifications) when backups fail. If you have any issues with the integration, [please contact us](https://torsion.org/borgmatic/#support-and-contributing). ### Sending logs New in version 1.9.14 borgmatic logs are included in the payload data sent to PagerDuty. This means that (truncated) borgmatic logs, including error messages, show up in the PagerDuty incident UI and corresponding notification emails. You can customize the verbosity of the logs that are sent with borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags may also be of use. See `borgmatic create --help` for more information. If you don't want any logs sent, you can disable this feature by setting `send_logs` to `false`: ```yaml pagerduty: integration_key: a177cad45bd374409f78906a810a3074 send_logs: false ``` ## Pushover hook New in version 1.9.2 [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 Started" priority: -2 title: "Backup Started" html: True ttl: 10 # Message will be deleted after 10 seconds. fail: message: "Backup Failed" 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 Finished" 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 ``` ## Sentry hook New in version 1.9.7 [Sentry](https://sentry.io/) is an application monitoring service that includes cron-style monitoring (either cloud-hosted or [self-hosted](https://develop.sentry.dev/self-hosted/)). To get started, create a [Sentry cron monitor](https://docs.sentry.io/product/crons/) in the Sentry UI. Under "Instrument your monitor," select "Sentry CLI" and copy the URL value for the displayed [`SENTRY_DSN`](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) environment variable into borgmatic's Sentry `data_source_name_url` configuration option. For example: ``` sentry: data_source_name_url: https://5f80ec@o294220.ingest.us.sentry.io/203069 monitor_slug: mymonitor ``` The `monitor_slug` value comes from the "Monitor Slug" under "Cron Details" on the same Sentry monitor page. With this configuration, borgmatic pings Sentry whenever borgmatic starts, finishes, or fails, but only when any of the `create`, `prune`, `compact`, or `check` actions are run. You can optionally override the start/finish/fail behavior with the `states` configuration option. For instance, to only ping Sentry on failure: ``` sentry: data_source_name_url: https://5f80ec@o294220.ingest.us.sentry.io/203069 monitor_slug: mymonitor states: - fail ``` ## ntfy hook New in version 1.6.3 [ntfy](https://ntfy.sh) is a free, simple, service (either cloud-hosted or self-hosted) which offers simple pub/sub push notifications to multiple platforms including [web](https://ntfy.sh/stats), [Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and [iOS](https://apps.apple.com/us/app/ntfy/id1625396347). Since push notifications for regular events might soon become quite annoying, this hook only fires on any errors by default in order to instantly alert you to issues. The `states` list can override this. Each state can have its own custom messages, priorities and tags or, if none are provided, will use the default. An example configuration is shown here with all the available options, including [priorities](https://ntfy.sh/docs/publish/#message-priority) and [tags](https://ntfy.sh/docs/publish/#tags-emojis): ```yaml ntfy: topic: my-unique-topic server: https://ntfy.my-domain.com username: myuser password: secret start: title: A borgmatic backup started message: Watch this space... tags: borgmatic priority: min finish: title: A borgmatic backup completed successfully message: Nice! tags: borgmatic,+1 priority: min fail: title: A borgmatic backup failed message: You should probably fix it tags: borgmatic,-1,skull priority: max states: - start - finish - fail ``` Prior to version 1.8.0 Put the `ntfy:` option in the `hooks:` section of your configuration. New in version 1.8.9 Instead of `username`/`password`, you can specify an [ntfy access token](https://docs.ntfy.sh/config/#access-tokens): ```yaml ntfy: topic: my-unique-topic server: https://ntfy.my-domain.com access_token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ```` ## Loki hook New in version 1.8.3 [Grafana Loki](https://grafana.com/oss/loki/) is a "horizontally scalable, highly available, multi-tenant log aggregation system inspired by Prometheus." borgmatic has built-in integration with Loki, sending both backup status and borgmatic logs. You can configure borgmatic to use either a [self-hosted Loki instance](https://grafana.com/docs/loki/latest/installation/) or [a Grafana Cloud account](https://grafana.com/auth/sign-up/create-user). Start by setting your Loki API push URL. Here's an example: ```yaml loki: url: http://localhost:3100/loki/api/v1/push labels: app: borgmatic hostname: example.org ``` With this configuration, borgmatic sends its logs to your Loki instance as any 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. At least one label is required. There are also a few placeholders you can optionally use as label values: * `__config`: name of the borgmatic configuration file * `__config_path`: full path of the borgmatic configuration file * `__hostname`: the local machine hostname These placeholders are only substituted for the whole label value, not interpolated into a larger string. For instance: ```yaml loki: url: http://localhost:3100/loki/api/v1/push labels: app: borgmatic config: __config hostname: __hostname ``` Also check out this [Loki dashboard for borgmatic](https://grafana.com/grafana/dashboards/20736-borgmatic-logs/) if you'd like to see your backup logs and statistics in one place. ## Apprise hook New in version 1.8.4 [Apprise](https://github.com/caronc/apprise/wiki) is a local notification library that "allows you to send a notification to almost all of the most popular [notification services](https://github.com/caronc/apprise/wiki) available to us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc." Depending on how you installed borgmatic, it may not have come with Apprise. For instance, if you originally [installed borgmatic with 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 uninstall borgmatic sudo pipx install borgmatic[Apprise] ``` Omit `sudo` if borgmatic is installed as a non-root user. Once Apprise is installed, configure borgmatic to notify one or more [Apprise services](https://github.com/caronc/apprise/wiki). For example: ```yaml apprise: services: - url: gotify://hostname/token 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 `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: ```yaml apprise: services: - url: gotify://hostname/token label: gotify start: title: Ping! body: Starting backup process. finish: title: Ping! body: Backups successfully made. fail: title: Ping! body: Your backups have failed. states: - start - finish - fail ``` New in version 1.8.9 borgmatic logs are automatically included in the body data sent to your Apprise services when a backup finishes or fails. You can customize the verbosity of the logs that are sent with borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags may also be of use. See `borgmatic create --help` for more information. If you don't want any logs sent, you can disable this feature by setting `send_logs` to `false`: ```yaml apprise: services: - url: gotify://hostname/token label: gotify send_logs: false ``` Or to limit the size of logs sent to Apprise services: ```yaml apprise: services: - url: gotify://hostname/token label: gotify logs_size_limit: 500 ``` This may be necessary for some services that reject large requests. See the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/) for details. ## Uptime Kuma hook New in version 1.8.13 [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 New in version 1.9.0 [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. New in version 1.9.3 Zabbix 7.2+ is supported as well. ### 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 borgmatic writes its monitoring updates to a particular Zabbix item, which you'll need to create in advance. In the Zabbix web UI, [make a new item with a Type of "Zabbix trapper"](https://www.zabbix.com/documentation/current/en/manual/config/items/itemtypes/trapper) and a named Key. The "Type of information" for the item should be "Text", and "History" designates how much data you want to retain. When configuring borgmatic with this item to be updated, you can either declare the `itemid` or both `host` and `key`. If all three are declared, only `itemid` is used. Keep in mind that `host` refers 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`, `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 output only shows up at the console and not in syslog. ### Latest backups 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 repo-list` manually, which can be handy in automated scripts. Here's an example: ```bash borgmatic info --archive latest ``` borgmatic/docs/how-to/provide-your-passwords.md000066400000000000000000000317541476361726000222310ustar00rootroot00000000000000--- title: How to provide your passwords eleventyNavigation: key: 🔒 Provide your passwords parent: How-to guides order: 2 --- ## Providing passwords and secrets to borgmatic If you want to use a Borg repository passphrase or database passwords with borgmatic, you can set them directly in your borgmatic configuration file, treating those secrets like any other option value. For instance, you can specify your Borg passhprase with: ```yaml encryption_passphrase: yourpassphrase ``` But if you'd rather store them outside of borgmatic, whether for convenience or security reasons, read on. ### Delegating to another application borgmatic supports calling another application such as a password manager to obtain the Borg passphrase to a repository. For example, to ask the [Pass](https://www.passwordstore.org/) password manager to provide the passphrase: ```yaml encryption_passcommand: pass path/to/borg-passphrase ``` New in version 1.9.9 Instead of letting Borg run the passcommand—potentially multiple times since borgmatic runs Borg multiple times—borgmatic now runs the passcommand itself and passes the resulting passphrase securely to Borg via an anonymous pipe. This means you should only ever get prompted for your password manager's passphrase at most once per borgmatic run. ### systemd service credentials borgmatic supports reading encrypted [systemd credentials](https://systemd.io/CREDENTIALS/). To use this feature, start by saving your password as an encrypted credential to `/etc/credstore.encrypted/borgmatic.pw`, e.g., ```bash systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw ``` Then use the following in your configuration file: ```yaml encryption_passphrase: "{credential systemd borgmatic.pw}" ``` Prior to version 1.9.10 You can accomplish the same thing with this configuration: ```yaml encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw ``` Note that the name `borgmatic.pw` is hardcoded in the systemd service file. The `{credential ...}` syntax works for several different options in a borgmatic configuration file besides just `encryption_passphrase`. For instance, the username, password, and API token options within database and monitoring hooks support `{credential ...}`: ```yaml postgresql_databases: - name: invoices username: postgres password: "{credential systemd borgmatic_db1}" ``` For specifics about which options are supported, see the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/). To use these credentials, you'll need to modify the borgmatic systemd service file to support loading multiple credentials (assuming you need to load more than one or anything not named `borgmatic.pw`). Start by saving each encrypted credentials to `/etc/credstore.encrypted/borgmatic/`. E.g., ```bash 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 something like the following in your borgmatic configuration file for each option value you'd like to load from systemd: ```yaml encryption_passphrase: "{credential systemd borgmatic_backupserver1}" ``` Prior to version 1.9.10 Use the following instead, but only for the `encryption_passcommand` option and not other options: ```yaml encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1 ``` Adjust `borgmatic_backupserver1` according to the name of the credential and the directory set in the service file. Be aware that when using this systemd `{credential ...}` feature, you may no longer be able to run certain borgmatic actions outside of the systemd service, as the credentials are only available from within the context of that service. So for instance, `borgmatic list` necessarily relies on the `encryption_passphrase` in order to access the Borg repository, but `list` shouldn't need to load any credentials for your database or monitoring hooks. The one exception is `borgmatic config validate`, which doesn't actually load any credentials and should continue working anywhere. ### Container secrets New in version 1.9.11 When running inside a container, borgmatic can read [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/) and [Podman secrets](https://www.redhat.com/en/blog/new-podman-secrets-command). Creating those secrets and passing them into your borgmatic container is outside the scope of this documentation, but here's a simple example of that with [Docker Compose](https://docs.docker.com/compose/): ```yaml services: borgmatic: # Use the actual image name of your borgmatic container here. image: borgmatic:latest secrets: - borgmatic_passphrase secrets: borgmatic_passphrase: file: /etc/borgmatic/passphrase.txt ``` This assumes there's a file on the host at `/etc/borgmatic/passphrase.txt` containing your passphrase. Docker or Podman mounts the contents of that file into a secret named `borgmatic_passphrase` in the borgmatic container at `/run/secrets/`. Once your container secret is in place, you can consume it within your borgmatic configuration file: ```yaml encryption_passphrase: "{credential container borgmatic_passphrase}" ``` This reads the secret securely from a file mounted at `/run/secrets/borgmatic_passphrase` within the borgmatic container. The `{credential ...}` syntax works for several different options in a borgmatic configuration file besides just `encryption_passphrase`. For instance, the username, password, and API token options within database and monitoring hooks support `{credential ...}`: ```yaml postgresql_databases: - name: invoices username: postgres password: "{credential container borgmatic_db1}" ``` For specifics about which options are supported, see the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/). You can also optionally override the `/run/secrets` directory that borgmatic reads secrets from inside a container: ```yaml container: secrets_directory: /path/to/secrets ``` But you should only need to do this for development or testing purposes. ### KeePassXC passwords New in version 1.9.11 borgmatic supports reading passwords from the [KeePassXC](https://keepassxc.org/) password manager. To use this feature, start by creating an entry in your KeePassXC database, putting your password into the "Password" field of that entry and making sure it's saved. Then, you can consume that password in your borgmatic configuration file. For instance, if the entry's title is "borgmatic" and your KeePassXC database is located at `/etc/keys.kdbx`, do this: ```yaml encryption_passphrase: "{credential keepassxc /etc/keys.kdbx borgmatic}" ``` But if the entry's title is multiple words like `borg pw`, you'll need to quote it: ```yaml encryption_passphrase: "{credential keepassxc /etc/keys.kdbx 'borg pw'}" ``` With this in place, borgmatic runs the `keepassxc-cli` command to retrieve the passphrase on demand. But note that `keepassxc-cli` will prompt for its own passphrase in order to unlock its database, so be prepared to enter it when running borgmatic. The `{credential ...}` syntax works for several different options in a borgmatic configuration file besides just `encryption_passphrase`. For instance, the username, password, and API token options within database and monitoring hooks support `{credential ...}`: ```yaml postgresql_databases: - name: invoices username: postgres password: "{credential keepassxc /etc/keys.kdbx database}" ``` For specifics about which options are supported, see the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/). You can also optionally override the `keepassxc-cli` command that borgmatic calls to load passwords: ```yaml keepassxc: keepassxc_cli_command: /usr/local/bin/keepassxc-cli ``` ### File-based credentials New in version 1.9.11 borgmatic supports reading credentials from arbitrary file paths. To use this feature, start by writing your credential into a file that borgmatic has permission to read. Take care not to include anything in the file other than your credential. (borgmatic is smart enough to strip off a trailing newline though.) You can consume that credential file in your borgmatic configuration. For instance, if your credential file is at `/credentials/borgmatic.txt`, do this: ```yaml encryption_passphrase: "{credential file /credentials/borgmatic.txt}" ``` With this in place, borgmatic reads the credential from the file path. The `{credential ...}` syntax works for several different options in a borgmatic configuration file besides just `encryption_passphrase`. For instance, the username, password, and API token options within database and monitoring hooks support `{credential ...}`: ```yaml postgresql_databases: - name: invoices username: postgres password: "{credential file /credentials/database.txt}" ``` For specifics about which options are supported, see the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/). ### Environment variable interpolation New in version 1.6.4 borgmatic supports interpolating arbitrary environment variables directly into option values in your configuration file. That means you can instruct borgmatic to pull your repository passphrase, your database passwords, or any other option values from environment variables. Be aware though that environment variables may be less secure than some of the other approaches above for getting credentials into borgmatic. That's because environment variables may be visible from within child processes and/or OS-level process metadata. Here's an example of using an environment variable from borgmatic's configuration file: ```yaml encryption_passphrase: ${YOUR_PASSPHRASE} ``` Prior to version 1.8.0 Put this option in the `storage:` section of your configuration. This uses the `YOUR_PASSPHRASE` environment variable as your encryption passphrase. Note that the `{` `}` brackets are required. `$YOUR_PASSPHRASE` by itself will not work. In the case of `encryption_passphrase` in particular, an alternate approach is to use Borg's `BORG_PASSPHRASE` environment variable, which doesn't even require setting an explicit `encryption_passphrase` value in borgmatic's configuration file. For [database configuration](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/), the same approach applies. For example: ```yaml postgresql_databases: - name: users password: ${YOUR_DATABASE_PASSWORD} ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. This uses the `YOUR_DATABASE_PASSWORD` environment variable as your database password. #### Interpolation defaults If you'd like to set a default for your environment variables, you can do so with the following syntax: ```yaml encryption_passphrase: ${YOUR_PASSPHRASE:-defaultpass} ``` Here, "`defaultpass`" is the default passphrase if the `YOUR_PASSPHRASE` environment variable is not set. Without a default, if the environment variable doesn't exist, borgmatic will error. #### Disabling interpolation To disable this environment variable interpolation feature entirely, you can pass the `--no-environment-interpolation` flag on the command-line. Or if you'd like to disable interpolation within a single option value, you can escape it with a backslash. For instance, if your password is literally `${A}@!`: ```yaml encryption_passphrase: \${A}@! ``` ## Related features Another way to override particular options within a borgmatic configuration file is to use a [configuration override](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides) on the command-line. But please be aware of the security implications of specifying secrets on the command-line. Additionally, borgmatic action hooks support their own [variable interpolation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation), although in that case it's for particular borgmatic runtime values rather than (only) environment variables. borgmatic/docs/how-to/restore-a-backup.md000066400000000000000000000001741476361726000206760ustar00rootroot00000000000000 borgmatic/docs/how-to/run-arbitrary-borg-commands.md000066400000000000000000000140421476361726000230600ustar00rootroot00000000000000--- title: How to run arbitrary Borg commands eleventyNavigation: key: 🔧 Run arbitrary Borg commands parent: How-to guides order: 12 --- ## Running Borg with borgmatic Borg has several commands and options that borgmatic does not currently support. Sometimes though, as a borgmatic user, you may find yourself wanting to take advantage of these off-the-beaten-path Borg features. You could of course drop down to running Borg directly. But then you'd give up all the niceties of your borgmatic configuration. You could file a [borgmatic ticket](https://torsion.org/borgmatic/#issues) or even a [pull request](https://torsion.org/borgmatic/#contributing) to add the feature. But what if you need it *now*? That's where borgmatic's support for running "arbitrary" Borg commands comes in. Running these Borg commands with borgmatic can take advantage of the 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 executable paths * SSH settings and Borg environment variables * lock wait settings * verbosity ### borg action New in version 1.5.15 The way you run Borg with borgmatic is via the `borg` action. Here's a simple example: ```bash borgmatic borg break-lock ``` This runs Borg's `break-lock` command once with each configured borgmatic repository, passing the repository path in as a Borg-supported environment variable named `BORG_REPO`. (The native `borgmatic break-lock` action should be preferred though for most uses.) You can also specify Borg options for relevant commands. For instance: ```bash borgmatic borg repo-list --short ``` (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 lets you specify the repository to use, either by its path or its label: ```bash borgmatic borg --repository repo.borg break-lock ``` And if you need to specify where the repository goes in the command because there are positional arguments after it: ```bash borgmatic borg debug dump-manifest :: root ``` The `::` is a Borg placeholder that means: Substitute the repository passed in by environment variable here. Prior to version 1.8.0borgmatic attempted to inject the repository name directly into your Borg arguments in the right place (which didn't always work). So your command-line in these older versions didn't support the `::` ### Specifying an archive For borg commands that expect an archive name, you have a few approaches. Here's one: ```bash borgmatic borg --archive latest list '::$ARCHIVE' ``` The single quotes are necessary in order to pass in a literal `$ARCHIVE` 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 do: ```bash borgmatic borg list ::your-actual-archive-name ``` Prior to version 1.8.0borgmatic provided the archive name implicitly along with the repository, attempting to inject it into your Borg arguments in the right place (which didn't always work). So your command-line in these older versions of borgmatic looked more like: ```bash borgmatic borg --archive latest list ``` With Borg version 2.x Either of these will list an archive: ```bash borgmatic borg --archive latest list '$ARCHIVE' ``` ```bash borgmatic borg list your-actual-archive-name ``` ### Limitations borgmatic's `borg` action is not without limitations: * The Borg command you want to run (`create`, `list`, etc.) *must* come first after the `borg` action (and any borgmatic-specific arguments). If you have other Borg options to specify, provide them after. For instance, `borgmatic borg list --progress ...` will work, but `borgmatic borg --progress list ...` will not. * Do not specify any global borgmatic arguments to the right of the `borg` action. (They will be passed to Borg instead of borgmatic.) If you have global borgmatic arguments, specify them *before* the `borg` action. * Unlike other borgmatic actions, you cannot combine the `borg` action with other borgmatic actions. This is to prevent ambiguity in commands like `borgmatic borg list`, in which `list` is both a valid Borg command and a 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`. * Prior to version 1.8.0 borgmatic implicitly injected the repository/archive arguments on the Borg command-line for you (based on your borgmatic configuration or the `borgmatic borg --repository`/`--archive` arguments)—which meant you couldn't specify the repository/archive directly in the Borg command. Also, in these older versions of borgmatic, the `borg` action didn't work for any Borg commands like `borg serve` that do not accept a repository/archive name. * Prior to version 1.7.13 Unlike other borgmatic actions, the `borg` action captured (and logged) all output, so interactive prompts and flags like `--progress` dit not work as expected. In new versions, borgmatic runs the `borg` action without capturing output, so interactive prompts work. In general, this `borgmatic borg` feature should be considered an escape valve—a feature of second resort. In the long run, it's preferable to wrap Borg commands with borgmatic actions that can support them fully. borgmatic/docs/how-to/run-preparation-steps-before-backups.md000066400000000000000000000002301476361726000246730ustar00rootroot00000000000000 borgmatic/docs/how-to/set-up-backups.md000066400000000000000000000365421476361726000204050ustar00rootroot00000000000000--- title: How to set up backups eleventyNavigation: key: 📥 Set up backups parent: How-to guides order: 0 --- ## Installation ### Prerequisites First, [install Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least version 1.1. borgmatic does not install Borg automatically so as to avoid conflicts with existing Borg installations. Then, [install pipx](https://pypa.github.io/pipx/installation/) as the root user (with `sudo`) to make installing borgmatic easier without impacting other Python applications on your system. If you have trouble installing pipx with pip, then you can install a system package instead. E.g. on Ubuntu or Debian, run: ```bash sudo apt update sudo apt install pipx ``` ### Root install If you want to run borgmatic on a schedule with privileged access to your files, then you should install borgmatic as the root user by running the following commands: ```bash sudo pipx ensurepath sudo pipx install borgmatic ``` Check whether this worked with: ```bash sudo su - borgmatic --version ``` If borgmatic is properly installed, that should output your borgmatic version. And if you'd also like `sudo borgmatic` to work, keep reading! ### Non-root install If you only want to run borgmatic as a non-root user (without privileged file access) *or* you want to make `sudo borgmatic` work so borgmatic runs as root, then install borgmatic as a non-root user by running the following commands as that user: ```bash pipx ensurepath pipx install borgmatic ``` This should work even if you've also installed borgmatic as the root user. Check whether this worked with: ```bash borgmatic --version ``` If borgmatic is properly installed, that should output your borgmatic version. You can also try `sudo borgmatic --version` if you intend to run borgmatic with `sudo`. If that doesn't work, you may need to update your [sudoers `secure_path` option](https://wiki.archlinux.org/title/Sudo). ### Other ways to install Besides the approaches described above, there are several other options for installing borgmatic: * [container image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/) (+ Docker Compose files) * [container image with multi-arch and Docker CLI support](https://hub.docker.com/r/modem7/borgmatic-docker/) * [Debian](https://tracker.debian.org/pkg/borgmatic) * [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic) * [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic) * [Fedora unofficial](https://copr.fedorainfracloud.org/coprs/heffer/borgmatic/) * [Gentoo](https://packages.gentoo.org/packages/app-backup/borgmatic) * [Arch Linux](https://archlinux.org/packages/extra/any/borgmatic/) * [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic) * [OpenBSD](https://openports.pl/path/sysutils/borgmatic) * [openSUSE](https://software.opensuse.org/package/borgmatic) * [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic) * [macOS (via MacPorts)](https://ports.macports.org/port/borgmatic/) * [NixOS](https://search.nixos.org/packages?show=borgmatic&sort=relevance&type=packages&query=borgmatic) * [Ansible role](https://github.com/borgbase/ansible-role-borgbackup) * [Unraid](https://unraid.net/community/apps?q=borgmatic#r) ## Hosting providers Need somewhere to store your encrypted off-site backups? The following hosting providers include specific support for Borg/borgmatic—and fund borgmatic development and hosting when you use these referral links to sign up:
  • BorgBase: Borg hosting service with support for monitoring, 2FA, and append-only repos
  • Hetzner: A "storage box" that includes support for Borg
Additionally, rsync.net has a compatible storage offering, but does not fund borgmatic development or hosting. ## Configuration After you install borgmatic, generate a sample configuration file: ```bash sudo borgmatic config generate ``` Prior to version 1.7.15 Generate a configuration file with this command instead: ```bash sudo generate-borgmatic-config ``` If neither command is found, then borgmatic may be installed in a location that's not in your system `PATH` (see above). Try looking in `~/.local/bin/`. The command generates a sample configuration file at `/etc/borgmatic/config.yaml` by default. If you'd like to use another path, use the `--destination` flag, for instance: `--destination ~/.config/borgmatic/config.yaml`. You should edit the configuration file to suit your needs, as the generated values are only representative. All options are optional except where indicated, so feel free to ignore anything you don't need. Be sure to use spaces rather than tabs for indentation; YAML does not allow tabs. Prior to version 1.8.0 The configuration file was organized into distinct sections, each with a section name like `location:` or `storage:`. So in older versions of borgmatic, take care that if you uncomment a particular option, also uncomment its containing section name—or else borgmatic won't recognize the option. You can get the same sample configuration file from the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/), the authoritative set of all configuration options. This is handy if borgmatic has added new options since you originally created your configuration file. Also check out how to [upgrade your configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration). ### Encryption If you encrypt your Borg repository with a passphrase or a key file, you'll either need to set the borgmatic `encryption_passphrase` configuration variable or set the `BORG_PASSPHRASE` environment variable. See the [repository encryption section](https://borgbackup.readthedocs.io/en/stable/quickstart.html#repository-encryption) of the Borg Quick Start for more info. Alternatively, you can specify the passphrase programmatically by setting either the borgmatic `encryption_passcommand` configuration variable or the `BORG_PASSCOMMAND` environment variable. See the [Borg Security FAQ](http://borgbackup.readthedocs.io/en/stable/faq.html#how-can-i-specify-the-encryption-passphrase-programmatically) for more info. ### Redundancy If you'd like to configure your backups to go to multiple different repositories, see the documentation on how to [make backups redundant](https://torsion.org/borgmatic/docs/how-to/make-backups-redundant/). ### Validation If you'd like to validate that your borgmatic configuration is valid, the following command is available for that: ```bash sudo borgmatic config validate ``` Prior to version 1.7.15 Validate a configuration file with this command instead: ```bash sudo validate-borgmatic-config ``` You'll need to specify your configuration file with `--config` if it's not in a default location. This command's exit status (`$?` in Bash) is zero when configuration is valid and non-zero otherwise. Validating configuration can be useful if you generate your configuration files via configuration management, or you want to double check that your hand edits are valid. ## Repository creation Before you can create backups with borgmatic, you first need to create a Borg repository so you have a destination for your backup archives. (But skip this step if you already have a Borg repository.) To create a repository, run a command like the following with Borg 1.x: ```bash sudo borgmatic init --encryption repokey ``` New in borgmatic version 1.9.0 Or, with Borg 2.x: ```bash sudo borgmatic repo-create --encryption repokey-aes-ocb ``` (Note that `repokey-chacha20-poly1305` may be faster than `repokey-aes-ocb` on certain platforms like ARM64.) This uses the borgmatic configuration file you created above to determine which local or remote repository to create and encrypts it with the encryption passphrase specified there if one is provided. Read about [Borg encryption modes](https://borgbackup.readthedocs.io/en/stable/usage/init.html#encryption-mode-tldr) for the menu of available encryption modes. Also, optionally check out the [Borg Quick Start](https://borgbackup.readthedocs.org/en/stable/quickstart.html) for more background about repository creation. Note that borgmatic skips repository creation if the repository already exists. This supports use cases like ensuring a repository exists prior to performing a backup. If the repository is on a remote host, make sure that your local user has key-based SSH access to the desired user account on the remote host. ## Backups Now that you've configured borgmatic and created a repository, it's a good idea to test that borgmatic is working. So to run borgmatic and start a backup, you can invoke it like this: ```bash sudo borgmatic create --verbosity 1 --list --stats ``` (No borgmatic `--list` flag? Try `--files` instead, leave it out, or upgrade borgmatic!) The `--verbosity` flag makes borgmatic show the steps it's performing. The `--list` flag lists each file that's new or changed since the last backup. And `--stats` shows summary information about the created archive. All of these flags are optional. As the command runs, you should eyeball the output to see if it matches your expectations based on your configuration. If you'd like to specify an alternate configuration file path, use the `--config` flag. See `borgmatic --help` and `borgmatic create --help` for more information. ## Default actions If you omit `create` and other actions, borgmatic runs through a set of default actions: `prune` any old backups as per the configured retention policy, `compact` segments to free up space (with Borg 1.2+, borgmatic 1.5.23+), `create` a backup, *and* `check` backups for consistency problems due to things like file damage. For instance: ```bash sudo borgmatic --verbosity 1 --list --stats ``` ### Skipping actions New in version 1.8.5 You can configure borgmatic to skip running certain actions (default or otherwise). For instance, to always skip the `compact` action when using [Borg's append-only mode](https://borgbackup.readthedocs.io/en/stable/usage/notes.html#append-only-mode-forbid-compaction), set the `skip_actions` option: ``` skip_actions: - compact ``` ## Autopilot Running backups manually is good for validating your configuration, but I'm guessing that you want to run borgmatic automatically, say once a day. To do that, you can configure a separate job runner to invoke it periodically. ### cron If you're using cron, download the [sample cron file](https://projects.torsion.org/borgmatic-collective/borgmatic/src/main/sample/cron/borgmatic). Then, from the directory where you downloaded it: ```bash sudo mv borgmatic /etc/cron.d/borgmatic sudo chmod +x /etc/cron.d/borgmatic ``` If borgmatic is installed at a different location than `/root/.local/bin/borgmatic`, edit the cron file with the correct path. You can also modify the cron file if you'd like to run borgmatic more or less frequently. ### systemd If you're using systemd instead of cron to run jobs, you can still configure borgmatic to run automatically. (If you installed borgmatic from [Other ways to install](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install), you may already have borgmatic systemd service and timer files. If so, you may be able to skip some of the steps below.) First, download the [sample systemd service file](https://projects.torsion.org/borgmatic-collective/borgmatic/raw/branch/main/sample/systemd/borgmatic.service) and the [sample systemd timer file](https://projects.torsion.org/borgmatic-collective/borgmatic/raw/branch/main/sample/systemd/borgmatic.timer). Then, from the directory where you downloaded them: ```bash sudo mv borgmatic.service borgmatic.timer /etc/systemd/system/ sudo systemctl enable --now borgmatic.timer ``` Review the security settings in the service file and update them as needed. If `ProtectSystem=strict` is enabled and local repositories are used, then the repository path must be added to the `ReadWritePaths` list. Feel free to modify the timer file based on how frequently you'd like borgmatic to run. ### launchd in macOS If you run borgmatic in macOS with launchd, you may encounter permissions issues when reading files to backup. If that happens to you, you may be interested in an [unofficial work-around for Full Disk Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293). ## Niceties ### Shell completion borgmatic includes a shell completion script (currently only for Bash and Fish) to support tab-completing borgmatic command-line actions and flags. Depending on how you installed borgmatic, this may be enabled by default. #### Bash If completions aren't enabled, start by installing the `bash-completion` Linux package or the [`bash-completion@2`](https://formulae.brew.sh/formula/bash-completion@2) macOS Homebrew formula. Then, install the shell completion script globally: ```bash sudo su -c "borgmatic --bash-completion > $(pkg-config --variable=completionsdir bash-completion)/borgmatic" ``` If you don't have `pkg-config` installed, you can try the following path instead: ```bash sudo su -c "borgmatic --bash-completion > /usr/share/bash-completion/completions/borgmatic" ``` Or, if you'd like to install the script for only the current user: ```bash mkdir --parents ~/.local/share/bash-completion/completions borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmatic ``` Finally, restart your shell (`exit` and open a new shell) so the completions take effect. #### fish To add completions for fish, install the completions file globally: ```fish borgmatic --fish-completion | sudo tee /usr/share/fish/vendor_completions.d/borgmatic.fish source /usr/share/fish/vendor_completions.d/borgmatic.fish ``` ### Colored output borgmatic produces colored terminal output by default. It is disabled when a non-interactive terminal is detected (like a cron job), or when you use the `--json` flag. Otherwise, you can disable it by passing the `--no-color` flag, setting the environment variables `PY_COLORS=False` or `NO_COLOR=True`, or setting the `color` option to `false` in the `output` section of configuration. ## Troubleshooting ### "found character that cannot start any token" error If you run borgmatic and see an error looking something like this, it probably means you've used tabs instead of spaces: ``` test.yaml: Error parsing configuration file An error occurred while parsing a configuration file at config.yaml: while scanning for the next token found character that cannot start any token in "config.yaml", line 230, column 1 ``` YAML does not allow tabs. So to fix this, replace any tabs in your configuration file with the requisite number of spaces. ### libyaml compilation errors borgmatic depends on a Python YAML library (ruamel.yaml) that will optionally use a C YAML library (libyaml) if present. But if it's not installed, then when installing or upgrading borgmatic, you may see errors about compiling the YAML library. If so, not to worry. borgmatic should install and function correctly even without the C YAML library. And borgmatic won't be any faster with the C library present, so you don't need to go out of your way to install it. borgmatic/docs/how-to/snapshot-your-filesystems.md000066400000000000000000000332211476361726000227310ustar00rootroot00000000000000--- title: How to snapshot your filesystems eleventyNavigation: key: 📸 Snapshot your filesystems parent: How-to guides order: 9 --- ## Filesystem hooks Many filesystems support taking snapshots—point-in-time, read-only "copies" of your data, ideal for backing up files that may change during the backup. These snapshots initially don't use any additional storage space and can be made almost instantly. To help automate backup of these filesystems, borgmatic can use them to take snapshots. ### ZFS New in version 1.9.3 Beta feature borgmatic supports taking snapshots with the [ZFS filesystem](https://openzfs.org/) and sending those snapshots to Borg for backup. To use this feature, first you need one or more mounted ZFS datasets. Then, enable ZFS within borgmatic by adding the following line to your configuration file: ```yaml zfs: ``` No other options are necessary to enable ZFS support, but if desired you can override some of the commands used by the ZFS hook. For instance: ```yaml zfs: zfs_command: /usr/local/bin/zfs mount_command: /usr/local/bin/mount umount_command: /usr/local/bin/umount ``` As long as the ZFS hook is in beta, it may be subject to breaking changes and/or may not work well for your use cases. But feel free to use it in production if you're okay with these caveats, and please [provide any feedback](https://torsion.org/borgmatic/#issues) you have on this feature. #### Dataset discovery You have a couple of options for borgmatic to find and backup your ZFS datasets: * For any dataset you'd like backed up, add its mount point to borgmatic's `source_directories` option. * New in version 1.9.6 Or include the mount point as a root pattern with borgmatic's `patterns` or `patterns_from` options. * Or set the borgmatic-specific user property `org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find and backup these datasets. If you have multiple borgmatic configuration files with ZFS enabled, and you'd like particular datasets to be backed up only for particular configuration files, use the `source_directories` option instead of the user property. New in version 1.9.11 borgmatic won't snapshot datasets with the `canmount=off` property, which is often set on datasets that only serve as a container for other datasets. Use `zfs get canmount datasetname` to see the `canmount` value for a dataset. During a backup, borgmatic automatically snapshots these discovered datasets (non-recursively), temporarily mounts the snapshots within its [runtime directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory), and includes the snapshotted files in the paths sent to Borg. borgmatic is also responsible for cleaning up (destroying) these snapshots after a backup completes. Additionally, borgmatic rewrites the snapshot file paths so that they appear at their original dataset locations in a Borg archive. For instance, if your dataset is mounted at `/var/dataset`, then the snapshotted files will appear in an archive at `/var/dataset` as well—even if borgmatic has to mount the snapshot somewhere in `/run/user/1000/borgmatic/zfs_snapshots/` to perform the backup. New in version 1.9.4 borgmatic is smart enough to look at the parent (and grandparent, etc.) directories of each of your `source_directories` to discover any datasets. For instance, let's say you add `/var/log` and `/var/lib` to your source directories, but `/var` is a dataset. borgmatic will discover that and snapshot `/var` accordingly. This also works even with nested datasets; borgmatic selects the dataset that's the "closest" parent to your source directories. New in version 1.9.6 When using [patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns), the initial portion of a pattern's path that you intend borgmatic to match against a dataset can't have globs or other non-literal characters in it—or it won't actually match. For instance, a mount point of `/var` would match a pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart enough to match `/var` to a pattern like `+ fm:/v*/lib/data`. With Borg version 1.2 and earlierSnapshotted files are instead 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. #### Extract a dataset Filesystem snapshots are stored in a Borg archive as normal files, so you can use the standard [extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to extract them. ### Btrfs New in version 1.9.4 Beta feature borgmatic supports taking snapshots with the [Btrfs filesystem](https://btrfs.readthedocs.io/) and sending those snapshots to Borg for backup. To use this feature, first you need one or more Btrfs subvolumes on mounted filesystems. Then, enable Btrfs within borgmatic by adding the following line to your configuration file: ```yaml btrfs: ``` No other options are necessary to enable Btrfs support, but if desired you can override some of the commands used by the Btrfs hook. For instance: ```yaml btrfs: btrfs_command: /usr/local/bin/btrfs findmnt_command: /usr/local/bin/findmnt ``` As long as the Btrfs hook is in beta, it may be subject to breaking changes and/or may not work well for your use cases. But feel free to use it in production if you're okay with these caveats, and please [provide any feedback](https://torsion.org/borgmatic/#issues) you have on this feature. #### Subvolume discovery For any read-write subvolume you'd like backed up, add its path to borgmatic's `source_directories` option. Btrfs does not support snapshotting read-only subvolumes. New in version 1.9.6 Or include the mount point as a root pattern with borgmatic's `patterns` or `patterns_from` options. During a backup, borgmatic snapshots these subvolumes (non-recursively) and includes the snapshotted files in the paths sent to Borg. borgmatic is also responsible for cleaning up (deleting) these snapshots after a backup completes. borgmatic is smart enough to look at the parent (and grandparent, etc.) directories of each of your `source_directories` to discover any subvolumes. For instance, let's say you add `/var/log` and `/var/lib` to your source directories, but `/var` is a subvolume. borgmatic will discover that and snapshot `/var` accordingly. This also works even with nested subvolumes; borgmatic selects the subvolume that's the "closest" parent to your source directories. New in version 1.9.6 When using [patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns), the initial portion of a pattern's path that you intend borgmatic to match against a subvolume can't have globs or other non-literal characters in it—or it won't actually match. For instance, a subvolume of `/var` would match a pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart enough to match `/var` to a pattern like `+ fm:/v*/lib/data`. Additionally, borgmatic rewrites the snapshot file paths so that they appear at their original subvolume locations in a Borg archive. For instance, if your subvolume exists at `/var/subvolume`, then the snapshotted files will appear in an archive at `/var/subvolume` as well—even if borgmatic has to mount the snapshot somewhere in `/var/subvolume/.borgmatic-snapshot-1234/` to perform the backup. With Borg version 1.2 and earlierSnapshotted files are instead stored at a path dependent on the temporary snapshot directory in use at the time the archive was created, as Borg 1.2 and earlier do not support path rewriting. #### Extract a subvolume Subvolume snapshots are stored in a Borg archive as normal files, so you can use the standard [extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to extract them. ### LVM New in version 1.9.4 Beta feature borgmatic supports taking snapshots with [LVM](https://sourceware.org/lvm2/) (Linux Logical Volume Manager) and sending those snapshots to Borg for backup. LVM isn't itself a filesystem, but it can take snapshots at the layer right below your filesystem. Note that, due to Borg being a file-level backup, this feature is really only suitable for filesystems, not whole disk or raw images containing multiple filesystems (for example, if you're using a LVM volume to run a Windows KVM that contains an MBR, partitions, etc.). In those cases, you can omit the `lvm:` option and use Borg's own support for [image backup](https://borgbackup.readthedocs.io/en/stable/deployment/image-backup.html). To use this feature, first you need one or more mounted LVM logical volumes. Then, enable LVM within borgmatic by adding the following line to your configuration file: ```yaml lvm: ``` No other options are necessary to enable LVM support, but if desired you can override some of the options used by the LVM hook. For instance: ```yaml lvm: snapshot_size: 5GB # See below for details. lvcreate_command: /usr/local/bin/lvcreate lvremove_command: /usr/local/bin/lvremove lvs_command: /usr/local/bin/lvs lsbrk_command: /usr/local/bin/lsbrk mount_command: /usr/local/bin/mount umount_command: /usr/local/bin/umount ``` As long as the LVM hook is in beta, it may be subject to breaking changes and/or may not work well for your use cases. But feel free to use it in production if you're okay with these caveats, and please [provide any feedback](https://torsion.org/borgmatic/#issues) you have on this feature. #### Snapshot size The `snapshot_size` option is the size to allocate for each snapshot taken, including the units to use for that size. While borgmatic's snapshots themselves are read-only and don't change during backups, the logical volume being snapshotted *can* change—therefore requiring additional snapshot storage since LVM snapshots are copy-on-write. And if the configured snapshot size is too small (and LVM isn't configured to grow snapshots automatically), then the snapshots will fail to allocate enough space, resulting in a broken backup. If not specified, the `snapshot_size` option defaults to `10%ORIGIN`, which means 10% of the size of the logical volume being snapshotted. See the [`lvcreate --size` and `--extents` documentation](https://www.man7.org/linux/man-pages/man8/lvcreate.8.html) for more information about possible values here. (Under the hood, borgmatic uses `lvcreate --extents` if the `snapshot_size` is a percentage value, and `lvcreate --size` otherwise.) #### Logical volume discovery For any logical volume you'd like backed up, add its mount point to borgmatic's `source_directories` option. New in version 1.9.6 Or include the mount point as a root pattern with borgmatic's `patterns` or `patterns_from` options. During a backup, borgmatic automatically snapshots these discovered logical volumes (non-recursively), temporarily mounts the snapshots within its [runtime directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory), and includes the snapshotted files in the paths sent to Borg. borgmatic is also responsible for cleaning up (deleting) these snapshots after a backup completes. borgmatic is smart enough to look at the parent (and grandparent, etc.) directories of each of your `source_directories` to discover any logical volumes. For instance, let's say you add `/var/log` and `/var/lib` to your source directories, but `/var` is a logical volume. borgmatic will discover that and snapshot `/var` accordingly. New in version 1.9.6 When using [patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns), the initial portion of a pattern's path that you intend borgmatic to match against a logical volume can't have globs or other non-literal characters in it—or it won't actually match. For instance, a logical volume of `/var` would match a pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart enough to match `/var` to a pattern like `+ fm:/v*/lib/data`. Additionally, borgmatic rewrites the snapshot file paths so that they appear at their original logical volume locations in a Borg archive. For instance, if your logical volume is mounted at `/var/lvolume`, then the snapshotted files will appear in an archive at `/var/lvolume` as well—even if borgmatic has to mount the snapshot somewhere in `/run/user/1000/borgmatic/lvm_snapshots/` to perform the backup. With Borg version 1.2 and earlierSnapshotted files are instead 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. #### Extract a logical volume Logical volume snapshots are stored in a Borg archive as normal files, so you can use the standard [extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to extract them. borgmatic/docs/how-to/upgrade.md000066400000000000000000000200401476361726000171530ustar00rootroot00000000000000--- title: How to upgrade borgmatic and Borg eleventyNavigation: key: 📦 Upgrade borgmatic/Borg parent: How-to guides order: 14 --- ## Upgrading borgmatic In general, all you should need to do to upgrade borgmatic if you've [installed it with pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation) is to run the following: ```bash sudo pipx upgrade borgmatic ``` Omit `sudo` if you installed borgmatic as a non-root user. And if you installed borgmatic *both* as root and as a non-root user, you'll need to upgrade each installation independently. If you originally installed borgmatic with `sudo pip3 install --user`, you can uninstall it first with `sudo pip3 uninstall borgmatic` and then [install it again with pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation), which should better isolate borgmatic from your other Python applications. But if you [installed borgmatic without pipx or pip3](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install), then your upgrade method may be different. ### Upgrading your configuration The borgmatic configuration file format is usually backwards-compatible from release to release without any changes, but you may still want to update your configuration file when you upgrade to take advantage of new configuration options or avoid old configuration from eventually becoming unsupported. If you prefer, you can add new configuration options manually. If you do want to upgrade your configuration file to include new options, use the `borgmatic config generate` action with its optional `--source` flag that takes the path to your original configuration file. If provided with this path, `borgmatic config generate` merges your original configuration into the generated configuration file, so you get all the newest options and comments. Here's an example: ```bash borgmatic config generate --source config.yaml --destination config-new.yaml ``` Prior to version 1.7.15 The command to generate configuration files was `generate-borgmatic-config` instead of `borgmatic config generate`. New options start as commented out, so you can edit the file and decide whether you want to use each one. There are a few caveats to this process. First, when generating the new configuration file, `borgmatic config generate` replaces any comments you've written in your original configuration file with the newest generated comments. Second, the script adds back any options you had originally deleted, although it does so with the options commented out. And finally, any YAML includes you've used in the source configuration get flattened out into a single generated file. As a safety measure, `borgmatic config generate` refuses to modify configuration files in-place. So it's up to you to review the generated file and, if desired, replace your original configuration file with it. ### Upgrading from borgmatic 1.0.x borgmatic changed its configuration file format in version 1.1.0 from INI-style to YAML. This better supports validation and has a more natural way to express lists of values. Modern versions of borgmatic no longer include support for upgrading configuration files this old, but feel free to [file a ticket](https://torsion.org/borgmatic/#issues) for help with upgrading any old INI-style configuration files you may have. ### Versioning and breaking changes To avoid version number bloat, borgmatic doesn't follow traditional semantic versioning. But here's how borgmatic versioning generally works: * Major version bumps (e.g., 1 to 2): Major breaking changes. Configuration file formats might change, deprecated features may be removed entirely, etc. * Minor version bumps (e.g., 1.8 to 1.9): Medium breaking changes. Depending on the features you use, this may be a drop-in replacement. But read the release notes to make sure. * Patch version bumps (e.g., 1.8.13 to 1.8.14): Minor breaking changes. These include, for instance, bug fixes that are technically breaking but may only affect a small subset of users. Each breaking change is prefixed with "BREAKING:" in [borgmatic's release notes](https://projects.torsion.org/borgmatic-collective/borgmatic/releases), so there should hopefully be no surprises. ## Upgrading Borg To upgrade to a new version of Borg, you can generally install a new version the same way you installed the previous version, paying attention to any instructions included with each Borg release changelog linked from the [releases page](https://github.com/borgbackup/borg/releases). Some more major Borg releases require additional steps that borgmatic can help with. ### Borg 1.2 to 2.0 New in borgmatic version 1.9.0 Upgrading Borg from 1.2 to 2.0 requires manually upgrading your existing Borg 1 repositories before use with Borg or borgmatic. Here's how you can accomplish that. Start by upgrading borgmatic as described above to at least version 1.7.0 and Borg to 2.0. Then, rename your repository in borgmatic's configuration file to a new repository path. The repository upgrade process does not occur in-place; you'll create a new repository with a copy of your old repository's data. Let's say your original borgmatic repository configuration file looks something like this: ```yaml repositories: - path: original.borg ``` Change it to a new (not yet created) repository path: ```yaml repositories: - path: upgraded.borg ``` Prior to version 1.8.0 This option was found in the `location:` section of your configuration. Prior to version 1.7.10 Omit the `path:` portion of the `repositories` list. Then, run the `repo-create` action (formerly `init`) to create that new Borg 2 repository: ```bash borgmatic repo-create --verbosity 1 --encryption repokey-blake2-aes-ocb \ --source-repository original.borg --repository upgraded.borg ``` This creates an empty repository and doesn't actually transfer any data yet. The `--source-repository` flag is necessary to reuse key material from your Borg 1 repository so that the subsequent data transfer can work. The `--encryption` value above selects the same chunk ID algorithm (`blake2`) commonly used in Borg 1, thereby making deduplication work across transferred archives and new archives. If you get an error about "You must keep the same ID hash" from Borg, that means the encryption value you specified doesn't correspond to your source repository's chunk ID algorithm. In that case, try not using `blake2`: ```bash borgmatic repo-create --verbosity 1 --encryption repokey-aes-ocb \ --source-repository original.borg --repository upgraded.borg ``` Read about [Borg encryption modes](https://borgbackup.readthedocs.io/en/latest/usage/repo-create.html) for more details. To transfer data from your original Borg 1 repository to your newly created Borg 2 repository: ```bash borgmatic transfer --verbosity 1 --upgrader From12To20 --source-repository \ original.borg --repository upgraded.borg --dry-run borgmatic transfer --verbosity 1 --upgrader From12To20 --source-repository \ original.borg --repository upgraded.borg borgmatic transfer --verbosity 1 --upgrader From12To20 --source-repository \ original.borg --repository upgraded.borg --dry-run ``` The first command with `--dry-run` tells you what Borg is going to do during the transfer, the second command actually performs the transfer/upgrade (this might take a while), and the final command with `--dry-run` again provides confirmation of success—or tells you if something hasn't been transferred yet. Note that by omitting the `--upgrader` flag, you can also do archive transfers between related Borg 2 repositories without upgrading, even down to individual archives. For more on that functionality, see the [Borg transfer documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/transfer.html). That's it! Now you can use your new Borg 2 repository as normal with borgmatic. If you've got multiple repositories, repeat the above process for each. borgmatic/docs/reference/000077500000000000000000000000001476361726000157275ustar00rootroot00000000000000borgmatic/docs/reference/command-line.md000066400000000000000000000015251476361726000206170ustar00rootroot00000000000000--- title: Command-line reference eleventyNavigation: key: ⌨️ Command-line reference parent: Reference guides order: 1 --- ## borgmatic options Here are all of the available borgmatic command-line flags for the [most recent version of borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/releases), including the separate flags for each action (sub-command). Most of the flags listed here do not have equivalents in borgmatic's [configuration file](https://torsion.org/borgmatic/docs/reference/configuration/). If you're using an older version of borgmatic, some of these flags may not be present in that version and you should instead use `borgmatic --help` or `borgmatic [action name] --help` (where `[action name]` is the name of an action like `list`, `create`, etc.). ``` {% include borgmatic/command-line.txt %} ``` borgmatic/docs/reference/configuration.md000066400000000000000000000013651476361726000211250ustar00rootroot00000000000000--- title: Configuration reference eleventyNavigation: key: ⚙️ Configuration reference parent: Reference guides order: 0 --- ## Configuration file Below is a sample borgmatic configuration file including all available options for the [most recent version of borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/releases). This file is also [available for download](https://torsion.org/borgmatic/docs/reference/config.yaml). If you're using an older version of borgmatic, some of these options may not work, and you should instead [generate a sample configuration file specific to your borgmatic version](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#configuration). ```yaml {% include borgmatic/config.yaml %} ``` borgmatic/docs/reference/index.md000066400000000000000000000001201476361726000173510ustar00rootroot00000000000000--- eleventyNavigation: key: Reference guides order: 2 permalink: false --- borgmatic/docs/reference/source-code.md000066400000000000000000000060221476361726000204610ustar00rootroot00000000000000--- title: Source code reference eleventyNavigation: key: 🐍 Source code reference parent: Reference guides order: 3 --- ## getting oriented If case you're interested in [developing on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/), here's an abridged primer on how its Python source code is organized to help you get started. Starting at the top level, we have: * [borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic): The main borgmatic source module. Most of the code is here. Within that: * [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): borgmatic-specific logic for running each action (create, list, check, etc.). * [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that's responsible for interacting with Borg to run each action. * [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion. * [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here. * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here. * [credential](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/credential): Credential hooks—for loading passphrases and secrets from external providers. * [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive. * [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services. * [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now. * [sample](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/sample): Example configurations for cron and systemd. * [scripts](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/scripts): Dev-facing scripts for things like building documentation and running end-to-end tests. * [tests](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/tests): Automated tests organized by: end-to-end, integration, and unit. So, broadly speaking, the control flow goes: `commands` → `config` followed by `commands` → `actions` → `borg` and `hooks`. borgmatic/docs/static/000077500000000000000000000000001476361726000152605ustar00rootroot00000000000000borgmatic/docs/static/apprise.png000066400000000000000000004722131476361726000174420ustar00rootroot00000000000000PNG  IHDR?GriCCPICC profile(}=H@_JE*R ␡:YU(BP+`r4iHR\ׂUg]\AIEJ_Zhq?{ܽFiV`tL'b6*_02$)u_b<ܟ_[ ij0m mp'tAG+-~\tY3'.VLx8j: 8kkߓ0WNsI,b D( lhIKRU# ~?ݭUl%@ϋ|]YwcigJ `zGuGS`ɐMٕ4Bx?o@Z>N u"e{ϴTrbKGDPPV pHYs  tIME9z IDATxygyygzIт]BlB l0X ,9v'>I,N|8xcb;`vd abB  AY%|~y033333G$߄i3766Q$.> L fR_Ӗ3E 4T+jِhj}CBT e_<T< e"! }黐w #]133333$j AJAGH ڀIuD )ALJ(TB)J`۪6pR}J@(ULJ}dԝ"K JQ[u'B^(ח&_p8H2;=GWԀE;ԐF0jS4bpfHБIt3R H}4'}-k?WyVLU%DZK*8)E I|з! ڭ2f8H2;E?CG٠`JuvK+88 qs5I]km >Eݾ #4UՆIm+\{g;uOnipA 2\L(C 6;neBzi9X233333sdf,3_ pi4OVGQ* T*VT$1G r?'>rpB3箶2~SDm˳oW[Bbi7]ۀ[oj!)/;lu#tiv%6F.mʥ-bL}5ݷ1ESgEE_Tvʗt o Jfffffvpdfk+\Ĉ!t !M$IJ db0;J rG @uMipVi9UwPw7{[oB]+ͥmfffffIf?\tf#|4plY #AUpG˲AJjL}.14nJ! 誑&uaʜPiK#`p'p'ff|=}L(AA=8P5,l.Iڊ8 R ¢_ݶMWei0M4mduwR?l6@*k+$V9GJ_mjTu8# "ho;inx~gdF@wȁ=$-AVfg8Fy<=s1mVuH?D3sZ--L%*K V$ ƳѶ'lD|-͉|g0Qw733333{ 5>fvP;d3< `7ps\Cn2A\ q,.ЉZ_@ԞԅJjzQP2KiDh1_%ibN";/&ֶkAsTf^ρgGY/-΅84M2Zbvh$i0oItS9U;A@ZDڀR mmpVu#>*$ZԈ6'S{v<ǟ33333ۧ$١C?0͹Á-9B}bpj$#j08 Jjlv6$7mRs5 $4jWtmK" ޟ33333'$CmE@m8'SnmG!`^ F mkzJ:kU$!@D#4:BMFIRE&n3`fffff?IfvQJiGץH"mi8,&u+T!m4lXV$~APAҦ.H)9I@$D#)P3q_M$zWkq Þ7IfvgL׮{offfff{AY1"$`#p!ecIA Jayjrm;{!1ZJ}v3M>;zCJr; H vFRS)QiyS"$R_v)e)/{{33333+nm33k}b m4J;BoP*r7 ( C$џp* |*ϞS4|,I%S\*W O73333{$3ÆQ(?1}>y]mH6)+6ՌxG"aĜ*2HiD3hskDWH$=)}n#&;Xf=s9?V t 4uE3ꍹõskBʵNtmi6Hן댤ų]l:Cda]fiGW$F*osR7#I$`$Ѩ!5H*'!RZN͉10Gg?ﻙ63{so>F)jvo n"$JDۺ[V.}+Ziim)u*K"rvP293`􂄞.8RO֯yMqdf7S.o^)> Д,G3^rA>w 44]NDPN)!+ T: xBK!$33333wosE419B,(Ph*JVB mmkT9ԮV+ѴshPڦDj+קr| rE~scK?k{:MfN! VQ/6X_EuHJ]o>CK9/s!J>%b&HJ$ !9%HI.AR;|집jTt PVT<1~cnm33}ؾP*2p')//EOT%p$4R]{ZEVT%Emmkg&i]ʨ>JDDЯMuEA7^۰<xDogIs#fffff)W$Kyz$Dz:e;"ijjҝl:CKyІ=!ږ*1R$mk1h_K$V+I)uLmER+;[OHvx=G~#H23ۗd-w񿀿7h'Be5 %ukmgou%t"D^BElrO+~#H23_>36.xp|T$Hų残7kqkOC4*G%A4AER02#]RP._iRas3333C+o O | 4y]P?)or]nD{c`Ѹ^cP4ԆHg!6#=;`3牘jV[q*÷Q{M Hr{q:s:،x{nffffvsdf@%w3HsƱncV(H߲fgM]Sͳ+)Dq' qbۮ  rGfL䫉n|iHf fFoҰm[.S+}l.VtK)6 tK=߸=73333;8H23{]&(݊?x׀岲fahf7\n^-*7qժkåHI. B8x󩤹_g$y޳G;.ǹ _+ԋٸeܓ-IlTb$5eE6R"QBr{ InmwIAڶ E;+&Iu^R7 @܋.[_^ @dfvWO_@?t,p$ƂHKjsX#wݓw%bRIDXI@ĊQ*~4W&=<#̬*m.G;3bxL;~i_>I4;/^*9unQtE{[r)Gےv,R*c DFxS\#"K"țsΗIZ7^كAp̏=w–- dQ`(~!UϟWؾ~=O" ֥<&1o[DLFѵFa^-&DEX 5 IDAT$|[NoIK)(-rl#do o\A {'+ӨfWF(gSL&׼}_{ؓHs<| f&R?v}2DvjVRʒR[fڤKk[ N7VF#䆹OKv˞AJ_K+o a~Kb.c| n *bP%UCNb*M1\e85?AyAw%3IfvY^ޒ'4F;hoDx|0ߤZO n)ImYQ"ۦ&I2;<:#i_}T!^WcӁC,;n?fffff$3;?/FdGm]bYD,S=8Rb SPFD%Q.zm ڊ&͔{ #uG} T}<xl1wb[ns33333o$a!/|y1۶)^m3ۃcͥ'K'6I>|!xF0ޚHOnv ҿ“6x胡nVROmV[\S/ѰiLX!<$xrj}Eҧ@׿meRU ElvPvKbW?udmu?PQf+#>CBOƜjnglsdf_O~ի9ڸ'x K0ChzlOQ%ar;G3W-<zl}6ȟpL!)IeU5uiyF^ƧوMD iJguڕ$ҴFTG0!atгD~fffff9H2CIy/i^?SMrԶpa`To*Z+ ZfXm#oEDFD[IZzJ4u#c/r]{Uڲ&E,q17𮛿_3333}ANzDΛb2Ʉñ.MjmO_Zb0;Dyy嬋> $)$IX[#T$-uv\! 3j4xgD&J>ydƧջ03333$!׾5~9r>"//:XX"wAPmGSiD_>n&L$"'!#b֏V$EeQR "rr[YMPpts sZ)8n$3333}ArN~~W<ꡜeiE% 0ZC5 &ix T(E,1~ ED.M~%I 'RPD(+ՏRR@ KZ] &FR Fz`j-VB9"AMeVwTW e9…M }~q^wEQ5ٓɄ3~v .']L*MHVRWuQ5fHRqցp<w"#m9"Ê4=h;(%J,]A[[3D }'J$XxJ]Z[7Ifv(oz9yi'˧NJ+["gLfSjX XhT*6GQ%( rW JV&ډApx))[E/uBdT}[j[۠. o#AQBe/v_o z\ھF1K4P*$нC1J\DLH-g]Ύ&*)=e\xg^YUZIՌ")%uwhW;[JƠ!=3L☙7ޱb<>g2}m&ݭ3Zq_1~ُRO >)6pl Jf4\MD6D*"AW%ʁ 9hڬ*r9~y }fffffi3;h?.c&2X]B^1ŧKF:9@{Z' ߃~ny$ڈq%D3R*qTR BRՖ5PnNsITjWĈ!ihjefffff{A4θ⍛&Kc1U/ՅN2}m }S\U랦$rց>|_cYG_#rDLA@ѷX]GOD)AVk!Im⤮ᶪJeZƒ|epw-xzN瞗dffffWC`fS^4ٵiyiSby\al,#E<˸ioOqOetmƔ7n{{:ߛ}._R.)cE[VßT(,TnH@jRT&w6)l#ꎣ>PJRJA*tIfv~7IJOqmC V10{k0Y%kirNʳ.bp(R0RRD= EW}DۗVg$;hn){stt~YA,!> qТ-Ұ,̃Vb[9ҊUcߚ!=R^OxG-nfffffkqdfs韧_ 8//9'$>`TAjDfB M1IPq-3g^X ӱJY1uXA"i3D/Q;{끋vpIKAPK"[c299/-lHa:V|j1^0Hz|^Zjg^D"q]| 4dO Ꭹbwv^R[ٔ^R4 c7!4ñ ~nq33333[Aν ?+&UҪʠXc]ϚiM_f$Znzۀ^u| WKnkjWuzzs9Gikn+ ki\g$3jtѮgfffff=If;?|՚4?8"ʢZ{W9RL}Z,i ꢮHTcۺۺ}6lϜ]E9"&kq n޵.`ҠI3owq5VB~ۀ&4߾ֿfffffAu_yk4愙ru=G{8uaTBB+ފG^kl@*öt6L ;j웲rxxhmwR]Mv섫A1XT7}z1jffff?>f`89^}([Ktm 0,VhJSu`*ESPg5hJK4u(-ni!r.yUw[䈮I<}@W~}3]-$L~"p{ِ$3{PLOF]"r!*%A1D.+M70 5 2@Xk̞Xy5w8{Oi^w/;*)IKtp=ml О>DiŚwJ{G{0t[Bݟ.=i.IМ/-nfffff$3{@zg_޶MhL<0^Z&/-,fӄ{Quկa^rcW~XW R:2ǣ3Ӗ |-V{JPWb%XBO3D$rg#kڤd\mp<+EoY XKK,p‡F6dimw`bPW2D2hf~Q-.>m{Z.0q&tbju3ܟ\B53:h rڒ'GD.Q #rXm*TA 4 w$W\r/jYvݐ"QKXW *LZk0b.ܠ0V^*%g*ϴ4p{}p0o<Rc&fs1$vSU))ӒRn;> 2 BGB 6n9ʿffffvrdf^8Ou O,/ͬSWN E;_U4#qFgk7p;VL /=?zP~S&wD|6wkqې* rDXB(@)k}{hRu ƶ1.o/}Iف^9Ow/$~u^pxK3?hnn 9)!% aڞLZfWVM7ijދUٗ`Iͨy},tqJ5\uu=t}Wޖ,EWqZY ô#wkjE\lr[bG09Gz~mffff%If_oK֭{HRCR_qԵM2c eN? g۶[nPto+M5LiJmDiװ08"QsJㅃж.ѽs>02J;uZ m*ܣ6V̴neb*$1H"o^-4/{6333Ò$3oNm~/`: G)ALv$//IWhEuUjAԧQjYcmQ[cihE5BLs+xv5 1C33 4$E1 jxeʻi[v͈j,) mK]}iXR[KoʿffffvXqdf1{޺3aii4_2]uOy5i8!K5j us(a4 꿷+MU' u Wi[mOjض 4su^r7%$b00;wl7p==iYB )JkjL0ޚb{_єϹx+syeȇ`ffff IfOl>w-y-,jSOvbyH@+#D+zPuRՉ jVX f6-AR0}l|<=U-Ű Qs"o<>7s)'_Q]-L]@r9|&ܮ6f+ǽYjcOgO$40/Offffvpdf?S^μʏu .@jE,/3޵osKCB+1肞3lŬTSKL07usbܰ- 5I i F7|6uсJ}쥤HJ]莀&I$D d) !ֶZkV盝կJ4NFDR7RܦH3qF䃷]6$vxܦ l?Yc>O,mVVk̷ZΆ>``۱r崩]j  nÇڧ6|]?yJ(4P>~{>JoR;s뎻U1򗁑 ?b*n*[Qd{n.!RyG#A5@@|o`ffffIfv?-faݩ`UH݌wL+{}trH>pV"c IDAT>57)ۺWVӚ&i@pgyT>P-i鏀;v~{ |ѽ%\ @yO`d8LP(oltڈbffff?}8+~7_fOܑ3~bm,oimO WncED5,IpoϫMDihⅩF};[iAS] ~jU#`՗0gLerXp#OVo ] Mi`(̒<\R{TKj-YW~}=[D #괮 Lؾc>t>A!AW/W?}c]5̕o]ocfx36 /7c@ &BtU]U?2#yKЭרֽy####Ns~?0I#$]47UTw}fXQKevPSOs"eTۮS+D ȸ wg6 jBB¼u Ls>1DӐEίUqgMțQ3D68N!>f\JuFH6r n>qpku\e.%A^LEwO~=2 2yF# Yg$6@ۻb"AD=<!ToSۥ$s{jBBBBBBBB HJHHhg?3absU6>~6sf`fԦHDaK@Z"4~UMIU>S-+2M7jQðoLe'fŸ&[W$0u7* }:R%A 3{uJkLiBBB1;nM?|b"$"Pb0=HO0#D[E)l 2k)`*mx,\S mş"V; /N[y"UbDEDh$*e 4gp#x{+e:_yIC0#HHڲI-PEˆ'IDLS_ꨚcq"rLBY[e`sQ*nxI~{HNBBBBBBBB D$%$,3M0yS ]jF:4čM5~פSƏÓYGSFVUkJ[tŶPT7GP9BzLsYj˕wQISYYΔ-M5$A+%Juw h 1 HJHX&u㍫wp u>9YA0-̾}(}l5D ۶¹*fuuaѤۈTc*̅dS#q+QDIHňp6 5Ir!n&m;'z-UI@!2S9gT(FuVIڂ74"kc*ڸV=DL|N(m '8v~?t:+:gA(}klTuqB^+ - j.R?ZTV\*J\+k:#Iz(lV+UV}ZZva ?T ED9L62e|ly Cø:.Icˌ1w'.޸' F '")![nYW̼tWtG$t_?+P%ۇb8gG+46t0vm $hԚe0c:ANTۛ$$"ڃ5 "Iw?0yneˡ߰l 0[ (TMhª2o>*$FWD58EVzi JHHHHHHHH8I ' v|*d٘gGQ[Q Pa0= EE$Q-ݏѶ)CkB:N+JC)-b[R! 뜡@T ȩXlˆZqc(КML|mHO^)wH( CE޾BĜS\/XK>Ve-Nwl,|Y.iz)m5YLԕ).:zQ$wg.o]Ol +!!!!!!!!a#)JzWw|𼕧lOK"ՐCB ve l XETJa *5Y( TJ9¡bg *]\8NWjixsX&dZHDf&+SA ֭DxRf)Ul;6UGB@ >9 R&y]keyCwg4yM\q_۸6ϷkY}ʈWY gfV#x5GZۈЂ+VK%<i:Ʃj5Q(^Du`RHut6U2f JS::_E~bv lYȌnpזI!wmj^Q=֍ $chk*>$I8j\oBBBBBBBBBqD"ML/|tk,AQeIR3uILFM}LPVfhݱ샵-w~gqԖ&T55=i0VNQ/ֶz;qcѴR q?I<$=[ӅpObi)P43WI0.^;WBH`J5UQQ .4(vm?h KHJHX׼=g]n|CGDZ^J1;aUqJT^^s{* \Q'j>U7.l(,V(6ˠt%nAQIK9Q 4QwHήt=|듟v x-{1$b}j mHm /)J"R.S@sOHHHHHHHHHxf n=}v֮vk閻ǎDȘ _l&h }\q4bTVPf9: sbL AqA@bP* ?@g*%hWF99AS̲t| HQE# %%K$RuIq$UmqBtEM "&?.PrSHP*ʑQ ڂd*{(V~Ӏ䐬m vU+-y3|秚<\F{6(Ν.U3 ҈-w[H띂F&Z_ӯLD-K\WpT<&֙,ۺ\WR dAuaWLQDXV3#)`hQ*oLighM%%pMҸ,4:&$$$$$$$$,5$")!b7*Z"dٓ,[,l,^GY6IKjBF ¤#(tZFB]{J*I\ABU$zœZ失%]TNN,[\?og^5JԮJ!o$D7nl(-GRYZry_mDQ @ 9quŮoNnwG0b"Qt{7S0ڛ]q)lp. K HJH8Jظua0fɲKl|8 $D:bnÇAkYVd?Dڿ &* qzQ%hK{"aPX.'#?E9 SWoQX{鮳w}0u*kVÒsD2+Ƞ'1DXV+%N IuGj Nw3!!!!!!!!a)!I ?%V>fSde/a=dcmFc~bH (H3XN/0jHuEm[BcK+T&Sɭ+"bcM N{#- aZrxHW+`E.ר=IoDU; AvHA|:At?IH!( $:mNDNIHİֱQo׿&ڜq-^|.\~gLb뽎9smsrm_)DRƏla͚iudE0D@ZMZ>/@osFLVeXf$o2[Cj dph;$$;ҍHEQ8_C֤65-V$(D5f;>~*ҕ)_λ O4A,L@24+Nz,MV9!LP B2<"m:m KHJHxuM@0,ڱ,1Ik%HD:N6 00LH1Ace󗑑-L&^'fQ/ ?p[f%O}avgZR0JRXsqloTUn[<'Q}ZfvY^Oų lkpO*ʟ˺`: V bUIm h$V:Iiv`w5!!!!!!!!a IUf/ IDAT o^S u?pPx׾ .L|5V_\ T۫$w;U߻MR&&X 嫖u[ _p> ú F( ?6YY].].ս )s!iMHHHHHHHHXHDRB_6F@tnlfل >0v gg1wR#)2]IX;F짌*jDj#-qTIYCh+{`z{o&8\t t7YS Ba#ùt\TV%Qe$ h-h̆t)f4n' 6Z1aYUOHo/ >Ü B'%p2 <\BBBBBBBBR@"=%[HB$Zsvj{Luum;xJuxB C(Z}r.X?EҜaKB>p0U SWhZA7IU$7)""MRQ"v|I%E̹foXl۶5{ٿܯq;1 -@&C@{Ot3LRcӖEe]&ܻI߇e%YO%7 KHJXv| wXvc9&ϟa ldY"\,[,&KD0lmyQ#-Uَ-MGvڮ%*-#-0Z o+C ) J\l-ΨrfMjGM4vUJ9(Q2Άgc~γ𮻾eO4dع<]W}TZPPIi{nu MP-"CV<{?xiNHHHHHHHHXHDR²>k&Ƭ$)69ps&&?A0J<_lvZq9ORnJiKf٘wdjJk&&!I*13b}Uʐ }Ԭ݅m M͒b$`qŀܚN[BBBBBBBBbG"NLr֮v;Wcvlvf.8$5lFh~*I__+^;uHPͭYSx-m\!6ϼ)n]wrEkN ?yWxg"rt{ JIҪk YFE;kuI*SUFuJQeڲF(2` FUL;aﯠ:s Op O\3ĄED$%,iU&5,;Mxwfcq_baѩV1FſNCVj]HբRԱ7*+1Hrg@8uG p0NHRmr.޻q%$$$$$$$$$")ay`O2rCk 1,!An=6;t;YJeYk1&ccE7;򛨵6Wmm5q?5f4eKE µyҦ,yL 6$L tZE&*2𩫜 LMg#6GQLz[,Ogmp ɸ~pjXNu:'PD4iM<ߕF!^?p @DX D&2t5kW*ޝ/A)xW]EgMHHHHHHHHHHDRѢBV_t,}nd+i*ciT2϶Nw6`ec-15U5*օ [#r85 Z?1`z+R.,[T.)&p^n9بƠYwͦԩ3tV1DmnWt)ŷlƦ?xFF{o %ݙme̲ixC4(:ݑF4V94f:^k$cH\BBBBBBBBB",a|ƍivYjη]old674L5F)EӁ0"B 8FgZ ¡=F{$ŵ j$cm:$Eqv2F*犫0W>*m$S?$d|3\낳.* [ItQdׅvMJpc|EYJa`PvA c/C_:G}BJT߸:mtVJ$"N sO#ͅ3ϹNbBBBBBBBBB"%Q$k.Y'M<';Vcf٦ymv8Uә0Zf% Mf kcE*OLo "ZTk8ꌀs#V2+Q%R 1Be{v~bRuӂ(IWQl>ha'I$\͒taSII$ڵiX(0[`00(0%**bKVHVM8E 8(huzO#M8SNRBBBBBBBBB"<\zuK/=\J0回Gټv:k&::(h֥āE,b.%(=̺ 5n'd92QGYT$RՖÙfQY= +PC}/^Cc3'aϣ#XJT{)?tss{>Wb8u5/Ti1Tgp$ћS9ZI;&CXW+Z տ(J]|qwή>{Ӟ2s_+^ʫ$]k741c3Kc lKZ7kg]<)%*܌@/A*6$[U H$>\l֡a?ԕs0*Re_ӼJk2b,}·>E1P圇m ýl"U_G4IV3^kfگvaٔON=$" ;k}!cFy]t#j[]RYתR*t77"Ļ6=d!OX*+I{ ^BBBBb=Vƚ4ZyEرE̲v%3d ?OWZOuZ"@^ "2b8]{Ç?K_o|sou3()!!ƞpnu;vxpt׮];u]V|~ǝɗØ ,_w;e9LZ[(2zh 9>mŌR5Є|Г a_h#:}&t@sH:kERQߏ}*u}ސ7 (:%afQxn%%>lzm3`v߾?}[_͊'=5_V.rtҠiKJk)!Iq T|8CkJLBҘJU;,i{_O,iui8`}s#iY0){Ҟ8ǂƏ=о!HJsW_WngqCX[$NPÚ֩+)\@ ssfvx>UE0%RB1BR$-bI ,9 V^l7d;M3a,3$A&T/.// dP=IZiz錘eE-yaamb'{be|䫬z̲.KcHgCuZ6U%(.- tQ)E*x ,L6BR(vL$;S/5BԙoXH7e Qa%3M"!|DuBb8=-{O|~?ac[U4t_BȤui=!>f{[?E~ߑ\rJZ{Lph:($ #aĔ4SS&1eskdy6NK1 XEQE1keD++NJW wm:{9guicl9j,/R$wx>|Zc`zt@ddjaH#>LuV$yD;s.Q DA?ɗ>LGj]UpACH!b/E1@13;u7cbʍ~ l'}&N n!\#pts鄛2y Gh;( Ma1[C^}o}[?ûo{[CN_o~n_yY7vN&3Y^c%aҥ Q>quqgjE]",R@C  ٙ_>2Nj?',/;^:"Ě/~m]XSNh!)A&#V{M69[KS,кkّn1Y~cVc1eضB eS,6dmj{ķ}K4?]u3 A!0@/aOM?2ʆWYv E& ꁥGƹ[6Mgt??z>pRiǵW\ylwccgr km-z߬s>Rd }O9ó]2pfF<$ssw:a׫n\c*ۇ`T&[MJ5b6n;*r ƍOpmE ae]b55ZGJۺUFL\8Aևwmfrڶ=m0]i2k8RdG)E)j/feJ.$\ԁRsB "jNzqL5Fc*`8;41?wR15VƖ̟#H'd|b !@Y<QR[ 9I^"om@ ZXcjXue17'fPV(&Hۥ.NѿWU_֘Ir 8᭵^K48팗̪s9xv_`__ZZaŊbucN9i=登O'}m㳞u\> Z(dd6sd0w=~Hw_&i-vv7[o⎋[CÉ)5lȬtrBЅH쁱rs0!P*f;KOl+x.y?ӘOJLE7}Eݙ Yo_1[<9dY-o,!i`ҒxծH8n#]Jڸ8'GAh?}_K{x17(v乩'˴p#8lLl1A "w:h_drIHh,$ZOkE@ڽ|֥6]+|t,#J-']6v9KqL]ҭS|)[$}RZ8쭕ߗbn/(;H]7xeQ:&/6y6NX4cvȇC:f%&1ZYrAi$SAZff?"!KeMlm/r. gdk娛b$cp]Hx WQW6FVT%{MoTOIv{^"eX/y z~w-K6ۃEm? ㇠d;͖8L.mSV?׿%4ZP wΈTauv<.NƇp-rE"0VP`7|_J_y+siz(@ލO< ښZЫEKAָ$ID&JMBl!jgQöM$E&"iu7d8@\DÂ`kG$z\{{L*=O%$H۴??7?D2H`0*cyAv+ Ԫ֎#9%Pt~[w0C }ޣH!Le- ( i|yaFhё,fQA6)gPDp"J L=똍e+>Ǝ@"ܱhL%Zf^^Z=QxS3yU:(& |Šs 믹{2c?[^B^LRHi<{G!Ʃ;HLŒ@ua#ɓhTDZ|UD@/bMe7O8}з1 Yf-2"Z9GgsXЁ[QZVm,NF}pQQq"AkM[`|m/yA~{˝oy; -Ѽb/;:}Qa.:Q`{cm^"pGmFq>vQevD.Ytyaom+mjUW= T ["͎ݲ^\s֗=6J oR6۔enΠ舢ūE*al{Z5Oo#:Tc+H>(R,>r8u]6oj._ ;6t8~,t$Kpk(vU-/a$|Qoh5ⅾ4) 'H-cTRUh-_*1c6R~e>: $-$(3jPّQjnͶ0j@;pW>{HpX>{C&͏JZ0TZQ๙^Dk-YUQTWD`hPa#f%:eksXAX5,a{㱑GT5tP4XO<~嗿d~yY}%=amj;mP م 3‘HRսSiULFP5 N7t{;e8\}ֳu8?~_ܷHxQZ*3-te-,ctd $)GnQDpxE-xt֮Y5s$Q`2rVnۀƬ^ Jo߲#Zvt#j|U|7Dir1iҺp0!m@`{uO_кg᪫~osv}l]RҨ^*,樣. 5)ŵ_Εlj.:Vpk}>seac~Sa$ͳx.ba 7e^[Wiښ)FZ%xKPM0Tw=kp+SUI#Zym l3MNvdz| `%zAܣZ'Hp4-,G8gҜ;q; 5? t{'?Woކ ۸ΪUc1Ɛܱ)ym87 aX,pT=A]/ g iEI#y9!r'GeeoG2OlA"E0w~ki6k(/Db*ђQ$'~ݟ>Tes9mWH1(-M̕7Ԟr%m]a^Y(<:Vc֍}SGoLY{{p{`,(MvoiYVV53r$Ĭ;rQiTB^0#z6<{\~;\tk8#r{E:uh߉3sQ _pG4 )}7rvMAT쉶煮%:h ;k_QWEdfDΩTJ [G,xIx-(0U4]*UU.6UC6)CDWfW2:~Gq˻/~C}v~ko=RF&ĭOfyHq]6a%h`wPh1 G2JpyJ;7ne;~x[#~3![gҖ"g;RLz C?ײc}k:=g@Ҟ{îO{~avk s``e.)sգ{9ͺ#] t$ZTjʨ!mGob>O!_HLCc66Ғԫ.w$`{p2@#3S3 @ƍoSFrޟ_=vCr둌@gg}}k^{0qbT1n{O e(wnnzQmu",Om"B3_صΔz3t/G6r`9tqG] /=7e1sʁ/u3d!O }agKnsoIrIAU٠HB0 *w{p#I: i~]2{9TCFQ"I?{fڀ3I~58L$kLp:"Gˢ@01@t]MhM@(r㱝A#u1Rϙ7]BFUq'>AX9r'nWƛ䮮#NXXMD J*{ئgUL轴%bp*u=΄MR@%VFӾ}x8|ۊx} $)3{\5XX;?oY 1(Vcm^ЀMִˊ3?s\I;ZyOS`hXjНS^ xQ97]~^C['YH0N؟tS򈰠b< ڊv7U|J;,AcL GL]HܔzBakcɵ5hl3-vT5i s'&ilSܧw-4ܻetlJJQRz{PL򞺮TLÑ6%>Tg& qmN0 JJW:[s5xҢp4XۄʔT:?v35':GaN>]+ё/[;ȧ)1` aH0:DHӛ|۰ʲ@ٻhxG|2C4%h֘=l׎NDx&Xpẑ֍M c٦i415hLvEo^S}d׳hDŽ'4@E'td5R1 y,`/z95`F@[Cc IB \(ߟd⃿{{^^n$4E66S 4sfј=[EeK̭)Gg}L0v~$= &lضVoc~bK^{^ǟi ] 5~)ivz_Q7kӋ^.ذ;T:mC^tFr9`Ђ~MzG@ cv IDAT A7`'0~/Gxi+یsڞ5S\ʅõ}Mm:tIOѣdA4PH0.дM0" 6kƈ@33Yu-[m],`)&S3AD:LB@wDH3%}T}7LRaƋ96eks~4fVbq<:P 4/1vN%=,A*| 1wc7|v+Tݸ>TBM$|hƥAfsq/Ph n- hhM R[MX~4 ǜDa:AB  ]|G'wʌb%r͇[Lq2/ fǒHwv/Y/~I )WΉlu,B4^ܮ.f_ջgv.4t7.I<Л={ٟV@żeoFJ>3x@xE$:F jXmjDmbCkШek.&L8Scj!' ҄dRq-N(LlN|Y= -]6V5IOn_] 뱢ı݃Pc|d)4ڵJ[ITg L#<>mt"P@PtnT8am@* ml }Ae ֶƒ{.D.F­(gZӅ2aj$0 Cg)!W '`xw ;?pS |(*SR%{1G*ڠQwv?9W ~qfϞyfm堯N|_EQ3rop<:ºvikvL(0i"ik)JX[ |Oվhj$G\I2SX2cѓtUb`|⯓ѷ=-"xqwA11t9u[}3YU PLzac.?vtLA.&&(]23t.Xu۰:u@A٢w ˼yC1 "`֍Sk0:nExsz#赥1{O-\s[Ed_"옽p<;z J-AP;:/0 on*UA%Iӄ nj S%Hq۶.Ў'?-8Da8D^dh;5$'B۶qOϧ:V/69rY`Xi*$$ B# ҨvdHDz۶m;poϓӂ̫֕ߣ KF>`]r[gvzYWyPB#Hc_&YcZ$ƿgڝ0KDCLuYJr14tsF/.tGFϪGN~D)Cl8TfQ-BTH 3fj"FvS#S{d}m#IMe<ؕK ik{w;ٻb']®.ʲD|kz綊qu hY9Hd'Z} Anj!a=Ŋ,E:?$%WjqQd<ΈHqwhYjܤ?(Wy`lGcJvWg>[jWU>L}Pȸhh5-HDR|,~ǣeKPHag>`1ES9(m25 , HqZD5FpYǾxcƴ|^܂yg0`e!!KӤeС7|3I8%ZK˰1$[[3g"6 &T:2-ԙ3"l30a\O9c6z3+'>@ҎU V2k5fLM$>~]eMX C7w.]I?z;x,"3[h[ZLv8*LOO@lPWz"ư Lt> [z`q'lxi0A+{{,oPʙK5 :G#hEg $0|Եf`bM']eC]mJTR_'I:H9mNպɃ]\m 6OPfU^5;.m@̼r{{77'RLݺQ@YtuZ=ʺJtw;ݢh1pԐZDlizԚktb Z6HzpQGj<Б6wk~i OD|N5h $i# X\-..Ǟ7k+mǑ뫫GNKf3Q@iMb5ch7!(ʹr{V~찘U H]D@CFZhsPc4#9ảx)NJHP[tc-.&MZ#cr.w^s/iU}l8I+8 G<ԙń,CthI;v j]M#/qK]kJ~K`VD(N&ZhH1\G;`nUW}nN)^d' 5eV1udJqiZF/:z_nkK>D7I5c¼{4(eСe׬-*y0+:2܌Bhٌfs\Ftښi4q$;qp)'EI76*va-"szg>?Hyz5) ˪3J%h9=j:EttЌKe.yFSra%{υ[=JZ!ړ3{/a~.t N 0xV|IYBDdbTˋc;$!KyaNYn O cӢDk?2w𢟼e/[s@#kRMDeuFn ;/za ( wotǁ4Yuͳqp f0ˢOGuQsojec7x6a5+&pwnV[5Œ0] H ]YC`S&cɭ}YtF6R1`l;g\3SlqٻE"-(#jKGQHQsRh|k]F"2752x͹.G+09h {tgi& ֔@vO*IdFCbh3Aa!pdGv 3u{h_9#j|$K'>n%VT*|?s`+zn= M۝m ;JZOȾ5L6fMgavp<Ƣ„bvQ@R`ӈHI$pl YSf^S;}ֿ:MPzlItҌZK=M$:Y+7 '-@ObMcc܂VcPŒ-y1 ~X𜌬>g{SnK ՚gGJH2e wq/f`+!ϟzMu؀y0ePXv-lF K_3[$T8cǞ4RPgm$hs5߶>㽨8F.oV2E P F7ز6PNH:f)@Uqَ2"flye f-o4fQ|Z|pq>cB+~8b7f ӾǦ}jz$PY+јZs=ظT LgkxA#ߛ}iasY95! Oŷ~eyNHU$- I4r3+4dJxL:1̶`wW AH3 [7 wuv|_KT)Nk]SNF( :"Fg!hۯ=s7Xw*x^*K( LդS[owzY4.ޞͺEms@j-} V0BUgal_iPQ8H"B19jA; u_ vz֫OK0L򉐔vzCbAj4+ew$25$*XkjP5h2ՇJ~WwN_I[w&ӊȔ~=#bzL&aD˙}_iXxR "X7),vd'urrfDb۳{/BA#ʼn`"43g*~ٟ/y6GEQfgJzZ+_:VnOX9k}ߞR E0s8G53avLye0IVk/q7zwҘ+G|q|777QuEq Ȅ5+,F5po e;Yy jaT ?0t%1*njqAR_{֩8A 9$}v.pG33.4W)w#% sgQcU4 Ɗ&`ے}8i욃X{rkD( ~&NcZ>쐥Z?|p̮tŠYRD޻bk>-,tNg9wE_,3y_z\*!KV E+1 2#$: Dx`.h\AQܮM P&;J, Xݬ nK%^12m,AJdpJJ BGҁx7ݫ`; {4޿?tiht\du &>i3B'h0ەJ.9 it qk 5{ @QƮCj #"De!."r45<:(-LKJWq),:f;ґ38'YU 5< `ll-5v5vz|Տ4y{]2^HZR:S.z˯(] mF KT4oGǶ/4z`?F]m,zZP)>)]323w(%(Hg*!nYO~$3 xOm"պv )qrbwo'$erR54 ob@nzZ3Zsꪹٷ;z33kQs!N^ )ݳ淼]Zz=!55ѡ/NBnm@ D!Q1mBQ%`d ږU1O& gXhŻV.AAfd(/7U2etŏbz`>x<^7R/-UR'1lv{(ӱUlguю"TLb+iKP&/*|&=5:tT >3xs"(G,#rX+ !M-l g+$q2MMnӆ(fڸA x]"d*zJI@2D;+ (5MO195(-AqP=֬@VHL6fC21Nq i=zw>)"N5h+@~ #雠-ՍA,&8 QfY4v0r]ayuտ*h&XX-{0D^UM;xL|2w G0a|m)el[C/6Vۂ=#/LY3lag1v`mBhI(d# ӡZcg~GdSڢ]e4#EmN U{< TI4ZeriϵgҞ`;|v=9O?{}ofhݮ ':1a:^({ˋe[=}odUú&Aiw uo]6-;?yYU|/g>p}iC]Es1vhӻA!*Ɲ `A۴)1&kj$V`Ut|` &]NŤfոJfߙ܅ $ݚNQ74>YK tjsuʢ$v=lz-=6BX=ODI4(ӕЫ /AW;1kE~v6b̍XS gHP03s5}{p@QgKwm^ {DY΢Fo(ZYht3Nî )6tn3t@6O!H"9U*Y4us)o<t]"xXFĎe1LQnք4Ŋ#l߿ qV#65?rdí4־:&8aC()K$iT`@< \3Md&e#߷]O Bi f\~r=4Oy7II6&IW ިœ4qFirS-PQ DF7U]2`CRPj{Ɓ%#ܴL(XF&v6b=4IeM?a%"N=fg40.X%AOZ"aeВ%w32k!P (&MTʐ'go*3z8}13?_yS5Н6Wy)3Y)S?=2o(HIޥ hcEȲ tޒKDK-}:*Q$v/g X-on^|EMO5u;Y/)v婒JfȠut7UGS+@ɂ NB뜙e V 09 $y?П77[J^c!"٢R6A,/*fW>ƪP7_R1iiRҞM554~J+Ω"pjJa . 5QU--):  :ǽl:6J5c|LdP.[O?:aͺrI^+ E*0q=ƻڱ4*Eqf" $*Kز,܌.VVma48(wA"ڰt x0t FcK޺Le!;&F_@M#JE=HY7LeU f@j J8~LڀeőzT 9%NP]10ӼMС֨jhٱ T!; Z}ы'><5a|qcpBlm2FL[azպEi ͐g{Z+~*x\WT2T4IMd9>^=sF[ap"86ZDy :!OZۻҲ[ƢbqhI8"m֭8N4PLX✮Qo W'/mZOqX921Ε}w6ET^5!"a3sZ#;4HMv&vvamC^EWL.Ig9y`~̓=̌+aY+HIZӉ)E;SWwԣZihei:#wstJ 9S'p,%1[ ,`ńIY^SP bl5-ɬfY Y2 'JI\?_~U-7l@WYu19XlƦQιv;Bt(>xBޔS |f0dlP¡ymKX 9L'toW&GYRoZb.cC6}$3X5sA=0xVǯK@ fjF4=w tIV ie#p7%gF16:P*@K]QođQ\I?_8y4Z2w% HQmX}W#q 7NPT;6޵,ȠF-Y-$Բ&d Վ4\'ћ߱ E""yN[KV @Xڕz7_ 2:0.vER MZ2ʭUM;Dl͹F dz(߮oT~ ~Օ򋛞km!dkZx~IL&cQɨڛ"_>kIZg34f[A zO) -3$ID"t)ŀvbR\ۯ~ҁΝbgGO:ZqA-P\"^9Y!k]x| )_qĔ(iQa"{ 3ahu@*j%$~{\򲰪LDnZ##ɔ Hjcq GG?/=3 d]E8Ȳ[}@D pZ64IC&d){m RξF$OY޻@am=>1Rx[XT6A6sC;v0TS1Fzf@}BEzP1%P9/|ؽ7N%,?1 s='y]?g&(L0o:=%Uj PTbI>MŎvL@v|Aݫ8)r/x# {#IjޤՓHc-*נtN ą%}>-V#;/4$KKHFed?/ ?˙vMJIT΄lF߼yO n{W7g0wmƙN>;` YN=r~wݗL LCiҁ`-i3^Y}Rcڝx]wJU}䭠&P.{(F嬪$ژNxEKbVF>?"?}l71vGKM5u˞2f3ƕ寥Π.V:0%>5sFRSRucz"7V|bB9]N5{vxr9Th+؎*QENJ&.-uR cW{GW񫣭^?mcJcQlmڋ G,Q .k>(q9 щn9tϽ-EJst莎 ȋ,ny]cAԫ+"2>N-5ժ kM&!"%p$fkSҙ@XvӼ  "1GW㥤!`_7VFDžŅ`?VcY󺑽ܬa ߭edS6N*_=z_ȏLJ@?H Wq tˆ"Ȓ|t"Ƚ-ܝAsYv 5f\lF)W"G@fҚO~FF- O DʞU=U1YI%:Ew8?Rk; "qݑQwt̀/َA#_JmOH}GQdY hjYRnGiv*7P񉏿rv~FH,r(3Hl'Qalbd۳Gp4:^W33OkH]W"2&9L֫Z^9zttU_¿+ʲ'eٓBJa͡}RW+P/Z d9)zo{>e<퐭kY B5ڝF[ܰ˫]3'iHP魋$ؽ JoJ#v^sՃ]ioR(- jQ3Twp+]vhͭaAŤ;m2ZZ= P57yfY9 dTلS:N 97|$x,(:2'JwLS%Z3ԯ`:^"RWO,|'g㽮ZZ۳25ݛ'ٔ.4@TJAm}FN*V)w͠R.tHfQja~/걈, }B_()zwRIO;f=1#*dY t b0G@p 5i'>;XRցx* Ȩ,b`:[jz 5R9&N܉(#uUr^ȝ\?")EQEb"ltU`އvTlY Z4]]R Y tb1lmvIIl-9et0qrfVq]ܪOD' RIȷǬu}wUP|"'((JY.s<^Er!@EE^9C4L‰0:uG4vͤm%Z !({?]޷N@d<˂sb+(GO}ϼ<;v̰dha4q7l\tlf~3iBÂ*4죪щߨ-_{[=|JDV6ߜݻjW3hE~QSuqyvr<oY©FƄqCشUToF0" ;úխ)qM z$#Y?'MU DNlD7ॳHw>zg]5s^: Vb;\M=i/6YxY@rU|cĭ×6V͑PE(i4$;)Sly15X)3=qMBnhZ xAN%Qh}LidnkIf.N^]=r7pO|yp8eY.Oا((ke5x\VWUUQϤVZF;>vWEb >g>1sMS8h=f{?_:i0`Fh|ɝ0YndX[w {z$5G\a]IzU*\;z/e$^vi9|#{@b[QsRc %9+@_9RY|vFY} e\xz@/J^PLx4Ro^{+7;c rg #1 m>}nާZ\]ͪZY e\^Za]/\WKǏX>vZ&è=W{}zsEYl#!z>zu5(DPBu]({((8Ki]~?z7d'~$:i\ڿرADNoFۊ;[BjZ :3@(gg!u5s 0l _Lj9T18 @0jḒ?BwVr_-fd nAT^-e]_."?S BSͮRlG7;_4L8+l4a[z]㗎d ];](aGw-=8ۀ`|,vv:,0K#YəA,Ky0_g54wHoq~a }i@3; hK5]Rʹ"򍭾;몢'k)$ӷvpA$iL¨[B֡ ĐXsܑ9 +3էKOEۄ=CBJdLdj$/Xvf\13`qE"K] LK-fzǞS&7yw|G?SYKZ-rkE?|/#e +_WCQ:[u~`:=*'H6/3IHi z~nf&D40Fr5cc0-vЬ+~}l׿^W"ѵqѫ_qR;Q}!)35F`+t̻ʼn`Ի$5vӎD%jr]Pi$jqqmi3̔!m,Ϊ#FΘɽX\O ~wxٔ|;L}ӛ> CspR=dD+)ť?ߺQ :ZT;y;"(кRWZE>W++7$8$1Fh藞e2JN..iY9uVIw|g>??mQ(ZՉ ?8!`F, bH3RVGd=Y`|IhcAe"hf~:F2ېr֭0lIY>)>)v6DlFoyCLrB]nZ;"T՝G&*_?24ZukK׬R"JEP%K6'fiMqg P?lCQcH$r7"rLDZo]PxFBHJA+ge˓.9'?bsXWiT023az^X6[A]=mpG?Q$I@ -j|ӫ`REI%T:iXzIDY~Dm@>uĞ^w'`0"kX^gw\ҿÿ_o0ζKuul|dJɱs)*91nc[~a!"r5X`]d9{,zEw`Р7;,sjJ*Ih>[R33^W>淿OOW=wjz:gPiwwץ?#$)@5XwG l z`ie?~hq_W~Ӵto{E""{ӛ$sR ױiF z/lߞak6%rH]ځ;M@AmlZXwcrp@H93|yǎ+0 Pӯ@59@a'J)ep/K)O}$!"JEUu&n%Tsk ίgc Lo3Z)ҒTRE*m`%akbREe?ڂ +D cRy}gmG#~-q}-$uD)n, _ IDAT̀n1nև@E!Egv VӸPAV4qf("h-Ej ԬM9`d1ڱc.}Co2?lsvw)v5) 5Fmi;k6i۶ZěE-^L +*"iLE-tp {ct#3VeuE B5g2zN.Õ4oh-~SO.q|f] Esr&RąpgoM"rx1`G!͹R v0x?'RQy&P5N YG?!"$Qi9/|F).{3!)/3WOzRj6Neлnxqqq}{ǹBmo|j_>EfUu}N+q9V^?[̢lpVF_iZʵzxpm]m i3Žfgg ֕4m7D}s'{Alb8xF Fz(U!+ؑ!s׏>EA A teqTM6Rc2yoȺ#G6.VE:] ap3"$1uI5ڬz<_:dWE;\_tjQxF;ik0'VvlTt!W[Q0t t_5XIZʚݒ h-/P8Qg*8ڱ辭F ?z#/?qe,ꦿ!9@#VMҮvNGn׿P j IJPه<)^Wxё3$3g/[u_\Z?&";Wx#Eb0ؕϡa8EzA@0B"F߾7$g靣)MKZIxȡKYL8gϊgzc r8|#"T%iG$"y%}!ʢ}lq@4= 4nei;Z^.\]Wq6_Y4]u6LBj!Fel@CY^O۱7oM;Ti ?5ж=v~ ݒrA<}6hHP:K|ޡH64՘A7֛xtQM9p#9-m%v%|6#p!?ɉ`^ZRqΆsO9/Q6 Gr6Thm_FL9~!cNf FyB+"X^\:HuG_ M:3G}_rz+,ZY#MqmcfBJPٛP7 dѠْH@-U۹dnJb~BEژ\ҷw\xiaݷ1X8WK)Ҍ[I!dvZk(SJ$M_+mpkd mBWQ\hܐ 8$&E'Լ #YZEeR|s "gէG2ů}w?W++?2Z\+\/f#M MK)%,[yp8۾ͧ~ i"sqPR[%M:'iMf\9pHݛU%k]֯گh?%J2z\a6HڟH\@.\4ԛ}CI8u9 2g~97{`O.VI-Rn #s&UD7k0Bu$ʸET2^YQtQ 7Kb,$(c{PwkJ'o\Ǭr~I )%M Dd, Wk1Sm&eAsu.$F2%6#1pղp36pȚ7ӭ Rvekm&:~J {wP:C={ qLX`)QmC۸Poĺ'E6\&8:oU7T+,uvׅ> ;kZ'=OyKN>gv~{ 0[G:۹L:vƋBʯG=~F 鰨x^ eoީ9XxM':/RN%?>ůy`sSr>vn'H4zT_u=__w~7sK"hwbzgj^Fې!)XDuI;K{۷E,`I (cZƁBBZ}aUUU1v(<3z+n 0YvA-0A3TsW7klcǔ6B(.=ȏZ{L[Bwn`p\Yߵ/?V.8lBNv-zS5h00ljp!Rb{~hVJFFφ!nРYzHOniM^mUK/!1ƿQEX.PG61 ФCvu'?R z@&D )S2hn`?[`^CJUp=W~t2%3cpqJ7WWl(3jl`Q-T̏>c׷^{n{hi"5 "yOqȅ Īr`8 sCo4]KBkړ [9lQf)gдٷ>X$`葏W/˛^e8ڀ_Ĵ\@.\,ܶ+y&քƄ6*&US:B|PHގ3f^V>#@h-~U$ٍ=(jDJ)׃`v !e6RW GmZQd#luִi ǵ/zQk*m[@֟& qb'1pC69FQEշ-EFxĶ]]3t<پp-"HI)5&{cڀ.7Y{c؋_p|8~F^e'gůC"CDj^q-2Na!.t#mЕ4k6dːnAV՗ _њ@7%O7HQZHaxLA~L/0uG??UMmHcU"J`;3)]`>׀u}j Yg@>谓ڬn27?ى**^4aS"JHO5Ӷ'8;l\Bم/ itDnHHND Y$=۵CT~u`LBEFE!EY@Y^u 1KB3õ$H^#ƄL?hV$E5 1{Q&pj!~Xs7[=v|D2PfMLr & ZW{^pU'YWkdx|uq]"z= Vp<k)Zrn Iqaaz :=!?7H:QqX--aEL)5 i$B ]~Z J9yQbͅY9{~G8QMUβc488m20yK]U)#}~LE0t~_dZ9Y{ED߸7.ud8=2y9Ȏ< :@E=ZP XK<\KC\o˗ \8?;?X1qċ@ ]u~Z^#xbvߋ_|iJ](1 n:F"ˏ6x ˲˥Ifp4FdIF20"ExT>倬@f%rA&uIO=)`);;s7ڭ\,VAkCŀĽxt{5am]$B+5sPhؘ LM@"O11 V= j;w^Yr[)%N $?/tkӥcsbL8 /;Rfٍ+X|ɺ^iV 1X?uM ^CGyWaԊ~H:bʉ1ڴ&pE6èՉn/Ai*?]d̲o^WD}ھ͠za溶cV('bx`bk}@}yyze9G q5Zqa-F"sYjuV6߱Qb/(`sAWpYڲ=Lj\*n/""7R_uߘ1xkK Rh-CHfM)Aǎ?mvd8fK[O1hQ(Y-+z8Ռg1Bp:yv^~FjvKS72\np@R9<ݹ3i0uB44Lٟy8x-w GlVZQB64T9ݍDBޅI>՛u慜KoG#Y=vL$٠fP9LɯbQzW itգGq}9gNXMN4Ih=q(gDkj;E^`.&u*No ; H eEBUzmѮL8i!rGaΨ]G`ޅdÍbl EsȚpzh+bQlD ?["![4jsʙLmxI.G8+'4ˀ&DlMV|{}ϥIQ) XZi6,[:Vt<2>n (f´) "QjLV:-)m;s|T߫ǣHOy.4 D;0MI-<$e xn"E{97:g>*TCց_jѵDZw$I{ EK䴘y>S)rJNI M:֥MLS]*҅A9;{]onv(E f>)P-uۿ$aEEn_v=κHr6Q'wYy/hHPA@),w:}v>xu-ʊWT*?(6"6裥Ǩ<ӧ3n>Vz}z8ήP%$hzuZg?{.IzEI:A?uݾl|"bE%h~c2-ٶ96LHw t2p{y\1EV:^њ pj.a=\3HrS|Yj<=ںaiYNM Ε nQ(z%&cg n, FgZVK66Tހ0q~$5W8a\FL~밉 6u12쟊x 0aYMаdFLqҚ_KWkC 8nhE!(6j FLxX@ldZڇ9+[`AzX*Vm7](Rx`̔9jv@>hւa  _Oz%+h#a FVW w3Ѯ Z(K?g],˪2}ΉGwQDTD߭b_ *%O g=:znu W<,ZJP*/@. o WVU""#✽wךsω̌̌=TfFĉs^k9;iD%NQ;\I%tggJbrJ9DaAS!-O?f4Ғe>>]%&|TR" iKH4O߃-3:M#gSU_3u>t1C75ldb>@$˵,p+uZ7m xXWR[_v 9A Wa<ކ)nhm1)`B/{EZ3+]<Ƿ-=ꒋCqAסvi4>#\Y19U9@6LƞəM3V:+;^e &:,|:Т9/1=s,/욤δݡ`m'#+$:ƺg,ϖUBŲ4fD&7 Zr ,G& Z-!La#I%4ɏi9FkH *JfaT~0.mRbm yw: 9 1imRuNI;0(d [`Fu^FUDHocힰ9#t;nZ*JǍ$I,Vz1Elڄ]@Ȣ]!-4RInݫe3!R'C3mC,`J) իkK#;o?YW_ L445V,HG.{bO< )286u@*΁jX)}%"#OONC+Ă'[D64T7 ɴ^ٵ;*b 㕠~RƒiOX2Mr57Y7 bb Zz$ԎRLN4I(N4W #HT7V`ҤYUE_f"0oL+sF/D;aTmVikjmFLLΗCeec (foo6 v ^NK*:A"0fTm_F9qH[qv|;'$M}:v tO1n3s HZX_\$BGoc:j`fa04eNkw[3kfը"i2F;=h;-D ZNt]"ёӊ$׉zʣ:GexV>s̰ψv3az{ZRgy.kƱ~nu@a,I0$C?i:z@fG%0趨.g5[17ɊKpcJJ8PKYɄ*|X,5$vžGDb<^sUý 70ޭJsNЬ,/*gx A墠Mʣ IDAT F6Tj-Ukϵx!bg%9Kre4lM3]yɧ5Ŕ36LucfTݦt P$VsRm22yRzȱ*"g Yɐ&ߙ-6Nq*98ioҟ2)~ dILRʫ].?WoROR+1LNhsS錧k:W/QQiqJ7K>6L eolEL!bI/(^æ9tFڪX1[f<%?'tm^9@I:’;rlw;f}9AσH(d sjiCG#'<*IKR _ Pi].uSp>jn yC"> iy/RH7KˀbpAd Tځ,ޥyr`tu C\mL&UTYBH'scE3=bx M3yP_Mo}ܷl&O܀y g$ Aan_fQȑl#rJ>!,NNɸ9E$ߔHI9Rh&.->+1RTdyhN8Iɀ/!;AvnJo0iۖpȝtQ2QPcEPNVo"64:4 Zȩz6Mhh`FP"M:4 8-gV52س2lI?K~'H66 ovZ(mnm_\XҝW UP -Rј m6E )AkVQaaCLBXllq3DGoDaLGu7$5&(DnU×b#(Qb2u0AFu*ݾ 7XRG(ij"nZYG0;k3hBsO&L-]ofGG"\" fy?XpTK $&$85 ܐSRr螻Qf [r}{vub zԙbL6©E$U+"1 y h 2KU p*p#a guFfx)qK;@Qq-9S&^i6L 3jT iǂxG8JjQꬡRb(g F`RRʧ|}" 4˃I_èĉf+6'|s SNŶs HB1{e,Jq 36Gh:ajjiQWJҭzyht'ZBZ_T6we.e>YPp ŠLdC0@G|@rOꪒD& 5$=I\RCD^#:9L4Z\{n@y@ک6=y0E4wp\ #dM?{A 0vJ2 HEzrIJF(H-f-qFUdx%]\OGi 9J2aG lŬyX&J*]-5=ؗŬ0Jm i/q\̀]`i 4 jvF΀>tU2,Ԋr8J(dV7sQGԹzƉVfp 9 kظu:\ ^# ,(3mFh'>:˺i4˦$Ld@*ed0  b0=|3t*4ɓc@x^]2AL @uIWe,2J4&h6 #^[Zů(7:rWn9ΪuyV!DE*)BD.MjW7 c H|6Z޳bׅU +PHwC-#t-w\ )kbl"ڭV/<$9 .d+(EPAqlr"A@_%A++VOȝZ @?jUZIǏry4j'7"lH؛)6)-[]uS&չو PW&H!}>SsNN~5Sٽۊ7VvT"aR1[ L-v"Bö\4@ks>c xO3јz0֠q$CLS1-3F9bhfP, M-b}2k99]bɜ3FP6qHLYf;RU9@Xv61{îy!7vF) .sort> e:a"+b8=KT^* hmRunI{ˋ"ꌘx)XuBn`T@T( [ہ5_t 6`: (},Hg!Ma`fmŬA9@ N&]8h7aMeHNV'V+cZǛ IZ~Z/A9 Fؑ _dXuhm(b]kqO 'ҿ8$kceMMCCs:$`&Uiī:LE`m_$;] ;CxFAUa=C ؚ}HQhy3q>\F%-g㉍Ii)/CΦtJ|i_8Z: f>UFؑlD4$6OqۇM#H7lueZ͉xΖVkv֌s[OWHoV RJ" Pp,TUS]]ND9̠fjQ6HQ}"MgmR7t6 X = l6 =&vZPH ky8=Zibw ZJ"05A E}3Z\1MMw=|4$m?;i$8QJ~ F_l|nLQ-kUBӼCNAdBO8 %ob+AI^TtP~9uDqF*W^@fCkNu4UK+ ގ3CGCzbk?yW߽f-52v YNV1cJU^=W_mЛ)Αry@wd(<) $S`uK$*=LKТÌz8g$H7瞑qT Jލ18C4}6 TB竾0>b#94D22胰"R^F(*g72Q0I#% r5RXvGG+?eSWw'^"dfO4KG@!)]h9;Õ*bxy; !d3g#v@ EDp`J*j}SCQf`}!dM#$J Vᅔg(&#i!h#{ڀҴ x'oX=@*! )B>x6{70qE082AkYd tOV(}5FZLMoŮ,L}{/,~2 M-雷IOql ,ގ=W]ۗEO~v9S*b<:a_D]Ulm_g9T EwROA$P&kqmf-S90 ޚ܆Ӣכ+柸~`}굵otSR\8sPG٨IOŒ(IAk~(&g4UDQqQ_@AqT~NcS`_Y߬W>Ճ%5 8'Uo!Ag?s: _HB -gNPRz6$Ĩ$VC)HL0%ͨ }O#!ki$!Gӌ'EM*aEW ٠<( : {kz!sP20p Ȟ8GY7tKfv J#czZ.1N0#d5jjyԳ.VS B TaҮd6Fb`/ٵ%ևpNDplM#t{" erҚ8%=KhahJ(fH5f읍\p4kkўZѥ,vdQw$1ׅeo ᆭ޾ZY>w}חvI}ʠt=AYܸԏB\P魕#پH:ve,uՖtMv,MiG 6tb(r/|gKK04b2&zЅdX֍ y*`1aE.MVH ggf8vjTO''mgNLNbEdp`26SgVYkFwX7!x8Ql:Jfa׵f'azئ|w&{. ŠssSZ6D6J<0 UˁL"Pl|șfyNbrLʁFG@dq [ĊSiɚ ZYFSvJ&sL"8NOq 2vTk_c!P [qS8q5X1?lSI^ R,`Lfb-DrNp ŕ `RŨ4C3l3qsC. &v{Yy7\4>^Ӭd)'P29K ֡.9s3ǞyJ Z]]G_ Ϊ @Ym.G}fL ŋ.zUye]$2S;xݵXZR+Hھv IDex_RHC)\`..d <Iq6עS Ǟ Wu-lqRpRL\2&a`4Y <{Qq_AZ%c("[#f N^zwsT A eC;fm`ɦȖt.B2Te"I|?ш&KutŅqQ.nf~K)F>QUD9ZP#PL|&L#66̩O}BDS¡hË--ZZ)=Ȍފ=մq Iy m >$d#M=-e åA^]}:gV'Zd`k HhT]it9A)gn=%=d:D R'R$dWMwN^/. f]5ƽMg0_f5 CF93 g0gRu] l uȆ܂7pqrnaj4GnG'yD|ZTf> V!8~n% V5`7:AScu&wguI$+9oے4y]2FNѹ:xIG"ZE4e(,ڡC{Tf4j#=?#*{HqǑ(H29Wh[2yP(93AW?꿧VXsZ.(vMZT~*(X«̡. ?pWQO8j8ZFJ9Q? VH4*mF=t<hƉf=Wl 0:-U xo`q)Ш< <53Q8h2ƺEFa&o JMܞv5^;~ w=Z]lԐMf]9e[_ّ!x"h0&Z# ɞe瀇xo|_,bM@ْs22sǘ{SE e1 ѭzE- YPP Fo$i@+zz .eYJ Ümsi-fh]Yx<\ݼYөe(ƩvDDO1ڮ۸kX&j & IDATE5kM%(`}Rs{vfg@`dqR }8 ؈aPh;*Nж-^VԴMfD1n p=(5A!716@:'Bx˗][W>2}1opaT: ǟ}mC+HzAȵֳLV[+QLP aPd̷厢rtUT{;%j=z4A=lA(g)Ιr2 6G5VD\>fE.Cw0n<9{ GIc^yNf(pBm#jbK AkC{WT_|o[%f{ɰ  :(WVv?~ի_}_RY$HZ9Ml{* 0G2%ZuVIE<ړ,棹RBnG0IHf`jKx3K'P@ENźz.^`: ぀` xD5l$[Ku]7Ѥ3>`mEkA1uB*ax j&jDr0x&Gf|RҘhB3q-5jOzK''"2m ^yv#O! SI3H7 Hi].*^[@I}caYW)&QTc7,"GfȂ9¯}6B1P 0<,Fϧ(ZUќ Bؓѹi,HUj@Ă 1L Ͷ]l . Zecyנ3NKa#UŦxʇ;ώj-kTgUYFJrtؚ׋yFP 34XR * kus 1Drqu`Ϟ^W\Wru_%?a~nL>GđILLtT`uI"lݑDgm98-&ԝ'm%r( 5?7z4G࡚jLIڅM1)&-+"ԙLT#9~ (d$ \VXB12` 6M k <|T9 ŀѨ˗4 \9Fcn1MrsSazj5X}vR(zjMrIMcw R%U$N|2P`1aJ.6Ǒ#G>pY&ƵGڦuLh1ùN{61N~1 "hݦ@%ra࢛ݢu ^8W=bTB4 º:^ʥļ X8m|:&T&zyiEU OS`aSG32sՌFtܦ#'$h㓺zOwQT,#3$]x){!E7pxSTZ"O=I( ="p((`rLC8@ j'T2E:tnY?$d2sãGׇjGC}Y$\ZUBK Z@3Xb!@! dүnڍA&^ul30oZ @kږFq1wҁ'"$|ӺB\P,R2(Vo`hQ+q-=/Y^_{FG㼭×L{ @2I#{TdU]we$΍K  ٕ3f hM5(=&~ر*#JF}H9E߼ͼ觯}q,l\skC(ь6il΁ÛsIej0 YM,ý0Yy*ozSsͽ^Y6M1fР,]D4Eq1.ѭՖ ї;/e]_Z}^~GoMK(HQ ߏXqݨq~*;z;eW!ӚMM ˌLݧqu}%!G@@Ju|延a:mo_kz;gxtn2|eΌbsz>&jZV7N>Ti3(i"l8 T' #3lNaTwj)>:6F 3uQ ~At0w;DS2}3q^PpV-'ur p">$`|ħܕ3tƋ7A߭CW9e T'`!+]dH" u2 L[uI4IGȐKڻQH|q?(頻F(xyL $lbXUٟݲx|/榱U}8MC̉(Q]B2VyBe׿@Sտ/$C1%nPݎÇom6pR7ipicdIp”ѕy^Ÿo?O\_ڒH6Vkp: ,sDxb]sP|>i 0lM1at:Ǧl@iה^;i"xLXf4sȡ "65 )rN(SׇZrCAc)0e*-aIw:JU%B'~l -OJ͓ '06mmFZdgWBݐMEr4[]1DҹL:b6Z?fSUT'XqԕTٵ q|2_t,R&e׾VEO}3t{7r xs 3v o?/ߐ+{ bgMf= |N3KEz$+4Wvu}Mo{޿5"; f&}2.rJEϸjeJ|fs":fNwbfqYru-kM 0s+Lğ3P`] 3P֭woCOqHȬӜ =.^\ I4Ǜ*"5RA],A9C^)P$.7EoΧoViޖR3~=NL$0z˜^r' BtB|~V_yN5h ɳ`c8<ȗ7=&wEM+TNafL4 V~gWu أuC$/Mױ(F`0{| V C.ˀȃ,McH`}k7 R6.Æ%ΊFϔ3ϒj%ipec]97nOV3"U_nt|~$qҨDq18:~gU( ݳwO?_nE/z%/x%&,SpDQl"[ o)/Z ThfV:sm=sڦݝ1gLiyi[:$YmYIVrSgw?^_1Dzi }|Xq&˻Bͭ'F6g35 jNzSЊg>sw^EZB5i&ΏBQ*~N_Enï}-oyϦ﨣Ǭ9\~o,."hm+b9qF`7^LkWv-鉰9{RIKd'2K֡RS&UF=Y}RR'0;?ah*n;の7Q Л'?NhN%vdlU;,вS.g;h 9߼k+_ꎷwi݃ڵ$ Q%:g}|hiñT*;̬kmFQiR&=S.xֳ.`U}w3=IPp BQ_ 7>[H)v] )ӏ)Ri-mX@y'.DOU_Lw>b7#cݓZ$'{bԻ@)N. bS .or~~EBDdS袰͓,Vh73b0zJS,Kvn[Vp7N^wLY(Q9dcbZy'_#חs{"l"z8QEd͙ {҈- )~0aF `_VV]gss۳BSSY;3]@oE2sP]"w]ݐ fGvd*M+ҡDFnIg3$UuFBηFrYdf?h;@(?3q뚺:ZW;wI i"-@fzҷ. '% naJ$̔5㿗&&u ;!Pgj~t!Ưެ=pxvj$ƺ!/B'v1N1 Ygј-G{RcJԟCv\MI}2 f)qңލ0 A7?.x6- x@rֈU^gH5\/?xߎcG_}}9VwAT KCAT<>]J4 ҝ. +(_׿p߻w˦I>}\рLacl4Ľ(N3vx^۬y޽߇~Z/b/fTuY0=6uRU31ssn[9j3ʿ FFtzP%XFrS)TP(9)Ber'rGQ E7(P9Aawڗx(6A #:k.61BCww]7!L> X~Ft4QxZ@+^r~ o-E\c@X3;cݱ(3(fōF@,Ho{_g=뿕ss Bb>mπ14 0ӞtD@1oF:gc'4Lm 1JTƥ"n$>zm{j~>q B#3OC0[TT( xe9WO|ЃO,h".$"IWGAFZH&tTe`uBt*kcTI L=lte?;axjr z~S'nAuyc P.pXwE9 :D%p0H)"}jMKii؀iȘf!(b[}K˛XX/{ԩDp$cx)a׶>㜈, iq"" "`4+K// ֎//ovut8~נE){PZvzhb2#v-r9=T#;ȑvHJM=&Ti,_gKb:.}{ot2 1 XM8tX͓&Ip%v#Q?֡) >rhMh$?( 4玗X#1瓋!@}[ްi9o^Woxï\ʗ_Zx1zw`ض-mO9`fB>y>9/ (8Ceڢw{ߨ;rr_3|YX5fIǧti@]×(n:#6ukMnFHo퀚QjAi։n }]@pz4usU˷ML{d9eC ͘ʄuPEQΈs[3_ _c]]^/!l$@:L ]8 HQ U'#p.i&Xva@)})=a3KV:U1mB^`FU)>VКK9ay6jec7KkUwbZ L`_s4klb[#r2"'Hc)a5CF+iuMPeY`"l.jT֣Qur4**.|w}_#"M42ވZOM`Ayڂlh@!T`'\).fq4q@ &S`=Mrd|b?Q eTBS`@6,J,g]/Ǣ,ds磋~ҹN+bfΜ9´d/ؖ۬H9zhgﭬ말Og9!Y׌Qj\7ܧI`*^1?xȃρ_>8W {nr競6@#"Cf;Sf%pl$+`C1Hyi**F ّ@;ҬK9#C󺐎k@U*#\ʪ;NVn)˥}I7 g.ɍ@v+5M8"ȸڴ/QqJ@n}LuZ{chQ.G{{S}se<^o} e8FQy|yepcKc+kѵXUzT5<7QWd-8,ŚcFqfZ0RN Y҇oCDX]dV 1#`DyM()t9L` Ox;:J-kQ]{jfWQXo08ErgQQ{s3u`}.( OZBD**_W:c"K =塘4 )rYȈKJJ\qƬm5)nm]c6Qǰ I5)h4yvc_iXȦHI^!?螐Ӌ$}yOs'~ox/Zoa"Wjh]$=Cƌ$cT 4rcjhC"dE2W/"}޹uwSrisȁc*1@D$9Qf{*͘,<{Ѻ"L;ɀ*m"P=r JB k*=vI_\]UuI)dF&' Y+⒴pڤZȨ3}rZQUSkR..|O9'xti) ^ *%J\v&8]$o݌Z@DFFMZh!=[7TM&ab&)՚jdO|oCcT' +퓃}׌6SVMsj8⣀qDG|0aD|}zXYN' Qg}ߵkO|/2̽I8,,@vor@Q눜jzBT%6e@A uׇT͗D"ʒ ߃dfW\G<%šV gҁz4zMgmǩhJd=e۞p3Ѝ#4hkaJ99xH=ߝ@ LDk&sbNj՟љd%f+HŴ4nFw%BƵpT,UbS7 ohFL@,BGYY!Ii2{\gp_- `!ݍ!ۖG i&e^JZkѬcaשy ōRPWZ^Y<ٯ>~]\^3p>¼ϖz5fI0I[&WK,l%s?v  i9 "_)H'E}6z'XCw*vn8(gʄj߅yT.Nf1嗀rsiDZPB6W"E)Һ@+@Qj_T m-,Rx{H(),żw^=:ラlWuGpY$FylNF&(s ;L^͞z+`#GĐ-I(3IAcPp)VNP "N̉2EHJ0#-VN\Wc򃣥c2R 7!? a֓g:-éT iV]L?fOI%㘉kĚ\8鳔LNdwC1)#"†w?y$ HrZ:@wLs,N9VN!ҩ>V>Yh5HTC@nc_j$&U9;8-1}e⻴>33gpģLK [\Woz~Z[ z`=CB'k$Y>g؄+ޙYpmh9kDf)ppM )wXG~y?>yf#[=I 3xӀT Ν˺0$ҌzBbeeW3Yjٖ=,٠j6tƯ_k\DoN/:8ɶ]/3".ϣ,ĽcӠZ]67JhQTwIsagst]خ +y %83K| m[7!5(q'""iɭ@8)b;l\;k.$;CRHSU2\YyvX~}i$Aa,U$(& %v,t&'/5 J^'(DzSr ;(OiE_5[C&6؏EAgAv=tY` :*3䌌ۉO=3=s aoaO!FHpØOj "S < K8+c4uH·mcFH5 qrzZunXuˎ%w5٩=vKo0_Oift.q?(S :?U£ASDoje6>(=tH{OFQGfd17AI [鎟vB ܇T? {* yU/y>>Aad`F}ipF]۲}F̸~q^`SzGf`l@uEE<^p@:׀$i[؊$);.$2BNnJaI;L##Tw juWͼ^ٱYK' aQlaDNfMn($$Q) _4M|?В*P1eCC:=5zd ¸+~{x;$n0XUUccIFv@Tm,T[|P754-qV N0 )5sa#ҍ@-)fA*q^Mӄ=HƴI$] ]LiÈjGE͌^'0Ç}É/au$|$-J$]:3^ۑP 6bH ֋P۟)xv}C2_rCDNhL2rq7/^IȜŢ`O\6C-aAC5AG}׻׿iILy 8FA(dޢk"E'G'5Y"[9|6MAcG?=W?qѠ֟U9†]fG}یK]Lg;;PS pjlIb:Hq/5 .q?i$?wH:Wzmb8@ tt]Q; (D뤆j(^3R6j};·T `p贇.ƿEw5!J1AXY#,,m[PN~?Ӂ$1^ `a\PIVOC`a2qrAF(hW1ݭkjx3:rpx{n8Z,F_1!`"Eݯ̀"EJ-MtZZxF!%Y>ս8JtgEѕ7t5aWE!m‚D*B^љ0FMKo>sn?]v0TtDz-:'ьF.he&Lѵ4Z3>gѥ:hw{DE^Ģ7DǷ!@BzR{ZBah >,QԲ|Vz H  $4OD%h"$4;ng9s}Mr?iZk17: X)&Ϭs5žFZ@ ٿ IDAT2jlX#n2=3`S@)Dj[ܶfG׮/,/P~d6l,RΠԒf?Ԯ5todͨxyU-.V.ysX;Y6͠Pd4@_8iY4`\h0<<; KOva%/vDj_mEu(C I!?g^@R=\dep{'mEcFAJ=PSmBmOqD8Cz͹mNȃdrz=)@Բ=̴6ѠȎT7t(EB׎P*UIpXf "Qwh ukk*Q"0;P;3Gv#g8gz~OjWVߘãX ɑ ut`;qK|?oVڕ4>+ŭ"%|W^%x3ER0"R T;N1DU}"FX׉*JTRȈ oSjEoGBM`^ LP{Ivw!{ 7>(J I4ujៅBPtv@% `2TDӹ~St#*5PY^2ylJDU4٭{Dd򀗈IHH38m4Xh`m 8 d䗰 Y̌YY#bJt ~X#,t!Ekby~7Cc]ws2ҲjTՖHW:GZݣGɃٍF\L5X:,GrW.\**C&c+ <}fX&}[iDI1ZނJ>٣qRwt L÷Bw@_!MUa<\ٳCQT5%Q= h;p:tٌ-Z\paF˖'?diJㆣrbZp(v85 49_ 0tHJ;騤n?*WfIkA~:C4! 7 m_Icn ̣e.QDnID %*N)g5љDPxxRz0-H[!z 84 ] ֲx~K"E^h {ӳխ1jVیf1v=|Qi*LWTVS eA=Ldt=wq;)\EDLBd6M C+TO`@`*s_xޥ_Dzd#Ad;bu@FU^z89B$tyt5Mi(,a~ UaAl^:Օo WToY͑k!<1c\XϭgLyv}:z-.+813 T-eD6񴢬J٫ftoC,h?t3޷v ˛HSʶSڭ)ލ) ̘>:]x\:[xkaΝoŝ8j6RJӟuw;'kӢC\Cq$OHP:Ǥ\^Op*[4B04.}'*|7%TV;w_ ]"Tfy1cxMEf%;EJJI/":&'w,X@J[?Tz6o: hϥ&wggԀsVJ(^7DƵ4y,#d [>ϻ?}U?PAct@%Ri:v*]J;U9-ɄwmbDjAbёJxL/XԺ4:%K[~_捞GEv6 A\ ;DdQDV3[)z#CRpCw}d{2_ºF=ج\ $Px-YZjrS1!Vk cB RA,3GZD+sW߳!\{f=ko|&=)DtInlV^*Tl0(С]9j+{?J|ӃQpMmq.}6q6ZXysn2HiZ5T<[%mʀ(R<:xu}CW?xK)nj‹[Ykzq}RGqp oq *);ENPB( t"GUϚrZ*"FR8[S.y#mŦrz>R7me2FOF ʛ ;~Nυ*7Z$>@gGNU`͈+%Tvf?vmb oa wQ_wt^ =@]2 )X}i&AT%D%tp*Eݵqi;K&iqc1;Eӭ=DD%T}]?ؾ!g Ӗ;>RuOjl .%Dg"ŕtg_#"Y'eʩPפMSkq|P43(СxVZf]$'(YwꙔnyC:Ƙ+FuDHL̮Wbp,4V@$4۱•F+ Ϋv@${9Ե" r^EO4@#)7ڌFa QioT%=P:۪3,-{@$·g<$MëP 3,&1Z:xe<1B3]:FxҌzW_{\qf_6mvب: =\3u67++O:UtdTMf#Z`=YvHr_NӎRiP}Ģ`w3vMk3Ny/|G)0+KAU4Rd2,'86 SpW;b?1 FZAq~"@p/Ƈ8~𶃴cZ \+B߷eJje5 Z~5,tru"MW-X]qE0wF EZogGoհàE2͠7/FMSd,loJx8woۯ Tȃvk4ՋX[D^v?T|i A8̺͜YJZucau&ϣŒsqXk\jczäD܈cj.놓+?{aY-psDc%537vGkeQA&]wXy.,ߢ5m}[V,nHAXu*8Ɍ0@4b]CW/o첏ף֫vxx^?BNs.Zrmn㝏-{Qc,KJ40tPHQmosXH ts@ Ԇ)^*I):6\)o+iӹ,CSouҙ˜tI?ώ~7:&gAtLyd}A]"U``5(hGG/0bF:Ep Mx @{)cȘMƇԓWϟRvZ k# צ\. idtg)|tGozÓ8f<>f{ʱ҃l|ҍQ@bb4[~I @v"" m:'])"`Fܲ<hYͅI x#-I4 F\\> ut2`h#n%@am1?Y?nmt9o'%*v1w TN>"A,Jܱ7< =x,BvҾ\f&ur8G(S%r~d76Xa8Jon :Gb“p,s,PԼ.NkSH[,%P#Ie@-KkTX|f]`6yBPj´,v&f" l^\tS^zʣ5(:Mw)ܰEJfh<ݴl']&mc˸@)3DnE8[VƎ&03SRh>](5e0X/떫f p`̘6UCz[3o۠ۤʨ/B$ܡ.uJ!* >o2:Mgy? 'si@5kI忨 IDATd2,-_{\#y&MVasjA #INfa)Z E,YbGf4KaLn,-+v_yI"B.uϫrk}n(6=;/~/du奟}& ,Y3_l\6@2ضu¶my/B!]Oq"HɱOC2pS,sCv¬=_=R ͇̬P:Nvn ?8ij]T즍؆Eטj{T[QyOw?3-[;'CYj|@wZ bFFDӿ.#rJir+fte%;JEr+*xtd4f$BZvŁ%5FG\dp4o?or}h20\ώ7NyNH按p9ܺO;&aCteС6mZX# X@yu7{eP#">zljqdTD̢CstL:0I dpvv:#݆DnBSBdFH/h2v@t윊9W #Gs3 p˱W#P*\ }MDw+.fDr|,:4mBi.tȠtF=Q}{ lb H ؉^{UЉWkY4hX+8"FGy],bDLiU*7cW}0|NFU/գ?uimgUC ~ SW5uymtMqt2_s8oD\WZB)ML~Xފox2#Jpގ75JUm&ymLw Nev΍!d%*Z4*Nh OCL`Ac%,lRᆒUO oB1#L5z W}\sUG<GrӃtcx,uJrҍ YPNwnDS7#wN~Oƒi 7O;җUO~1 +N|te?Eb`!Ãx>Ip($sK(&'-/Jq\m^ZTj{oxXo'~0NNt$[Y%!5%`.,mҨCe#Fh/n BQ#! G%hwZE^hV|-a̦y}aPәX/2NVVnl>~UW "rϿt[4yE F0_Yx?;db^rϡqy;PJƲ3;.Oҳ?e0sQ\sU%M?z@/: ls>(ٮQKpu&Uo0J5NA ix+~vxVq Z4M+vdJDDBUz".7.|A[ɄzTM%}(jˆBEn;Uz6[!T =TR( IH8Xd = `>f!UeAÎK=F9JQ2mb*m XS3O9͚o5:]R@fT? 7U!tﰎI;!LS 7>[cM@tc0Pz^x4>5 `:^hxH639TB%ҹTP^B}Ќ}!br`Y8Z` 1|׻-=zӛN2H ē{kl2@J2d5q}u1etE/}OVV^j0jaaYZF:&_FGwݐY-rwW!IA'զO\ܲmp.e+3)~1dhVT0G:Wu|G}^]ii:chu+Ŕl ͍U ggT/-a]Ҍ'3tRN0%gmewLh\P[ypǣ1ڲh` "zJ܍EԵCw PKJdDFdq-34KVd62CZ"oށa! Dfq;4xî@9 QIđG4k"@;:Uѣr%'HO.fT_0 HӲiFpt3{8[eja0ɍaC\o\d:2 EL]L \#^9^|~T iB3= F>b{I=|!;PuɳytuVj= X3~_5:gvSDbNۀq:_.57* ) Gk7|z_D~— "\T-nz"zĬ2B~nG)QH*`61ض9" 5W$Sng'8_$z/mق_mbCad:ؐXul!"N{w_ſS M4YYYoٲY3&,B:\sMew|N.#2rml"Rܬt`V9u1Pk۲,B ƏA1h$S$4.?|-T6B6~ëᖕYC~vk\"`F+'PLEahp>,d?yC4Ed]=*@1H- *dFKc"}1\v0V,|77vLO(,gԦkpV_{Lp2W|x 7Ya.!ŀp8q=QUr#(Q(q6h Z (LJQiA8Mn^LXYGd4?T,#.s?2Eb+dJ2G,s4DdY#:X8b%H!jYXi)2,l:?ݧDQ Ndwҋݸ6UZPϋȳEWfzOA0-.xyX K%fyF,nU z֛iaΉyDΞf@`M:s">6pL_N^^8:|xH= Z˝CRJZL}];`~OPAI٬rKúr31ìeK2:kg,=o&*y5DEYf pG[ K6Xer x:XI< zgtM>oܽaGC?\#k@4kCf h9~ ޮYF0)S~F~GHHha]q)C/pY&:`.`HiݧEgMú5ѧYׯ OwHĭY:wRf@ ' $4" -N7띩G.ݶXWaK5Hu.tk{1k3{Şh G7`2;9@$C8-,EI*\^J.1`SFZpw3tghtnF{BxZoO g *)NM~?rnfhвDc^|[^׾Md<$l)H3I2A"'ђO6Q@'PP* m NA8_Tӻz bݷ\0b^BU+^' kGqcwbp&F XMA7I!VH{qjT,N~f,3 S.x&]lTȬB )'Řa:#dg "tK ߋO?\MeM]LKs`TGpZ\WMM5n $\˸JtSWrZ6CFZ،9=Qz֫N)UBw~ZIIn{~+y?~IC6_fn,xHxݫ1t)EIMaB0/{pzn:?(>2\e=l9n~;rѣ}YoqzBt yh%!7U-Q)nMp{B>Ny.eM&9hGj;Q JFOaox֯?Y0HLsq@O)55`\4h Uf4I3Wgod+eX1XJ2,: 3߇XۑCmvTQ`ͤe:g%ƕ09ޔ3BV%Nki͠T lcϭl&(']R},*b$Vk}Fֿ{`trXʩ0ݐo)CB&L8$"_7~{ :u5'a CO! II8TB%J7)EPEӁi8T٦\gpW76͈u}@*Q}w^LB[N+гcin(it)ciPL:b7FJ[j0K Q|E؀ Io a|ZzG3~CɽxvrMi_\\i.ltm̯+EW{Foq,]3K3wd_Ќ&ils7&TGg/lny#PhΩ}$B5N@}}n›7uUsOU8Ёiz$HPxO/9!jjƓi&z}jN􊥩SA8 #5T Z1Y`[185l쵕s*=acs11hA6U$ȨEY[ڵNC#:q" q"TkZXO$iS^uvKCW/!fur;} "6)0 ,@F} !Mщ- ܋?;vm!90R֒lA%"ZI!so1jM)(vxLXŰMB߱2Ӻi"҃i>𮥕}^{nOS?NS"CY*N]Lz3N+7%vZ;[iKׄB$醹 sHqNM]sRHU- >s\O*/l3!5S\$[ [Z/QDꥥW_ȇ}_~vstug'N IDAT&-9ĥ2}4,lHNz;~Ň>N­{AF݀jFrN@yE8ֽn}=1?|#SQU5ZS v?5{1bXD-+vA TOϯMG6/K4ҁHs$i~=$o/ر /6dQqW}DR坶UNș:IY{DP+j0Ri.- nK4#}hxVҕ9oWLvZ\+Mxɋ" ǧ`L5̺\6m(+^hoH1RЁ0l}gtYJ0JfA=I_xǢWT*p_s(H$3 PTSDS|tb)A% ٙ>8pCBtW=aw_JhXDMӊJC$J3 Oo vN [^{v,T,.r-|gO~?Eo;⹷ )&vm .hCzQ~tl6&T:ً IW=P 4krT >GuuO,exu:u! vX2h{{Aw\qϞ۴elwu(ٮW:%DzwnDhOml!;}ձ"#~}{t_uq<_. ; )4$m AA5r7PEioU58{^_ 6M{!6pRaռ!0+_g i:r?ӿ_x['إBLIئh> I ۧwr\JUMX_ CF6>;`"v0WRtN"OI:IJVn'LUS՝-Jo2RAv&U6,4ھDsJq"MBvyP/Td;LuoaiT7#=8qc)1K|.4) Zﮪ("Wo8WIZua y,nJ)֫a ux,(+ͽٺGY]2|n޼ =+Tpj0Fjݎ8ctFFM(`vZC\nf1iִA8TYf=|$4`jcX Sbwuk|o49y7q20@!9)0Nu|#semA Cx3u?''I+_}>0mdL.VvDbCŲCu]K_ߺ^-[M[4`9zS(k(lӵ8">wO"[%vLk[|F(}CX5NkyrUůzgv\ʍYh(z;m|m+uEr T~o9cr`߼!z_鐛gb9L86X. if֓As²,CkwɈ$.4N E6ATCF7^~OLʌ9AǼO"ؠٌ1VPnxbt}є>( h6]Z])ѿA^X lEsv>Z+׮=~6t IcXPl-0~ցX+>/QenCc )`ͽ'~'^%  xƎ~ O#O';tBh oWMaΩYnlUn9;I H C.%mb!ɴ"u_'i(ḧ́ '>z^svT& _أVm}GCSL'9{k^{ ;Qd4maKT_I: R A* ]tѵ+OzEKdù4?cҢ)æodSmk^K=>cmdu*:@EbӘt䃅4mTU%զM߰qTt:!Qٱk1IdԀ VhA 2̧ $?]Ja\x )3%K-CŎTFwq6=QBru2޷ <JT & XʸxfހE(" /Ēnl ڹI=yB~ъ1Gɬa:QPڇg`?$')MQ/nK` wxl R0LI2ڐx,J /5eךvi5X8ɲ>E\Pbj0.U}>Q)~ &⤼5Ы3qj`\BKٞ&4B ZѪ~I]C| UUI~~UɦA6/6o6y#_p_ ?pM 5(b*4P8ȗDD|cFXA =nR@j}Hv7F"l(Z=F̸dMR)K۲EٰM3dSbûzr '?^Dy OVe$ #E7Ҁ+H`.}%~Cg&5:`.Q56)j96QӯzU'>+{|{oIOS*>|fkcwn;>g@yw'MMNPAlEףz_5۲)#z  Ry !5DMZO~D}k~} I""K7ݴG鉿vN ;Y:6,!1ǻQ\եF%*(O{iF}9[;Q?sL#4 ʂ$őB8RH~RG5 AK/jnZm?(b N_T!anMEbMWtb4J(Y[[ (i%1L4S ߢ{5݁xҢ`]FΉ|iX Y-xj.~DTȐ?wٚzJRR dFWr̢ѐ2lįC#[O;"|V%*-lT !TZj;RM8 @3*JmZJRtPQD~ʂ.𸉝 ףܑ_|-?aHR7h7@4 NYuT*ʱv_u:E/ډMC f- DD*8NNv*F o3$'_i 2S|IL1kl9t,W]yۅ?Y//e9Ѷ-Ke5سyQ5TaIJBՀHU zrVcj@JY64 xGyrs+.#__f}jxM#C:N6Xi`J,-KU3$ sh%2w qj@ .YLȑb-ԀCYFIKj[.$gX0@a)%: *}!x}͞Ngo[@jy&i}>QTxJu(Tq+L=*jI{AG?;`$8p*dWZ6<& L4AO _D=0dm[ղ3\-N$qHҫ9e@sҸԮNVkHilyf8 ".a#:`)oUD>)g%ӍӦbAࠗ/E)*@6OO,=Xܙ TF0IM(3ѹ:x}ze#;_GG[/BugAG0v2J<+NM?Gfr??GާU OH(m.^fdC;XJP PTZOӯm.- tP >ԃ=HGD' T6kZnu" &eiuXjjgL3h&*G;1o{@&j-j0Xmژs-ՈZ;^؎"i >1'hkAT-;n )B2Di !"bF͵4XIMơ4"N]⽔ <$"0 \$ 쇎3GU:H6ObĚm3J꜏IrK$>Vww=]QW f(q[v\-ҥ@u5VޏmoO{yXylQDóedȸf~+X9.$F ҡ9GN3MɝKu_"ߪ'L6Cx暫9i~4^ib߈S Qy.(vf=Oߺ-g3g?9)D'yWrje!r8?FBzts76cm#+1N33>?Hb]Yy^Z`uʼAͤn9m0Ev90H9[޳UGndem:uMf$+GH~۷ SRՓ rD# a,&}j`8-8Hfxy--ljf|YvI]1-3Mr:tY PFdZCV :X*[+Vc4U~d~Omaڡ!ŊT0$kpiӮQDv=PDU ILC@1s6r|`v}#LE.d h{ '増g]v_$"Q1ytm Ыd]3_0~P:,@{b`MqID"9_en@c- Xt-rNvm~ AC?Gidzh%2_C}߭JNM6tŰ !S%X07RU}|N.0ڵI5=*asƱiQHZ\(>pyտiAS1n5ݎw( N}٣ji/<ŕ 霳L!ˬ2@f05@`V]%t̞_kHY6-~px/_ڳc' gg h7["ft "`qg~OڛFzub="Dth8Av'o `y)!v{z|md5`5iLgPbYyij|F 4޼o]#nL0'" =?mܼ02(^D6}J =_J9cݎQ7tYr4=#gD%%9L|*EZ4PoDǣ3|mϯm ID(|u/^޳+{o&㜄hqXQӪ"4)SBO*|MIxxefJF X-sA~iJ&fbz Ѱt`T70RmI-p$#Kwhu]v}}5M?TYy,ݯT i%+WF[dj8.@̮q(&HH_sm>iVdfx. se&Q,Db{F,$p)>JҀAGTb@ͭ5Je@ ;z^1t q[ M@Ek#T.M~3Rֹ Ij;GNŶ)NTÒ@{*5yT}$sBBL#d6m(B1Sx8g e*<bKQ5jҝv?wSMs<e RxjFZ` QB23:,ff z^xO>č5b%Y8vet9qZ_:Q)sb4B).sbt6ϔh~-*BaRU#7@!IWh6դcCYi\C'')2m__ 7ϟoD֭O~o7᧚.y~~պѱ)D[Æ:[[UAȟ0w>i֣^<&kPXA68Y:q;dvJUHpܔ L`FuysaCicr[UU[`遒'>'fbqFdG((8}̖_۩5 dҬ1QZ.HJum,s 3}T5PڏNZi7H +l%S`D'6էoMBYͲU@m0G t*̠wP,AGϗY_bի.`3&R^s⭢;zt| E#b߯}tPM'";[)tSU iB?ltѓhH5}k6ToBx>)RN6luR; HjXtF6ep<;zыϐ~wL!xLCU@YbdmI-'eS]4U;?gOb!tK/"}=[S@ u`ŦǺl233mZdeNAϠF4QP̚ 弸-tڬv.Ѷ䱷_s T'\[O&K/Y<ع`c}ZɏR1""=Wf<9$>NK[16t'5"amjvOJؖJkto)O֎ M-li&GI3 p3>z|f2Mdu}7p4K*ѡYGyfIE5B Ly7 &QhआŒrrL& '9kׯ j IDAT?xԂjG'T XXj͕K\ p+-V P0釹 -]d[PB*V@dfEĆ,[@X'?K &* qɌ/蔛lzD>Vգ7TNɱ2`dZw0iߡhZ/!д/DHORRab'2dR;B&AD*Ӹ iKS<$1K^g,:ϯhaЮ.ݙ̙lj#ǫ7l~u뷧M΄ePlP әq~*-Jzg~׿c#}7޸D IX}|]/V- O˲83 莆5.jd3;&?ys{p-c0d$mF漂;igᔿILG"mLW>xN-‚iwhf:Coqb "4եf4"'FPz(ZoJTg$2V̦Yb8.3lx△oU‘a"5') ltXU.Z=kF8y?UU`pVx:A5WU2xArN(<4==Aq("7D^KJ3&0BX}@uigFGͶlGqZαQN[ OXRH "hrKңHN(S&?d9gC-o'M9KB01҈7_&6[v)$ƩwUȲ|L]:{.YaL~pr|ѭ2L7e\\+ c[[ GOd;Ǽ_m Rc]etʁPS?&exbDg{?6N)F>ЎQzpzn~q$HGn5.3(Ƣ8`01f#;zvVG!QWV#>z24.-̯5f^G>{E۟ύ`l~`j0`6!(%5&]նr@-!N&d&r:t7:= s ;u-4ºz2nx\F8,7^#om~6f2æqUչZNU34ЀИTz7I\4w_ 4Ơ$hI0TWw];?ouCuٿU=g=ONesYՌtEq]g-xWj-_D;+1Z4U` dTXEo@6pX1BȺt~G/{|`h@SĤ1&IQt2F ħGS1NV8nDJ18EfmJ'D,!&9b(Z$臼Ywbnzney'@Bi[/JR!uUYC?:0 { ,5 5Q40FG4䉡nLǼU = n&سq26.|%[z#yl|-Xn6}81 [` 6,@%̃dJU$8y q 9ҝUW>=g@.j婺C@5 Q8KwuZ;v:4>tz ӃP9ic-\D'Mnx#!՞ajcJ\bE /ҕ`#q!{*"\y1'܆AHsuR~ek82W1+u[ o%8qP)Lwn1C}‹6Tֵʴ.I#~{; "@,o.CDs3P1-QHsIq; \U%Nsix-Da-,ØiYe&1C dv_̩fuFLjof+3p92: {D-+L oEm*I[-}?[[غTO9j굄L<Ujg$yFD+zmVc>`B1c IaDPky¢~/.`噊nߙ ]gQeqt]Sv̸ lDH_S#9熅[oF R8 IhA@b.׃CdÀG2 @k/D7qrmAyk6AQ> `&,$~X(P: FenßkAa61I1uF&j$-ܕPGMǿKݫM!$!Xs:1*|TW|ޞ7İ=| >^͵(E@Gyq,Gq6U&C=3Qņܕec%!@eS{"XhXV2%QH 9 #uYYHZhq\s^V{Eh,?jREG"y:M=%b4e^xmS_栝0HKgNwNNfm$mhY(tӬke|3Yt6^}?΀ڋ6SI{ ;ovk_{E21Ҋ+#kc!b,` ^)D ĂefEU?{` HBZ;v\?kݳÛІ~{\0vg#8s&s$9=՞aŦRM_}< UOс9EFA1 ttz3{\Uq-+љ@ʐH9[ZwiC͠'NG#a Ύnf !kY\gąsFڄJ&dxPEŐkrm#&A*RQmbs"AII@ۍ,v$qV^Znhݨ,s :BfHHTT/(0nT +H`^L;2Y "[URM!1}H ,:06u{-;m0O %DأP/,(:ƬF溜ĜkQ r .UjWk"vuQZȨD|uk<g/]QI(ݱ cS?䳒_DڳFc}TTLRA#@3%c}g7n%|KgNx ,i~M۽,uNw{t>K{evHY_:ݢ H{xы66=mۢd kqjtp;kptp(wCJ˿{];w0mƞܗGڋ1NN1Qo5ZFAcM\Hw87:Ƞ,dNdJh+,3P$ZpQ9s6l2⼽!k{'@)yb$ p- 1qg0tI* V-+@ ,;QA;YJ!JG7q$MD#D+Ї)- <˴c%fR@mIRs?^nCjwylZԨjMR'X#Vbl5čx|]_s' lKTх4.v>>0>A@Tw"Pϵn!tbAvf-Qns ޞ @P'ݐs53 |y^HZSS7g!j4^l*sjyX㌨R]U*$L+$1sMr㓴ޕ67v<)ڝi$&d;׻7t/<}ٮՎ;QJ.5Fj#{t0TP7CN2 Zv]3=^!*3*1wi)h|QHcQ`R0f_9@X{|X) h9s H uG/0HZTr}$([[^#&pkI%j@1w wKaK}rXRkΝMoK' m3J0 uV̏^5#Hm=Փs= ).U^e˛~ ;Tt}|SLrpcQ Xyrp㡿 :9 мȏdHI+d 㰎g = Nbfc/dMt3`9Ub.j0|r@~m= _sqBS>7TmxVOզR"8{(4oeμ%v;{M]Nihs$_]{nk񽐤 !FPmȄN#ɞ)3Z"&D,ŒК/iy}p|j384/7IV?TǛZ}e\&f*1LU*oPWuʳ%Ұb ڠo2p ؇ (*r1N Iq f3@oP] \ yvS"Kk 3`!7-GQ0##aDh#K#"(P9N>7tDFnj:SP6UjA]=+xK6OD>¾ݽXhp뿧zwzUe{s,xtN=,|:(] 'Dյk7橯BbTq ΅n4h)iEcdxeM7g;G)>ן"SkgEf:dOK4KkuN/-wҋH^[2r Er&۫9Q%yzTT*rbTI2k5P|U wd \Q08" \xUS.>PѠ,k"!YQ{v7=8LCkn*O{x|쌨^?7O6I2a*$ 1}'8--!ʜU==8zoH$ Zu>`Hb0vU9%=s3'7$I<ڿf܆43Gbx`_2٦>dQe`huHh0M@dx$lr& &%n= x)8 .ǡ3)O] Vv bm#[">WR'%t[vQXuBmڏb;VJڻT`R:O{5wmJ]JG)J[$ N'kg휿+xƉQ g'DӣzmUTWqGh U!IB0j[5V!FrVz߶U6 Bh *Wl+FQV1  ~H~{ ΃B` eD 7Ce PD0H1bQ'%aik7`Ĕ;)wyX{RLNDPe1GH&{(hi0\bq>~W;?=ЃoK/*a?~d p(3B5$7|F2>6}3;ЬaQ[#s: |\D` cLCm)MOϿK/}e鏻v| Uqa$:+^׻S9d:,M0idk}W5f QH:AtZޅ3VI<>?ɺW ay$-b`i% &=G3kwEǮʪO'V^R\jVM.;^Ok `)FX!&D#%,'*(P"E!}3")_Ǥ1{"Z˼ iǻ@8JVlwZ13*JhmE 니V?qK!^җnӟaE6л/!҂Uqo(9tkА{ƥ &b'#W"OW~h`;ߤ%@j|Z[R"$%Ue6` +qQ\+FEwcu&z bߪIOuN(ƨѸ /׾vW D\]9X .+LTC#j ,Jg:g߱@?^}up'Nzg%+>nI%"D˺@ ZH]CD(fk DҔz֕{zvVt94{;薭l(r-c^2Dh Sf/+=@C}6 9[v%#ބ 4^8}G.^MN>Ek߫$(* bٿ9A-tx_u-%Oƅ w { :0RR@D`0i6 7bva,$d{KrYY}>n|{~5Վ n Ƅ#dsäP>sN<)@LnZ@Jk8Q@eL AP; f1i(ѨčK4}ij:o>CcdOU oyT@_ c S#-H'YhQU6:j8Ҭ+?Lő`!>b{EEl:#Pr@R9aLvٶ@.d\ђÕKjL<賑=?V#Dwi4Lwzsձ7zgcL51hAS: aЀ^6BZEZIwAV .D#@2bdn?85tDzNÈU8' =wX+ )*:j@!` ϳ] 1L(BmͰC|_2"l =2H@[c|C E/h2y/?j*A7OL;!þcHɍ1%l\VNo5JbdL?bo2ևy3"woiz\tۯfJ\Ze2(i"?p?m'CfgPX"kZQoq ipo(PFjjR0K}HItWrtL$Dӎ QJ/82sWeET]Hi:։``y珖@R9qկ~Oju2-W*J$9#)ߨZI[Vۣw|Q 7'j~z5ߙ4kMZ1I8\fy@<`n;xe ,Tx H\UyI,J"|};|SWQ)lIQ37V=N$&Mc/CqfxM A t8H\N/jL v'Y 5]J#t j 2һa% =BXPg/ |&#Ttr0o=Nsw@ic}&ީA"(2 p:[%;lYYio'e1fH^ Jq7VM}Ԯ~a"tAw@bkݨ!@n{#x 2TpFRW@2(rpSk˪WA! 9j#.xBŢ9%^3;Aܱ_M#k}pxյ;OɨV !G<\X bh§]׎>,bsQA,+mG!͑\1@?:2c(6M'Y*~de=Z+B '"02 2= ԀaP(Փ+6})S ɯK@V4D/Z{Q|%(zlOX2r,+ .(}`S$#O%VP1ڸ+_tz3Z/Bع}6 >'8cݛ/c_گ#ܒX?h6z\I?fՀX9PYd{0fj}*?IvS(3Q%. X|-vP4wqu૵g=Ċ˒+NjĉQ"x;ovcK 8G,kA<|muac0I8ݳg#EK6v"EhB8KvQjmSB}`N,gY棆cy 5h]LNR$t ʱ\&8lϝ+sdVnogPZݹͪ D>@PzD@BC L &R1&_?'0m*@ rp7ّy B>^UXauZ en\ Mh"0J5?ni@R@]Ȩlg&(ց* NLIy ?) "eTkab6qH|McSd;ΨN;M)DkBk D!ƏgH{DpKK#HH `H2Ghd GwaʔaL9Q?㌤qύ?YZ?68(IkW~S=gKyAFC,@ R*1ZC5.J~u=O^Ɵ3e˕͝;{ siE7l "BT! }rAő6 5HRnFDƙROo$S^C2sP%A_(wm%ZOOh02ջ? #ڄԻC#2qxޅll0ߐkSa pgii!<B9|ZV5;{iW< 1@4D2Ĵ\K8 4BmC041y1^x!E(=j>Xwn/JԜ|_F21$vޚgi=8{䃯ލ&9`*hp-(gWV@ڊCp)!RTE!1[ZlCkHDVjw PEc1U(F~_?b\IL58JbؐݔH!&9~!>kI{ΐвٴp CB5hX:e,%TcD)'+՝M5m,xxLTR3aE+ouˀ,ܹb %8!2bi@K-myS[5?Q@eMu1 "67F;I @a~B z+00a%oO}Nwn;/߲<]i edxwjWH"ACCntAzq| s q :c%Q ѳƼ  95aHAʔ$ be#&{ۗ]tDܽ f$u#N] U\pt!'%ўBއ^ RqFU+/HV8E\w)l<[6e{K!6' xmLzC.e[lnG~`RMm8ݿ|KG;gY/hB sNj0t|b֪dzgp 1ت<? F?L].Q( xMEe A0X,l Ff"} +/Cv[ 1!A%WVɉmbh?gX.3o!m(U_i㐤M5DvNY')&t<1YF֫ctՇjS=XK@E7} C-,aH}^s'1n++_+Sɺ*xe>뒔 Z THLHml 5Lp㞌 APrPDY?>оʥ)~UYKNOj.2[:ɥ/B P;h*|e%\D$,Yϑ,Ϻ$[t_W D2(]ʱG㬳Έt%',`>a[lc;NT$zy+v*}bڙ}[Ѽ 3qOn3dq8ƌ'& n:83-ӢOXœa2` U>~ùL_i %HSL[Dk{!0).\]jP= ⃡y+ FB'OdD`ߎu0q44* ոB ! H]k9njA YVːEh;"ʂz@,[gaC*Mۼuc*/~lEcL!&zWhă|j(T |l{y(5O@~>-].[gISvegKoݴg%T{O̭ +^Ff<H}_3Dh~qZ&sZ[>>@cJrm$6HF,Wχx@Ɋx,q"wX-*bYLg$ڮBboT8Gu%5@֢ڗ\=UȁzJKB$%A"gbH kޡڣD!EYX|%nlX~x܆|z:׵a0sBKu:YncU 9P 9[T7jf.2ҡBlCXpɯIeѪۺ5ɏ?4>JA JbX /r『/z/$~6%~L2P$G qd+.npLle{jv|ftz(>l{ooG7_cnn۳m| 9D9uCŀ$9P@rx@Ӭ} _P -M눃*Q!TdQ*6n"$QhPfم:uMŒbkr%1=L@wQt64B!p?%o7{Zt1I7r, l 5 4cp6q ض&C'JIld^o>N^|=13w0I&_!(5_ÁB" pd'֖gq2&]&}vNh8Q!!,d5G3Y6HƤ4Ђ7ِI.v0{Kգ[h':%NF a% W#@9 %3@ma;/j+rc5ﭬC?6ͶumޝR:[Q`:P>&%]bl@#2p&A3jZx;iVfw-v5פ@`{cdIKoFT%u@bX$ Q%e܋}뮽ٍPnNFĝx(")!t11@֐gyқߑگҋƁ`rRO͆k< ]dCj{l`qxމba:cDvC4{C|@l_nIXcK_h|Vsv\ٲ@>aPDa,O2Т-X+ ;O<'LNyM<>vAu3M(v>M8xB>->Js9.P "dBwvz? R1/䲀y RRSNA1SIp u#Xȉ7~'H,E E(M{(c \1)'ÒQE֊K&c9HmGsXLėTm43s0u_ $9N$=JېkK_:lʃn 8~ɁM+8Eά>"]81=}Pڪ@{>,$DŽ]S0_t42}c =Ⰵ Q SB)h1o DjCeEpFXDL'|.c@ҽˌgvZoMeQ2=B<#v); N?;@9c©$ *MHc*MΩBnPxp:sCF:hr,QZʕye (4,@UCYMRq|n%IDȪ;m~/Ѿ ;qqjOO~`qjsͩs)M?,mPg"c:\.RV~Z~̉QCOx6n}Ƽ 8TdX@L|QUhS r7FqtB mCLw]hBSVbBeIsY"F+ *ಜ"lsbTiDoˁi.3/ zf(u|%Te;VLTNZrjÓ`p3wB d`y0 Kv2?~bc_#{N;ol]3hmrkGVKWUP4! b *=,j$a~R(d])D)SR1! I6U. [ٕbȄ3ѱYQND{Y;p9L4db,asqlwδ@W?1>NLd(\2!պ$[BjJN }@Ue0e ]Ub~ͧd[K:{|0KMje댬v4QQ.>8:DL0 a] zݥ矉 %/n;C7{?w+/xQDR}$ MIĹa4 }Xo1"ke$ 1aT>YӕJȶ PFЛLzThDaxd XV-$Ӧ\:&C 6=ؖ R#1=,fdu$~q'vcu}%ֲD(I5@R9Z1Yu!@RBm=|.yn \Nj"du-a?wsov£~sls)|rAz`6rQ!gE^rSGL%1E .#uA'6Vjl!6ǩ3r7 H^"y@$pq#‰94l% j p[u  ) ]C5z484\Bo+w*/)?LNOQ({L c(k/!0(Fg0juk/> /gȵUHxUNܰq_*wYI@WlL вF5?20(bb~gE87Y%DН{8MO_kVq3QYcزRL{3/h$ssε#PBgϞ?Q>Pϟo_csqjmY'{6e$y䁜\ p)1Fڒ6Y.<,NUƆP@bJU P$F4M Q6 0 Kdzv0 $e ;~'訤PECbHROU? Ҳ!|Y I%If/ @@>qdԝ| Q 0zCx #Q":GL_afNQkоaRNr RE$,ɲ)fDjW$&&d;7ǹf$hK# FTv$Fv >U,.{6PƂeQ| O;?xlmg/ a$ t{\\ ,lb DR1S߮fWIa,3/Yܭ;r5mdrH 9aHX8i]I(Dz*ccccq'g0udtOޢe!yAO>[v6&NU` ]Xt.a(=w폽7fݎM醠4,X*A #bh tʘPm?" zhГHJR4qDҙI:f_f-ۈ9 D6юavR4g~m[r`0.(NH kmO.T c f44z#C:VVFDGޘB?Y;c #rPH*O.&$ǟ𵽛2$(aK(9rh-A&.@6E5hF$be~54kʇ_/ p!A)A CS#y}څ7Loamo8OhhΣJvS(QX<:pmZW1>-)/>_L.t[an l-nR $c5KVNQo /& @Kadt]KPaH6C$W:1^::ӛ((C[nO;w~c?wl-̧Tda4ro mJ=!(UNA52B*=mO5k'L1ЊܭQ"S8d-B@v3_j}@yfJ xIp/Q pcp IPj.ۓ4:RҹmY" .B*} u?xDL͗eU( ul/34q5urﰥJz |1lD{^ˆu؛3|~z3 b##g;f75 ,Z{BG/Akdk;?;]["(?fObgYv_庳Ux)އn[o=>C :ŵ`"a`cFz(r』?+.ju܅W7485Ν3iEeE=X7zt00!%AɇϹS5~iuU= lȣ#md[yHRDS@bQVr8,dyk!i~2>i,tslY Y[F &A*8-2FY=rP8 5HGRo0ty " șDn[]S$I8ؿh\ lO!InFmW1֦E{)`YNjaˀlm+G9}Q;?LXek$Vg҈F@֌(:WNs_oټo/q{oHkzݹ=q DA r നR/җR L@hm@K#e:xr"Q 6Z-Β{8qIqPK qSaf{:Jo|#峟-@"P_PˈA+T"xlzh=kf(!tgYbiCs$3coR o}˺ K㉀&vJsAu:id͒z2YY!3!U$4tBwnM݅ ^y tPninB0^@ˢ{ *a1p`b }}5%\I~6j% uJ 8c^+^n* "]RpoŢ)i/מ5k6 v: N$'cD.;ڕ `HT$c@JDշ0E#K6A&wk~ <{4oqwl+kL[aAi{\ 83`΀pC ~|_c7ICIb8v 73zaђ'Xz.6~ܪ-1<`(Q hY(_D_:<=/`he8W7137_~]WIF EF_$#\ʎ} )WK @47IF". $ /ql ~5[-.wR1p=lj }րAő'zNs6 BFeDݹ{>g7\ W=P0Ä7`|:轸gπk Fy]t,ذ\ GIZ(qPկ<ɳL>_ ?+PϛUJy#T.1iض9xAQ>|HۖӮF{󀜻bd$㐍կyG0G lSF+Z_WDϾUKm$~"֞5ƥ>=;@c0z;nzMՓNzsgFР׊)DYgoDṙ[-hܤ7$XhfD-$J6ɜnW.[Ċf9ZvL.8B@Av2A Oj"D<}ge63j?|A. 2C'gȜYힷaYF\f(RFIaRNB;i"a96o 3/GQ.(צ'ܫ\%9ur q|Sd>uR+A95kngۮ[ \rfuŊ?"cU(24, %iK.q"C_*ضIAB)$2;rP0=~,R6׻}]wyڇ~/, MK@Pvkc H nD)O=Ri2|7 5H {h(M!Q?"տUfVmBɄn(#EF;`Pd?p_G:3{qf3s䛓F 5km4eu6DN`Lh"o2aydMݺIQ/ȲE y}B0ʦXhrLʸa2c%%4 G+-]a|;jzLx8T:*!Hc2 Ci@/=9e'"r1u:m=.:^Ma 3 3Fʏإ]=vLkC-6YOniBgisa$:vLEM#G1KgU?a?p1:nnNDr~>f d媡wNc\3ǎ R>y7Za/j%W X1JP7=%xxss>m~r8;rajqqgՅ#'30a眹%uf9:~ʟ,+' \9IΦc `0(.g羼as-ֿQ1l̇ۺyEKin5F'eʨq_._ֺ-+7q|EH!ܴ&ЍsiYbJوԗUMs׋9^g`rQb-}Yp`s456>5.Tm{5(߬6yߘX79~Qcי^x/@޺q~Kj yޣ ?/q8rBWPZY]%ݷ(05Y v.ɵhÆs~6к<\s)jLW;\ )^A ڴ9/c[}ݙiot7(msvZOd嗀6uRDp]~X'Gt)ַݛZ{HWwE6%)0}["˝~Xjyt-{s y˭ @Jmԇ /r~/ΖۖcD<۬U>g:n1u‰b>Nc{FC-6ukNYd-j-mccsLr}}ZVՏ_4JHie?ݟ )/\,vf9'P-"gŘNՁۘf<+WqnԿ5bP_яύv?z@X鄖l_Ⱥe"-;Xwqʜ aY垫ZrI{k>DbS$:I1=36k ^$5'eIlwck wZ)#%nDS}i!}o}‚@wP e~\Y$G]Dg'?/w9 O1:1F9nA"-c|;nVs+/d5Y͗E'u ̿ Z븒9DRgӺ:=JW?]yz# έa[8R]Gkfa؎480-nYS F$Gr\+\EJb9hŬlۨe1m{t27F䦆8դ2{{krr'k06 cV 1j$4t`Δٕ>VojN_|Ya87g.TBA)wy_UXk׮bi5Jڿ'툯.nt'߾gʇu0xcp;IC[# m =0#]-JI_K3 l"vm?+Iх Ue of[%_|g=ZC<~y"3K^d׹>۬F<KWpϾ,WxoK7|UMq A -)wEZk nkproj!뜇%Q̓uy-[ˋa5gV:3( xZEsmm_m#֗XLN^oi8N( f;26#c"DrG+j`Wjas?M{x:Nn#UH\{n8vA0l [-rG5mYS"R/) T[u6.Ɲ_dVsB=4}aDZ'V7&ر{}k1qLZ5Zu9 9۾5b7ӧ[S~AzSS[a↓ܳ}d=`fcC>y>1SG16ev82ʦ_ GG:36.FkN`$8.(Yw{)nw r#sLqI_>ۿ?gyۼ>;>oy{s'ʜם5+9nrgQ&s 8W+9*1/.OkUCVM&Y掙YU ٢~0:?c<w;}l|{y_9^cSYyQYakRK|WA5wQ6jN+Hre:.ـam룃;:{W,y2SҟxK/'>%+YŽ 2`D]0#l_'>7^+?=DaڙD璀ӊ(!9 x0cPkb8 G(I;Qhr$? ,&!I!>o{/^EE1 Y(145Xv4r> {3Zc|Um]~0>K4BHHRAxo<∿[EaryUgk4m# lw k64[x4nҗ5b&O$WXҗ?}s>=u&ױ3 Tm2v%pYm,-["Z4] OUm)~lEL=z _S=X1=&d4;.nF?,;oX08s%k_g䀨,E7R]0(9GO(.j!`̟/1lm$10xsDB! {e؛^K=lafj?Lɺ ogkBU &HD{u=첥}Vxconrp~K4Ǻ` 7v! yt  vaxKSy>tм%dϥlXf080?Rt poER!Bq@ !I<tw?v&p(940T! ]\ǧujȔff k{E|ǟ%ziN28PStp,,k(:):%i=Y"zTg=GcuU M]p/_&C}B!$8ub͚'ȬTVz ]XwJ ܖ?Cڍ)f#B1N3ʗss`vvQ{߲eҖ-G"(~zFK3%ɍCy/8X֖+)I,b "ZGϬbbR=Ie&ˤh&{S#,FI?.gB!)Xܢ>jyZ‹BM˙vQjI=vZ[ "iݧt:ŁM@1*&dvwT.. v#3W^l$Sn\hm}ӽ7Y + PB8]sgeVzF3g懑ppv8F;Ňl !B@ի=zpEXeE|W/hlbb&RTwbnqu7U*y`uZҝ-CS3 ggʅ^Ж ,7O6j f/#p>H.ДpiYU Fc3eP,cni"8dD"A&W߾~ȓ4B!E$tS'֬9r Jb-`m-]9ȬHхS(kLTvRQLeRT ffA \el $|o0x_NѹWx~3|wzbkͬ!i;k[llT.^B!,r$=Ί_tPժW99\MjȂMZ[vw Fȵ,cɘ¥EaVVG#[RoI0(童v[4B<5H(Npa,bB1a; IIKwYKE: ?zY{4[26-IQ B}p6AB!4r$= OW:grݺ7Ӯ^#pg(( -IYY\(FE6\[yH^K8,_Q n$v`!HW ?l{bNS,%}#;]j)o)˿ EJDB!\[wk֬vi:YqT)D 0>)Ak(b,;4E70dtLIYf(wmKKK4BsCq=6E}s"-Kf>䬙va6lu)m:Ě6r$̛}FQ!Bz!8c:gbꗸ~$.0U4Tb+ ;q%l/QVM]*yxeMK~02eu!Bu2SLrXۋ,m̵- jGR2vEúmOUg]MB!ļXUV¹R^eN"s1w u(O;ף-e'm|K5xC97үkԅ5; <ۖBuԔNO䂗ؑ[B@v^3d]G]CpB!Bbի~ibڗIBk彔ZיK $kp8^ KY'lq}i83sF]]1ɡsY] >s`.E)(o[ bm\#6֤b[6L8am1޴aS!B$G8׬kR4شnE4&iģ|K-)M v} -P>R-.뮓EAQJTS*.W#CwV֜f[yl&o6ٙ\6#lXs B!$۬:ɟX$CnY IDAT\-_׍E|J]'R"άn$: eɦOZZgg?;4B4rӔ4hB҈vyZˤ(#]f҆Nf 8%d$PNqK!B$vC49qPoŊX9VC55fa#\B.W:ŮX-K .dfUrvAѹ_iԅX_f:7|lSLMk`1L i. m*cp2|a@| E(%" !B@ӧ27W^Vv@N5^V#v.wQ1QOmg,_Uc76Z^#M7{bbX/%9RIfl#,n7s3Zhfݙ1=ln0]xdw7`-WB!ˣe[W>~#N` !B }9914a%iG4B!BJ[ vZ뭀sYud8"6ucSbɓ= ]Zsdnm6 c|OQ-.?k_۬b<巀 * E^jf0O1J~ 3cS$w~ؤ$:7) nfe濸zaS!B'AB]HȤX]ʖ/,4j:eegF*}_¬~O]ŪM_K+n(ggU,O^E >dYW0 #ĸQ<+ H&q.jyw|#ɃL]~H !BdHHGQ,:cțuڴK:idžڰ BS\xz9i,I2Q.?^~`7U-x޿8{`'yB6B@Z\ s:bh:_h; t'3[ v I!BJOolX>brV֤:U$•4lYVFj"R%7<j87FYQׯbŏ ̬4Rf55 :D&}kH4k j{E3w1 !B !Iz`>L"f5DbRд(YmfRycNJrC1=G{8Y*CϟxKd!ڬ8?% z9WHH2 =q6/ї.;>x˨c2L$@㊄@->p=}FoI!BJnacte-",l8ra>p192Ghu"٪dfYKU0wP#,(o7}+^l&@GG]u HiNt\H8 3< s1􈴐93Ǒpf@a=$" !B"!Ip*AtQkpigeΥZ]etczõ/yv[U]g8/wۼFXQ 8Gf]dD#h0OҲƍشwQ 3Ac 8pxҵS# 9HB!BHH VΝOݕZɺWؓFGH_tsFvLpbs" ù_~htQQ430R눴zyyo.Ycd!¥pl`rҗ[* WXT0B!?$ncZ\Yg}Vuyl1%FŚo(K V֨ 17} G\<pZZt$UC5jI6G_J2BmIL@AZ<&hR "SFC'7i5]B!@BmjXҎn-`w)>7ޭAB!@B#vW93 -̖$kGk76oAdR]Rp1vp .QU[s;?4FTt2&Zi#:0|u$D4ut("TOKZl)gf+oJ;NҠ !BC"!I]zUK[}9[suyRhV=,>moQ3;•a_kruyss32snh ww_P816fuXCR8ͳYPf#1ۼ#n| p#Y_솥Y G_8d B!$>L wqKz*ۢNS҄h[{1tbZ ƥj<(o?Q!9JnY::q*07p$<=LVT|$@owM{B~Gc}goՠ !BkbO+֋/~{s1l⏲.j#ahb7Y<6-j):hK-_Z+W(Dr58׮"qh IҴ8ݢ0b'DuGu$(Ԫ#̢jdqWg* n : 7# 7B!O@in>v?\E/6h )׍!.cCH%jHLyؠ&l8pίv͛k]ye_E$'A1.قgkɘ].uc,Qttѥ-p"r~/Р !B$63\n\ez%Z+R$)-QXӃP/'R\6a2k|t03/*C4IF=̢Hh0Ǧ,A_XZ/JB?@:tG,5Kk0AAAq㪸SHB!B9$$.-<>}W۰z0aey+iEkuhےdl0+I@c};+ĥ_\ fvm!5z0 `Ehʀh7-Y.90l*:DI7Mm] YZr/esV_ p$_$B!@<#GZp!眳iP/[ +V>:f4yJpGjO:P;I)qwFVU/|ZZ_6df(~p`g\ifIBQK] ZraT(N"ꂶqV,}֙GQG6K%tvqweGSR1X2gQbvR린w6F#> Q7ti/ S !B$*Kowߵ09Z_T:3;t=TW|3fC¹* Ý3s7iTāN*犉x5 >ͼkE1,QIn$YTRjZ-O6*o ׍py _> B!O3¦MOׯ~8|!ιΡ*ud YZ{ kUrvL97?^tх@+[$"p;iڤ745!n1[]JK!GJ(5%D q+a1d"I!B Ie˅MwN]`9G}ekJQ\3F(%rep >{IO;$" !Bw$ >X}cns/ssh)z)?4JssÙU_o첇"R868{#+& d@AjTVQ6yb~٨Bdۥ4R 4yGy7'c[>U+B!ӌ$O1x¦M;Z3(t\K52u~RQ8:2!N;K9o'$B!AB'YڼyqaӦzntz$8^4KN+prn~G5Ν?>{u;ZrS;+ICt[R߈H@uf,!Y8s v ]J Zv 5B!B%@}YGgu~V m4WՂ/.^>_[-ﻩ;)dkA n6y#tFET0xƼCJ,ۮQH2vQthm@Y Tm `lؑ'|c^/};\!B!~Ūtmocz^oCHo%%CUY>nf[ ?QD 3D$D BcH83L%nua.׏NYv(Wk☂Ij;.o?xM_J+B!^DBx6PrBQo wx 3x#&X-EP Icb7Srh`fd(s {9qV[\ gðDGΘ lfS^9 B!{,@%kB,u7 /㰉u'|HLQHԬٸmqCDR}MHtf5_fODB!-Bg'߄g;uw6 bD:jof#Eu51QRlL:Rdk@#<5.hfB!xQ6!x'] )$"\c6bVG!$1mjţ]-W3mYҒb.#PFrU+=4 B!0r$ !ij?08LT8w":G )l[pة`1 >fGxlma2y)0 IXJbHDB!bBIB,ssV0 ߼g8f.8\T{c.^cvWwnK BGR7DȔ`1;kvCr`xD$!B!$$ !ij?z/;&]ICIlI"Žr$EK B*--5pMRжZ5"ڝv e ~]"B!*mBU}ߟ)λE k 6m-osT#oFG Z=ʞѳĩ:~m.0 xB!א#I!s?}+V^kf/5ZπrQ25QsIlhH fnpᰪ,Px֫)I!B9b HP e8AX*cXgGǶke>q\;=5!mΑ7~f$ !B@BB<üƋ *P <8+Ag!VZ+9k)s7:n>e#!gӳ!Lc>26=HB!BHm_wiKpʀmYdA[tr} (`f m_?,5B!BHHB=埊 I01\@`j{Xw^딬風8x !$[p}{O8-ZZ20nM:%c!B!`$$ !B_`r"0CKnvU?| dD0RڳGnƽVKOd+urm:nqRB!8e*_WD@* %'T# HIX20j(e}X:9|HlGo[xFqx*[е $B!HHBϜL8;1*>>, Er2q'Qvk|#|HJoFylז:[* l9xU&B!h!!I!~k[~{j/H(0ƢrF,f7iEPv ):,96{KEptBp!mp{}Z!B1$!x*,ࢫ)/V8& hT@<̬xRQM@ ,vmsShf!.$( 37jUݥ-JTcE؍Χ,$!B!rHHB'~7WV; E\/Q HYIɺ27R<<;/L jM63EGRE2>٭$-.$ !B! IBgpKwA𧂮Jؘ2RiZnHGaނTW,+tCSxa ;pQN`2\E( CB!BHHB| z(`|16hT!LF1Cɝx#hY \Lj "@$f7^!a}0[4/w!JB!b$8nDY {yFPEQբͥ@];,l75ndgW6K[xA4Be@*Wxv~&-@o C?B!b !IqO4j=w؀+Ŭ1|ôwyrQ)n4¬ou6_yImE|mww0 L1ۋ/{\!BG$xsi@2h+wz) `ZPTVFR)\l<31q);Ea܎+B۝n82<ئџs!B!ӂ$!ij/|X9.VG@Dpx1 EeQA-& NٚƉTw^U׶͚P3XpB9n 2rw!B!ӊ$!ijKS!TU<ǃ\`eiָ,f5خ8#-(s.5]Ur$s)=1{ ٲھ*#s!B!^CBb-6<qp(0=X1+c )"Psw4hp0`=U(3KG>A 3mu~MWPzy6Pu]`*k@NX&7/sΊDw|?B!BHHB[`j³S5" 9XFS sӎs.ku6ƻ\:qO+V@< Mq$ExB!<<}Hp5QpPܰ:07)èU:pM6<&=>fay5C(e3(j,rC [4î%B!bAB!/[ N# !ϭjrQ1l#Tj"4m-({LzGBCe%6ޱop=$I^I^gǮ2 [\![hцTA5V1+jC4\"E}0T8Q?=gT+Z[D@;Zy?b̥V+x $I^JI߭7::Q^yb}3"\Bl>hkbz]&VDͫ2ʱlcT>m{O|VI$I: $s~GB:u(v:" RlUGQNKR&*8֎W#혜]F8Vmk؆ [[$IS IBFYG:QڱVr 3AOLep Ɩ}~|9 rl/+" :}/wظr jx$IS I|1\|!֨AOVmkX@OĉC5-kȣk4 ŞogAQ4ܟql[[ڝD:*mԧp k$Itܚ)͗퇡ъj`5"&& qr1-ZqΜLy0vIQQh] sЪ6LLe/xxמGxbc%I$$IWF ^nT{8PӇA(*cZNj2UڰqQ/jƘ7 B9FË꼥Œv v!CO,<׸n} _T$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$I$Iv|IENDB`borgmatic/docs/static/borgbase.png000066400000000000000000000132761476361726000175630ustar00rootroot00000000000000PNG  IHDR%b/R@iCCPICC profile(}=H@_SU38dNDE EjVL.& IZpc⬫ ~:)HK -b=8ǻ{wP+1tñάJW""fs@u_'ď\W}~XJRfK'"hABggTa{2i#E,AQ()6k2Trȱ2t(~wk&'p |qݏ ԫ} \M|^mj#njp >xRgM`^{kHQWSzwwF? r-$D pHYs%&tIME &B !tEXtCommentCreated with GIMPWIDATxy<ǟ0c{"B,V%ERD тnڴȍ67J RiED.T :O=fꊹ?>>ۑqyPO{3egiδcNAxyDETUfϜ3}su!0ЛVwp*-y.[[,ع}nȨں^# RHPp?N+<D4t=n*ִOx'Bo;~SXsiXyQ?FӦZr[۩>*x́>|l""?pdBmmm>>}y~zw7eqh*9HIm{ 5473i(FmjUVU ;1Feu5D*HFhW8`^^^3iiK i4ZyeՓ§Wn|շ4'7Rb_tF+vvv~)IIk s#=ъ$:+ѵoJJDl>Kp8].omk$%$2FvKxyy|BBBBJ f%o :-͙??u'o9B{G\".Fp]<,<;6ٜޞr#ߤRDE4얤MĪ"nWzeU $1vbK#Cŗؘ|'MzBO'I#dCôMmlKyDmlיeke)#-5cTuUNh5JR[emRƆ]%P};CVcI/d=̭rŲI:ڨwcgϘSY}yY9)bFSV;;JKIO4?;]oQ[7sjdYKk` .&6Q{2k#RV^U+tkҷNkpy3VS[gFŞ)8{=.|ZU]:::vfr;6{I\^K,AxX vKs .]z/Ŀb8zޖ ;(1*fsfg3RJ߽ɎPi,:-M@v`9wusn'#8yF)wR3^նC>ɺQ'F~BL4հo`0E`nKF-gʝb/2s/_aJحFm }3yHHoh|`>۠AYukruMƢi験$-͉ >Fqa;JS"ν~;8|RX:TOfύXkimmi$ _ϚWUQbTdj?GyYAfXh~#-Y9#W4Y("DZZZ1L:%*+ Q͌ a:`_tf6`IL✼ vwwgd=(*.qzUT-+#F j7"z T ]/:)G )`ԝbOaNlߒ~># 9wvC\&83 ;~8;}anM^N+g}N!,zGwp#$t:йq;icr&$]Rָ8 #-iq7y?GMVgu9.>fyyyǏ/f9;$]aK&Z۾dl%\嚺:ˍeu֮tX뭢zCXxx5qbw]+!6wy]+Lqpt5Z$G`A,43\una>*zm,H,g hAъ1BAXyfo!AA4&;0we Da(WV]iVPHP# rz[6zmp_sk^Aܛxw~d0ĸ+IOc,zs6&v7*%!>J$%EӍ0A')+8uo #LE7RUQNNy.":n%cߎWT~99:}TԋHI=Omlmke񯶺N#\vc 4^q7)¥^9gdIEEOM7z-s jH#0[l=\$1=s3~e%A^OL]YwOݥVc vOo/0x Mkxy3 ۿiˣ: fݴDȡ޴nq&B_L@q4}Q(׮{7N#th?**֙3lPTgFsZ:JlvRr$ӛmmn^C;F8ػ8[_Y~8`XLn'ys{===7o]h}9DƦ&٬/tttԠU'kXR{{G`hة9f)7ljPL:phiߕ/Z~1nWW٘K^f3WU9E%YԷw _[R~~F&f){l~ńj q1eK4z{{[[ۨMMoJJ ޸utK榳 (==uO;iw3Dž,?o*J FWge_V]Skp>vpۿ\<1#ܺgkea<@yh!Aweg$$]G3CcJi)ɾ~um]F'"̙ab0e}\K~nk[۞sMgMA/@-*.ιz3y(~ a0U~6g$ūsf/^lh8qUQsIQo\*'oOwncFC1,h 9ߏ(~;>.. )9~sSv'IyywddySR:(+p{LEi4Kk7yέ<Qn#;BF:,̅f0LJ.Af~?yiFGe&%E;tt^^I$()ZYܵey\w@ph;`KxA&՛)\Za#&|l(yv{{]ۿMH c6zQI?"~ttt\Lǩʞ7RlwowB012eb,-%O$Rޗ$Lppx0 @o7@oz7z7   ڭIENDB`borgmatic/docs/static/borgmatic.png000066400000000000000000000070111476361726000177340ustar00rootroot00000000000000PNG  IHDR<qbKGD pHYs B(xtIME Dw IDATxyˑ]$ /"@AԠ J%xĨ`Ru FK,( l ,,{#.߷;oOL|Tu%tLO{B@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@( "h;Z@CM V[ 迃݁߮mv3h>>jm)t48 oX LHLOxq p5"T Ti>-+Q"j[_szTVM$x&JݪxjtW$09x`]Xcp~xYe({gs |OƟ6Z~>;b( M(w+c50xe;;ܞ huZEo⛖Ud1 x%#B.Շ ?ɦ[\Az:rꋲ5S)^3"jx6pSJAnW^5n/FySfErI0OhA: [>HX^0T e8ȘTOLByE90r.oݞky? ,mThV@tmQШNP_K7eK-۝b#U es_&fheR\Z -+j?My02]L٘z et-%~^ty"K]i?wLP9#4;鷵?eׄTk1 LZ@n]|'̣ETLc@n6+0lSf~{-$|v83#E'9G;ruewj=*P"*& x w Y%6da)s ]o H5Sxr`r. S?qLڃޟLf_2HcQBOQ!v,poDWVEij@ro95_k=8[ < [AkX= z="|C)O9n>Hqg6oנ >ؘ)V32&U *6Y0$aGXڬ'z#sCp7B^GzN NX=.x*UtDxL, Y6=0Ik 5$+֗iK19uCQ>`O%qH}Œ097y=p3rKR8JN>aOWobIMF(_ {8fOX35G'jOq*QD戮}&*EJ,SrbOQi6$M뇵=&oaMoƢuLX 9x$%M#O>ʣ+K/u.f;\f'E_Hju,*}.CyuN'=\aGuR;`078fM4T1 ³Q1\גP *Xۧo!>E,/oh^Ga `]RL7ak .j$'Rxh@7U&3"ȢV-˶p[esEqI]$T3 B%X=b(JTcI[! [I0D9_$@[+~ejRp汛1 ļl71E mK<:di C-ˆX9[4'I2j;ZF=</8][A6_fIlϟj|^<$TcPR? BL-̪$*,~"z'܏ 7U<vKXa.av0k\uRi|l||]xHp#¦*[qĘn"P")&9~J(WPzѾ Cx5g'G0`dЮY+h}{Brm+$Kک`+VnPFe8exos?NLU5_o;lG>(}_UQBj c't!oC5/tη<9 rO F`X&noTL( 5(ӏ|${|d<eT_ޢ=&U3QyWT8cbw\Qb[M0qqNU!T13 Y3Wڣȵ!F2YhU(f6;.Ӿ1淔 '19 3;I(GV8ɶ?*//'b],k"Zfkя lx"2gO`ät`^JX,_lz/ L٤1U>I(_b͋P|<"U9*ue@K(!=;)_gXL4*?1ƉO 8 sı*(nɱ^ar@|FT{pPW| 4DtG4m ]Q1"^mo ]9%: wg kz,)^ y! W MP#ch"0䩒fQⰘ胼ͧXJXXf5yaPc^(;t~Oj8t\w;Nһ&) [ ,n;@{lv;jz P>j0ϪROYUPӜF? wD>ef;fjıU78cpMikb91ɵ x8 12T%6 ;]A|ԭ(̈́ {E%Y 'c'F}Sg Y 1\\Db߻[93;Yf;YUi ԼR~ wU 9zs+?W=5v!z,+CABA >Qz6TX$C"qi&U|{}Js,YR9f*R@ UXLtVCF*Xv O6r7b=G*K^ *Dʧk!PS~gDIENDB`borgmatic/docs/static/borgmatic.svg000066400000000000000000000032121476361726000177460ustar00rootroot00000000000000borgmatic/docs/static/btrfs.png000066400000000000000000000141561476361726000171150ustar00rootroot00000000000000PNG  IHDRX/թgAMA aPLTE;tRNS  !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~IDATxy\e]" ^7'grh4j<[96jMiQԀWb5*DE"hD( Χ}3<;g|9fga%Rǜu *8Teo+KAˡ3pT)K{&IG; ,NacTq2R7:Ԟ؝fNV,CcJL},OsхU옗U<zs"StC&"s*'ZUqL,5Lܹx`Nܬ5EC M}ԷUHZ?ğdGUbMfVlEyߵG-4Q䕇iv)9uƐDyrA[raGU&L9,3ivk+DqQW D=2/lƝd2tfk,a3Yc]'JG #ppp p p p p p p G I-p3mlm|2v(ԣ*} "B=2nhxՔ6x6@;g(QQMJ>V Ax4oV7?Z"cP*M^ѡ>TB-4G[-b嗨F&lթʏĢuIi F"qCikks4Mi/U6>DR[qGUC"p pppp p ppp p p p p pp\Abkh nzTelk+mh e {ʄ&K4;3Q1n'l8Ѱt1QaNK.6}zTgVeQZ!ПH) ԢJsw 譥: .j1x/ZdT|+ Xz-hDCy -~MN9)臦aGp8> %p]~98]ఏ]`ఏw+aGA8Zp8w8.NEpTJl`AL]o;;v1Mt$gj\m &y\QJNsMC8b.oc[w/tv*qo&|I&%JKdD_2ݳ/:q-tl/!/sze]GSW$b4yol͊*/?tX87N4Sn95/Ǖ >7OK~i'9:Q߭r36jޑsphyX=.eoLv8A6v†[79|z節0ip!hvXJ'C VYje0ϢTts2/W88Gϋ{ͨ3VٗG]h[~cq0'=8*gV+e>\1͊(*AYM\s92nurNm"Uɜ\%֊r ՔB4,^t`&^5%JZ;5랠}!}s2n=%}au'^[KEW;_ΓxfcMC8t܉ 8B9wⰼ`GG8pnq#px,c= cV?fcq,v+l=px,J8ٜ@}hZ+XVr i.V޹N]Nw<GxZfo%/  0nt#;NjAZOsf߄l0\2rgmoQU;qjyG \ou)+g[dQ tD;q ?$ ;[J?&krKK\#\p+8crVjɧ\ >^#{[\jeThz`_2wઓI:vོ*^T1~T56Jق!$U-cufo:Qjt]T)ipZФM /xm`~:&Jֵ4CaUT)LN9Fu0ݖd{̴fN)l_9~lh6^ޒ?ߥLݱ}oSnݲ99)i |~?׮5WZ"qQTHR8Y:F$m';=y3&~|p?ȷy-4F1LS]DžGDtHhûHY*P7h$\`(u C*d&oE3N`/gwJl[=NS04<a+j}ػeHxhw̘T Z9}J0/zΝ8,9+^@L"29r?3)a Z rWn:tFOס/"w3Je(z 0/D 9f6,Ǣԝ؝2uXᎵڧ1[d,5{aC #'E2^H MJl)Q|M#?bUSKA˸)ޣǜu *DoVeoS{= WHIENDB`borgmatic/docs/static/cronhub.png000066400000000000000000000557601476361726000174430ustar00rootroot00000000000000PNG  IHDRD4zTXtRaw profile type exifxY$ Dy $.!AL7=#3%SU.\#*6j(zFe'z|{y}ۧxsQ.z49?.hs|}=}韁>?|P|̇Yg F~?|=C|?$yL*d5=s$>G_>mE!?m[8}'}(qE)N?~Dzo!5~~yw7 8~6m'N\S*Ɵ=w#𛭮_FDv[2Xɍ-sK#ts dcK'WFZ33N2н<ʎ/W"!~bO|SxOA}bi-y,QB>1]RI4[-%d/24hެ2Jrzܬ= *P.$-z@h-ZiסJ-Uk:O&4mhK/]{>y4aFcɤLƚ?9UkiNJUkmyˆ®Ǟ'tSO;3/Xr[o;gկY9s:k铵$k?־ NsFrIdytŞJɞ9YYfVcﵶ(2 JNyD*{P(}<zukR9=!{.Dԩ-QL\k֤i6Zɦn_({[#eD]q8O'X@-D4g@b2I5DsX+sr d6VKuw+$?c+ (Je%?ʺ*`<[ ό8 `fXY+T.hLe6# {hg<q,0]FL֠W+vhtBֹLV(1K<@Vx> I<⎞SaCc:FKj_Ĩ8۬k6_:%!5pWVt_&b`d֓Q_k]wLSw'ZcqZW> XK&u,qVBQ=ЙGCʒJǖ$!g]WjᘦB$0h䧯=ЍWq5 qr(H]Ա@m?2?[}D= 6вV 9k1c$tF' {ZGÜ\X$dLu_K UTp PشF k|ƂuWi:Yp>29cGg Ėβ|()&K9%pz̙oFvxb[ؿ#Œc:OCHxSyRVBpR@:QTy=ܷbxXX&eJ Eæu)2?B0%ܰ2N%tAyF3oݘt$- 8m|kac1)̃{ Qg'2Xw.9u` Є b}¡C욀0J݇Lg ѥS%%' L5ژ dK#P߭:n̔-LuhR8 M.V(_p_EQ 946[)y]+e2o*.5&Q#9E_AI:vkQ~B{q~ruCHhqW dec@cpt"B! []=.lCSZ"n~3*^sY}hrQV<~;>p&}Esu-TݠcɃ]Q频}u Ӈ4`&R= (!Uю"JnW];ˠ}Ymޱ+H,E \zG?c>K0Xz, k}<ü"ܤ̑ 'Hr?~陠Y<1~efEЦ?-Ay q+=tݻF~h$M;=">N6*s#34KH@fx` 4yt m]w験:3K6#dYLĈ*,'¹a-l$=X.hqXz' ɍ9Fp-V_tة|nYE?=_XVC`wrU1U[jloC:nB,!XcȠa&Tۂ| Ã؃ψ~~@@#bfzTXtRaw profile type iptcx=A@'%7e[{ M]%oC`@j3~s'r> +iTXtXML:com.adobe.xmp J=&> pHYs  tIME / IDATx{|\Wy/Glj%@!i ~ȱ-dpIuRyK@{i:oiҗC{ R %04HXۺM  [[z?ޣ,HAy^k?RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ-Pjql$qmlGCA q XkCɮ5 ltdUc=IrMQV[Y^)V J)QJI D)V J)uChR'V@RJiRJ+RZ(C'ѕRJD'ѕR`j*@R"*@RJiRJiԪs J+RZ(RZ(*RJ]ϟ;TZEu_VTpb@6:H/yyP=)Ndy;ҽMZy\Ɨ/vJB+jU}*mGTB˨OOOfyQ2djZiӟaSR1N'UUn%R2rVTmSYIS 7߬JҜs3[mg|P!en2+ qz۩cDZZ揑bԔ1 8R<#־hÆ $;B>H;)N8Q;\!.c^+yB>GwB-XOD&exx\SĚ ўjIyNB}}}v ?ŤrQ I!SB Bh? B'%nno>ZDCǃD粓>&<7F$=Y/ !~@O&x֩, J*+\.`" =sn`$$H;YvcI<O$ET_%Z@$ BK]|>ZLLͥ|i{FXccFҩEAϨT*\< mZ>,r"R?F`zz[:̳_›X "$A@EY ޿n&S{GFFfjs\uB럮ղ4uΫ6e4 MdZr>?^ lj#ft* jE뮻RTzZ+s>E9x:_X5"LPT[L"A~qy 03L[rj_T-4SSU"xefK }*s-` "> {ș7c^{gn5Ŏo@,b~BJ[wm]C: _槽0 fZy@xvcփspi΍ey$"Ad}239M_*(I)"[+Z D| M!#4똨2~/`f8{B0eR?AQ#km|bWm?VZڎPJjYt6g(<,d1 ?_67fL)Rc".Os9C6ƘS1͒fi>Woi^B㭽 $ST ") /3B_J%i"s-0FЍ[kϗ>S+V>1ffRW/ 2{DĹ4MI~Z#wýE$̼!k peǶ*~0a}s>Ӧ JB\ꌱR9~#s+X87'B@WgatygѢUM9ZiP_43#7diwI Q01izB _"ryxj/$+˗/߰9 !kt+iTCB-/Dt6|{Zy4 'T|=oFǩ|GwN(ß.9C%"L|Nb+t_B @>?tYZ +H%4KiELHeBǦR"ө cVvygml޼XX2iLin޼8I5@'}ƘSM :{ '>Q*{X B]X=VGSvA[جgF8|Zۓ(k$D!Ykz$q}}} sn1Gk p D ݞN`$4{ⵎbTcǎ.xQwȖFrl"⭵]μ۷f~` ~+3(9/ӽCq7w x[X!&g౱C l{&[5ЉxüNnf_ˆes捙 |Ul}Q^:Tgo)N3"d{ݻ`O;7o_ BA:X$d]zN@9O1gĊ:!!Oˀ쌉a ,` C 5!Kԍ1 oܱcGW86Zm߾f3CWyffc1Zc5Y^BFcm7sCX+[vu0/^"/!Ck 3?݊i(KQ.^Z+夛|92K: ǡĭTmBKgn_ޘ782GlQG=M>T`r 8Н_bC'K@+suΥ#λ[%c i-7{؎"늠U$y|R8 /Oyޡ5Y8>:ޥ1W$`ѫ} YlfK :٭ +L4c\QO02xZ41Y`H/"a#ٳ,ŮHE2 Dװ>Vf1xG?=Mw]yS9xuYmPȆ~0햗CNw U s%3]Љއcf}"YE߿"k!c̦Lv^ !8k޹W|"qhclvA~ lF\YoSqq\'`U8O_4.@'\ EƘ󂈕l-`\._066v-q ;1!x`7c7M]wu`h\R5F'Ӎ󮿨@Q MAv5{O ڟ FLDH~)X erlG01qT7SƘv)6C4LŞyCVT!錹k1=#&6N;Zff:nrΞle<,@rcs6g~EpF3޷o }*<?96zqW'{m6f]Zdރ<6*f#!?σ[ ^ſqȧnҲ,5p___{H2M[ QWmp ewHʩ1Bn`{N$/kN3fa׹t/`f_ޓPAنP>T*|c/!ne^^d<v-w [nQ?kCgM-6Zd=3N9pN!D&Ir|_+Yp )Py6!Ϭv 7g<W #DFo0M 2 !"H=> ےgÆ # LOO=3==]8&6 P6[m>gr,ŤybQ'"_^%RuO<{) ٰE3UyQj(zQ~ܽk\~Zf>CA*oh /^]I>Bxdd$=ZTlҕ$/RWwgE$b6}z),ֈ$ ye[Oy{?83}+]K>X;R/tddccv3f?G|f6i6$Xk9Mݗ'Dz# gU{qdd|p藌1opI{Q\4҄bwW&wgDZ\{-3olՒe&vQzmϊVxs ]TZ$9Z""+ />3ӟ c9IWT2,9ky4StDc udOMNN@8s'/6¹i Mf%|n5xEJ`ΝNevq3;5t`);{[y/< .[-\T&'{c(GY鼧go M2/孍 [ߞs.B6Щ瘊 Fƒ[`dSEo9m|::.qrttj)}/ {;=wfE''ձFv0Hs-E~$ND>\zEmēc^$<"{ EK˻ds17w6S! aÆ)vv<| މ_cӞ1|6ey @5Z^\x!GrO6$ԑ-k"`m-<rO>H8m&_ F;S|}14jh@D$OiäMa!v>D}9a[~E?n~[;dfC:6-MAnm.9Bo}X{%BD$laLVZYeg s,l,W-[h4LNN 2P;CXi'Zj1>%{h`wn5e B333f9,Ɔm;eۋ{b/[/B"lpO ~!PWtZ#C;4mcZ?:k [a,j*,pf @y)P'k\ [\Ȧ|(u?+ IDAT3`n̥*m=h7 ~hT'E%R*"R#~q*C+!U{S;͕N >/˒G۹bᡇ:%[_4[uj,U.(F qxG usp"݁n{/NC'lIk62Eδ7R66t5Mk$ jCj`]y"#{]C+1dU|;I:DݽY hd Kp&a"9.:fvgthiE:Z}*ZxסZ:s.H{/{X}0c5̟[)xt6 md1IȆvR\~n!9ؑGɁx@(1$:ؽ:F;DNpZyh}ă!3pcnHvQtM?dim;#zsZV⧚gsqȷM:S:A;9{dM:R-Y#z 3XKJGX)M@Gt֮'/*k\^侼mg~E1B"1O^w!74( UPȚțUvT r8TJB1uo馹@L,so[U̮gR0jd[\ޭ|zyn{[[} g5ӏTnE^O۪86@yo{@XE<.ʇl_; @[ؽ{{5 "G"HYc;B!H83sZ-V!x!O>ZbC!.inXHG =P>޻N:K"@OK$&/#mq$ɽ}}}Q}}W> $YGD$BdI ÄU58*{AG|iDKރ ?=~퍦"Chiy!A6 ʖD!y>5d#V\V!+J@JS%vFy$oQTriIh%6;mn>f>;lyƘ^**Z8ڶT.u?0ɜb}CѥYa'BߘuZL>V*7== z5-V du6B켜gLLLZme8l^_*oHZ=VvQVo{sϴ $Ifq-Hi@/ޔ7:hWR1}}}vdddv c#|YL$"ya)qy%r DZ㘧-q,"1Mou^j55wZX)cZ{s8_6222GʼʃGV8~.@dZ)CiYoW,"0-HH>dQf>-Pt1[yз jorb Ttw/D&[;皮F3XkxW2Rq"F/$ozFWWby|>}g1{[=Di]U@+B0Dp:1=#{Qդ ]1y;r&@BD {p;38*O$'{c<r_߻kO :~>53ƜݔѭqChCo}0p y$.Z}0{g9{O6yx\=cSs6?s-PfٓU䭾ܸ{===' '@} ݴM^l%<Mv T.f:Yyɖ {c"b\< &ރK4C.`/fc.*j^Ȏ'FBӵZ}kID@d[~/Xk" ýq"O$31gJv2q2"3OyZ(%DW0t-cSRJOa?o:84EȘ(σ,ݝ=Y) }˭86Β#{w6![Ieؘ_`_v$_le_8{/;qi,?5W@nt0"*1&f>N8^jӅl%}Ebˋ$I~OBY/h BٺwA mftQ0=眀9{KdzV޹a_D/[_|y@z#KS/zZqꓭ8;@nG˱b btisxP)! !}I+{:~AV_`λ[5Œ)2mu'V;X]IUxZxদJb&GGoѩgq~ay^H u|{keıo>ʗ#I rm Vy[#4a"p^iP<ٽ{xDZ{B{0S ْNCz@Utzzֽ2>佟2Rt;ԍK/WVho8}Hn ރ wGZ'` $kw?4E/M~ O[˙bZi߮5CMo?&3spV |3G*o xV-'^X6: "rg򚏯Q#(xC}}}QI#Wjf'\=zs锵[ݙN1: ?|ݣBCjL7z"`c,316 a?$D/vZس"s8Vv c`jjA01ʒUD(yA$/Irw1aVkb dBV|N Q)bB:μGkDT8ݒsgps9ֹ(X뜻RFPań迃u޻G5%iZg5d>eEƉO-Y˅ʺ4ME,[MAT\"Sק{q'vyC$fdsGI_V8OYԙ;ޜ$ɽ m`GjI^Z[ʗ QA<>{@cjO%I:u\9k-BP_$XE&M1{p____-WmY;8Q||s7JSX7E8S`lt'oWSrQ+RV*^pFQAx"="xr{0d0OF_ iwDZ7%ocuW4c+NT*~+MӻldK! e^Gw=CWJkw3\PAZS !x\muQC*Jٽ{/yAZO5`nH =u0Q)*y8LJw+p6""f;5?F+HQT !p޿gbw+7<ƇVGwH$T*fϮ]wP׸ڨ tG4R"HT2!q>AT\!|=<`|S"(JQK~nd= dRBvbb&F_]OlE2Ӣ"ex@ulMwݻ>]$Xk 4W"cgX6_B6~E+Wy }Pw.hry:ƴRtV JŌﺅap.nDL0NFsd5x "ݶ50|%al>v=E cK+KMd5ΥxPZ7X@&ym>BxFQd)΍HzYBWy d#޹޿muWLNU*srMs#}%k@ooﲷ&$qVRO}5H^|aKQUG-2J6Jr{-œoG~x"{MDpPX%Gʏ簿 ӫ@|Q~?ݍA^QzϑL><8,OFe[d_XO5̌"^b](f8"2Q)*wٺ^yȣpdPzݢ}qA~%LXssn||& 4hA6݈~yw`fEQ$?cKyrrYRV;6˟_˘B6&+>(tY+o spsKI h9zJ눩[ bvf.iT}jj*͛nJW4}۬ ۭ417j~,1HY\\}npgW*uo[ QTŒKOmܦbv tZT*iZng (۩q X3HX_Z REplh%6J(-4 YN^sZcD#Ɵx}?KHlf6Q!k 23\t.G ]4Ǻl5#A$tz?fGJ]%8}͢%"]^7٘0qqPfl_;u1 %m[|V"JC 1&ilT1;o A/|>>991.)ކeyȮ+YJȔx}Ɋg}(Ijjm0%s5H`j-N@lZ$4|!|@B$7>sKqZZ$IG6}v9ćaO7٪,)䝼Q,s4=@3(J%LMMAW~M\E!iΟ[Jr |s{s8<%c='kiq_>$9F1o9{W/z}s"a>pVy@#ne6ߺyH {aǎ]###|K!Mc,c;d5h|vF<~Xrg$`B,tӟr]^V6 nE%*ځM16{!L1Uձ.kA$ _ Eg%DTp:'`=@b w^aSd|Ԕ===2۟*EK@t` g|Z(@?v&>01==]A !D!ȷ#4~)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)RJ)Rk~E SIENDB`borgmatic/docs/static/cronitor.png000066400000000000000000000241741476361726000176350ustar00rootroot00000000000000PNG  IHDRT24zTXtRaw profile type exifx[# D //r|_33?]T` 㧌Cm)~~>d^=u&Bw ǿ.T w+mt{?i7S-皣j9V%o̵͒Bw:5SJ|'(|-Oe7sWZ~O)RǓ|->vwFU>{Y料zxhlvcK'x4R7tL7&Ě-+9sSStRS: @p4%=6:!ĩ9ϞxHsqeGQDSORI?^BIsg3YbIS‰맗wRĥ`hbKER#"YS"MB4ԢI$̵Fqaw4=fɟÐaHiE Hj𣵃)E4Q2dҼZ:hUѦ=K]z{}< +~}1'<9W^e%-]}>nm{y)>>giɀUkmؼ@[v5~ǝ?wPV-?G8Dr:5QpjT `{bOf,LWH&HR~\ O[}jy?'5Ꝺkw'/ךkYH9-w1tijC66UB"A RZy搨Rm*ݭx*A=萓Nz*"*۩ڥ}l&5v{fLk7kYFH)`eA6(6r))$Cz; `x棝4gQPڴzi=;M8;D,BZUzݛ]2 qW LOaa(=Oo ܈*{aNTm!V F-h!r7Mj ;{@},bjcRmRXX4dž5Wi{7B=n,2![e85AP/|; R:6Kl$67\rv.gɨ &)mm# fޛbYlRЀM*:-4Ü`ַ>PB , yhkؿ2l0BPd%4Ij$YSM?[cdÚt _u(*|N"s_Z+-oՐ0K ؠ ~55"Uwî*FXP)Daxt?8;"<Бa/iFH4X y\05#H%d\Fԓ}r ℣3 ؙOZ|ve@zrGY;O gb@(iCd*U$gdiPdA: }XchF+ '-ׯs[TL5ㅳDCߩhېw̛9rFT\e Pu!5CC\bϞ[;0oే qEJG<.J3vqia -D)f g1$h5<G&vϙG$ 1LM.;XjeCBS)p8t72 *w<@{kNW*S+hR p~"trt)d'x(`S3Lثzm"D8 V&`bV?l  D|? UZ1!L`GAwd!fS~ ۥ79mN㇀h ` ޔmv;0X\x'zht`Pח㗦#p^PS'dg=~w hFU˗.a LplӯNF\B_-JbP f FQk)uiF/ң怍qaT{Tpx~1H;+L-ɢaSxI*"t;v0%kxM}ztM/.ya;j7D/KmZw#0(P}[l\ݿF9mmy0G 0 3PLA\K9N`uDa⒋,e"m an6` `nv6Ă `XVӹ TB^ t\ٚ6P+j~+B!'̸1h9EK w4@pu$n!xQ i bOqOm?BK \Fɤ-l c5Ol圄5aFOKb(3GNc@6:N"-RM;&.-Q<$a&|Yi:kv~ǨL5| #Lohe~4ȅ6 Ij |b=lڎBIy _FȤ.e&4g=_FZQ"Yy&JE tխA{ R|3r~#陭LFi:gw>h> *h(*5{giy >VFcd5` gRٳ f6uհt%DZpb FO'34V$޼Ndܱq~ϔ6C<B6r4lo9 #lH $qDw̶e0 b}4RZ2'DZI1&S A` XhT//WL"><"0gzTXtRaw profile type iptcx=A @ }Ohs]{112u?öwdtʿ2cxPH8-2*Yt^b n;M{dھEiCCPICC profilex}=H@_[ ␡:Y*U(BP+`r4iHR\ׂUg]\AIEJ_Rhq?{ܽ2S͎ @,# |E0$fswQ}ѫLYs'tAG.q.8a#' 6ۘ x83.+8*kޓ0VNs ,b "Ȩ2,DiH1U#*P!9~?ݭI8b@phlض'@ZJ$"G@6pq=rz%CrM>7e[{ M]%oC`@j3~s'r> DiTXtXML:com.adobe.xmp 3 pHYs%&tIME ,,KtEXtCommentCreated with GIMPWIDATx=PgVZV_B^⋑= dB1E8&¤ⰋӘPk&G2'EC B "KZؕJp$yr+f?}Y>ԯ\)" )"RD!ERD!EB(BHQH" )" )"ERD!ERD(B(BHQuYZN$V9O 2dt !$;8:2nFJ<ȴ(]Ge=k''^`_f4v7nF?FdD1{Ao޹j,v,{:[ӟj5uu(.#ҋyN؄MS\L U)X>Y^&5Suu~_ZIM$B:5<|_ c8K7y/5X+inLKO Y~j!sFc:#!Ӫiwd-fo*'Ȍd/ҩ5 v*'C6SF^?3 Kuc) pGqè~ЎU%|Wg_xKnǧW)-NPn}xEB,mfldmY ،/ݻ[[U:GU.i ?٬0<ɽR_5vbl^,\?=k+?=vQeTW<*eGak箃w2/B_FamSJdۼ|x-/Zb&U(xWT d UehR<͋tx" LxVzη4Q_,/Å/ ۱TGMd3(oc{eZ笳ïZ5P(F[-R['.tZ-$R<~QCڛg}P-sayq*TZ4HЃ([rYjū@*m-Ȧn.ėZػLpj9S˿bQCG:Ug-$4[ U)dI~fp爨E>8EAfC#&NBegd{2i!E,AMTQ:)&:~\ `X@d5KSnR8 8:-> f?Io0 \\4eF!;P*gM`\s{QW){ݡ=BmrF!Z xiTXtXML:com.adobe.xmp V@bKGDC pHYs.#.#x?vtIME  70nIDATxgř$d@ 0" sIҁ06&; &9l2 M0 h@$6I$܏-;aggvyj _AAAAAAAAAAAAAAAAAAAAAA^PۉFDF,_!;%AA  DA" A AA  DA" "@A $iɫo.BmGyQH4Z,),; F6OQK 5-.[c+Ɩ6Ȩz%4 Ix+0Mj(;ML[iJ >kIlE|#[^n33y ϏU~jȨ?K55_P3RS"@:s׷tM^3}I #l`w*}̬ U0D AA `*(PS +! fq'Bm[VA"tK˱jV[J DJ&tz00=v;!AD_ax[N@[MA",E{qZ\2#v--ADj*A`ҹJXofxG(B `g +=.&Hb[)i8C){ 鯧m2Bm׍z74(Xh$OKpەGFnrmsU\D@ x x12.J #,/ F-mog3ͨ l|}`EB`0Ϸ7 .Hp{-!mH_WǾω[M2hJ#?>o/X+a=}j_Ɏv$aP۹-Q7 M@`n×3\~XGP1Qm>cxL{=}; $y<T)CmSb,󛡶;GFVIi/Ip/pUdL ŰJtM/E0IFu5WCmxz>R=|nw +BmgEF]./@ZoCmoΎj{h+G9+v8lyEF-:  o7j;TNyY.#PQ"@%v0?:] W=i+QL =SR~_>n\j{2012: K)2j;892\::vȨg2k5ʶDRO3k"£BjNBm8I-"?[E+ndq 9fbܹDOpvas};fHc`rgc$fAnANks::^ʐ<9"ZpY\EUS&}ݴ=fdTNEvKX/n<  16`@G9p;[6$P3 z~/Ųf8 qIYN~#%hqg+|#-JU>Tfge"Ӹ_dT/(8xSvȨ;D +g"z;ǽ]ew@&]":[4p8SȠԢ^*rN%([vS?FxEMd Uŗss7ĸ{E6do09h&DFݗ_Hʵ8W^ ķa jodmTow>@(3lFY|ͨOs 9Ȝ  MȨUʰ&.Keuj;DFOp*sxgYs}kn BA{ۊ3ڼ/C9g'`<޵_w'[s^͆(va ~u< 9w!zP{c u`tFmF-*Ýϒ Ȩylzqqj8;Oc0&3`8+/&Ϲ xEx\(<H(<`l"cfˇ{2ޑGxA= uzUS$EF}C[%-_vߪq`Z8"|)<+/2[=WMNH2[v^N/p#x822׼<= icfl \cOS_EM>W,KfM,3FZP8?M+WrY{=PqwԩW98cRdy?ͨEqr/Z5\9(wmȨ'Bm~s W郼/-. 6֐o߉l p@6zgbvFnT-;Q>y)'46W6EEzz8._{Bjcd.U4c[~`YIrPi1k"j{_v>8/YT;3Ƣӹ~ g@vdȨ'3Pgcj*6(^ǫGҝR~y3 `$S?qf]Szm,Po7XoȨDdԼPۿජR;-ej[)IJ&>2">==EN+?Bm+jl֢퀌0W]~~iBwD:f,dowգ~rl4c^z@DʇK+S*y:}·]dBOpI'$:籌A25ʼx8F jv.,<}P-"^IA*y IPJ8r<X)I87m~-ѱ*ɖљ8W@?s@98{mzzdGbFidyeH} n+:`Z%.N][[lY &"2ޱNh^2i٢mP٪\Fc@4u9_-хqܜvDKP뜧,ځ E L-i% cBm'1㍂].EznW7hLpMK4_VvMpK]stqA\k[ţt~flҕoȨ $աFFeQeNb;3ԳXyy= k GDίڌQ3jԒPۗ~hmۅ|J< ݁ k$tVPۍה79&zʺx7_jMbէQ!$x5<u]3nFJ*zs]nߏI܋(⵰.&NWG$|u`F2<*kd( R,+y\C1i#],BpbwgjYM%2ꦄu 6"IHd-kcj}D+%^C,V/g£0 .D7n`^w"w9졛;G (GBm/H!'y?qaET̔:rȨ,ϿO0; ;3H$¶.pOn2Bm'ٌxpq@gs(v<A^j{LJlo\98IP )׬ L *WCmu{jmS+/*k+r'DF]lzdƎ+3X6Lpk.%veoq0xw*BѾ3Cma[!f`:,btmN=WċpxI5. 9a;O|GCmV_gχڞh@톡>jLC'we0*g!+ڞ`=V:հIk > >RBx6veX% UP~pdnKYʱiٹ P%OwH攲jYdц"*|F'MqۨDžhj]VR˫3`Y j{(4}8&7l ,/3HrMZ|mINO>5VNO&]#JQ '~;;pՎ.1Ȩ=n3k6{[8=EVfps.B?x҇"vNQ cÄ,; ^H^??|X"^ Y!^ e~f `=pzix-ͺ3FRpix8Y&;Zwv-2^u\wCઔ~8 .r+hn)T8^;qןAn_BtM`)5$E].@y0%2'O[?N 沛̉ګ+ї- ف3pdumR$xnZw)f1Q+/2P-I^!O^T{*vm m\Zݭ;sT;2{{aE_>!2P{c\; 8;vR/ibDO r/8C'|1+=XrC|u1"$Ye8/=?y>h]~K`ICm'kGoL[.AX{["\O՞{r[ =!`LeKxzF٥ޤ83K'({Hg6@w_W;kr\ɗ<zqR5.2vc_t嘉+G[1%Zyrdjq8uG'c횰}[<>I<l .۟PoW|w}d|>}4KT$Ҍ:Ȩ04Ql[uӮ6xo C"l3q0c? lg3g|Eq!N$ 2LΤSWUNKg&a; oڮ;fR L }:/82|?S_vTYRdw(bb*A#\BŴ|;l$u '&@Q#~ӤzVQ0x.ԁ|^5u~b>prfN/pYd}Bmǀ{vEe_V_|q͇gw8KS6δFF=^Pw©\wȨCkwʻ ^3 #̡[x֕&Qpq6`%\h1wч8P>`6z1%2jNc+`/?Pl`=_۽AOa%p${"mW(q/'8 .Ad9 ;_3ZpvusaE;!o%Pq8۠ʘ(EFͯ+ G~Ȩv7l)i) B I N4SP?̌%ZHW8Ik!Nxr|q6^`~SHFAAAAAAAAAAAAAAAAAAAAAAAAA 5e$>"{;IENDB`borgmatic/docs/static/healthchecks.png000066400000000000000000000107141476361726000204170ustar00rootroot00000000000000PNG  IHDRdEgAMA asRGBPLTELiq3334443332227873333333334441113334443332225553333<34443555333444-222222333163(3-/00444333$--333444333333333333333222333 1 0333333333111333333333333333333333222 1333333 1333 1333333444 1333333333333333222333333 0133333333333333333313333333333330 1 1 1444333444 1222333 0 01 1 1 12223333331222333333 1333333.222333444 2222/ 2 1 1222 /01 1 0 1 10 / 0 0 1333111 0222333333 0 1333 1 1 0 1 2444333 210 1 11 3 3222 0222 1 1 1 2 2222 1333"f*L9 ; h9kv 3Ľr>ߔ[1 3r}!e?) F&MjZ6!-O<w@UKb5 B$m;]ozGR}ESo`Nk[ c `O+T-ZP >"W/LwY;\I@_N%I5g8HfUǑO)L9`ykq> K)C/ 8 _e~p;&Xsd5VDٍOWtRNSI3 w0Ug)'" `f 6",F]€% IbHܐ!  D>" ȥ|73" R9ZW;fw$#켻>ߝgp)y[שh;=c&o]v 1cնIf>}ȐtyȡCrOOz|Ռ'5޳lӂV7n9؈MhUfLtunXqI9-qu،zuuWk 꾡В2SozFY1" WOi8G ~Wfn f,zû-Hb$svA"܃6g_ZnΞD H:E_^]G\^o{jfvmmoFs$t@1kk׋{]t@cfT^AMUW: ,uuVދ>ޠh{uv: 0kijzw킍+T@Yi"93 Q7J1z!f3uY$Ŀ "9:G_b|g)Sbw[k+Oi_g3$ i+ 5J_qʰ[m^YH l(` 42 ںɌ%_ 1 ]$xJ]/7 #M ,h٠ F'׭ T[`mr٫.2) !vz?d5~?8LʴrȁTl1YobE u4iêRѢgUW ]љӲP1q (6k~g e6R~gh\r @:,Z G +d@Ha sŤBk)_H+`M6(ŽT5HY`^LR -3zB/@ʈlNzQ#WDPYbtbyr Ű?4^-Ӈzm[耬wLj\KY#Ol̜ l U̳e@pIx>H {0Oxd';][R$p搘2ݩy/L sf>?Z4#z|Tܖ% )O ݙp -ĕ9};@fj!'CEi<~v?0} *#Er*^vB+Jn#sʉ|ٸ] i%z97ہ_:ǵ{ݝ"V( *sB AYt@bO ANG0ѾxFnp!:@"!3w ȎU5mLrXDL9NyT@t(Wz e$kv  aχ˅ڬBxj8Q `ZHv ZN5 |C@桍52uBr_N]>],TJ/- >_?CRD U`8&URYxPj9t(Nym;sp:CM#*吭Mx QbJdX,r`Ag] p=ln)@ ^mWaJo89wvvI^bc`B)w|Q<ޣZf. ;a1c[b@OԖ40_jz~id,ԲKL uv9U"2?I5y] }[̆y(u2t9G4^rqxF  qĕgB8b5 *^<7Q́E$)~rJ⒩H 6)bVh2:UQ\Ŝ*U8 j@.zO101K^)܈|C3$$x%ϜrdhoGTR 6^|1to,KNdˁpN#s4xaㅱzf,aUغg \qܐ:Lș/,:) s;/򆂖\^m*@ '<z!ev)y@BYV65g4TB w(;SXMaQIRfvP8$c'HZ89+xOhdk~m̱ȁĤR|hy.&TbjeU9āo]sLƀod{_3e@td;@|u4fvm4<"<^("^xrfvmx,(u}Ax|yz#38Y2B8^qxLyƧd

#^T|W@WktLIۀtK+@%2MfIƿ^Ǭ9pHr[Iugޔx.zfvmͰ- 'wF2h% iޛuh ȋkܙ:d ~IfuE[Gh^_tk^^0ft= f|i+2jVogIos(շA':89~Pdj4 ~z=C{hxѹ)OU k@}bdh XPMX.4DdZG^P6MuaLorv<:Y5bV65IQ G(RY1'uJdjKKK<-1oKK('䴦-SRg'O L:D^Me(Դ"RIENDB`borgmatic/docs/static/keepassxc.png000066400000000000000000000572061476361726000177660ustar00rootroot00000000000000PNG  IHDRiCCPICC profile(}=H@_J8A!CdqU(BP+`r4iIR\ׂUg]\A]pRtZxp܏ww]cHntJV+D@q9Is|׻>爨E>8EAfC#&NBegd{2i!E,AMTQ:)&:~\ `X@d5KSnR8 8:-> f?Io0 \\4eF!;P*gM`\s{QW){ݡ=Bmr8|( pHYsLL7tIME %$tEXtCommentCreated with GIMPW IDATxw|ſ3-B Bқ X@(@E슍"P)]`+ HQzNhHmwf?6mgwIB9#dwܹs}yUUUtСC.)СC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:tԡC:t$C:t$C:t$C:t\JJJ2)))?NNN ˊFf3^^^@HH]סCW/I,(Bll,9~8gĒJVV6yy`bQg$ шh˂ꍟ!!!4jԈ-[ҦMQA_:tСjML^^ٜ={-[?uEEI2 /q1iɭ%]UEAeTV+:un[n$IСC5$SSSf߾}_lݺ Xb( TJ񅔒gѿE{US\jq뭷ѻwo"""hР^^^ ѡC$ RRRزe ۶mcǎ$%%c1MD hhަ9 Gdd2c20̨B )'lG .&tXji( m\C׮]ѣ;vl6+E:tel֯_ϲeˈ:A^^>%IjcǗkڷeԩS"JJTG3-+xıo#+#݆,+%ĩ*ܹ wڵh4qK:tI ##ݻw3w>$IH Jxz4jڈm;иE#BC Fz.9K||<!qN?MrB* 4m6;ΐ!CiڴnYСCNk=8p?_~A0rNm5tܙͮ!4.~(yʻdiĜa>^ g *\BBBxѣѡC$d,Yի8}4fz$f2`moZ\*璜Ė=?IMNEQ/Y֭s/={bH:tx1fϞΝ;PU0hXhѲ7uz$JU+s,YN\֯nTdYl61|pjժ*uСC'Ɋ!77;w2kLbcc1HUQiҪ1=oA>7R/8$X9)j׉#vZmAzJ:j 6}3ШQ# tСC'IHIIa|أbϭ=4l/Չ*J"JZf ٙb("+R$Id6㍟?uB/Ii:2s_~[Q\kYXhYf .8~ "296C0:t踊Q~|'lذ_ #&Ȱazz}&3-C; +3A: ʒ\{:KauF vѣGӡ[[T"QXp˖-#??__a:t$f /3>@p` *(BV^F>ˇ.ͮ$',(@U cl' B ѢIK|)$9 7o.W.*ѡCH233_~~Ȋ2kGpߠyyLQQfU|jN,lܚ# "*BQUQQePdaOUfi||&'N`mODXC{1HV*((fyfdYW:t!IYYf |Ii2A ,Vzp$byLY*~~YAk5a7PhAQj a(h微U/ R¯ɼ 6t/6ttl 8m6~)JӡCɓ'WE[ne̙(hd2rm{=km?WR>^n}3-x}OM6Z9|lE.hҞ mmY m8csfy94EDXDQ'O dddF۶mW:t\EtvkTT .$==IEvC UlB6k E>E2yU4YQCi'E~dih+b**;|) %0M-#w>gJh`Z捚3|$'#I*6CӦMyᇱZӡCrfff|ݻQM8}СC$Ҙ?>"I^V J]$]]? $!If%WȲpY,$.HgJo Ef IO`ҥ+O:+$)2?#G`p6iш^75"?|iX"+&+W"ቌ 8vA,,&G&fκgy|߰=|0& AQU׳g}СCU %8p+W ;o'Nc$*O1`s8QQ|N* lb E&1d숢P"w3n3hEAKka$y^[. E٣(߮KjfgsO4z71i{ iVy+yڗY[P7DɂOuB oԔm2W!säHx >?P*xokY=]{qHbbIHJ%-#BdbZ+kИV״]Nth\]ڵk9RTzz=:uH2i)|vyX-T)!FAue-#gR?(U4㵪Xr͇7b18dA+l.w!i1ۻ/"x,d`͚5r˭t Qz~~3]pzǔ+pgP sH#.6XA7;>cC{1V&Mɡ?c/)l~< :}c/]+?5{a A"} FW E嗟mDyY.';3رU?5-7ӫ_ QQQl߾ UQ fmw{tʊ_6.b﹭xx!JܖeDg-ت{$Od0L%duU+‘؃HMۻf,,]+'mdʈ -©:AHɊʏ3F3&]j@-'j:ޝ7' 5%w<ӆ5n2/ec?Asυxv8}byV;)!~ZGM-7X"%N1ѥ#2qщ.5**rB # !HJˤ3$$L.0 Q1M߿?V1Yt4k5HVíM[vx$]Y*َޚZ%?r?IVD*$6~=:6DyNr>d<_o#ǭ*95ʐp4.D,,]D;A_W";EJF]K)ArH:Tȋ]wPU!:01fcL{1V M}0xN֣PgV%wTUӹ['bNƢ( Ē%KСd_yyVsDO«>HD*EtR}GNyfL,xpbo[Wajw^c7ٔ^Ə8޽'@uJoLzkP`lƒ"MUL}~"?F)z,~T ՄA.$//s=JMhyr:;7Se^<(lٲ3gb`ڵ+""mr!ngOzLƒccY",!(.kki䂝C#̪V'Ez"ҟӞyߎ\2@}Xz#iJdd$t?l1OI %ϜYiaM z?{DϊfRv8?52%CG51u{岤˒Ios/3 !X4NP5OK}+.| aS3mz1p3R,=;7~͟?\àJ~j>zs냜qrssY7VoDQ?]킏Ngٿ,l LdAQHM__pe" rسѪ_AO@Jn@65/ I%3uԬzn*`i7| ť5)XFT21R!%uKcvTUE]wby,#^^ jPd%̢ TAF Qd} [0[9I˺Wk)xvEmCVn?~Ft ѣGJBčSϼȋܩ[9+h!z l3MT È~=o1 oKjiz+cU^=5 Z=zDGfsX,xykվh ~n{ cԀ`v'O$55APu#vS`gWv VQk55Zwd yq_˷-~iHUU9ܥǡ_7a0.@8hT^Υuj4hڰA ={s=veym]Nz+<?=j6'unsaIA 1t*o$]XȄ6QG哙{K5OKݩtJ*Bpp~s^31dddЪm $;{( f$W6)-Y"8'ŔM1 L" z|l&ie{MJn~ǢXb9?Xb5 9BJ!$=բ^" ZH2oIk8k1\ǏFW[:>2bsuAÀ= $gQS֦T@_>##E]K:*aUsLÕ:7ӉFdhZތ**1g4ai,84':,JY@93S|fj.VD=ɏ~NjstMD')8%9#֥(HB@/&** Eᙃj&{g?cM'1xBf]bL$T RHד\ٛק=J37ogْe &AgJ| )_JD g0#ɘ3#aEuNtrI)XzU(V0RuJqxw5 *P4qYkn*]GYL]c`b6!JbY?cQNZRhGSe,F/"5b`ϔ1Lk\: 2-0yJ<\rٙdeeS;UvApUYĞOtH#F7~A!kЄ&\GA'iΜK"-3|-^!~j{_s.&sdQ0[}GxpVlwn)+UI 0A||1O@׻MTJ[ͲVdGަfĻLGNNqqq%qMZ6Y{><!i2NU y1ARB䨻,- 0Sf׫T6W$ApjFNL5ᝐQo_+ٹHđ#Gj%)'a(,>FKؠ> <\{nXź-r02$+C۹{k_X$eӿ<KZ~u@xvt7~+74󿪊'9XV}8i=7mfˎM@Q=VC:; ;űc~_{`]+1m~7skߎV eZ]gɲAњNͷҿke i ^9)[27*dnŸ:pT+A?p$`l<]k9dn*}-ԩ!A@dff dr9ٜ=X4EmZJEEw׬ؙʪ,C-bt)~eE&ǖ$b: ZpP9w?א(9UU@-Xɱ%IԩSx+[,+'>ehuX;@ YQOfmgSFsI>'wyYF*#zai%b89?ٿޥAyJ70U232]k֬V*NZ_"~_e*l8sعb!{d[O; ggs(^8T\y+039}s#nowo5>?&YP|ROa=]S#zpxbP[l IDATzʒr7>u$egPUw'|I kxr_̤ƶM]n[cQki={DQ_L׭EV$g&`rݔ{&-ĢXb}[/UY:H}{Qp19Xz^y 7[Jwm^%serٚGu7i H{>~;zOW A2çqUbr_znwz.D6\b6e=wO\k=39.]\Bꔓ}>xvnEf  Myz,M5s?GCSp"z珱15p$l{ϻtLi{`ߓmQ?VBAV Na}>WfsRe63SVAY+#'WrAҫ+GP7#6rR9}l?;6d-dFd#Z*VUR]:txEj7j~c67}׮tܙN^;@jy fp!v?S|nSO нfWtoﵴo՘ڵdDlٲ3֧*qLfH6S\׌vLh$oJSSII>ˉ=[&"USdGR X 3xjσ|?99d&K6) ջ7/U b7FI<8(HyYx)ѡ( 9Tz}Tg+K|wha+U-<'kRV25/c+XE/'bvGq emҕLg$-'TV6 {R2@ho|4e2:j$?䷞_)Jӎ4ؗF=_svb~Ky *t!HAEw%.#W4}z;B/qT_F7-?g4o6s ]֏Z! INkΰ9|OvSk^2W|ׇ5`ɠI|]4Ѯ?jvFlߓ;FFa.l\BTMr<:e%}+3|UgLb\<.!# 5Q$^n4 HGODDGQtvٕJbb tap".I@Ľ QAcH1tkվ).) nc0[%6YYYWt1 y{3| >\D%L^#c1n La~~߾uu^$x vWH 0ݟN䫣Λ ƉAn quaG3FN-%s+vUR0mߜʎh~t./LÀ" <7υSxrgtGITQvb2W, dx5Sao{kD>porHZI`o4t3n7a o|ꕟD $B\}NGmB'aEr$0 YF.!:h$ ajcibb Q&R;f2hݙKB cnƊs\T4A%sb8%]9h5s3}AǗs aN}@>}k:hhx4O.˕Iuxw) Yi؇@ʕ&HuiLlc)d|ϧkЛy>K?GxzX ¼{<̹I itQ316TZ-8UtI4gYA^Mcb~%^y>\輾8 La}y%{U^:"w[ÐϿm 3Fr W7h $skTT`0`0ܺFj֩XVMN#ָk.F, Su bťB#ԷISRRms:+'dFq$NP#z[,!Z_l3s9Fڴb*d_}9UxVsj^(DjC3fw;sC,CUȏϪy6pKb 嶱Я\).=SsD}h!eZrg53*i֪q]fږo)fJiռ %^:\?b8^êȍP{^W둗gw˂FIDQr+GG֮"# kw\y?Չ&nJ@yJX оqYɹg[|LHUw@Q=fG ,4ELs2m0/W+ JM<`47cuX8T 矤VW`姵)7<(pv]McrutzzkMJ_̙_YBߊtR8}GϤSz чvf2:\n5, T{ bd6\Ϝ (mٴ j(Yd߯ɀAs+2ig Zr0AQFѢغr{EYҤ2( 8[ub2;RDѱq7י-5<^WmBA6'+]/^0tUɏ1 nx闯kkjPnz|:Yع7ay=';45 b}Spն9nc@.MVhQ4$K>]օT~ȇ;{%vcujSܔTReM( @3Iw]1_a1{`h)/9oκg\3g8 Ú,%w ƉZ3J]+v'A}Jݑby X($JZ&@𶌺vݦt'kMF*!vdE'wV.Bi?J[5[Lx1S[99=ݮmnӞeXhnUDvm*[dɚԀJRF32~ Z]-$'r~)ki68sfiۥv6mm39_s5sL[rR֙>/|kdr jX%>olc=r]JX?˪ǣDW(b *uz^=#H11r\H2a‘s Qk|9yU62u;ť[68yHTW} bpe'9m0Lq2LΜ9MФZ1{b[ \lrD灷16ms㒶93`@ J9>kNPS t\~veW%||]՜,kXRU˿$n#ݏX|-%/LztkiCTأP*'\%",wɚL.ɚ=waI\ԕ\2u<D̩h #GCEIbOMK1W\S, tWHMJrI _PMZw==oYu'L͝Gl V_|Yg&ڍK&^0mF]yᎾ$>Y'R5q339dj } :jNjaߌƆԪX$ еSQ=P)/pi "~ HόuRxʊt*N1I*e?B£˸[%g"37;~"2aZd߂k < R; dg\ @ZjĢj92aqxU21gHrfaluRIp9̓\%$rJ) $k3/lV<ہhr.a){o`Lgq83=HbJ2i3&;' l6첂(j6c.wN΁ϳ(JSԯG4m߅]ХKWthJp{u ~7{OqlS 6DԦL07mt҅]ұE(ޗ:EAMi*41#ړldȔ-|7&MFs|"u q9ܡPcZhIR$QZd!'ý]%5p?X[yFАnn. VH[òXsҮ J^ h!I:+hIt⃃k0E; }}u=w/>Jyu[XuWʺGj5;;Bjy* XʵؼIeF!|ߘט+*J~2wU|^}njmF3̛1lkWU ϟߵwOANn=[п+ 4d6ɿ̉Ӽl'D?cSBʺi-XVZ X$pT6j{s$YNdYT'QIC Qs]w+D*R(jeIDRF ;?ۿaőU$pH.bAI[(GWD4[nऺ}`6:X#ն헙&TսXluH{"ۿ|}o㱉?{7^M,zs8֫٩9k {ao?eɋ# gnXWgƲ|8Qn=Fd5!mQ2vL1Af$XRY:RғU($+k+:nǑFvNvQ;/0 ԫWuB@w^%~ϏfֶTSjcG˙ìZQeR$<42b`'B5aEKχ-4ޭ^U =[XS5] K@]Q78?|zY0ILwlME ,Yo^k6i&dS^5~? q 3}DjU|3pln{"kVMlu3% (٧86ǧs=;.mč@#o"b;._Ж"[O |--k6&fC@ 9=[ IZ}6!MEK 8DT'wjZI̿2ZLRE,Jn% "+?~b]OX$8;αHfRt#",=ĐD@^w<,fAzf۸%0&TSg i^x]M_GzHvlG W}oYYSf(*vvڕ0s۔|HWMN%fĔ aʬD{D'r vj>'G#Erp*Nk,1ҁ>F2Wsι#Pѓ|r>u-Zo%՟,䰽cKp c$?YºUӯ>ZT2wj$-ak9#B@V2iER89QXǬ@Z2Q.bKNXnIAEl2([ 4*Xz%ih]奭Jj!5rpiҊ#̧I Ŝgj5c_ +(H]=#؛}M6C=$+pk%r`!`r_dtn)WfhWZ@ydODW~9tfTƚ-93Jk:ɺ%ݓdI.͏hxѲvwsJfi69Ӷ"9Gˠ$q Ü%q<֪e]2<֪ZMfbS?2BF&@ˡCR?w]ytk4nܘ@Q?ݤd'? p+^%[S]_~J]Yvj I)؉=M\CQ:vjvD+o9כ<Ω6"5K&.Zbln)ghGM`78L!:ܘ+Ke6#m+֓r$VӲ i%V.5lKkN̪J;K31/OBmnzc C4I;?NI<@\R26/frcdDDM4AUUEV tmt#fٿ=" _< :N__A0YIDATA -;E[>`D1kE.?=v"""MV 7E YCi%Jؓof%H9IR;W&Tۦ.}55ETudJS𡖯&vE"Xz\/h4VyUX+g$ ?8nR(Oj!Wt<~=xeڣ46XRv0W)̬? +9{ns_aMGR$IpCض~7Yn$л7D܅:IlO,<|CP+S ۾doZ|k{!EاG^( X ՛Ȏ {K4k֜_=V7Л&i皙s.[R+Չ~ڇWIe:-L0{}¡BG QfW>dWfm'Yp}{s0jResoξo)KOTP{/;Džp8M[MLUfza,|b(9B0ןAPk&7Yq 4:7n]%U1}t;Z &fcp~=x!40zV'2:2)2Sۯ6]Qڵ+!E u\Dֿ_1HܽҎPo٭햇. T~%{Sh@2=*8]ˍ4["EQ[*HD֭ wG0K\$$dZ3 }17ih'3[.B8̰7Y1\7^Oš3 Rs8wW XsZ:$ɗ`795'/>S36ͯK  ;MEY>G4HvtkOE,q%pXq4Z|vKm񂇄 14%.`n1w^Q M̴~b:/us)' x9v "_HO|= x}׋$ǯn,`lZ[(,,@UU2Ҳٽm/v2 z)'w+v Lm_d!WКTQIHe[j=''wMw#$ª_a(rжm۫#ܷ^0~k"OX~?SWo=.]~NϺo5էj6Y?== ]f9?Gbyԅ 5/ywqHh^YBoYwޑ=O}~̊mJ Vx?ګ!X>q'n5,sB}ɫg3~1R+dagO?[ZtN.Tk3^_+VбR 6u,}\]3:>cN2hT@ *j-u>jkҖ:{Zb)E(* 2$LIC2$@U?8v+&S'()'>46f< stHw)B:V^b DGj.0 xih-q-~tr ,SWatnޭB`5}Os ׉AZĕFAYM;6g`-}a0mڴ`S|xb=4W=`Ko{XA.chf1c3/&v/JQ#7lC1q|ϧi/[\ W_?7ǙT"~15O<>/]{?}.=ʎꆷ¬-fm'1Qy |sJ:l_1JVJ[s4ҕ|>+ {8x'oq>?=O\W?VO+'[Bao_gy<7FڞgnmK7`'FچP]SELD!:"^T:kO!PPۋϊ`U}y&Gy /3R| ]u!=K5G(_u~@Oj+xtj^s,֋/(}Uk^T%sm$e=u ћҟc?|W>3Cq3+^ï_+@fճ6*72qeYp|1Ie*S6aYv ~ʇYr]y/}w5/ҹ@^P$9rd{(UZPbmҢڸn]1%aw*:ԝ]s:e[Qf\xGa}=ɷJ/l**#Lghؘ.|Z J ٲ3[}x: 6\ _dޢ-jɪ4=‚3/L`N k<Ƴ j-LxrNG}l3ع#T18 'ip_1)<:iyY,}\H$)28,\WQUUU1YUo)3] b3p{_=e8>[)XUXHDyE%vس"a %?qQӉe].~sBAuf3^;[n??9$?U$ǢECNNUU0N3f5]ACU{ɩXv„vNu膆DeP&cVܪ^򋎰tR?L<[o9$?]$rrYd vl(M2} cݑN{jn,' 6Rp U0/nahU/crQ8}°|h6=k ウce(h3k,n&N]D"EU| </^̮][EŇ1YM f݃DyY>xt7:UZ ΍m[LUaBK@҈Hfv`UmL~-u5j>&k=L&fϾ3g'gD"HHaa!˖-c/hnb2(b̼y W30E=] ax4\T8ã5ռ4^3fՌlAO_BmiÈM^aV=Q*@PP7LᐳJ"HHvMuu56lwަU(U܍.Ǒ~]:)7÷ h3q s;d{R_݄ɤ`-JZZbH$)݌ص+%KPXزEDUUtd0Ƒ:LEo 2vfmߑli\~$fϾ8*H$R${itRكBQTE}- HO!9n wHMMek5j @"HH~ \.?ɺu.^B(`xA>FXH1ѱ KXX>}b Uus`%Er" Ii]m{Gzz:iiiKVD""Gm}Df.ijr fX)0@(`0--6"c#KKXD(vvŊlu<.\M.\n7uSDUU5Z3&MzKDٶDzU8"##ꪫ8q"~~~R%D0((( ++C|8Ç'""hVD"nd^z(,,d޽zHf' 88$''fp8dD"H"y&mm_qq1TTS[[K}}=.=bTUՊ8CHHH{DJ$DID"H](H$D"ER"H$)D"HH$DD"H$R$%D"")H$ID"HHJ$D"ER"H$)D"HH$D"ER"H$)D"HH$DD"H$R$%D"..`IENDB`borgmatic/docs/static/loki.png000066400000000000000000000362601476361726000167330ustar00rootroot00000000000000PNG  IHDR>ltEXtSoftwareAdobe ImageReadyqe<kj꒕oOgX$,Ѥ`|hQn4(qQXJ),G<=׺OQC"ZAKSRvQ)b#ע0L[#>,RD ?1,6($n|I~n==Bl F`Q7j 4GE`14 > 4Xy=LX`@~9Ja@ me8%*FG?BD;:S"ø?{^^km "Ld 9 }N #K` ӹ7X  qF®KaǸ c Bb bDҢ()Ql,;.Fz/0,9 S͛f@hd^Έ& tmy   @vJT 0)˄\ۦ(L@h(q|Kx5`A rSȍDci^[T璊&*xgcw|iKz|yNQ)湗ow{,f q11' $N{Lh4i Ffiɩд m2 4`gfuE> T/l lPS)m:F53nfr lP_|Mhv B$z\:؎ʋBGTc59%ǽ䟿5ī2!TdM0$7|/6Q{BA g[WX`Mwj߱Q@h ^$vS M`0C<gnhh,[@hYҞl QFz7_ʍa8w7уt =a #C1u rZ֙^r* xf7 To~^]X_x/=Vkʀ0 m1 Tc[ _G!4ty%s?'Q!{g^A4iS[`р: +ɱu}MVzނj{nXkge{|pi͆&[ 4u.犡d_557e#;lh qH@!6?h믳{fIek˜{s  GFq04z5D=e&Qsc{魮im~%B`_kLƼ^Ϧwz ;>&繈)˜jT@hVR+8[g}s ^=1ޏ a_}ٙpL:r%iE@h(6Rbdyg~@E@GgK>C:`PF8[@z5+Adعiҥ:C@( 4a[ FF@[v& 6rT@J!6 3Y||" ctȰgĴ*-%4rVn6~n-S"vV5M47?0}.pdƸ_2^n+/ɱv ،|γBl(vŴ,BѰ5)F!t)U Ccnj:~ssB?m3BZew {y4!lhl2zIeoRAg6>E@諷cMzGsӢB K9iBsdjN<m$&α (~Ը#MfϑV>l;R9>Csw7F 4 m6_^Js=1*@h(/vٖ}0wj}:p4-`x@h"/ K[b-2fS-}r% ٞnnfsLc[knr4ʋј !B0y4e_]Xza!t;XP}m;1E@( 4z4R9YXO&&Oc%F\ǮnY xU1 +#lPE@o&ÏDi3IVLL 4<^b1 v!t.S+vb 00|:7 gKk !9;a2x*e.c\[?ȋ[nvlб`%G2fOEF)e!B2^3e3F.  k1C\^`i~Nqߑb1"^L 4viw< I+Ԕ>{+>~'ty 4lng$,X ѾKϰB3 q#6-W1 Y!?n7􁥻D?!+ T54z0u 'N-d$e&߾Mޥ3N"w 5?tKH1cg!5~ *>bJŔ@hXbs/o7[g0Xމ, # MQd$*w%!2k`Equ004B?F?|aeulc=h!{-wNn'^Т[ƛ? q! r3D=CB`͡ߺ'Ze|c{۽{^@hװ1</c2 t;Z " o95>P@h8Z$83ۘs$Be}T@hXVOY}s79֨1BsM@ކ6FDa 992Yrjw ;1;2IA}vӤ 497BMKN]W߻so$F;0(l=#KBLl2;1 [믵iGZb01ݺ\@*/fo-?:pd#gߏa7cMNTrG |B|W 42<&wT̼NZLH@K  [}䍼Vn!To{<7;ɏnޣ1'kv\ E]~j$ 4My?t>z" T4%)1:G 엀,_"73b9\qp苩kR, {B[k&w/=4B=l q!  )&ښw}oc. c߸?~>{jaXlG&>kzMk1HDvdh*3oq.^疌B@( 41A|E@RzG_؉!4'y q! 1#lis@h 1=roo?|쮊@hseh~\{~tD J/ Bo\cê -ySv;T}@G[cޏ1%'M5?A- ,Gmmoun[@A@ko_z6;C7~xj2C@9bDaYIЇZx?D@*{GB[bTXM% 4q[?}vj,b!Bl+vۛy^q1syTZT@h8 ֤m8ȖYɱv(=]&Ch:}~j^ÖpW9ȳ3~" ݩP&a3nD ='!TMBSt!Ϟ% mײR!Ԑ,l< !Y8?\ƻؖ:_}m_]CP@hxa5}8Hn$BsV, _Bѣ:lr= ??!tH@~.?:9^g1phdz#|< u? BzP@hXs}os<opП }@G#K4 B@ ]4S" k~ksœnl- 4\mYBVl$Ybe: BmoZ]sY@9Bc{#%*tF B" tEf{*G ->.`e>o\nJ6rR/F/ڰl䈛=4]=VP;&CϞ|}?NGizC +y)O+YjaH ,XMQbea_BsDBTFV,B.#bDE{Џ}xsa BG&rt^ z$Z92"h_=C@( 4l9 1 n"w?vG,)ex5Hl2VmMmmwL+WL׷Ƨ`LB.BSe Ch鞏Pu#yS[e~@聟}[e|*9b!WńxhB GC%xv+xc. !T/ wD T/晈J K7O7Ɨnmo}K EuV̰e< 4JjĆNRFc;W&%/y+WB* y;6\ţaC!ӫD"XvDia-̀Pt1 ~" oij=L4:@) TƀPόBƅhw\|eB`'1e4KKy?Υ!rҭ (ՈMb'`*#Y~?vx0)RB"oy7l7 !tT܌`3-5K@( 4=%BTURR8Ch^FF @h8 %VȖyށ֔!' 18% e΋2L~jB^%kpjnRF 0x5v=VɷSַtA 4 e-rd@l0le;VBrT:phȰ݃*P,k[j~q!x4&^XJ&%u)aT=wK^''p 5BO>^?|6)ۡڄ;.*!R: 4jLjo1mv2z6A^Z_9*BANAf+*-BjS'ن8 'jIjE:yB׏rԩnB͹E>ɱp#9F:Jr5 v<3G{dnt>oǏ3#m#"B[9G]:p'Hy3 E=2 hl-5Y2Br4S9k*Bohד뜼k6;MBc !f,KTmn}s!C:4P@h̆GCx4n笾[ 35NClJLm}h]:AQoB`8SoS"}a!˖EB5ҫѮ4Ĕ5Ri􅗍BTYL@( 4f8,# ZƩ}@.Bhߍbh7ĔnULWQ\T%3CJ3o'b#~|8{7i9f ǽ݋!tkGk' 7tIDE^@h[h8J#i0}wX:_Mc fz,HWHO#*&**u`+^,ahF=cKs e1uWWz)bD7OjHDr+V% {o. *052_?1y*2ݷˉs 7DV1DLGDTqCH G=q#ډe|[bX1H@h@mD<e+ßnx@h@D7CqmnQZ A0hBsrtV@hTD7Vn'Wwdyv{矶-i!*0y:E :7Bsx~^{B ib'76Y},T=?rşaa 50|m<0BS)6Q0j'*NTʻ[G}wCB2AZEF܂@h0+q*W{铖2F`?T"*$[B`f0ap3FVʙiko݌IL%z򣋨: Bpa"#l@hsYDT\J~\BTX |WI[ ?hƂ9+: mX1QG}M@( 4fU-xE`GeEɫ@[ߜ.V5 gETTBTd}[B0r:a{#኶1jgΒw"**+*t  "S;abs!d"Tp\lXXI~\oaCF|cv|a;;-П  jGН=Pfr!*f@TP@hﵷQp򧌩ATx=caX_r:bG\st2qQB`fhrj4VA|Y4e!ZϳZr ).T@h;lPä[Bwnɿi4OΘ UU?-1O{G|#ۈ s;b4 * O@?ثӭ#*c:#;c"RB J04sDEt D 4@QgjLj2,5-ﷁ@hy..'?.ۓM.[GTL}3OGS#+7={,B}$TuD7D.PxnQ |Hsb9xDt #.&W{5+:bts &n]y4 (wdݎEÐ0J֑c95KO6 *P@TdT|aapf03  ѓoiwo?u度kP "ST !L삟NT ET 4,}qQT舞ˑ0|wGTε0Hް@T 4@4EW~0ɿ&brw%bߎ<7s@q~'1ݞqmeiS, ^[]Ðap7#zqk K 丐eafNBm2`zsJ& 4Jm[rzldi-!Q c˩q0 Դ &f<ixs,DAK8|  ,z{.~g@)h@1. tALL[`2IlE q$eNVxU}{Ih)r*y*.TSMBAT7%Ěp"_Mka%12F0>㸇X;xPه/(׭2رtc,c7GA-h9 @!*)eDƘ(3 )0|"#v @nvThP\oXP64ݯ ~9k)F7#HXԏѴ\ }\F.G+q$T"h2YWURPtѦPYW JXHA!h Ēʩ89ny\~I6 5UjW,N8y4V<KeM9Ekx!ey/Hʩ tRdž[-߲BāG5E6Q20JCS*ߎŵJ,˫L95ѽcQzR3nT}'Mbm_tBgRV~E.5w[Ku.=gNp]iϒ ,6Nbxs8`N:h:t-74Ա1_17,{(eF~V{KSo]cNEKFjF:cnUSjK7T`1V8#oE0^^Pi)|DZYOiFUO{A\j2{ee\G}=gDUxRSX鬘^G.$6R_M^榢s͈?7I!G!y%ԍ%`ڢཀ"NWl)8\J[%#S# Cf.-Dke_$&y*^F+MRz^Q*M+t#V2H'80AͤS ]tʭ;0oWuURun%V*ZFK¼WOUfNc lDNgb^)6ҳ`D!*f!閿6S(Ry1MitMQ$ֱ}I~c )sXj#>e4FT V*/42ijXW鑧QVrZHeەT,y\L63̕a+4\>nM;:Kԙ]WU>_=i+AZ4FƽPĵq_S-1XOY,#H0m[`wmsnu(6P*+GoR4S\jr4i6RبJddpStSbض6~lJȸ։(`^ Ժjh{j;Ubm ke8U nR- |=C8 (5.驓%wijdT(4N-:} 8M7|Uk 4b91~u֜p[t?;W#:544ЪЬ{zȲ(6Q+\)>TakX IRk?tq!´o .E{nˎ_q8U$J5dBӈBPR4ZJn] (33H^pgAruhm:ss6WЈFUvX^] +u,H6]]\VKr1Fh5gFkylC>/ժbks<<m˟]NcW.ə)< \pIM_ m}Xؚ*٤'ç/@mNykgњK\Wp6vpM0ѕMP7mά>ˢL;^c.ǸT:@%2|kތWeWսCUU-_Mi أ]I^RY6Oӥi׻9KIo\BF!? SBmW:~IFK]S8*l|6Rmojg Tz)Y]LohȗZL;fCvqC/W9H|P&຺F"Ϊ|}{sI9z(^jĪ}ޒrzu AugD=cEUxxWp0{Fyr39uR}i}\_iyL+4%*BQ+)-*ǒǚRuTI\*`d9ԷHU'J@qW]O9.`8N^*n OFwF 򀴔螥N? ,gNRDYyߋJttF(Q(wZV e4 rvI P;uoji{̕𒗔a !8dŐYbd=pƪOz51:=[w[寞3jɶ+Q7Q (+%!4Tࠂ,L+JFqT#'T{{qM{$;Y1dg, bFmS'>T[+/k+!\WQ`K^J~ўP ˬU\A ۸9ρ۫ޣXM. ;ET:Ce6۟N e.wWDz Oyk!5`XWJa-:J4u QB6y;X-c+;5UgDd\[]r gC T x"O|nзu鳃z8D?vTv%vݔ'40u(\N{?K#UCF*|pdeL@O`BvG T&1|TnW:>6H)fU`Pߨf/p[]IENDB`borgmatic/docs/static/lvm.png000066400000000000000000000121401476361726000165620ustar00rootroot00000000000000PNG  IHDR`s{iCCPICC profile(}=H@_S* ␡:EEkP! :\MGbYWWAqvpRtZxp܏wwQa46Ʉͭ=A@C蓙eIR x?ǀ0m MOa%Y%>'0ď\W<~\tY3'V:Lx8j: YU[J_+\9$ "PF6bXH~?%r)*cUh]?*LMzI8c@.Ь;4O3p0IzEm⺭){0dȦJAB7-пR7!0^uwvV?.rAbKGDC pHYs.#.#x?vtIME ctEXtCommentCreated with GIMPW9IDATx[E_U0F€PHtFpFu@@@pHqyy9I2[,.NTX0pNڽY+ʮ}z_wιhB8{DO<圽aNQ_$zu㽉e/4񁎤KzlǾ|u/jc~ub!bII,fǟ-S$} ҃,nJb>>^׳,p[]QJ&`[&ܢIJv7f{زz "G^OHz [IJWjsSv6Yn3|JPyZq9ܐ31O|k UÒ74fL:.3r\o~8xˤì 2(uPp# /_ݰP٥C.C0#sM(4}ZdQlMJ? QF?հٷvnmTꜳEw>7S=CdX:OY1qd%EyXe`tT*jzF4D1;$4JA$˲edojkd>UʲzFRga^Cbܫ.(-Fw5r9*6j_i T1j~aiO* EV&õ uٸEyy0^g:M+0z??%x6gιEƠ_`2—;S/r3{MLߴEydZ Y] "M3B8\odH1Skk aQ^IyDXIO&CZY$-uFk27 "t[e/gF0K{ѰLu{G.`X~SU?c&iA*Xaz !@_$ݠ>7E% H7JA|1P/T DZˠ$wNc `/%LN!s߶1L[R\B8_ !,txBk,n#([cص_[Ss1J+m[}fYv1^~q<7DF ,isBc$2;9wU3xɾ>jCjv:ߒPfI"fk$ -; V>ɶ>/B6Te)ᔹ ϗz9wLsFʖn{/AdFfyN0Zn&,J]kzv ҟ'`d?X3a 5vijYIP"ČHS܅J8`^Ü/r*^1$Bc/%[M}i|9 ȍe׋ 2.=o$K=U.@Ç[6#EyM)AUIJLj_d=&Y&cXfEo*)D5Kd_1AȔ1d{31BԌJvέJ(+Iڦv1(_PnP6'A8m!=bPĄ.uQޱ1Ɠ ߻%BckbwOY+5vE_5enHZ2,0'K:6Q1cjp<cX꜑ 21˲[}A"yzUBF2I :8P^/և"I1|JKEA/W yTS.xVF&b&4?̘p):6-sΝm%u u#˲PCzvi"t]sUloZDSAd-4sZ,{M~-1c%<) 2اWJF!3*|1Ư}kd)oǝsgI$ӫX /qg㝒^Z IzpOβtesMx)B&s]hgre%!|obYlJeDIO+LgAњZ,:zg$~@0 2~̯Tץ)2VdrAl{-t:HP%q`(]g_n!{})ۢC5iK<ϩZ9!$e[ srTCm y;.6uM<nV޿2ܙ v7ɸoyBEV*˖M ҲڽBUof{ի٭G>3c0Xf8~-\u"SI/Dpjc@X$[.NLjY^b|{cI-c\_0F!eVJt" aIz3˲M I[Ulwo}3H1k C t}ҵyD6ɐj ऌvJzz%4K1mus~IOry**a3}<Ҕ Ҭfp??PNrBZX72-c'܇l=eE0ɂ꒒,Z1U% \LoYnU|&F< "ͳSR]]5w`$kdǨ%45`([c6.i(7JDЫe?ًYm26ouKUQ;AHsҒZ VbZ#ιŜʄOAIENDB`borgmatic/docs/static/mariadb.png000066400000000000000000000224021476361726000173650ustar00rootroot00000000000000PNG  IHDRx'b zTXtRaw profile type exifxڵi >Ip=A>?({zf[fU)K)q=7^"?WW]u͇~{s._m}}]8#_#t|8'.[ѩ__;];aM}x ÅJ佭6>xϴ5]S'\$5'xaXK9EsSFYH,5ː%W9D>R%w'3̥%3X?}?uO"F9G0I}$C0J2+o Cb 7̓ !߰%/򸑵2wc1YHAYZ9RFU$AE@n\YH'717^[ZTO.,$~F`h7ih.{=jnus:s9ܫ,$kڛ9w}vܽbo-*ZiסS|N=3<[zwyݖ (=VYaӖmk.^yӗY꿼寬7Sq5ΎC9#cf2>"D̵\,BU"[洟Ia\-sXUʯd2WYا #I>eC>q}&>jTBnVM)rhLAٓu葻GS99)4flG~ z)R.f$@vw;y0%rmOo52>o z1EXX׺\iCN_VY$IA%<aV7UZ\u 07.GC=MUfI:V'\S&@WEȑ47GO& ge$}d0`7{b\_1[S.,HMGϤRn>;'#] F̽fE{W- 06c+/.wSA }>ד^]GbRg'[Ct6-uzb[\Xkqo$!gzC 8!Q'>NvH_,X^(?\ⷷM,IZtX(Y cфdWVN·EL`ֶ̄x}\`Оk3imk)T2@!%OR< GOnjiudrW,eYi{a ]Ƈ`u2Ѭ#@4 [+ ʡ%,C/S)f7oHj:Uژz7Ff `(wLyAJb.b5@HLځɣ&X(fߎy1<xJ{CB- ]Q@aeQ-wJSO&vSwp5ryN':d`$bPȨIP$&i0lSI.aX';;zcP?%0GAAlIA78J!~F*p.I*Mٔpq:rMAqJ5s"vD[ԉa,:6ڪ1D%A D aV ۖy><&%Ǹ-W*~j(PwnAdF)>2rIwa}k=U܆m |AiQ.qohļO2ƔyfŦپDļp_PF"̉e@5i P]DGE5!֒5O [xB+O]@1e+~QYwPխRo$ZE`z0X H\$|j{M/~0@flp*@^`muxr@'W=Ճy><pAipȌZop21ß2Ӽ]p(9$1QH)8`i *h;1V"G/:=S'=]j/H.&Q"C`{:׸ !m(2핂F5hpWAC0ub3`FpQ<;7EBi`k!05z^-hvخ5+{3BGyRǣ_s[߄U>w-q^QŊ?hg|;$dvC[e+ßܮ<1AOTՀLӂo^|Cq* (,QOx4 ,EWEmo +hR"#xl/ۅ탳>ʣH3a/&4MXD:`ķ(OK<xKc|9J@b/ ,O&,2r!X`z%( NqS`SH V4o5ĽguLg7Zr#Hp)_.6:5RTksl )OQ H@dx75Bb,:BԢcq -kHqb@Codz tDJ\]@@6mn+3.(ԓE7}SIX$j%jp"Hp3j{a@(i'Ǝqa@bx]oPyV>)9p pQGFq+DQ"OtFQ *:Zld쥋Ft=GzdT?P wY |(=>$1IaHmLz򱋟蠂7@㟛ވ;zF5B0}l2Go;"7pPʤhQA*^yxű+EZj;N;=!B Jͳ'FN;˃l0ja<*l V bphgJBKq&P7#j;6.s2uQV"}nL G;k`fYӠ x\>\ ( ,=0c62L֍TDJ1u i,rr{$&Ax) ̠` 7j -J!̉04PNMR^|S :+OCi O?hu !80"+ hJ" B:A]Ai\g'h2F :<pW:;qx YpiCCPICC profilex}=H@_ӊU;dNDE EjVL.& IZpc⬫ ~:)HK -b=8ǻ{wP-2 nXTLWWt cPf1'Iq_.³Zst>xMArB PLTE0_# )4_-$$-=i39^7*';Js==^@/+GB^IW}J5/QF]T:2Wd[J]^@6eO]eqhE9pS]rK=s~zW\{PA\\UD`\[He[`LbMi[fOlXìkSm[vcnqVrZz;vZĝʧбպļ*bKGDH pHYs.#.#x?vIDATxnJqpȘdpxŃ1b!$$$K]0Yz]]%^\U]]= |@B !B !B !B !B !B !^= @  Rw;Z*A .@d%% ?BTO S/Q^ ez tq>w,S+&qWAچڻli^{˧Z8鳵bz[ጟ5=^~t ow_+y(-W[3>ro;!jb;a}޿Sh9cE߱߾~.5#Y:B%n<y%u) 왿R3o~wo {oV>.3oo{mHC~+O~20O_/A`Zm@{ߞ=J,B˻V`_2+G>v{$&P}!z8 yx~3?7Yٓק`6'mIm AnP#kyF>9๼#{'cB O:y/يd56JGi~wHo$8q;@rbk6#2>nC9&YK WdJ_d~@xdǿj`k Rx4!@سLdUu6-M.i@-3E Tw,Cr 䱴`UXrek_HC5=0Gk)Y%i9jlUFi@Gž=F$!F{V;TcgG} RWy$nJZ`<ܟe0vmn>F'k,bRS^i J,9V݋![*n@&ҜHX47?/ٮ5.ia]5 U,1M(3K=iaAvo$Jp:^$`# -IM?lq{:i2&VK:V*|,A̎DY8rttfB^V\i^wRC[0z "PWyQ۟`?tTX͹,f9@ ܌Ao y8Q'5oܷ0C f:)#hQ Ώw%6Se!0'0F}7!=@Ț"_7Hk.oe)1|-6E\9/@Kxi#Nɝc{u|W)˻oSi䵲 1KHȯT=0%IxH1pRmF)0K EtB:<]\K8 `dޒMVG_:kյDAY.~fc5ä.Ȯ@h+dm_YUl ^ͤ,pup;Wj7*z%D`z LE+ǾXO@`27ՀnćͶ*h BS Į&}úNe{:NnUf&hJH~Y%V|O;LQ(0I\s;U? @va;~wP8U)ͭQNPH-Zܥ. `u `N"Zq1x5LE7kOMpBZ]&M#n+L\@&uЍ'mwmYY@Aqx&tU:Һ+@{$e7J9x ;J~v?̃8&T39&H ˯;Хw|?ޑɴZ6gS0-6$LԺ8*1e_+_;ZupY2LX~¯;OJ| :/u!iIs<{P bvP/MX&xTTT@wLi(`>l_їM @0Շk@2M%{ 4?~^mޔ/4IXtX{6*&:g;~\ej|U[W AujkrUE7+]vNy5w4+Y}QeyuSc`QzD[d7^2ax-NL?R 6V0_ta,nn] jРxzт?m@tʚ5E @Ǹv>ȊetVg1i_ra|Rlye$A"{~~*ײַT}á9<.GAwPyU6SG8s;LjA/k閺Z/yz8;4xd1Tp^XP`3LqfThT&d©zǯ@wgBT0TU9k*U`%μrz fo J.kqKc 'nmEM`(7`@K' (D"]4 f "HN7t:/ Qa@n_4n u6%RNr#ځIUo?z!^ bbND[-lW1EN"ޜߺ/b}t28\?s Sҟ&-] rɹ zBԜU## ~K9*ڛfw*~6M$SXIM-4P ~_9/XM.,p @PbkɕqusE۷bi6 `d)٪Eպԡ,YZDȜ~8<ZtMv^sG69Dfzfw3vrotBtUEaߡʆf0]h5瑺W $eϭn|f=@f1RF[j.xض)j\u;XeJ8Ej {bK|p3-:E}Wf,5 `Z㛬JY/~;?xfi6>1m4&^G/ c֙oEA%ONlL7Q풳Av:ɘm+IoF ! ˏ /q$+@Psؼ>/XNwNu뽻{Psj90P&-ܠL&mǶ)w~ߐ G!Ŭ[Aλ}Ш2463˯WЋ,cV{ꦺK,?+,Df6:Ԧmp'tAG+q.,̘LjR+ʦF{sY= :_j4FAQ_P Ϯh4-p /k1؍5F#a[qwEh4d_dIre uaݫk4fGMsx*h4-p'cY5 ͡h43{ӫ1=oc|B'&~]7Fh#VczAa(U3&&h4(@e#6,Fl䶇^\`yl94FLd[UWa..:h㓺y4Fc Dp7.3h4Bv{rk6VbxDF d.S@2efGtF 23):oIm4gn*F3%7Q1Dh4MI9: qkظM\F)V'5$*F &h4M1Ig(l*ͥh4&J@4<_7Fh+ @&C;Pi4f @h?>._:Q7Fh+7F0ӫNh4CkOyNh4C-\|5FP[]E3 Oͧh4f!kqn>F rdhYnFF;stj45ܙ^-")V0&]^7FhG |tL [rynFF5崢pnFF:7 ۹Vdu0eusj45ܒËP֡XݔF)d>I,r tx඼~9ŠBYDï\o~]iiRM.cü enDF5%,n&4j1Ql>d96~PhW~ 1g2~koΣb}ȑYv ?\ݏ Wi J 1rlH5X7<{W"҇YhM` B0,,O *pjv-N3FP쏨+P?؊?tcM3d6=x; ތoڀ$>Yj#bClX7/o_nѠZ-@ #Wlh`ȊQ ~ Y`Jcúq(6>(*`/a]oU9TB/\boq!#Ǖh6DCp1Yʫo/$On>#oiT|1e?u VF?u!aU1yp)؈A3YC2|[2m > 4[4zK7ms|7f i^ r\dNibmno.n 3x>4l:<؛tslsD#i_Ý2:DaH%TT`6].kn/Iv ( يRxT:Y0e gBa=zazP(3ۧ"%N(*zwXtch^p.*0|Rb %n;Uq'ؖTMVL1D"HKuT[֝1?wbpqu<"M=DJcH7+yM %v:y>MZ^n~`p·B `V̠5@RaWKXX2OVi<ԃY aglw忘RCkv;͢q-7Anpm|6>be{imzh^(4ϝ@,PVv '%`:i L!r-7lZfsC}9B,7 )MۻCi: Chyw5sIơL>ՑRMRH*В2mP1J@]BknFw rͱUxa|CfN@͍6H4| Ɉ\gh8-oDk-qKh{6&Sl_[zңm<-= bch ޛlAv;^`Hחr2F#a_B.C3UPXw1lR .SFs(F1/rUmnyDϠ0s)UwىIRnڰ'Eͯ"x#j+ؤ_`+-}CϚu䨬SU+K-Ue9IbE@UAY?RHPwۧCyX/E(:?%ׂv$X _ qk[>ȁ)Z;J _:vE@*0"2/;I8 ӷYG%Y{RN8nR0}M'h vigSUwSVNzWu#+4K _=D̗VDߧq+r_3rQ5;L&J~GP[|=z:Zs{x lf)pUsbCPdlM&ROuYL|Wa*Ͽ=6 2X6GvS<;M"Mt_L [0fWۧq)D+p1,,Z.Xzz^t=j^RO"Eksƞ^ԟ-:I/qH[̑\j yc9TMϳF&vp=Q:d%-ٙT`{g5Tu'k7_-pC>''* cS7oFvǤlܢ6d[7N XO秉bZkv&:v4ц 1IUi۝XL)``yyp3^"*uT80 ug8ѰJX^x N@qVOn[ _ȵE%6ʡ.v>!p3R=_{7BU|1Yqϝ`Rryie#]x|u.*[qŲ+Kb0Ӏ_=~O.+߲eX2.-%|r/Xf% PJĩm.(M27H=ygx]{:alDfU\H@,)+N[\ux\Sk5eujz,$ջf'V{Ӆs*LP/ΕڒC "sjKz^Mc1co E-pㆽ~TF66wigmDKӇ(r1Ҕb!-MomilB{ΤҥD^H{(=\mYtWiNP|u`#V,JOSRb5Κ'ZZ!R̘hs+c 1z+o(bM|͛O>osx⑛ s3AfrħPɼִ9\#t(y6G9;&ŀJqD Z;fjO))(fl jqQԣJAN7? D^seu:iR^IعքG>Xe(%fa +-$Z^ʪL'Ol#ur}::}d-FO \jlh2BV(BuW@(ϾAӚksI[hy1Pw4QI328KY6\N6!1 6rx>j%3yVm9s_=h3~*(P:w!-bX }ױs͔v]͗'qmЗ^I7863Ȭ Yhȯc.YosO<evX\1_słxWUxO`g˅ROUL;uHEE]dUr%˗w\ÉDmZ?W^رtX.CRQGeܖs굪}{ R#reco&=&\wOps0=oa^#9~ B< R1߭]ɪh N9L lpe:*R#mj1;*mt7Pt?H_"pYGroZiP)6<\Lq \S=C:Y–>Nwguq-lm 2_( h+`:} k+印#@,QHՌ =wq_)l|H–bbcU޾_cԥD1n F̾l"6JN/mMN7)h4b$RrGVwJ #'{UQf)^c=y( e%_Ĉ-&!Z>jr|x"awNI7ZP+޵W{O_2%wT\rݑ-L{:Ħ3fsK_U9[+V`><IA?DGweiGv*X9XVΊM"TIqtȵx;[ ':_pu j : nm( :C> +ObEgQ" o6@b^ Όۑgya^}䤔:ovٲMQ4ߜ$KΛ=8w}Fa cZ|yƓ@9oKE(6FFppNo_y/X|DnL.ܕ/BƱT-=-şp (T {爴% N"ovIϷ1efDU -i0~(ȡ.ZIDAT!A4r# H,~ N - y/]sF?+>a?;,}1r2@Ց}I9+p0唥߃}Za"t~7;H௛J=?^V|S SALƔh ױNO+cPoa p5& Nk$u wX,$/b4`LRJ9|#X'ok@8fN?,n08cͨp+Q3@'ѤE47G<&֒9-7h(eNm{m+u]}U[iktީd|PYII0~-)-55Tj@~Z>db`V- Yy@Y%U%[=?Yu%S*CѴ8 b1/"7(Ęcp/=ػ(:hx~ $5խz\ধ?AK㟋&&P{~F|?m78/Ml .U/Kվ񕽝)n4/R3M?E}۞ZH4eo{w(IqQ}_F^vV=%X _ѺluX6j>!.Gzі/g:6ZN`?6&׏'i ߟu_m6$%Z\1@1HSCPo ( po[ԧƿ8-} s,*cښגVz{+x[u̵^(5@DT$kNtڭz%$m ^~`Sb` 3oFG̑O݈?W+;BlU-}㦘T IVQ hm|Ӂ7;4dJ'y Qrܢl/ H}x.J̋Fj]T&OYHizy){p *x>̅qΙr\0ہR |ox Ak[Y j"(*TE[5%fE47w9Xf`lWQFgVSpB*EKuV[w>&R?8HCBbeLPj%wda!rg2<媥Ȝ*5$ c9,N"Ԏx[cs(X^ksL: p>ls?Yl\pkY'1|oQ_r8}W)m֠M#7J~#f{ \)6Pjq[q(jk \}6>K w|TlkgB^?28# 0[(Q72o"\au,kѬq`jrhWXM*(C6TOƤbєB+2NWm6`?t:==a-J͢(mվ娍Je ^u ~ )^K4|yMFXGڊi>?36&X0USm* J11JkS߉"^КҰ-gPKٌ)TF*&KKf{$ѱ?(ң?)ߊy2Df֙e.-ag64`'𠜊}U(<UK|OD7Lc)9_H+nqվB3@NMqIldGF I$Lhބ){YXN\N@ bzmcr6̍6 j YLsxM^`pUk'VxT3Q&0nC1b'j˒x~D4\ՉH!ވ Q2Cf%bЉvEjOE]cی\Z]ȯ@s>ڃrwOWM$0FHyRʹEV12|g"DA5BH2?lPG8Y!`a-3%Z5zM%;S~61KD?!aghkduM2a(_HODr,M#>5$Ui"CP:D#aPVG|H-pS=i4fH^n͍/^% g+sn')5\͊h GH5 nP-pSaz\S5C,2ȪA[^CnJIYãFSpɩ/<;8ڛiI[4Zত؈ҟ])8 *aER }^.tM^eM}b2Pv\Rg5W эמ FShXdQ Ի0/1FhkrVˀ25B UKWbn_p _L)j79x0&qq7_`t;ޠlK}$?FT+AqH*FφvgҜU R<Q_ahhc+^'*}f9p *2ugQ5c;Rυ/n ,hFD6wCq֚msvBԷH SGhmj W b Hf]`9 ["LIsːsY|nf6R:0OY5Zj4o~Ԟ_]z (u-5ۚ5-p5f_(NF8@ɣbhh4S]] z9Ta `=Nc yBbo ç(IENDB`borgmatic/docs/static/mysql.png000066400000000000000000000072611476361726000171410ustar00rootroot00000000000000PNG  IHDRwZRtEXtSoftwareAdobe ImageReadyqe<SIDATx]z8SJ.]t 'UH%JBR\neI'0 FUWA H=-9881@ c|G-=9?F#=6D`B3[~<㑓y1 M1ϼs mCnžQ}E"/9_1 Ah0 ~{ED`)vE>7% ?BSH0?MKbIyB LhB_/b 0z⺎%tXdفsu,[L }, 7=L T BIi4-yD`BU?͕E!np2PY * ʜ UUq&nNr{r0_"Df1M*~i^LB|ga`bY^Ӄ}S_SźU8b͉\QqXTSjD o.r&$XJܟ.éoS=Ҕ7M0o- dJEbƠ &N0CP㎍U-Jw\)UH՚MsЍ0*Էh-,<5D SK Ey4 ZV!Y\#H }7a!-/2Ԭgf37fS;D~rqs6c#S٘tUDɓNrok9 IӴIpA|O m :}lD;&!ąGBT&_%XtExqWe@aNaY69#dMq.T0IUBNHH jGTOy]egN78'$I<fS"08#$u+vW* @FmCef7,/L("Ԓ'5 ӟMRщ3O8>h d - ɛ rS=xQfLX-XoS@S@\ 1NE^xc껉Sa(0'={,ts5&Bd4/P*#Dlލ@d%.YJb"pO7ߣSHxILj;T:nL*eE9ZHXgjE  7T[Gc0|<+;)(jV$y.jP/Ra`4i|| .F,5Ϳ%#mBd_ws$Ӑv8xe;akH><e/իp<N>MOeUOiFɻb F.+R)f-P`AY_-+ߝ2A 6a?'I Ynt>kL\Z`Ae쉤Zs&:!hzUZ`p7k$_tg<0-D-zvR>M„lml\U,/DrLBຈhkD+߮Mz$ B5˚JEw{^5}h?=MqC|Z^T^*ơ(zH6t^٪лf`% XP]1ΆDm.%-E Mz @ӦK K jl ;0!@%Jrx1WxGx^(#Nabm{RZ=CGoaMZo!C3#%F*źu=EVrȮq=]*E9 \ЎQFj>ЍԭP%=c?NF͢BwZ3'֪_ A6Ux)9 y*w檈-"jF&M,v ^c@2 0 D3x(SDJMhbUTÐw *(vjNՋlD΁hIh.9@ 52f؅V A:ėWF% 86 $]U `Moa<]iTFV6BmEJ 1?`̠Y0A}֫y9TG~qi󭮔a:M`dnnH&cXxh# KP_o8ܠdm$J`cb#<$O"lfGc8}K <*HSEuSnd Bn FyU5- Cvu|*^t3$TQ9+C5j16+VaMU{X2%m#e_Y؇*_XNkVҸfb/n^9*[`vvԪgJt)Y;Tҵ!y귺C#{*/di(?uiι.QH>>km-v*>¹ sބkx%U6-!.e fS>t{Wxګa6f( ˜}k"z iY6۔꺬>p=:3AQ,aأ*/Q~&|עw{ Z%^+$o;fM_.4؈Eѭne uτ5<)7fEX"A"umx¸y[ yL=ǧwIs#d&6_Y Miu^`&uvCEI ðjsEaOaڅM "ogHt}"/Fo@x BGT\؂YIENDB`borgmatic/docs/static/ntfy.png000066400000000000000000000240321476361726000167470ustar00rootroot00000000000000PNG  IHDRIN!iCCPICC profile(}=H@_[*3:dNDE EjVL.4$).kŪ "%/)=ZiV鶙JLvE ] bPf1+II{zY=jb@@$aiOm}bexԤ ?r]s tjX  -0+$qTtfv z\?I6л \\75e ٔ])D3gMY^zkHSW)Pϻ;[{LZ rHMzVbKGD pHYsLL7tIME #tEXtCommentCreated with GIMPW IDATx}yt[u;"E")j-[lǶbq444L3'6s=g3tNҙi̴紧dId{mŖ%dHq@N{(Hc{߽߽xw~8v J#"}+@d׀ařE 3Q|l9-9{,:c8Cr[ufޤxrAT(u` LyNM723T `[6^{R7z=8YnR 4-ּH"F A6M#03TCsZE s,o:d9zb=+D$!9Eg&^w]5v ꥨ vo7-:sQ2= "* Fo]6hNS|w'o9@WʮG9[ j8 !?_BXqs"m=Sg-zl5AH@?FoM;Dlp[qMNtObfMw.fיa 4u74xd\`0S(nƗʢQPXۥ_XYH}2-JAD[uZѮ7dujì&ɾI ;m@*B ]nB`<2$ϊbn F4m<"!)Kr>a!A|c?&63RCN` E{Ml/Tl9a6auZuZ <{‰xR(HlrP=\LއhEQ*2%`$+Tԃ)z8pPI[ko-B b,7u3n[:jܥPFsFU7֡HJisq@gE/(4>S}D'_) BLo bli*$ ˁ1lqJ=OnWZ+w^JFN~wˏ ͇XDfKN!^Txm&79N-$2i,6 ʈ*ĖZnx2Grא2_pȿLVD/-A %DLu1w5I.ŨͰ>zHEV(U SGن4#tXCCI| בGO1:C[s"dÛNСLKVr댊S;}GRĦhtSx:)`o ȕm՘gܮ =ڕ> d5(g.W&vy EKNXINLAurJgHU~fTwNC_Fh Tۘ"pẒXř`"'y8 '[M? iPB6G[wbͰ=W?4$rEj]:=Ϸu5I{q)A9Ş1,hANlQy ȩ5phB0?bt{/x -ֺFc.r+k\\chjfW<`2Ȥ;UYR%,#.+)25w]g>Y'I~||F*Oa:R@Q=mjw>:?l`-%J(%ʹ&UV`Kt)$ϙ];{#AD}BPgUxH?XsFkpԍZY{bVJyuL9E ĩgR 1]ܿv.p7 ːƓdrF QHuZ]`iڝkv>[!Y%B`*R51`^BaC'yÑ/Ϋ6С0.Gcgmז_u/3 D,hҦi.mtL㭰mֿlW槗'Ī V\ٯjM:{sAѵO$>(^JL8RTu[wTvh9mCN^:0noP4<L&hLꝓ^&lL)FM귢L/cw Nma#&h0noe-wk9dvݎ:gif& c jTpKTxSmF5tݻ`U:t>Lsז^ţLF^BZ;(ŵq嫠HlFJń o"?vp}S 5k9LZ!Ggy{RيtsPHP+hZSw2Ma(JBH*qea:/uv[hr>¬ӛdnڬ !j$JI7-إⴢ2 )rN t/зlm>)-j$tseFk5Xڼ!"Mkoﺺ4'NA$^4P N'$~&6ZzͦOT$^ 'ܞx|r2z21q- [:pb5gpF':54OnqrN)FU&6li8ڃ=ҟ6H,0 H "qqu|ϝ_|);'o^'M֝{Yo>z5$:_71Υ ߇?~/>ۘs_SERk#h^jN}2h=ו?<2v NɈb$ZX^8}6hj5P+g@ KEUkKz9#&}P_ z&_|5ן~mu ;2vgf-H$]<6$gXzXĵE1{y67oxnf$M3Z?y+i;7lh>q!M%6vq:UqӟiXxFbߜؕŐ?%dJ~ nmPmytq7ޛui=ʀ䅯gc.e+;_Ӛ{>IS՟yf¨/#t9w7L2=c_muCKiх>Z#QO?|mjtI[<ԁ^q,Mld5s>: 4ѥ2d 0=3=s_sǬ;qu.0uHxstv{wq%5%:GQqvQgko}*F-ĨT/2QPwس 囮Ԕ''SCɁ-.ǡr_yqyKٹ {B'~<\sOKLӭYi<0͹w!58-]ZY?jݱH(ξIxGm56佀]S98"&z4d>\Ң=jzF-5ZΆ!#'Ξ?AA7(y~M_C. |9ՓZ7DOȴ5/--+ 2ZNgu}@p2m>Uq5P|7.,=ߡE =7oEڞZ}WqRFC\s^w$Lg2V' twmm]g;?zk gFF|FaF3c #W$fۑ-|]jo6eB2D xppe=LH PHG5ZbL D4ZEb52=r <ބhh?U5L;2dk43C4ŢHh,OiBHḯ-9 "1}[LoC 9N8;<כ*FG_I6R-oPEB20_Yykv" zѦx&KoiG Fgͥ@ dL6!@r؊ŝM)M P">^YFB ^ԥځ՚.  qgv]ykN hOozsҕM>W{}d.{9Y"Eo,/SU_,sHoҝ Rd2t^Ϝu՝KL Ym++1bZPTkS#;^퉮+Ry %:9̓Nlsyr/]|ApM}y{JL'ΕV 7veWaVKUki9/y_<*ђPʗRhNť*BcP$R@UG5ua:=.rN=l=T?nX dm YO&@DzU[ *rbTim5GWa 8A'Y =M\R׮5350QbhBD1NmSJnjf(aOg]Fb5 vk}h\#v \{3!㊊|@^wSHhyҠX~B]#JD2)Hͼ 1$Yk9wiqȝ0rjPwHxy2k+>TX] +L( J4Yqezrs@d޼W3897?[%o[Up`wF߾Иr}Ip]6cV]Z+VkI"Nd2xT2pP2Ke 5L/@\F2\zrEچS=Mݭ1zh,&fǵ/w tj7Dzkd*P 90T iI_j"(P$*RUmŦ VG~Ǟţ\xd3OwH;s]TE;bCX<;T$6 J-YyMZ+ ʈ_O/HҌ3uZgke ۳0UY˼zi^4u>J=<,(֋ow~sFg籦Q! EkK*/^ ix5>5wÂJ)M$QI&%ANl[w%Mđf)h2(#ʐC|=Yõ:27G]UHقo[.*n34ULa(5s֨S[jb2&[CufSmى_$5uq"$NJX~MT}~J@/@Nk36`V2P,J0TSO5:\'Z,Ħ:Q%/,_S+ G> r: o-:Iw(@Qc 'ij8ۤt?¾,916{Af˰Ldayic5/p܆L8kZc3Ko⎋Up;oZͮcGg(ikp+7D&/VLo~Uf{|y@b: -/2s;5Xl:x!D$$-dRL$97}'DB1GlpE6\-< NVۻ6sf i}x_ K%[ 3"D0㲮xvg+c[q>jST&EWEڼד"!(2X2 ^w(H o4 ~Ƽ&^n3sۨq[HHF$X2FbD$X ݮ)_V2-eelvf79ܴܦf8.VB2]/Ɨ޼5DSI@ 8'Z:,ys)|Ϋ mVgʡ!$NbEڝi+$ҩX2<`,X`ĸEnE:f|5NdUnM:KǞʤx Y P<6] >f+%iEYrKL:0Ͱ[ܮXl 2"f1-dt*c~kԪY/Z\YQa A>Ir_Cd{vsMC-sFeőٱ(2LQs`3& /n[y>s6f3sP!nR9[^w0%h  \>oL7q[! 16Alg`n{QҼo= p\~e\\r1;k:31[Pn#BB(Bpr%e@[gvr?&f`<!g !!guۛwA,G1] 1W@|MTR9٠ A @ KgzYPYn#ADB{ܱte-#@ 8M̘P>yd&Z3aq[bB50aoq[X6 *]mmmmm1?# IENDB`borgmatic/docs/static/openzfs.png000066400000000000000000000372071476361726000174630ustar00rootroot00000000000000PNG  IHDR|C$iCCPICC Profile8UoT>oR? XGůUS[IJ*$:7鶪O{7@Hkk?<kktq݋m6nƶد-mR;`zv x#=\% oYRڱ#&?>ҹЪn_;j;$}*}+(}'}/LtY"$].9⦅%{_a݊]hk5'SN{<_ t jM{-4%TńtY۟R6#v\喊x:'HO3^&0::m,L%3:qVE t]~Iv6Wٯ) |ʸ2]G4(6w‹$"AEv m[D;Vh[}چN|3HS:KtxU'D;77;_"e?Yqxl+ pHYs  iTXtXML:com.adobe.xmp 72 72 1 Adobe Illustrator CS6 (Macintosh) Open ZFS Primary Logo b 6jIDATx |Uյ9w(R2VjV<0X[['V[>V}[m+CUm@^m80hi H@D ɽ o{MHMo{cA`t;[p{P {R3czRy,3fp{8Ikb; =(C[nĤxP? *88-"P$e/Z^̉{LJBkͥ`o{siۅbQ#Zo| ˎ*7@opK{ iSWDm_C\"0?t>M'Uth%Hcq4uS1bD`CLAhR>xIܦu#:$RE)/hecٿ A #sLԬ-q|T6f$n*Db1)a:sV<|qH MWilR6Fm+&͕]`z1Ii&EAR/ȒWۖoluSG36QPyv{ fwx݊zR։.ag7p_op2kȻ2XS+(߿UCu}aajyLBɟ_pK-?LV"P̉Y 8|7Ov}@wďᾗ}e!s}{}>4 &mJ֤M 2U-D0_LIdZY{- kLb|}:VAOZya)*U;<O2i_!Vl()X w+Frlm'wUQ9f$R4˱=gfN~*ǍƟq \H# 3ޝ/yL. o&*Ger倖s/[EBWC<>ܧu9~bgwi O\b?i$ZK)Rx¥4BqmS+ &Tâ~-yؔ$`fd*Bp[DNͨ}|Ը;'sy2@VcoX%=u;%d ESv8Nf[dQϦ @k+FW9 I&b2y83GqW_@ se@0e C0A^Pߋ 3 aϷMi`X ce:Nt|vp.e8vg\0bU^4iVYv@&A ]xا!}^F X$lz=dJr!Chݤ~6+ 2puS?zJK vT 06Κ:!W*A}אS^5 qu˃-z93bc<9PIb ƥ@Qo$ͳ{Vlä JXd yz A!6㿕#x`-W LoS@D?/|GG8m1?X/;icJHnA4D|˺lԸ,'xI4ex)1sb+FճSl=\c#F;]/VYDT^ UW#[@O ,]Xg{9d;{Y M+oҌgdF.ƬX32z3F"%y\ ڋe(83S?( qz;=?Tds+]'X)O_ 7x5'CxmW1jE O͠QWÈżU`1\/V b78k|׮~i%:;m'Kkcv6s˶O1}' cAFRjr  s!qN!/.w+r~-#N(k_PbfYe#Ǿeݬu.3+ ͜z+F}u>=2ȗ+g+hM |nm]O5Vou12e}Bp`J\fքGűw5ZpE׾eu?TK{Mad kGHԨ`zE0LH $dSsΕ%lT '@}+;]f5pAՐG]sm#k.eG1T|Wam0w6rcqAx$iqn`YT2FWd%MKN[Fd1 ObxG1‚=f^-VpFCyU:YAJ *8Dˢl/̎%2]Fд G4Ѱ \FW"uSp+<>9\xB~݂Kw?y0 Bz~gXPΐɲ_;4Y1c2;OE ֯kr~) t`2oin&{bWK8Xk!t{^>"7gTlΧ^g׋"G(Mz Dpriۂ*쁋 {ɻ`PaYckͻrֳ˩i+a#Ov]M!%e?@)2_b ïz + )W&ufȼP-QͻC q $%@`]PF3Of9%:YQ)˃O~pYQ]96zf*"uvVLs>7h_iF"ݶ;m,| -k%16wg70[ۘpȲ ?肱f6ByM'E_P2N7 yA(l*y@]?fr5bsʼ0ClqB,ХPvh`MZ30z[mvcGde1_ۍY]c= @lCX7 (ղ!!żN(lW77ĦЛ7D*HGMJǴ@;wVЕmM9 ט>Lh҉U~X{w )QS*Df^kkB[SrȀ^TB] G=Mf=QAq W:ORێTt_AvK83bvn&P.bi[n FLHe5F6: TG>2yu E}7zLe&,7h!4&{ǧDަbĎ3 4K$>'.:c/XL製5j+jʋOR0i7 X7uRU>6YjxxO6" W{Ж7?6MsOA^mX0pXG;|/!O0hYf`rY)Euu+4Ys=4{h/knWå?vVTxSv1.ՈYdSSnGt1E=&?2~wlCѱr=c 5^e! ;AApxO<(T` 4+u+] 01c L.?|*fKәL#ˮb~qCYUV {J$S%4@cqk|^f_h}62j5w3z 8Au: ekA)' 8\?e:7Eg,qƟ 0:kPEfrhg\ ލ{+ 榿oxow3M4v;J/YT꥿&l3fLvcYbjQ\iַA(𵫝H K?e/* kw.;'H6?ۖwpc9@. o }TXr3Uy"vQ$pvq H=Wq@<))ٝc nwe6߄ >h1R[ ƅVRx ę`DTb3j֬86!Q*]׬!  [ pjG>pZ@_2W6gx4jmDrnE0nZ ["ɊnBFxMm80Ȭ$p@b)a:'_a U+5@ A1r ]pZXα]k6&LyX))?_ ȜgB³#`9lrVx|̸ѣCTi #`K#&mvmmLlm̰ c>]NV 8M;8?s&gD 2'82K^ R7KxRMH.hMCvp]Cy<]6j!S 1P3ڣ9lv6YnvbIŸ9+"eOIt% -dޏp3)A1߾YouZDӬSc W\i3YfBk%bjcf3tQ?6 6&NSh9`(f3SI% Sbx՜[>=V*Byc.~(,!-eqh ZVNF6 9E|^i/ϲvYt\zZ,oK)kׅ&nNz,w,O$qG|ד/DcvZhZmyOh/MSb][h6V轄Tq>lXҟ{I(@YW׹^}V5^/'xvŞALP!Z@$f5{f378@s%c0LÙOh3 j0kchPt:#59 .Bj/Fm#]SI} Yğ3[t /RBK/3?qPqkazaVX7hʯ%(!ɀZ_i[wcCqKArAHqͯ08 3܎߯ mCRor=Զ4<ė)~ |qybz*£Ǫ)}_<0g3fPIe砳jaCsag Ti(N} 6 ?}.ΐ 0a'Z6BQphXQ]jɨbp5L+@Ok[@Ų8JbD]m,7 LpDhA4 SfsPH].7P YJb4Vf$?'oRQJ="p@04$3^o^#CXPP,a\zy cYMHc10q I豼8f0=YdӐw8e2#-q mz]^zӰb# 3+Ͽ?KD(ޛzAȸ6 tHiϟ,P`#)|c ?XgpDFo;Q +.i|pҘ8+M'_ !rr>LAC G4Iaab SO+Ųυ:F7iAm$4ݛ9 Z%_Y%Ux7jY7~b&ΛɠPv{퇌Pzج++,8>I'a'yPzG#jYrp;ӠQN1tGA)JyUh:lf5FSM <:Ou(Oj_gq" K-&N w.+pIJmvAb3$yi JpD<(HQˈI::/)0q )'U܇})Oa u݂R "+3m07LQAڣ`Pk}Gs''mq-h.Y-Unf=?U} Umk0P*Ho)!,mύ???mLX 1^~B̂݌:х3#* –"} 0r^ Tǀ"iLe۔acm衄 @Z衄 @Z衄 @Z衄 @Zڌ:DȺͫSj շZ51<[,HZWj݊Bt:PZqDBtjrU:8W٭H^9{4'X&qg>ޱ!,=lz54"Zl.MCSqs1ByZN֡A<ӄ,+z*g`qlyـQ[Ԙ1eef3$eι b_ $Qz̛!;9aqp+$n1F]n*6ygVtnUNc2 0W S)AQ_u52[Q==VU_cl'1xT2ݤEDr琭sy,Uti{n@gJ6V7HzGM&|OԹ84trEnW]qE|t U{{ʮV|\ދ#&shgC λu 1CdVÎf t 'tcrny\/K?{T\'MlK7'sJq|u09 g22 :LX6m|=+y2dÒ?u UN'YrI@1{r,L؜Uoc6sA۝g킣]qx㫼A1;@qNPG#8\߸9cNؗ 9;*Υ2E]$]Z;k` uK^2\#QBȎksօs3qmn`,fZY7Sb=ĒiMbv̑ӣC//s۴ /ApASFpc_ `?0w…S* z925 b2n+|s (Jv4\wG2kfXp"D/e ,š# 2 aU~/̘ 0Lg:E27_4~oL!?tNX2{E9aJ4x-Kd2w)lb&ਮNg+VM{LZ6ىyq[n-'nc:aMߟmLm8D`<ϜLTö|8aߤ.Z(2/]՛Ss\Q=?1uck1r0jdth@-f!D;+ SiEC==F?8)mGqJ^ zTV FADRQSEWΔ8pɲE'9xPDpcez`A}_h&7WˊѦd35:,mzG AjiO0T!֐NTCu6IJz:DEf,@C/?!CěmTiu C9xRhlf$~cEZ϶j nv@6BM_2Le rTv󺵲)a?S~?wmo.N/8BZ5^!*Gu/a6qG`2b@}̧:"!ǀnh`!0­8=|ˎ 쩕+֬$cꢔ2FݶQ-c2wZ  G-^cQn=/s׼^eY@92MU\x M걯ց\%rഄbɽܐ,M[TK704Ag"C-wA aiA/ Dzt0A_8kFp557i3$$? Y/SḢԠS~lƴYe/X0p?f@1>#C 5"Nڃeɖ8uw[R<ѫ,L@\u% P2 un C/Ry 5m`].Pi V7{Q&mf|Eis̖zs#,bFіkx4o[_)4Gzq*dUx$:LP'@ьioo];S|+uv>e\HnV& Ո;vr/OoLd}~QqI5>[UB#sJi|+\{fdgL)<2N Nw|=/QDcg0/hP]aBܿ,WDU$اJ R(Fc&]Ha2X @|nR fXbsW*1l{BLxeŽڏʛ=L۞$0ytk2h7WDh! ˔)rM>GċΣg /¹jQݴu[2mޢLNs>$;٘MũO)rLm.u ~t.L `b LbǣKk}oJ Q7.~WDtv棧-:i<Mf816 ZLo;Kk?. fd1}ȀAS?=h29 E{Ҽ4/H}bm;k:bl&na\0܏[ڒ%=gPx@eZ\R82cfX'Ti陭 0 H(!!Y)V鍬UBԀ.'"4PJb~in2)W'[qݖN#uvVqs,k/-LJNic?gZ_]}n[!oXodbFH/kgܾޢclÚ0YyLym]*` Kjnm8v6^"Zx_xUp7=޶/X;] bs831 ?{Ҍ:nf'dϮLah4iK]!__ Rk~W 0x/op)_}/RG *=mbv |W&A&Sk1/"k 'S)?^Qm 2p!GeD' 9kQ+7:cxJ R{?vϲRzF Y+k#He:C5^%`k$yЄP]yw8C\ W-? OpIf5X$Qs%tӆX[Tn>aiי- U&A7D6GxGG&+'F#4kUS>˩6ku`P K\78%hX3>z ʧu2%^pӇpKV~5:YrTY1tBdXc jBu@$Ts%;E̐gۍ`FD^m4񼿃l=g}D */ \smY`4%ءMAֹ^7bTvVzÆG7Fk+Zh|c4w3٨*][:nnt#OSE ƅk277'ig% LVz#Gfot-u?YE6/?!Y+F%4i[2%^:kBC6{9TMk=W_|PtɁI] ?Q` 2`ޚonT)E`f+|ttć*I5VmN^Jhl5./jvk[I;#evђgx;נ0Z_Œzn[ҒGWaC&wo[ζi)S3~:0-nVSzOjf0eZ*??9 j{ %,uGd\Я8Bb#dM҄u F ?)^hϤH\yt;ڒc Aziᰒ0X= 2Q-k5qŞ#(I!fSiZ"aF]Jg!N)kRm,\Fg.F[E`kn34OJ0D٘_uOz>̰Ұo⒡L3B}|w-JlALMa@/cL3JmlA;lC t/kq,X-ޞg@A3kVJ"SGG[-` luA8GOo [ՍMH.IENDB`borgmatic/docs/static/pagerduty.png000066400000000000000000000472131476361726000200010ustar00rootroot00000000000000PNG  IHDRNhiCCPICC profile(}=HPOSEEE2T' "Z"TB&/& IZpg⬫ 89:)H%1^xy޻j%Ym〦f2әU }29IJ.ʳn5k1 2ô77m>qdx̤?r]segTr8L,[XiaV05)∪/=V9oqJ8'a(,s0X$PPA%؈_'Bc>A/K!W (Cl䄗/1t|;N>Wz_3WZ.\Olʮ%r=Sּ5qhVSϽ;[oOc~?#Ir=A pHYs%&tIME y_tEXtCommentCreated with GIMPW IDATxw\TXzҥ {Qc ĒDXr$Fc,5&zc4WcĎ5bDD/eܓ-gE@9sf>33S( .c F#   j4 j4  F#F#   j4   F#F#  j4 j4   F#   j4 j4  F#F#   j4   F#F#  j4 j4   F#   j4 j4  F#F#   j4   F#F#  j4 j4   F#   j4 j4   F#   j4 j4  F#F#   j4 !KPxe2z/IF5ZP={K$>r99r9W"H;'O۪N}A&8::ZYY:::v\$&J%GnF äƃ 9rMMMaB뇍i&Bill #('O55uɒ% 8;;/ZhРAĚ~z%Si.Wk2Q(NJMM]nݤI? K ߿ĉ'L ``ĀF_z>\nݺ(X6xavyyyoT*}E"4@/8p+WnݺX0@ U` ly]χ'{ݑ6k֬d2C:FLii-[NzuvoVj켗׎xBa333rT*#:E0Ǎw޽`)g5u B$+**؋1:ɫL&IIIϟ߿?1xFw}ܸqfffP(ASSSUUU]]]ee'O4MI&ݺuZ8"+iP(111W4O>P^^^UUUZZZPPpR\.:tLJ'_T° :99z?s۶muBM Q dffO-"s:lHh//={r\./+++((-(({n|||MM G& Ǐ?rȸq㘿oC^:#Hb1L8ۛa#G7ܹsqqq^4c?=zL&9+H`F*j:s^y7SRRsz 7Q8qSFn \$"? d'{]\\bbb-[_E|>0wft!s֩q:<1r}z{РAfڿ={LMM_tѣG_|YehlAgmJ8q";9ydaaaKB!Ht'dsy@@k_Zia6nܘ 2J-8@G 62 ]ri,ƪQ("cɒ%laFkܬ6{Ox`̘1ϧ>))ai vl6Tv´Ͱjd aD"Q~222&Ou*95~95r''~uԉd2YVV֓'OtZf48 v>|}/ػ9VIbڃ43_uP /Q٭$@&_~\LO0WGEz< {VTTD,//ɉT(p" IHXIWMFBH$  Ðy+9& _(̝daZp֟{dH3R]`cc ~ ~)0;w3CSrԯG_)H5@q>[ t>^.]J-P榦owER%%%-nU#ZDfdd@rrrHb1̀rhsRihhF'O!Kӻ166@&LÝVGF?Ԑ*jL}˗R-|p5D0l0/DEEq|d%/=h"j裏fFDѣ~~~;9r.sΌ3(n5\UUUaaa\ZKffr(idU>iĈ]t܎-ѣGr?zСC+++n4[r9} }O=zAg=p1..>شiN>;բ%f۶m ,ؾ};2 CnkrQk֬Yp?Os|W~W_][[ J(G$h]INNN5k޽{ 7|+III0ހ7/SdlslzڵkZ/~a?afe/[IR<~۷|x~eK@uDv ӧ/_޽{J yΝy-Z#4uA=(xٳg:p!%%eӧO/**'Ep"{}ݎxW^cǎ|/Jվ#5ІUԪfܹ3DJ̙3D )O?!!!`v#;ISU[$1|;w""" )gkO 8~"nS\zz: c6L@'MOP!۶m7oA~fssѣG_~U:xǏ=|kny;;e˖1jv i&&&0O?ȡC`FQ6J? 2 k#655A5de<~8I.\rڅMF!!:?~Υ:t,++sF뷈#߿?{줤$̴N) '۷o߲eK~Zñ =C-c@```FF[[.]WH52O Tۤ-fYzY"(""BKJJRSScoLJJ"E,/^xǎvvv-9NبVٟB___H"!Hz{6377Y+o߾Mt Q655mhhH$ȨNQ~[[[766J$hА|>UJkmm=lذ^u֭X\Clwޅ8Y#v_ .qqqjMMM%%%عs'_>}ؘv ꐐ~z.|kΜ9cǎܹqbqee̙3:믿2 !ƚ@ qرc?55 ]hQn<<<5...(arС ?g컰UVٙ^UUebbPwmǁ!!!D||X,(˗a<;& Ϟ=gQ~a'N411QyB yѣ՞cb7o2DkXOw!֫W/h:CbΝ <ƆJ?#Cu]O>`Щ=fjllЧO3gjZփO/^BdBUXXH6)Pɓ}}}t?**js̡$/޽;""b$~@_2djǃۡF5i4yފ ++ X,/xyy[լ:u1b&&~!CD`J \)NϞ=6|b'OHsss(:III7oޤhBغu41 uʔ)dw{?Ѥ\ ۷o4Y1tPgggbV(zxx3ܹssᒢ;ؾ}{bb"ė9r䣏> S\LسO񹸸L>bӭo݅VꚘ8{젠 ;;;A)0)ӎç) Ν;kP5W.堜QͻvJI >}ӧ:'7ndddhRGEEYZZ3ۤI)RӧUOJǠA˝;w=zDQ%WW?b0SP餲ɸqkMnٲE*.J֭[~-Ä`ҥ|󍝝;{ӳK.֭[dI˧;wFo+"ӷo߇j-նmݻǴ8+ٿtt0W{l}JRqv6pRBh=ێ?&NH/Rvvvbb"2<==cbb 4%$'O[SL'233(^ySk#>ngee5jԨ1cP VqF K&}ׯkZv"%gge˖͜98}%$$aqM%wqqYvɘӭI$؀8 L&Z8_umǑV]=C&nnnpOϟ?0#>z 3p@ځ)S.1677w…oj'={6!$C &;g]C22_>rY\P 2/022oj|Qxbr?`") L28Xt:"On$$ɀ P }0o1x`v;C⺥!cBE"vF k!=`S.{7TmO}Zեmܸ2ȃ]ewwwBzj0 3hРݻXw5;wQ }K pW_}eaa_;}!!!޶!(`CCÝ;wRSS5R( 裏);Y\.3gN=5v9aJx<>}ҡ̓‹D"ʮs;> =$$k׮ ۣGCʄ>bJCcV umMMMO<ÍRi]]]~~~ZZٳgw .2O>f".P;9 eԹs={Raccc]r.ٰaIvfi;k$ӣG`>scccȷ|0FE)ڵkyKKK=@)ݻGFFrWLMM!6z?Kmr$**СCZ`AAAjjNmx#sss_ib m޼y:B]3g>|(V&Zjs0 lݺښb^ؘK:,4r*}}} ԐNNNC7r( -@䀣CS2eJtt48 C vk:yM"$$1Dֱ : dG3 3|p߿?33300"pKvv Ė6l@mmZ#SGDD"(HDzwlMWy3 sEFۑř3gt!ctttpppjj3ܲ2SZyAa0YY[M>ї.]0`;Ov҅a%_v5 2֭[^@* }J3,zzjW^}#G*I㣚U-IDT666j5| `Q4uPGZѐk5*?9}4}"~Cv IDAT~BP~ر?H'H3 DZh%0 3iҤ////lll$g8:H;M}jɚ^%_G =n;vx7/*ˈ&j!O6$IrOi k: S)..]'<} 슃쭅gQ9.91 sׯ'&&nE$^Vى\O~jll4335 宾>7/Wk$ۇ. `{_YTTDqtH$#&Lj ,-- BuHoq /^zwN=$zk:d9)ơdfxKrY>{n4J E[r%DBBBdd$D!W2p{.I(VUJM)DUaT*ݲeKll _ӯ+}BG^vL3=TomIrz_\tiBB{L[2www''';;;N:ǔ݂t$. )5y^8ZЯqww۷/\e]]RV}V ^BS EwU1ZS,2đKۥK rZGMXX )NGhˋKHHi ԑu߾}}||}||mll4_Jaa!DI4E ﬤΰhѣGNHa/-E{e[[[:o%hMo| .1:Y"255ڵ=;wJNP5 EPPЀnG@:Ff߾}w 4СCG 6G8fmmM7o3=}XPm:Bm799Y ۰;FL&ve(-Z$`ީ~֖Љjjj My<3;wn$*uVV/T@P TFa|݃L@@&>wRS Ro͚5aaa>>>DV [pk@@Tٹ$炡d7Get̐~pH¢B؟G% sa/^qjCQ ؒnKR'O& k׮}ɸqzڡ٪vIo=iɒ%۔)SufffFlv$ͺnFFFNbbbQQa=02+v'\͎8>>LSVK.z˅U'oa{#$$dذaȑ#΃ʷo.--Um}􉊊*7n+ix - cbbBܻw/** t AÇӪ_~%V;M2HD͖4HR`H,]v =w9???\TTKW + E:4g82$Z*._ޮ~n!>M?ȧM< \Kt>**ۛ2\pw PabM<  \(Cٴi5 Ԙ4Ҝ ;,X$IKqz$xSNjz*D52iv*M|!yv06lp1ih⼼T_i$T9faJJJ u/ \q+nNj XEiiiR)W{WJ+v?yUy)鮡B#""\]]@vMMM02tW=,/'= % OwbqРAylHlf(QmmmffffffVVVVVǏ?~|CݴCX$/hѢf>w`MT9ŏ xdKC7oPlx Ì;`^Ӥ$=u8f]55M!zÃH$j|7##RQ122ZlJ0~oܸs ɟ9Z"5K#g577$1b;/H$DS(L^ӧOwi!CFM2Ψe{{{&wUbG8FYPpXGFR ݑ^__): }\'H)؇)W tWVV&%%Q ىT?~xx~HKR##̘1ݻD_J݃$1kjjN>p“'OD41kZzǎcϻ[2#|?>C^*J/ެT*MNNްa{ァU`9w޽;ezׯڗ7oބHa@m bcc)$&&<}TrlG<>O뙌<>CE2.V^Ms TVVuBaLLL~mYbM?/]Կ={ܿ6-7ի۶m7n͛7rqtt{, ,v.У@ LvSٳa) q/b޽{v9s&֣gx ~ uM-[S{Ÿyպ:{`t@ܼo߾Xp1 6P3c_zBJ;5kJPxcǎUVVUCח.%Ʒ#200pZYWW7w˗o޼ӏ?Jnjjjd2Ễ>[e2 ü$;Q{u] C̳5[H#}ҥ͛7ͻjKUad{MMML@>>>d§iҡ؉O83-)/by@ϲ_bǏÞWMNDQǏiVSBh:q755m۶mϞ=\޴B'.Ff͚5kp/TM9E9;;kI T*U([nߍͥc58r\"455y'O*fF[\+R?^{uuujk>dggܽWHb[[XX3QT`0h":::--M`F>ƛ7oKRTJ6}+K.]r_~\rR2'OTСCz[EFFƫJe ,kMMMUUUŷn_7n[oEEEU\WUtIRtಌ |EEEpL", ;w 2D'|2@W(gΜSׯ_WNZ">h}@zv4P(ӧltرc`)-|>_(/X0lr֑M<'r{Ŋuuu#|ٿOݺuꫯ9J[har'>WOֲev矏3F$鹪CWXX?YaE ͛{Ng>]/^\d ᲭK>_WW1iԆ'*GQ.{yy]|ع\4hѣG :t(111>>>""V ; SNe },ujvttJ_tkVX={~bGPٳgkCYbE~~~CC/bdd? !&; L[ $11Q*Ν;W;ZrS :a6lpں:8Z___WWWSSSXXx…KrSk2ډݒk|groc :ܷz/))!oǏ믐E;? ݎ& P_e-tO>HKR%z-[ yv4kmm=k֬]vQ2b}_~ʕ+G gϞꫯ&{њ666uqe2Y```\\\Ν׮]<%b'\2""c3Mc@ JZyyO7^UPgQdddppMEEE}}}JJJRRAGw҅؉|rlޚ^oGG%K[`sttkN.2dĈ%/wϞ=@6lmcccEEEީj8p(&>4wǃj]lr7CPteƌ;w֭ҜamUJGziGٵ{ȑ#!4F~j* E4WPϘ1СCdLaz- BLfnn~޽C!/UTjHmذѣGXkBznvJS]r%**PN$;;kqJ=PwW(jBJ9MGֵk~a Z}얤VAHΝ yI:+HRpUs.)f̘a؅+o&55ucƌquue1S(Z|Bz.^y7n@3ze2: ^-JOO޽;^&'Hm۶Ei[ꎽ… ߘD"њ#?6`iGFFB 5A411 tqqR=zt֭%ݘaiɓ'׮] +~d6 Lgg9H+HS .Ǎ5lhdd$""",YRQQ +** H۩S'[[[@@F/M9)v4ζy<^ddѣGwرb XJҚ{Í><|p8/ `#d ,8qbxx9Yr,{We[ 7XXZ2JL^5볰1cFYY٦M]6oܝr9N= Ѫ V-- W_}uժUE$CHӷ0aŠ+ ~ IQ655D"](GSN:uCi.jEptt[T+P˗/0a–-[I)%<̙3gΜ 8 \%gg) zSSSkkݻʊ]l6ţP(n8duݺusε3nԓm999\~˖-] - AڤSxx֢v f- d166$jseIRr :[.^xĉeeei5H ~@ɱW?R]]]RR~ro;j}ҝӬ ]IDATobtb^}ҥK㓒?~ )9"!itt!̱F\(:;;(L["p|XMM[ZX! BH$ pssS5ziNr죏>|_XXXRRCL@@Gn"""LMMgpu5HUUUiiiTjkk%Y;MMM% '~Ā=ܽ{֭[J_trr rww󋎎ڵX___)cK|.]9vUݻwSRR򊋋kkkvVܹs =O>+KKK5^;"uGꢱӧ555bX"+@mmm"#8Dۯt,d- =:qDJ&O>dzySSSiiiyy9X422277wtttrr"[C˖V):3sZg%fʦ& [{{N:g0`j“*͍****++# jii A8p`ڴijҥ7oV;]c?v9Nٛ:ZP();͛7ݻwЪc >3///$@_h*7׻IhӓY\ܓ{B¥rZ86PUo 0=3%򘦦 TZm%U!%oCCCeǶ&<r믿k;6zh2$dp}6}A#**ꫯׯ_gaK$v a& UkӴvVbׯ_=7nC$ -Lk5n O2~tqq!ZߓaL?j2^z||⨹(1Ïtp Z Ia[ud2Χtp=e>qę3gM|>رc /]xx8>Ɨ ˗/_jtk7z̙3SSSrD4")?ٔdpNKfee_oN*6jt48.:w>>$X(a9!{ڵkۧR7s{||vU>l' pPH1q>C'g'îcGm;ȴ/ 'bG˓'O/Xm;QH$%%%N֭޽{)nvZWWW8 ATa*Ŭ/,,_SRR &:u*3d!_ȽwgxK.=r%T o3f666}GgbqeeeeeeFFF||6 Rڵkwi)oYYR/--KOOg88!n00!jh .>G_sXXȑ#ͅB!onnnnn*//իW/F[-AݻPfǎ[f mmllԩS7mDFw0{ƪP!uJJ d#C˽Kߠ;wׯ5N =3f̨ךUmF?[[w>(r<88ƍ'1pڄ :8iSDѨѭnJO2l'N%1hZx'O޷o_޽a#ǔpep.ógφCd3ϗJcǎvo +xcb^GGG_ d1"S`Gݙ4iҍ7&N(J[?9WBL&ҥw}wԩgJxU3bFA]{RPAM8166qL"1kstҤI#ƍ'NwQ]A䊆ي /=dȐW_}_~@kՄ-:⼼;w\~$Mttt޽}||\\\<<<`H7My;޽{z-e!!!Ǐׯ);dkw4kZ,744TTTWWW2KX[[;;;D"+++tJ7[Ο?Y;BiiiiiiiccÎܺjt_@3"l EK7jO|P( UA?4fEy v7ϳOlyZİִ3| 5dTdi6۶F#_ j4   F#F#  j4 j4   F#   j4 j4  F#F#   j4   F#F#  j4 j4   F#   j4 j4  F#F#   j4   F#F#  j4 j4   F#   j4 j4  F#F#   j4   F#XIENDB`borgmatic/docs/static/podman.png000066400000000000000000000400641476361726000172500ustar00rootroot00000000000000PNG  IHDRp0&iCCPICC profile(}=H@_J8A!CdqU(BP+`r4iIR\ׂUg]\A]pRtZxp܏ww]cHntJV+D@q9Is|׻>爨E>8EAfC#&NBegd{2i!E,AMTQ:)&:~\ `X@d5KSnR8 8:-> f?Io0 \\4eF!;P*gM`\s{QW){ݡ=Bmr8|(bKGDC pHYs B(xtIME +B( IDATxwxUE?9'-$tRquUP))`YTB .Z 7DAŵW R^3?n@Jzn0y}nΙ3g|gޙy_h4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4Fh4\Lz^C =RJ ΐ7Rw&h4VLlJڠ@՗*U="Q$4@}a;3?z1(UJ>P0O7 FdZc〭^6uI u͸EZ [JnFS9F}%㸻L< ø | e6MCh~WqL+ {_p˲ޞK7F2&e'.-TD ) 6}|$?M7F@La(<=m-UGup\GbMDh.Ŷ+\^C q*dbH`)Sc{fh4]X1? q~WD0KgEh<, vdj=7&!@ ZXDqt:Cf`pϔL? `y0%gHn2F!0(X"ŋsE2MQ6m ` q6ohܸ先NpA#G͵)%"-z x8RYHفnV &0/(W̊>i\A)oN@Jb35XQp$0ӫW/'qqqDDD`UJAA;wdÆ XB\ز)@AE$nvg,swbvll4X& -gG7{Tה7' iO(3~J]O~4J;CNhhGj߾}̟?E)'5{}g1P&$MGDNwaM1P P<2 ˈL0aUV<̝;Wڶmfڦ71'=PO>z7Pt4Z\kR Ԙ YI_{6322.]u a^y6`.0iMNLQ6 m>hԐt˴ ̝~|50c ׷LJӧu]{WUDI+}R]3 fh_@5a u&)))bƅ{H`ʔ)ңG1 6L~I˔?Ǖ⡴{uh4Z@AZl#"$G øC ;7M5TOQQe7`vҌ o4r~PJ,%ԘKu3h4Z@*aZ ϊQ|\k'Ol6m˲x7ӧzdݺuy]v1qDC߾}x饗a6Z'$/B 5Jx&jn+x &NNy6]vu]{XŤIxh۶-Ǐ|uz 60b6l&MW^dffr}qeѣG4ͿQ7&<zQ]Re/s] V LȎ1Ěq?j*RRR馛N_4l0fΜIttt(<<2vXN~-0fV^<`5*^|QSIFAJCIi4\H Jl4 e zrss {fwykΝ_\fU@RjԤWux }PӐԄ5-_&eVSBrY|~ 87[nŴ,?nƽnDb2fhdjtF X O4b[-2A~bˡNP!Ncq77p@R\S׼&.b--nZNk 2[Lژa-ӿ*$$zV>" c/25}m';qE5 ލ4oޜVZ9Hk6&--6+.hVYlSF~_]oaoLm7MoϞ=cK4ᒹQ,Y{Y65R&. )bK>NI}mNq>HټgϞ?Uُ jZxD }/3Plfred˄ę7XƵ  AN?*vQ.tt z*A*KBdQo&ܓuݝ To:)h61lBYk1'fB-1qEN{ˋ"r E)Z eYW^)}9"bA @RRDgǂ-[rTa%"4./$pLcVdZk x çv*<=x`߀{_BD:+E0pdZ}_uwnꘉb,UyfZ>;v1l/~ OTȆy迃?MKh WΈB_VTTȎ9p`?^AhhkD~v;rM[z_ʕ+ի0DbFJ,7d骬:Qa􄍉\aZ#F +@tbN3ˇ115nR,Y<]\S 0L^ܫ`.QzbcRu4d 򰂾z.oEaD;8pk~=Cȣ0\3j9Z_8o GcǎD܄I[\JbZ\ uW D@KƹL .~1&،a,EqwMţƘJ6Ť7˙/:.cswSc{6qwHJ| Rl52OJ.NJ*j(gߵkڑ#wH0Mg`*8jR,ڵuv| bذaAi:IxRߤaILL T@f)U@)9iSbNNp)NXv-?<~^bqQ6Ryg!6IM;?x? /2=NPP|.~ ˥xs;,_?闶k)1>رK7Oɀ]Y!.OqL@kjG9bDfD$56c (OQ"`bO-Ьk /4uu<8!UZl=vHH;4U!ut!@)Bb'''t֏)e,"\jeRWa8=="?1q :[PPPʕ+ٺu+iם?9kU!;{h O>Y־4 %/"HӦMPw5D$Y`!вԥޚawRk8r/_LuԩÙ)5kˢ\v%oNxj#G b|{S޹s19ŦJs /[l"<4G[\_k СC' *g*J:FFF|A}i-|9~j(1P֭_3f,;vf͚5 0FѫW/:uDHHcǎfh}C0!Fv"1r,>lY剌4E HBFO6UWG^OMV9W".@U\#fvOd _`U81-& >FrM?_:Wzx@R\mMJJ%BHlT//_z(v.g/~8Yc:;u -sw<S^Oki{ ZnĆ k :wvB~0`IIIytԉYf`D'r݆ot#7qǀz-"X)3"AHHv0EȧMII;ն}yd{lmPJ[\!ZhQ 09;;wqwO Hy/JIZPБ/^x̖-D9ġ 2žC 40v[$(A,8xOƻo͒%KW_eժU|G 6#GrQJ=?bO-)-[["=5/ dmSGMqLoϪägthI՘-$6mҪe oV\͚59z4":͝QF "6egRQirksJ{̶'{*VFQ_.)ʘ=>;!6aݐP] dUak~M"&W_9 kVftAa;@ T׿  r\.j#{nOO_tcnĪFmX@~%߀';bFojϾ-ZTaոi9i+\C`|||\2Nzeb. le(?%kFbT1Kg܋*fk ԟdJΐ<)UDU幨"Tu`cWɃu)k )) Wx}kMo;wfP)5e}~~IRmNh<*.)aR+R~jbǵU(҉YI+=UV˧[0+[U)D=UւTo=(e>_̙sjt87%/SޣB~/N)Tw ].ëZ5jE : iCCC # vEDPXTľh׮-}`xL OE1M[oz-1cFsUh܈{r MX+N'~ɓfCCCdKaH<;膄= fԛLoJΐs=RI#W.:?ܖu2 r .awbY8жmۖG{EVm"-a~z,Y O?L!88Ş=Yn 'Os(zˁ wo޽w?a-l4o֔b>ɼ|B[8L8&9fx6+:} +xW(jHw2s%(+$JWi5RJ~#t\i׫|"z_@)^~ܞ4*T]͌1iZE2TgA޴i݊ͭ* 9w|XiX ~0pI%3==nn20VZX<2QcUttYnSgl-a0ݽP1 cRc*f; uK+ q۷ĉ]۷sq0 N93YV\z%p2/ 0!&IDAT'&+11X% 8_6i]iHD8oٟ)ݛTQlPRڭF%rrq^>0[\n?4HYF%={룬Scy@j .=%# PZ޷t>%2P*)v`)NqT Kp{H2Ty)9CRc|]9E<j!y˰y /ߑXXn >LaSzbbb9r$7nˋ[n?OVm޹s'>,ׯLJ믿>}0c G@@ׯ硇"""3faa0p@zMJJt g &L:'spcpBs悪bP* \iQBQDɪ Fk+K=- (4Grrk׶JFz}i raۀ :?cNull6Wbb"III=z{;3ŋW ߿G}>RYw^{1ڵkkV+8???N*7x#""p]*U7 -۾)0v H[—UYkSc2.K)穲N / G~iSS;uCh{?[E*uSmf7lk׎ӳgA Dddd>ip\4i>أGZhk[oKC9wRMF`̙իlDEEUks&l޼Ʉ d׮]ꇭ;vMT蒹5\Rz|հ(J-QuMhF' Rez͉s TEI^ ?T5S*@ X{Pz岵nYoՀ*HY'xyrtۂq dңXy6"j5j3ݟe\.~ڵno#c~YǤIٶm7p> Fogժ[V^ΨQx4hvҠ/}YDU1eM֕/k&Szy Gy}2UnQLΨsԘn 9IT1kZܜZi17+dO#ɓob] wB}}||\11V'?\3+c;qxqwS|/4ՁMKV/o׳FGGK. &ܹsq8nݚ%Kgɒ%lْtΝKLL =7t#6*;Nu崮7 Y0fW42ޚٶydW/"%+NM{ԨQ~`̧ȿ]X@dMnwг2NJ"ҽK.b&Vq߇bx u!fDɖ-9Gxx;wf=ҷ=7ζE݆mۺek'g <8|XyW,t?!4iN2]3f`4jԈ{\='qm`T0 *OoA>LM,2wj Jf$*턬b+*X956}eIf{vc[8xg_PZZBf)**/`-،.\8x9-&Z<6?$v#5bs3s#5Z<.,)))WJo ;ϖ4^6WV@X 1t:ݧ˲xd>;BZv;{-I& Vf2d̝f\Jj?佊yt&ڶmeY˦2߽{wԌ<6ލۋ>}\[wC>f*v/vp$MˍM6RJy垒 u=*VM˲Rc_?d[)i* Ffaքչt|Vi7CnP:?}0;6\/BH57uWu,NؘH\Cp`xjlم^SaXBkPQ աto4RQ%\.kmR.arGso޳g[iPkT˚oDr<;u 5H;hms.|1cەÇ@^^aaa^)S^H׵2?-N@, wQJuR3oDPKɕw[9t.`ڵ@&c_Nwrãx/alpլhbbb, l\Be`n_ +18^i!,-<1&7LN\5-JM-:DY p#mDP /Jc5+"܍UW##cއþb`WJ xˋa%@]2H-@*(XJ Eus?E[@Fq`.K[~|>|o#bp)x#] '(:_e)h FX۾:W-e?䈋⣥de'33/Crq֬ dɧ, h;v=q+Ԯθ7L8Xmy?Xr- H6qcB* љe5ɵ,kФ鍏ne7?c=~[u*JϏŞkU8qرi-Sevd?\9 m6e\L5 \T*(]_jr]ز`\7tYb.a7?M]cAK ΢ܻ?-+и" -8|Mm[`rZ3!ȪUfޚŊoK^Wp뭷w^?Nll,WwQx?˽bURRRȲ!IkFǜR<'YEC})LN5}+J^D6mSO1c L K|ʵߊ7탲ϒ|$''Yش!LNתiю1ȮxR҉9U]8x6).a7^a Er/l>;pIS#p)*'7ʨP3 iW 9tqX|N78yҧiR!"Rb<08QӅ%0a5f^(rhE;p//;M6uV@~=V"77#Fl2N߲e ˗MF nDX6qaNߏC8#sw)AVE٩e$e6tQ[KsaxEyf]5%,mbpE6J)#44^D igǎX./}ύ7^o,/䢋.y椤n\si&ZhN!4mѺim97F(* ݤ42L, RZd->G{dsāӡ%-˫xߞPWiq#/=^Kneرc;rrZFmԢöb/Yov_e]]Mb/h(v^_.g!&hWr _{fA .uR'|7;= ;[(c~ҟhQqN~IcNCqUEH'`DQ$J޼SEډى/Sk F5KScӿ_.,VH]Pb}{kXLm(:"Y{l_=Ns5"eh7peUJ5gݜN%{~+\)gcwk4MCZĐ/9-E]x!8eIJ\_h &f%.MxHz/-섍*wCI/Ǡb["򘋒qa<=Ľ9i4- HNKSbC]O*'Jușf9 E^QLC?0^bPL-l6N< C$O̎{53L&yoLM"2E"dڕ-2?KQ] # F! s]&{bCh4e1'Eee?o"1ۆ-VԮ92-ySP)lx5M>}i1}D BC[Yno2eZRCLf^膄=h4f 厂cMes(u6}|&s|e=2&b{ 0;9 9I+/sVyAEU|yp/uh4 R@Nw]ӻ2ƃ @1 ̤j93bܡ#. ʸ/ܸ2SpBex6䰴FSL:GѸcL'6&mT@ƾp'aXyv} C^R%3'}8VFR3p1^No#0R&fw0TRj@OYUBʚ9h4MS1޴Ln>g&d%}_Fh)OHח},yfbNj*5FS%iSnkBh4Fh4Fh4Fh4Fh4Fh4Fh4FhAc IENDB`borgmatic/docs/static/postgresql.png000066400000000000000000000754501476361726000202040ustar00rootroot00000000000000PNG  IHDRxi=zTXtRaw profile type exifxڭi$7r:6rf!&g(SYKVd[uſ[qj+Ͽso| >¯M_r>_u__wu}Ӿ' f=QgQt;^o뷟s8_.JU9пWbJ|Z?]0_#r|N :}_{UB'oZ{w|ndp ?n{\zo+|T7mnu:?Hoan8 %xbk+ZK5^S>эXt.rc-]z\q82NFpD !Z uN9&:h̍bZ93/ X6sB XC V*cNМumS;6Z K%UZøЬ-,Y6b՚u%\RxjTsZj:ZjY+z=zcpё?xaƙf6ˬ>>+/[eV_cǝ6vu8riqM7_n㏮}k~ܿZv-Fk\Sщgt,@ǫ:z[9s1,JSsvP`>! ge}9tΩu_7]mz RS21~n뫴)nim;7AH}0'KN3[vu2 yf~asu)ʖ.$8.#uk4*AcyZRbck9F3O֎M]Ӆ4!m)O:nROj>W?XniA mnufa֝+@zF.WH6 WkOj.Sl9IJK\\jkxm0;t@xr=?pODX=i1..Puռg0PY=t+\,RX0#U@#x,}}i"87rИ-*[v VCGwQ?;'ĸmnbɥ @g4ƪQ܊nSm<{0*3]3VH=u]cb{ sep{~@ܹnJ,6Ix bCJ1 o3P͚w +Ka]PyA0E:cu.}:G=gZ],J .,z"5డAF6GC}Nd>l=JoCweX7 7KF%U*MS%])8-DP )Van4.\0a?0h Ӗ<d V)v@ 0B!.P;iK "枀p( P%miմ挧tz*Ǯ; RⶓFaJV7kNQd8٘L'\rf.u\ Q䭱, .X6p1f̗6*r5o"egpoFG-D( hV ]c(0:0mEꩽO˼ٸJhFٛ۽+\z7TR  I~BBr#FQzt;@? 7`g* Mm3<7EǃL+;2*Lx~a`?=d5`:YcHecߘKckn,cwvk<甮iHAubDI@m6vύ"afjL%pmo6 S,qF7L+YR~tz)Ps7d9-Z-*%lfdC@p@yq1G9c+ C8@gq0Y؝<wĐ@e0@m3A';BQqp^& K! f7[זΪƷ;s}C ITwEmQ+_MIv+`ERD>FLix3e qHf 79&} Q;@(l|6Y4 x|5{ y4yNtrQPI(fSL␃͹5 A{nÕBD;b51F4 24]r/$ _; M4,j0\$=ETwe ?|"ݢ+݀\?e sѮ}=HFu@oQEFü, yEmUQ 0䯓2!{] g)CP.3;B;-\sVFњd9 `b"q):M"69fQ!M/?循[;*LSdT$RH0Ŵ5 SL_@4`ch^0 0L"@mNfquCبzn=''Ƃ_^DT53܀SǚB't$P+RRJ QC;B3Saz$zbĔѐghASHnȭB^ucpD3RA1`(ӮDH G(CläkSƸci j&mݶ6 vj6 dbiע c﬑֦f Z6]) HgH`wopEA+Hc mIWȢ !=[2|\ðiO'sіAz500 X1*r hgaS "X<ץFedXp2#6ȑ`)82'~[(d$87mb0 0Dz^Qykcԓj *LK,@ꎓQ$&@UdUYF[gDYPHT[oax*X*X,^vtn4NaO .dLfy㚽<&ɿ_oQb0$+"`L3.ɱhFAW;`G? /c#@\H(1Jo/q -mٌohbӡ) \>xyY)/-h"P^sHw6C('1R9E۵"`X&c*eR'b;a)K֥ɿ2wL3.aƽ6qܶTx@|H:Yѓ~y6DZtWK[t3( vo`p'ܴ1jN2ͦ&8M 1g$Xjq'="4‹z7tfN1V<\LZ9ֹw*:1 XDRI}'F`BMsKUW- t0m'LE-.FZNPExl=>4zBKa}s$$KM<˒l89 ГƝiqNOO)9m 2rSdhFl<=vDt=A'R%@YM<;Ď& P1m/׭h䝂'i=[X"܃fC(:*f\nOQF=700&jqә֛b)H#8.gB)!4fU'\p|QnAXb1(śb,^9Ugy0~)룍Q2.5aDU&o Cbq;rro!McӃƚ'baђH^¦6?1fVk+x(1P*=%jV¼FہmSpw]R>$AVN#^Yhy5p1٨1ڌ'tH[z^M޹LB)9"lGH*3K#? |ͦ?j2U9mIsRMmn=d<5(.%n4B>.1`Jtɺ iv|<7p&7 fUak$ANN%!LׯRm,=m kxD']8v &U<e.qvM~i="ENrOJ *"+Q ɥk洈׸Nfd> YI$@qF9ٺ"ț\S¯gy(`;+~Z{Pʪ=:c8K *KEOtc,l`_|u)}CyVDPBezTXtRaw profile type iptcx=K@C#1,ܹ!6)rOҊMpGР[c{¡0uF"d nY{UrYO#iCCPICC profilex}=H@_S"vqP,8jP! :\!4iHR\ׂUg]\AIEJ_Rhq?{ܽjiV鶙tfE =CB2YIJwQܟ#f-Df6:Ԧmp'tAG+qλ,̰J|+M F W% pHYs'0tIME ;bg![ IDATx]wXT]wzwP{Ekl$31Eb^)S^1cĂWP@A@@: L/LaPx͝r̹u>{MPEPըFEEJKKQYY Bz֢ yyyP(8p OOO @ `: DGF\.L&5=p8RŨJR4=<==\. ˑt߿wnvy{{ǰa ___f42`AW!4Z7o"##HMMűc #F 22[ ˑk׮سgOKbb"uN3gX,ft2`AgZqI]UUU$$%%!22)B^^<BQQQ<<<wwwxxx@(B*uuu5=rrr$ɒ%Xt)"""ʀMhj'NUpf?+PR Bar1o< 0=z@vv6>slڴwBBB777 PTAuu5jjjPUU|Faĉ2dbcc6NJ8q9G봟OϏ 0`ބf0p|ᇴDqaܸq@PP<<5/^ի࣏>BRRR0`Aheeexcknnnx饗 /thpa޽jkk4!W^ػC7@@B(U,u)Bi/Eȿ]py6b{a`D A`aL,YΝz²e ˀ-% ,Xז,Y>δɻ]̛]S5rH?wB7O׻Z2FgΜi\Qo# 2:іhlJswBT/B|7b_Ǻ՝,npmtx!JMϟ?~)̀ڵke:r|6%Srq!5re%Ы7zz!"1~tvJ ~?3P[jStrER0+zY 6#ؘv&Ҋ ̘1.\06l0 cF1 ܍d2fΜi%ϝϾRy3q=з»yڼ_TV܀)C"XG8(?$BrؘOM;Z-?lz-22Cpp030`=B#I+WĻ 1ŋ6}Faņ8Ui / QVSU}o7 5.3GYFgg3Ww}\'">||MSHRR~gxyy14;toݻ/6Y'N4VсVYSDzc͋3lL͟)4FsnxFIYS m.HR}rºG www$'' P^^$|fD3`!4Kcرt9sXWv$/GS5: g}}Lc^H+k>HqQ!eРAh48{,ƈHFѣG) gк[<`\rPSHtۉL|.щbCpzl+2,(v2x2si7n^s #}oUյ`06j@7o߾lٲL5  0vΝ3z{{c٦ԼNm ӓ j>4wTIX.$u=]XtxOtJn Vn>lrr\,[ƍnjj 0f4n:E6 4y/wAT%;(0Ne><] m[9}̙1߿ 0`-;;TbY%OZ֩cXX4lT˰bnP#IzZ|t`vJxxꩧL[dfMh&MBPPx穬{nHFf/oEbarJRrMdzf2=ohh0i\2`Kh)))9g5r%NfvjÚZgU2$7?PP^gzѱ.?{[7kRɌl >'Urs2qReEh  0xMVZg[GAV)u: uV [g9 J330`XhM E}֙BCSH8g$->UVVZČl A4JKMDBl(z ;~NxdDz-2ݻ78lvK:u&M\'1`?ЦOopU̜9µѐB|s9IH:>tUͯ?b*WPWLBX/W1}a zt:(fT3`&ӋW^5=pU"v`Gœ~. 2 QP^zTIv#bCg6~|lNf k[ͲZ|^@gccYUVZff&t:8$$ 0x r"ʲ.êsYh* ~</硼6_ڠF*컐{a㉦2-uńa\p[X~x:EwƨMF[vMl6B̄fnfT3`&4ˉ0??Z֤ Y$ +琜r&Y,[`dl(XiSo`cez_wv@@b\sFw7NF{}}LJBs ˕&Ǫ0`!4h4"''$Rܳт|r5{F+;!PܜxxD4Bb~R?p9l蘾cP?nZX-ޮxj\^Coɻ6Iٙ͵ƎۮMPC`X #CE7nVzT׫!q,],3\\ߎ\U ~Uյ+q j=:\6$|.=*;ƒ|!p۵ U`( pPu!&&`e"ChM_vjnTHjG@k+FDAY-e |G4ADie2)v _γZ-ٟjeI暎; ,<4$(@ARPH sŘ>27mQUHBIn8ù(˶?)P'\b O w1vb46gə j&Ꙣ( $rF0^=~.`~F<=BF>ݱF&Md"4"4:)@L  Y'2 ƼKM!*;,<.jgVu7*˱Y,J{z?22]łO+Wm{/blxFc~#3d > T? =ļ~ ,P$ x7F L{-A^i ~2KŎ ls8q&vcnLw<7+=yD9V/ dqM&Ẋj >@H1潛esGvQeRk2c9{.bɢltU8֯qaV%͙3]f'<6o6 I;Ҋ0%.-AF ,Dž['2hG t[yB !ݮÖX4mx,DO.aᆣßzˊf -/ǖ-(\&1:]|u )tAz{;UIQ/W$|ܝc:2nd8}ڜ_w`WS׊1ퟰ9bXX$fݴ4X0%,3 oד~xedF;p6؉]p1߼<6Vtn/))\n.ٳ܎&֘ޖ Bc\dؠu&4G-d1qqq ENwAzDE~:x]Wk^#IA5JtE*=5h $X,LѢvn.敃j~t$j)uHU萭ԡR`Ds"E\9%XBmvadyQEu/O]|%V$Hbu{5-ݍFK A"c6s\ :ڰ{.kl9!.ٸv NvGLށpQנFT%8~u 5M5x;A"9܎:SnBȱb4${bDT0z{YćJGI biىW`Y~:$wМc۳:M]YV[oz{*ͺ">pz9{L*( `0 99tpBp87j$r׽&)*cY)lCtYkxY+SNfAƈ7p.:-0?hYϕ߹ƻ P#WyxcTטX}搕WOdBe.eZŅ`xDM`Ƹy^\C粰|_:"<4]9l|@T-`CˣG,->c'Mq E;D=p) w,?,rM 6#N< Lf:8p`tzhQ.|0~X60UOe13Kk208[XCwlCftta@P,>g'`xôd#WO_pz2k^X:w >jJpp|.lW/lYa#&Էc RvB)̕]; Ng0Z)'R§'4eu O7nhz޽{w6aPogjU;Tz5 +VT%ո]muX5P@T*9{6VnUɐUP5\d#5-) 9RۼXވâZ=NUwBZ4z|D2U'.zE.Mh63iϞfaK.AP FƇw;-Z{XhI<.]} Lژ,`pd d|`grmzx9a;w4k v 4@qԣAעs4:HJ$_?/c*@,@O##ѿww8KD`)4(5+R.6 ,:H:#cz`<2}! ͫTitH-KOL&<6X<1&9~*+v<.v/Iz^Nxr@8 wx`xz,$GbOEk\{26w6 Hz ze3JYI@^?(Tؐ$Ų䡁tugjրX5 ,sq j$C7BsXb>< "vM ޫ9s &LHh{:1ua 2Nh)%kPݩXoM,Og.bF[EnٲZ6]{/Lb>e59ݰgjUxasMB"z fCbp'5 !28@\xOVrlxqxt\+Foݧp85Jh6YܒjdY(4Qw%g)ȪEEGLb"q 33N%)7üR60W J@襐t 坥OVTm=sj#xY])70;^~B#*85ur)N;ӆsWq 4Z .9\<ӦǏ;اCDp7S$E^iv$#ynǞͺf]<8<כϘ1Eܕѫ;ނp80Jom'ΡV M/C`(oV"ͭh*Ɩjx d*-lI) \6t:RKxg)մdf>_hN}Ao0bQXTm:ŃBXZ3oǡbp=xxŊ:7 bv{.re}m~?۟&(ՂߤF3>Zl Rok$)ФۂLKa!@c=!kP5s. ľWZXs #3g ''th"G4GOԝn.,?JI+%<|/)0MkPCK'Xtx;vus:t I \d;nU;t=|y'uOCshLx'E @` ÉkLFoꄡȕi(X{""4K 4כCûu ڃں)rp1t<'!";]jyIȎ` ֭zK5[ IDATv*&ԓ~2Yfr_;F lB|>S4cI  lv"a}:=OmZ:5RkTHQi^Tk$*4~V*^$RhW,sh?(xIĜ*{Sk&Oo$E lwe-k寳v'sl#Ecھ)IlX27M-BJ%ڥ>hĩSC!CCSia+;37&_ƠFWY#P*g pvfXXg2 xpssoxf֑hFK3U‡E5rqX@6n[1v*xhd*z8| .;. !!Z%RTHI) ۬6I.'i'6Fٙlmצ6D^NB`,FFY,@mnb_POClEaLd7쟗!"y:>;ipX㝠V^v, wĻ '.Q=jWo^gۿ3ޏg ތb𛸇Y;CW ò9p U0 |j(<6c YE7Y0Kxw#cC&Wuhg(EfA9bH76/JDdu: HgJ?jsI2 &>.OGSQԂn^1`0<3y <]$"P'W}w ~FYQZb,l+7PĄF)4:JpgbӅ|%ϝo>f`Y,~ 7mYCyc7wtynWRSH,Hcv`nb,fۇ䴛V)AhnsZh\XfjBb|]L1*44l'쒙Z<}$EގƠ&cJN?s)0{qĻhI4\Q>EkH#l( ˘P_eD/쒄fy`fp3aZkUޡ?&sܝEV{`d_8iM$M5e """p?6Gfԑ$^׬j#d4l0^<=U,0\=]082>{ =jkz~$4+00*nȷDCfr _ uW x\\lZWE5Z`\6al]3ٴul^iBfMK螺DrVq9B<`yIEIC+z*˦'Wޘ ?`6@d?_?.͵sqpBnw.3:;{l._b{0qjÁOP+k YpquȽp '>ZK7v r-##RYixtp'Z\%BY$$;*Zf5EYW_w'Lb&_7n#~"D5`{pj1f6ak84Z#3 '!W<1MԩƟs.t=/,(UStzZbꙃLfvoF}ZݯwCo៦6`ۛztxb?h .Juށ3,x|\ȥX=VE[E6TfAsYlE@7Nhb$Ib~rEbd{²@"Fa9}S,o0o9 q6.^eJp|#IxMcԨQvA; *-TZԫ)Ԩ)pJe<%CCfFĨnpw?$) ӮÕ<|1:<,>JcRB^nbiCu;(Yt4JN{9KVK.k=#r}X#EaÑtZ/'«I% {5?ki_ g+D7hLݐ)5؝ZW7bvEQ6*&:<;$:%ǼݞDXbx׮]-Juh\ -xInqJ;L>wRoU ..o7ղcP{tX} 澵 sڄGڌ9o+#csf_Q3-RkS@KtC$jYPл.о><*4V\G Sr6i ୮35k4z$ jw o@L3j $F:s0M,r=<{|Y:Ǩ Ļpm~o^rUj3AtXh [͗rcǎ5=?pUPҮnm*JEsg]f+M&jbK Mkaś3{YYgǏZEB U -jU:4h0Ip#I~4f #Jն1`p\x,0?£J,  hnt^/`mNqg>};lS $@7\@;TF*C iĠI+v=Q!f1%L{h_-FJʶw?Q*R2XX̀hQp̘1@=ڵ1%V$㊢ iΕgn<ߩ{G1_8mX$ICqeJBkt9z1}ysN+W g.DOժd hFDv\Ȥ] NYPiwd@y6;ډZ9^xz%j Nn5p`o Np3{Mz#f o}.^vus]-r,[&gsœlJx׶p,B̛[qn\ n&Z倢(kHu#i?M+1lBswwNj/Hv RoB+Rp0FŘty$UfaOg8̫;+M!MF]BRHi 1{deux${zACw5vU1E"p!n~_'ҡi?28f"8xy[!etjGdF aX ٰ}؅IqyL,HA]A\OI;IPQ%皻&N8|޽[jocRSȖV"">»3w7%+el'bB-Gz3f0[233M\]]AQ+;=I[ǘ ~ ^WCtn4G.BOkP]EX04:yy.x xSpy Ok`ڀfnW@3`$A FZY_b+s?mT 4;a.xaNt?/M _'P @ZU{N7'hiƑ`Af$76fQ޻ƚd2;fIS۵Aۏg(ݜ>, h9cQWiۼ_"&mJIX2uUyVa9jJ|&( 3ݺuZXtV*ipgb`=$u4ZK .MPeUbdJ,#e+," Çۭs'OW Bf3rH#\%ǁJgD~uT$Iaj|ĢiB%&O5~³Sb^R\zsMFcPaX}*6޶ W_/W4H, uhKHp( $|OBEaÆFkgnEU*Y0_^:SCq!inswˆsg{(J8yI9=#.DBV}?#\b:^jUIhv,q^tKPnCNHXDcRn׋U$y-7\U EWO!ߣӮES'IpP&UI 7MI5j tٚr}ڦkG,à\%&-JŬcR>B`x߰vo[n}rwڴi&B?fX,DcNZK-a_M.Mz^^d=͇RhwWiq :66Z]]^{5qdd$&Oܥ Z|N]%B6֫T „A5Lr܍bq` C?7oS 7t6$v з~du/NǼտs9,*EZutMxD6V>N^!DT""8J:͎:[/tf$i-^nNxw84k$N`k~}ݬǺ=l'gsp9c ,Y -*I8! /UJYM$E67ېр˹%߻4oUɐRZx7 »l:ٳM[n5=sq>~Uf7XL+@X9`*D1 ˭Z )))V<.\h heT娭\{o7 hۭ:穫hk lM"Qqy8lV'wwQQ(Ǖ ɌʣQ9:\ 3gZ~_m 761^{?MU qLF&::}q^ .۷-K 9sc~!M 0V k3dDy.b+6,3] F/ًo?n_=Jxɒ%VyΜ9~:PSSJ#;;s'NĬY>W hy"Dm6)y0e)$Ro"f/)wm ϞX-qX(]PڱwTx(.BqbB"ˍ $E9/,kuJGg$)dn3]|=*BAB,^϶zl:C'ѣMhJM{=C# GQ\)Ó73%" x,1 Bv W +k?]lPR-;NGRCZZ-d̙3}=|D8tB߬BDޅ2ohE,_?A$؄jm3GVSkP%Sber&⽬=Bђ)eg2z@O&Û"۩,PzUZZrcI{ٷmkAomŭ5ϡWtg;Rl~(_hp{zz>30)B};[j\Rϖ<?S_,6i*}*nUZHR+̶`Ν60DެSD _vy~v_<] IJ%@祹h6Y@i+"ٚw/p1,2kpjvͲv9*T4 ^nJX4 W4 !/˾ݦ} V8˴X.=>Z4Nj6JJC Rh3W6j5Ȟ6x X5/ΰJ `(_$|<\\\bܹx ŨQ0o<,_k֬={:aV۳g yZۉ"0}ȽUy6콀Ec]gPZYrFk/؜ʂMVJ60<6E)PԘ&X=Zzo<>9Zvy9x)hb@)LYv_p_C)9+Z0_U׀U;iJTz=DX7odN8y #^ۈ'j?Ti(?4)4VyjaDҥKM&g"_<ڥa{uK,,zG1uTL0ގsu#Vo;?^'7Ჹ0''zR^^^x/_}鸺m#=.colH̗,xcQ_ N4y@7PfMfHkP"}{!v8b}8(s!5﩯*'ZUej=n|Sx=/#Ia0u'D…zl?6g#Fyg6X!*F9:n٫aMЩ j]V+$)Axq^<+߮C.Bv zyJ'ifEk`(yvB߈` 뉘Pfsy6ƵLbPwZ翃/E) H9#6~Xq$_+UiiC -&fBu6R&DxwV :}ŋ<| 0c[ِXDV=شiS@߾}G}=X Xd,Ggbp|ZQPJ~FƄˊVZw{BkPi1m&2FFH(ULv >><0 xxp8x¾>ۛakYT5 !b\d!.."p9l $nUJq0k@H/L ̗N,Wz)&ԫp^̃H"峧lSeWwi( J:Ӈ"$kMG_I WjTQ&@^uq6@tAh-&/w!4*zvw 7pPӠj4 4BKRLĆ9&}S 1a_(ks# ΀[5F2%b\&Wو2Qp:B#h'ߨӀ Z#ԯ_??|嗦0~g$ĆXVTo7 z~6dш)SXP*Nd`€^cr67?nY B-Gp,&$_,AnF㎤(իP%UJ>AVA%V?N">6/Nʮ|@PՍMw?_qbnb އZ~=)öWiWtdFࣧ u:#ZNS8e(|1A=/Sܳq#g_{ \bu-F< 8h~<[6$IabLb'~a<.xرYxF: yղc4.:2 [_yz4+ͻuYUln۷NN/wOw\7ˡPkF+Ww/ŕαҺyXCfYlYw{MwALJUReJI.bM"3\իWwtr7fb誝̷"udGQ}VVM--<9Df\|[zXD*ٳ=c߾}cWLd76dE:17ufW˰T~9éyȼYP0'!]`"J1vX^{饗OP\yovC) x|t{A9ԝǚy =CߙqmߏUWzp0Ѡq1{l<><͉!Zm0/!$[EqL>XiZMhxM_}Uӓ"-A+&!ޢpŋ1w\Z?l6'||j2  u`7̿\fMVu-,!m+ò*] 11/_66x`1DC'pl R9PHꬡ8SsPiCÃZFZߢ HRH :3O8z?|x  GjcM$LBaTrRo7?*HUH[ѓEfN=c;0}p8RT!͡FGji&lx5jKsHSctLv|iw:5֕,rs⍹#p0aP}7G8(oTVV"&&UU~QFĉ]ˡgСCM5k1|pZ%* 7I)5z|%416oٲ7r TxlL^m(0dTUUa֬YX`r| Ra㫥0bSĕxeNxW.Ƀ{[#JȔmPIȇF_6l qZFyG`Xp ͪx3@h0(S1AA޵MK@R"z(q7YX~?ܰ'2 8B-_n?/ٞ3xˍ VFk6{ FF$@Qw~<."B] Fh4+%Yvx;~ ~-_4ifϞmx|Wݻ!(cjOOgSJ%&O Xׯ߿{-/9Mw,]7o-kEfht< vZVk톚2e 6l///3:MrC_ι~4ݺkx q&AW=n l9fseSnH(4k̭1** dg#^t [l'a•HЪp=du())vK8^Zar8X67>V10ϙ-_䠬 ?ܹszoǎؾ};T*dp!c>;^R:AoOmi!w:"{idɱ,b\~^mݺSN!99 l^сkע7`?FA.FٷpUM-w`х?#6+bBlr%޺u uuu?g3gU]7ZK,kaȣC‡D,3#PUw|$vULJA|.Xۡc>ؙ9iRvYMV ѣGdž lʦٳ8hp*--͛QXX&W>{/sK}[ ꫯF۷CA" |> \ 7<;s>v;3۽t'Ɩ6v/W:LX6kLT|?Ncc#-ZNE!!!2 8y$0%jkk",\qC0U"Lg 8_lr=RDxx8bbb2% v'\2˚aV3)26<*lØS>Pu^Lff()M>rTO睶["WWt{V<͔ hmmEvv6Fe˖!((Vۣm6,_j׿PVVǏgӧIaK+"w|-ҟ/`0Օ 7n"  !EE!>Qд`|BH\xs0τ>Az/P[ьf5N&Sw> }&l Ɵ+p'hLoPOOO 66zMrr2z-HRjٳ}p"Hj.  … ?GMix1}t:Cx˥ؽr6t>v׺pۇ^uņb6l>ܾnlǻaT7ŚF@>$DGG ΝCQQs,YTBѰ3w܉ӧ[-ى vHs$:f-o9ܨh!vpap!B2 z*|Ld*zvB]'cJ bBRARa޼y#ze !GC.bzgQ]Ӈֻhcb`63p89(@*q4D]"d1=4je@H$B,@; 4:dʙҩ&,r鄀vFˀ&0B6"n- 4777B6,:9c[&F\F=4B6bzzoײv+ooojyBm$mmmm /I Bm,Yie8T@KHHiBmdBBBlc\ hBP6U$ЁXB!F&D一)2P# h#}vaZFB(Jhh(e{yrv ZLL T*:B6r=ܼys蘵`R7!AO?NU !(Jŋ{n3,6\S-..Z!P@X㐌!dozܤRQ# h7PG믿f]lnJ333)#!P@sJKKЧ.yx9u)8R7`߸q#6B66ZjF`{a1bo Wnۻw/ZF!1[rsA$>>kL ƅo ^YUUUhT*EmmB!C@P֭[0 ۻ} ],¦sPF`6ˣ`F!Nz>P^^: t:Ѕ5 01 n݁hw2 V=!sb$hUxW\իٔ?<^xji`̐#П-ߟ1lڴ9Fk͸ց.|( }_Dww7 -- J!;r{fB!:Qﯲ JJJmWÇ!˩B4@RRΜ9n[brss3 ϣ6ըOOOj]Bͱڰ{nV###~zh4hhh@cc#._6\`rssǮE#BẺ~dggi?iiiXf t:ݩEB43LѣG7ڵ :QQQVD!&zTUUϠP(J???`֬Ydt!d?B@EoIENDB`borgmatic/docs/static/pushover.png000066400000000000000000000227541476361726000176530ustar00rootroot00000000000000PNG  IHDR*eXIfII* *(1 2iHHGIMP 2.10.382025:02:07 16:03:45ĜiCCPICC profilex}=HPOSE*qP" EjVL^MGbYWWAq]B ,PLTEjD8Pw]Zh?$v1L.)'aE|GiшbKGDH pHYs  tIME.iIDATx]Z뺒;a 7x=ow5*'|Q;ǑV*IGGƌ3f̘1cƌ3f̘1ciǖv{ '>5#cְ;'MGm!2n?`6][ƻ|s . ZqpF[3hќDfŒwc(?~F@C,@,C3†Ɏ6{2z;>Mvqӌe]`=kx o#% 2*v%_ E"*(J~hgV35 XE%,vEG=T7!_UFﰂVJ],5߆y~(›'-m;VL$>BT^PbyWN1E0~ZPa{T!PUj>Y[ M8aNbw [Po ƴ T:%8/8x3ØCa: Hq2/a諜tR|;7*͍J!xH+RX@),QQRHHe)>QK¦7(zB?=w L0%QZoTD6`Ay}Ue"_?@4SBBzIMOzwҡ|e6ٲ;JaZS$= ~of >fG$[jMLsR{ N~[%Lk 3͈FYnjJr\-9-zJžjP_e"AH  yT57ʤAHWq)1N[T>]-)tT\E"EܿѿK%b؊ AJa L n+@|DrR _XEיV^~slݝ|R*T^TcCfњHofFnN"߬"'hs=ZPA/n%D= wPtXY),6C|?˔>֔qkͿ|` WJ|ꠏ7Y;dvMR:Nفo; `d d[Y\ >=ĐQZf;}ҫxK|Trt51,`ݖ^>T,6*T}>~ZT {";巫n߿C9#W鴿E26)mZ _ăcKPZۏIn/(\aT'}x<#s׏ ||>a\UVH aiPќ&Yߥm%LĪ0vʋ~yh`eb2+)RJ~]mR.Yߥ`!P <e0>- T$W jo@j}MCЯ,Aj S%I ᝒXko7 xqIuk*Zo*Rhzn.c=R VdQ tANYP?/=Z7L|9LvPTEC)8^qHzԗ9kU :>6"55iN:<8H= sx"|8>0ʼnb*k WCVnZ5Y(o}|}_”'eOoMT /V~~$T]rKsSE*F mZ=0x7 j+os[ 4 )h^dj!0_ oz YEto CJdtG~]!$:.ZRShQNy!WWb#:ߋ6~R\G/$E{yfOX@γ=/iJ&١Z֫VV+G(}RESLf<[cfَ#]O?WE:[@^`%2w,W|# <խM]%*݊_H'V-l_(T$!Iv9CUG.Ljߥ|9ٿU.mxʱIϫc[͂7dB*$1AIU_ U ?t[Gn]*OėI"[q񿖾+(/]F+Yз~E]E.l.Raޟdw}##c|Hߚ1n|+V3+$\6X!! 0}mg{}ȶφ+AJ+iv~slFvk$Rf 3mkseܳ̀ M#ܜY#.eӌezؙK) LP}L]$>`|;ҹ. GcxsZ=۴0}_ hh]X໣exǛodzWcF2Qvlٝnڠ{bfd3f̘1cƌ3f̘1cƌ3f̘=gœ|/IENDB`borgmatic/docs/static/rclone.png000066400000000000000000000307431476361726000172570ustar00rootroot00000000000000PNG  IHDRsTTeXIfII* s(1 2iZZGIMP 2.10.382024:11:24 18:17:21 iCCPICC profilex}=H@_S~T␡:E8*BZu0 4$).kŪ "%)=B4+hmq1]"*3˘$<=||,s5g1'ǘa3y8ĊJ|N붦;!rSy) }kno}>i*y^xwOgoi r3n& xiTXtXML:com.adobe.xmp 3bKGDC pHYs B(xtIME Q IDATxyյ5bcQ$01*D 2/  11"KhQDeQcŅfa:@fmoq֭[ܳbX,bX,bX,bX,bX,bX,bX,bX,bd{H;6z>~7> rxCD_Mj-5}Nȼwvsbg:p3lN>27sgWnX,mP_'H rp;e_b>$pbn@xOםڳ=+R d64ެ"NoE`REحCEV,DȂ[`B˒SzabX)M_OZy47ⱵgZqX,"݁TTk" Ee7ZX,"ځLPu*(RarÂSXX,K(U~\ V4,eGP.m4-?CN9완 hs;Dv>w O+ɢ{ɦ WMpxD"yzzH@F`M#bRQ` _7j;4Bƅbu#f_ XV V |/eۯHJqX | m~F~,,h  (C݁\?c/dj農%ѿ+N|1;G|ep0<ܪxYY&d'(+&]pSmP~ ܖA#zfٜQ"6d\(pFoo5 PB;G/X<١zau dV}*Sϣnݸe@+n;ޑ+OBR`v.%m'd8: Vx]B0Džcp#[!vS P%%JP:D圖3ᄞ?D,*2Њ[L?%Gq.s l(¯ \()\xPYa.^8މDDפOH'[ҽQDB8,=ÃBwCa͙v_aZU\ {Ѕע&.\0g?xێ \XC}]Ū+UlQnê3BhR}[0Y48= Wy.<zp :{<Ү89Co.\9VP,]BXef <%r9j.(.똿P$׫&ϔ:Kq8U);CEo|26q+U< ϝ.On]S8qg.Y^ ]\Chxf¤eo P.VZnDz*WK经 x_ k^L@u ]Z\>W"T O9sڙ#KZPQBv+mg"ek&^z▮q l#+ฮ/@7}={oL@ ;%( ?2I_:-q7pvlMl1JnmO*bpot(nLw,q(v qsDItfnV n?,p@ Ff+3 ]x&Csz&$`КpeݎIれByYFtŮIe1x.ܞ;9bHۏnQɉSP~,ꂟ|p8NUPF[QăA!u'nV7?1ѾV bQ7*WpZ5b2e֠sG)Sd7>ѝH0{jߑ""b4L+3¦mT>A7Ϥ80n V=1=^Q 935[<7Ar2r'^ta~pbxEi';<.>QU{pX x8>?Lϗ2n tsٙ85AT\i_ dCHT{ܾQ*V?z^=}f4% Tde zqkvd%eB8d= a9U{-ӥϒS5`ӕ 18N!`bY>y0&udȅ PeMti湢_kziȷ[;r(HN۫_B4*xZ~nW!(55[wQj-܁W4he%`U(e yс*^I)4@Do kf²ͫ޴8ʷ <2lY{FɋMWnұDfAF^곝Jt2sLUC1>ОY=,}83BL)WW#(yt pJ@|E8R|9{э#b$0$VCm\'"i}+/U V4*4  6 {Z逗\Dri%a-[Z Z M*xFQ<}Sͅ(޷-HX%G'n|6cYf/dCh.ZZ_܅yp?3ZINsՂpPd7C6~﷣g{کJy1q#gj'`R+cE6$pk~gt#XFb cnyFq`n/AdЦF"[l'mTwhkPI\r`Qv5vYw  SDl-ۅ{,ʎ:pOxЪpOxqGR240)\l%p~1g"h+>rD#>' 1֦w .4EG ~ ZkǶ2v8N)/뚄Ee!ՉJNTc 暳܉̃ xYҨ)jBA,r 0&v u%'_;"'kd>N.~Y!~oL6ϒ&O`n[h /y->Oc|oP p>>,uaX]R8̅'RJյ]T]y!{*w^3vF_u1m( 8Wl\tnc}w[g^jNE!mE0AL%Ҵٮa|X,Ġ5&#\%窙Repi,785i+e<èM)vnqR.uW,@*q)'r gM_1+j>j~ܘ^ foLXLrxТfx.x(2蕄.*MD#`fnnxqXͯa@E$ Xh*IL%L|Ikn.՝E'}4o@dYE 'ɓ^##WַrQOL(]ۘ<}6G(\Ȭ:&8,d!zה(w L*%> #@~.3,4OIAs^꟟bέp\c&em1<|rDH1fSa~^IyeA4J#C]7}儅zؤ &xPMk2N" ^E*[|W{ijŭۮ L^!aΊh2s~n;a=x[Jmw8{3FfnrlT| jv|Fz0xG# a{⮦3s1'R@ *֮gleH`P+M_Ty~7%;Q^ySZ?,..ǬW8+7&b ^2 ֖0n(6=@D5Ate3C)rsZ7gWr=p_ܝ,pi^u nf{1U'u78aJѥd萓씛tlz 0):esX0:G.G2sN/~\;=^- W*m|Lﶲc9xP*aoC ]K:YBYՉքjDo9^ouVW e,=k=kڻ`g\m'ˀzZcx1&}(E_ a݁gm+t~ 'Ͽ cלA|u9dDW!yirLk4»q8>y+f?9;aX$311nmQ LJf]e@nrID}/ k#], Klj-!4aF.>p&ߒgfypî=pT7›eeTȾa|`w=80n<ڌG|sr*ѧn4x2pqs'.LVwbʚ/apahJG:aO:ob/dx?;M G$<7^I8KLx?{}0)Ϥ? W`b%R*cpI %e2vafrZ)4Ϧ2cM5J8{=k3;f/ȤPXgޗM#c^M-«EN?6p̊RPTM?Q,܅C 쯦GGCjwQ/&}[Xh2vZ $7 :ZagR$ȵ-`Us{Zm죏Vel+^Q3 5M$K?vCe /|- g$FA_E:yw"UșOUC|'efo;ߘ޿?Y,6@0?AP!ԠtF's"h5"a2Mち ZeSU!;g"| A3k'Aة㓆+:*2YHA"zHOT  R/q^~zO,%&ׇ-*{v@>]pSTbX,bX,bX,bX,bX,bX,bX,bX,bX,bX,bX,%!NO__IENDB`borgmatic/docs/static/sentry.png000066400000000000000000000344221476361726000173170ustar00rootroot00000000000000PNG  IHDRXUzb IDATx^ p\Օm)6@`ԘZ\ IXkKµvŮ )Zk3$aށ&l@6]QcOA-5[.I^Un\cUP 'Ӗsׯ[WE%s~{w(Sim @@@@$ Eu    9    @`9@@@@ s@@@r Ձ8&(,pL1PT   X    c@@@ 0@@@@1,@Q@`acX: Eu    9    @`9@@@@ s@@@r Ձ8&(,pL1PT   X    c@@@ 0:[7]y _Z/wǵ򎤴:S]CûF54    *֬YxE_27QpOo-,@@@D+k#R_SW ]E   @`9X+ҫt{7!e&]:xx8f    e9i21>,~oʹ?eFwR<"(}ZźS`S ,R@mDL/aQ1,ݨXwN4nXd@@@9,!+[o=KŚyEJicN L+rx968u(vy[%ۑza   #tʊR<=}/RO ¶/a(aK1!hig.7M~vIݓQFpk֬i<ާWx^J%ib;J_XP90E[G)l61Gԁsr7_fNT{H#B,>jPfWy4[ovjBVΚpnN*R`Cy"% 2 R؆>BNF`g}ɓ7X"\E128{ݯHUL$ _I(Ksx#/4f~)7 "$x#K^@1 dS!KS8_y?}ȅ7gƭv-]?$^_g1TG (?ڒMaw)vro0ĭU|NnmSj5zyJ^\E's~p$kQ2[1--oVhetUZ"lGz^iٺv R&/k73mA`1]db^1CC}E&]kz|6qQyp{IP V+{=?90%WB`mqnۆ Vt"|?g.&#߸1q}bW4oB?^JK:[ү>:n9rɥe-jr0)|=48XNfnuʦ#جԁHWW)"miZ]tYu4wOfXA`)h|Ir4<ڒ.yywNi=`ANi2K8y-KUmlm$ u+X5{ZQFM:/[ү` sfi[qhӾfI=wpv*r@h|mxЄglo%t|$"}d%!cqG#neWlͰ[B`` zgh1[9;c}}}NU/zw9Lz.[jXa2;7k㤽)卑?Ty+jK_MZ2^A~зѝ~a^MJ{G3J:ASOXm^3Ylr'm;XW˻9+Xu1ݓy!X[^}z ^s:Jߤ8y'Z ۾xUJry괳 kD1硯{z8Gm/íg.حKsyC}y wFkt{, Wkg/8_Xݷs귬&g7tv=@ƴEt OZqr҇*qgۛ7Kg_ P?gmq=2Ow:V&eC6n[A~A"|97>*Jb.CsTXrOؼ\ T=rui(xbA`1EH|4G^ն2"kᱵԟ fzԕB`kk.xF5]r y3!t^^}a^dI3Kqݧ&4G> ]szHm!R6 z\U82'`T52\taEhb |stAnNSYt'BGIxvJy)@~[GQ$y}!}$t./c貿$y?0,dAD9cOxI"\haá qmRF;9CvyqK+f;/2'4sFX|EsךT*u''0`XDGNA&RP=^T(G뜉eBLh?yo )'v>rQ2Z9eL|f:l kz5{hi/.xmMdqj4޹㔆ei%ӰDO5JV1ZfL&Gngb 1 k+PUJ@ BVawU{g`4s3gvŞ/Nr?i[Xef_^EJ7J8J:)lNYMYW|IXH[!PڍQ(/xh+m] jX`Oa;bYzq9#1-*R3A,Ɲ=NoEX͓ݳc=h$R1+6jms0Yz{j4'VV&C,:~a}79NRcUVfF-/'hrܞ8䛂wG䏻rwGޟm B`NYF&齠B\(mzHfE] *)d(؞XQF|Ѯ+XencUBg@xHMψ6 (˜@wF%}WjIa7Lo=6Kz#Om%]M[:VKM,i7)RI[&?tiΓw=7;EOa}˗6bn2=_u.!zF01 [DVz("nvcLYGk&z1 IsmTL80* qK?X2F+S "`,f\Q/[ܕhH_(~LF%ytkikpN j$ۡu>1sO,)%[A{h~Vg ,cśҾZ#.g),a/z>i-OF'8"n: jӁќ\6+<~=A?q#fIY6¡[%Sԣ=̯+: rnŕ,X+SaE8WLPfw\&VH+Qe[;'3>Z_$kNSRA` %߶;FC q0H9b,<9S|97Z T;|pPQu} qcpGYRpо=mvNueMG;¾=N>VPnU$'{2}t`AX;.W%+lEgK$̴S #g/8vU [E75z}7 zCe!O@k]8{W Xv˔ ^UeI|wqT L`Ir.R|߯E"FtN^ldȬJ9P-BISBi?݂>{LҼT~WFY` X} JϘGG2 RiXȬU[җ5xY ?[[3| ]ʜh#y$b=>YW ~ǬbqTO*Z;JU5}A5E?7|k VX=q=ʩ3lyxw+lX1@@`Չ9FT"]􁩋J_ɚ40Yml[558gXdTs3kBc[=v+Yelg&]y cY3nܬnh(ӘΩ+N$ ,3dy\K#EI)<6f L'P:J)-Z1Ou58gȾ7I1)C$}i`g-+[Jyӕs ♇P~l⿈WG1 ,q)2I}V[0h/auh(9u~F鲀cUMr9 }t3t2& \Q聐|Z*q "oeOs$9E7G(<(|A4{nȾ#܆hg܇*B{uVBm- S݊@@`P ,w>JO?1t2Ӏ)ށ[IZ`ut3"]#[pp|E8S89 k*Kp+%^BIXfߔ牷sxqWaϑ9)xǮk'HeZ(G?qk[Nn~đEVppiQ.Rj8hKO䔜y%n^qy3ǣT U lOz1#9<e\V 4Z0x.vPY?W"goj9u+A5ﲇ"wq,sґ-bc% ep%+f^RR/̗g\b(ťA`X ޜ`}ֈ$@`G1-#N|W.b5Jj8^LZgVK<l gfpNEYBAv 4Xܯ902#ӱ}B䕥;Fzi2Jls2NP9Pl<=THC5X"m,nWw #;Y•7Vz3h?V4feB Pxs%O9`0ž-( HXG{vf=ZI'l94-a>4X|,4gq]tK>ק[׭X]w{ISE3Cu-e3 m܆i{᪨QAfu+gk8k.Ouj =T>Xpeipt+lJ=S`{w*9fق`ISlB|Tj yfȶnR`ޡ8ܠ6Sܩo[LJL;Ҙ:.WnjNv\/7D{mUd{&ZۉD U:ϮbIDAT{zKN G`;R҄U!uR`u4Ӌʜb]3t{g\6[C? ᯱ\SõˋC_2DM# C`U^` x) *o'AZh{>w^mqfmЩghWh+{щ0 c/gc/mqt.M~5J>̧X,|";KWl5 zSA_w1BWz"qD%\Q2H]~؊@`CE1GTz֞]8of_l߰tl]0 FR)Z^Nh.o.G/($3V|˴DJy>7ζ-U92qj NGW.)uvOۙvK]/:7ˈ"CJe:G$qMrB}0݋%'V/.' ]Q,[i߳r%?zځ0Jhd sm_,:X\z!`4?10{uL|n~R:v  ׄhqe0J_BَMtܒ*MUR-H&b B`NIS)ŝ9\zX-A:!L0?:-ꖖte%Vz}:Z퓶Sh/Q8W"7U=eAգ=+XeN| WU'+d|l䫲R'Oٿ urǭO[ϕzi`gB : z!]L{ }3+ ߃ADH[C~N$VBV^d0d=Iۋ%Wl2X3)a$\z˽~`$@=&ҭP)(eiΛ8 !JLWCW&2e#5e. `Udϸ8FN6JF㔏NV>g_vv[O #!]'VX3q ]/YTO*-NIZ)d;e濲kKT>q{. ,AbUe oM_#qL3 N0s}5|f| =&7joԩ~/" G}mEQAx,ck>OWKo\` 2>X/V",&w+? 2i\^X Xy%}wxuyU]Xu&fc격vA4sj~̘C_P9ѺoC;Zޠ&VX[&LBW;ZV{Z:Ǣg+Jn͉:ΨXp_ʄ22TJ Vlu?O~RI ObN9Gz^؆' 2 =7)O\gMLo=VJ}vO73'9@jB`U">nwsI 5roLZ)6A?6Gm~l5g%v9Jі1\Ѻ9SxIV,gVH'BZϽQ:}e;x1VrV3P+G^IXS 5GVE00IhZxl-T0 ׹a#2[+;*~ꈎ8ٌXj5{<%Ul iKo/ _>B[٣ŶhM۞9S[Nv>̓sjr}h̉UU `IXA:JHS5nPiP3jNl 5zXYXw/S)7M&olǔo0rsʸ&HtBrw/4N pm&I}8J?gHG!JsK \GղKLrZ, <>ldAՕheN r-%-TuL'QU$X%}smؤ߃ZƕX ή9|ezT@FK$%ҷTx"mJ&K[i&V`Qoly(A^h'Ta{k7֏3+vvJ_88OS*9pC[pMQӷR!G%7Bw}S"~0$6Sv8&V\'fsM)}lkY$ws*t*c6X5 gsBS]fS qSjz9$6J+wA+ro2N e +ҫel;~V(rf͛sOte+SuQ%VJ֪J[.]1jB`UuҷgB!ĸ||@h۫X ؄P(5)L9)Iw>I-COO2奁%<\ۚyҠԷHĮs]E}S p9 z`tVX,3M䧻O Ʒwc p%緇oM=ḧ dV䶽ڸZ;E8U~彡ql18B`TK s?ܭBZx7 z1O@`2q%SqX8"o*pM:#%m(KWMPc'lq Ilj\YPDG$[\6A;%l? %'#Wj\@@.VnjNbx#M ƗVoDY}a8rg8WS*+w@@&Pu%9fz[)Pp6CX&<8t.cgkvF5=xͬ @@Mk:Id]J40ܳglg8wDxHf.?,STu$*J#ULHS ~C;0: Ap!jp5n1÷[~mc'Sh-j/J|mr“$UmдM>-4-$CfkKҏ[iB_-CûFmBH ,C6:g=8[Y1,XJG{I" 3+jQKP͘Ɗ@&(&΀)pepO/֕ +h,ip P~{s]$ާª0&F顨^R'&1`e0Kc ;Z5,=1Չ@)3Ya/~}ydR#A&ʩ="R6aXw3Q'L@@@ VX28H%}|qwq<.DA`j^kJGuM,vYǩHUU h@@. TM`Y=;[XM>5=MynT%  9lA@@ .UXf1ch%ڬX 2X';&'>p\[ f",I߬^ XG?lrЎʎ@ TU`unRԫsUU V+>X Xq4B!@V^d]^}ZYXf/b_=H2<[)OJp>lcBkGw,WWgn+E{q;XYwE*Zh,"flC (UȋR\|Jy'ug(їўûY`  5H Y    '    @`9@@@@ s@@@r Ձ8&(,pL1PT   X    c@@@ 0@@@@1,@Q@`acX: Eu    9    @`9@@@@ s@@@r Ձ8&(,pL1PT   X    c@@@ 0@@@@1,@Q@`acOaX^qIENDB`borgmatic/docs/static/sqlite.png000066400000000000000000000110741476361726000172720ustar00rootroot00000000000000PNG  IHDReA}iCCPICC profile(};H@ƿ>";qP Z"TB&IC(|,V\uup"ƃ~|wwYeLfTRW+bӈJD1 u_<ܟ_) ̦s'tAG.q.9#' . x8s.+8:kߓ0\VNs),b "Ȩ*,iH1U#jP!9~?ݭYpIŶ?F.jmN3pu&0IzŎm⺣{}%CrM7偡[o K]oC`D3~Srk˞PLTEJ\DԤDzb ̼,f|,ztZlVt,dl\Ԍ􌪴nVl\~Ĥ,vt\RlDRl^r,lT, ^|t$ ~jt Vlvml,m<]ZX%^#Ӧpf;K{J C+W3~'S8۽e6 M~ ⫸9^8E/-W1p;^vO$h+'B]zJǓ) e]"kψY ؠ!pCW*|3s|ݾּdpĶ(Yuhܽw8n[o4ײ·*В.6ࡃ6xaܖݻwo)hele8н(O }bQZS̽Z\ē=>AjWg^7㕔Զp`Hg333WЦn̼#rJPZWq+rl5&W(5Q0hhS'YMl/NpŜO_^x3 l@-p?| EJtK,]Cv`,HilD?.]zSoJ5xete-`&:RS'1/KmƥWrn{TnTFd:)p>?{p7LK/T:vk?jG--Edn#ssx78bL< t 7N婉ȩ`3Q!p]Qpۿ8dZ?L(3m0jU<yFA<&m^xQ ҔF5,ąQL2LO5(or.Q[. 0s繝;o>3GaYgu@wLu2p;zeȷb6o&ߪKNJ NL>]l+6Rv1 4ǥWv$s߇ yrvW:H=[;/ΠLj!rPSJ3΍ܩb_?dsE9+G¡<(EsJ{kb.GsTz15:s="s׽[ßVgn~rqMM 63RV]xZlSpC}5J6rlm̩67K/ h&&iDB~OC;ose*r}Ls٧GW'ot=Ikآ2E`CmW8 3A0h=IU̍N&ٻe^SPmJ )plTJ["snxGђѧ+(%0]v5!N[?f3"]I,?qΡ\+J&ѩ8&wJ>^]hkrt92dr'Qtu1ԁ aZV Iia>cdt,R /q6'2#%ߵc=\1K&bt%æ'Hx͛rf+1gN5ۙ7H.)z7'Ip!.TTdC&t X+9r]r`%Jx~ ąof( 0!:}y?Ch2JxZ"0fnVj\Vf‪4a#zW d LCyB]@U4m;d&42W9\'$9Y(hRB%Cs!6=\Fgeu||W]2{$}.ٜ9DxX>V'gҨؠ׹ vG9qzXM21'_k_`fHoY!.Pzvq(;Ed|"m׾q(9Z=Y@_ B{@ǞZJǸ;D+:)~ 7,.ys(twS1>>"9/xQk"H2C^%خQq}j#5m}BRІ0lA6HTh,mԗI႙˞UL5vkApMfDz7mv ȘQUy9#t(9 ʣH@*\AȰ\#t`aylk"9׸t JXzi5w9_el]49D:6 Jq[iuvBRnK,6 NKz~6*s̙Ⲃ[OpĜL2'!66BEp#JQ@蛘T<'Bg㊥qL3F`n`Y} q09pz蘦pT.*E l*X=ڒkp y a<6<)d(+yhu|RHOwc%tK=aVEW/&}0뒹/C<=OF;Zx^̘ P."} 6&~7+0(sW\Eˈ>s R˺.§X,܍dr`W}Chǥm0꣨v["Vu J(T? lu-G}y_lSFo2TfBT\N`eӄN I mQ;D$ݻ*T~mp>Q+[s_ efF325oH-cB9܌gA?^t{.> ,E_f(L?Ubu-~:#\ɒ"wCWKcZ1c)gkƠ-pۊ 1Ƶo g:X#'{kRKWz_k7:Z.;\COlʮ)}S׼8}U88J=Cr|-[iTXtXML:com.adobe.xmp 7(bKGD pHYs  tIME &(b`IDATxg\Y'B$ łtE "ve;Օݵ,+" 6)E]"*J-!afB _~zfw*_ZY|m*K~S(\jBD($BU(P3V yG7o'DױbOqpRXXoAT9?je=N ,OLnmjaVg6ܯz l t$ i|}~)ښȈB* ]ORm»y>_DmRf*%<.xaDvwx@Շj>RR 1WLx):S"}{!Ek2!~>̺՟}5|N#3 YL%KU@hA$:ݔ6z䝫`%4/{psռoc맶w\0-RUUV\T\RRDQi4ecS#S#srexVIJ۳B c p맧})B4yQ#YY( VyҋrA a655q-ǛzDsGgg|^T_WG"it83^퇜sP,g,>߾ Rdm3􌈰Oy%ťD"Fqb]f2[b>{!CMMd2/F/ܵXoܨ۔\}] -9Zm]?0 nu.v_xx;iHpsNq[^YY5V߬ق*ߴuUKQ?>ul,h1|UX;=)ߧ4#nū'^$/]b҅ zϞ>$:vs!yv6v?ge{mؒ[D"rݵw`zg_ imvaay߲~;|Œ=D$48yt\A%0 h̅gyxJ shZY #)aV8j5":ngUh^n,(?Ɖy+Q8d8v`e%Ο2UwyV/]˦֞fek|l3-%}|nReAwxiiiټq{{^455u`NZXm\cgx_  :5Dp1W`ZAeeկkZ"^&V2[TTTy :c+;/?/?{ uvOu΁ޏ>.׭O*^빁yp*lnre߫rQ)tQ&Lw^UHnںݛ9cm3cNА?54ɏX;D&Mut|?'5 K >|q4l0/Cutttd$V?T$1!n* Vݚ̿uRiT111~*d*-`0"'N$2J'ݷצς>Jj}~AS`QZwe~x ,H~z:?G0c8Tfr8_S3޾:Y^7p}΂s=`X׌ A2dm31$<wA(l9\7ig{a}}D;7dAィ"RQ+y7}N[XjP(]JA֬_6jup] 'Z;ADd֢}')CQf555NGt*:qGl;U(--kݚQ}r{.7c?**CQWuuߩ=\D3V98b'C}]6Iwgv%:>gLqg AWgz´B" I #ݾ((c kk0L ų^} +{ k;O>Oz`㤧kjnlljd8eT=;"I ɍl"/;^ffa*)%YWnM egNanifbjdljO0O86/"rm||f#ښڊ l5ni:BMTY%$%xY,VvVEv.Γ]1 rOءeAlq_K{Lw}51qmjo1~^OW Br݅X}$bBu I-%h[>i""]X}Ed{@>!8kmZ;pJf5[n8 UX[,l]nni=&$,J]_  `5^~"sY=eH_䌭ͫa4FUWYo^337 څ|STX\]]#)%)//GttGv<,EEũoҊKTY999yȔ 1'CUUumMN;&0?TWU4eC#K:a.]Py Gԧ'SޤGϚ;!BjԙHjklxTXZ{ BЗPE36{|PC&&Bπ0Dah ʓj_15WԱH)J"T5v'RB,Bnc>i5q|gၫŗ%O,v#=yq} |UQ^&=gm|. lcҳh'"7:$Q4xb0MSCu C7m˯p:SzǂCGAWw /4:sOdjfsx Ec^?? 555_>mjj@&lyǶh DPP@}@}P@}@}y4}IENDB`borgmatic/docs/static/uptimekuma.png000066400000000000000000000362651476361726000201630ustar00rootroot00000000000000PNG  IHDRdfweXIfII* d(1 2iHHGIMP 2.10.382024:06:21 21:20:17liCCPICC profilex}=H@_S"A#8dNvQqU(BP+`>MGbYWWAqvpRtZxp܏ww^V4#hmq!]B xcVR,cN_.ʲ9z՜8MAXeX+W= 9}e4G" B*J(FV )ڏ]H.\%(X@$^R8t8(5>v |Rf>Iз \\4y ɔ\)H3,0p y5q pp({{rV3 xiTXtXML:com.adobe.xmp 3{bKGDC pHYs  tIMEo{e IDATxw|UgfnoHBhR Ho6lֵ];l󵁂"]T@]w) .UBB )ܐ^n;wf?!7w&$@|ΝܹgΜyC( BŌaC#--G[$IB]]ۏ 󉴧P(ʅ" dbĈ G$`BPη9YY ?Ɂ?_Bek( |Hb|,ƍQIAO(ر"lxx rsN3p`2z\C,T&߰5P(jt!bh4=њ~w(9^F{BdaM?wx.zQt:ˉZNP.I> L,pGa Mӹf'iP(T@B!7? ةVAQ)MƢGeV.e40|! 22hhl=B\tiVDx>ނia6H?g zH"&e3iCvyFBҞEPt1 . nرX]E[[p߄00Ň!iJtƐ?'W F, B>}z|%# 1v}x?u6#&fBп?L{Br6dYFFZZȮ+sj%: cӡ fFA boJ ދF7J'~lhDFEt!ъ!kVJqI+q|}?;!!aV5^v J~u-:mSp8kNoCYym \ ,ʀ6c΢E`γ۪xf@˜4|s/`@FzojVlIP_l>pId54@{l]d<={^֬lc>BE:MkMg͏20uc Cb㐔2ܩCmEh6TLV MFOAbǮݺ%.qXׯ_1Ӊٳ7o={7\QFs{ܳiB/aOTϏ/FZzqA* aق>ϒ S|WoeKEgyS!23z8NHddRQXTҽ7c)4/~F;݃WfQp A''2ZVQ@X?8S]3|g/6 .k <'E8ej&$$X€ c5XBUUU Qr ڰ)'kUG^(.61 o88CzyY@'|hxȐAHOZ*hpƈD. 1\Sgn-aNGcQMPךՌFmaL<cǍCN@b£Gg*сضu kW | YlVojj[ @M] V5Q;-@h=-BrH3! FrBXd66"0Lڠ/t:deu0<1)/>} O@g~ YYيDQ??V]G7lX$11!\m!L _ vE  Z}*Ǝ$@tnXz5n~ޒBǏ3S /Gw܊As硩I9`ȑw}%^Ly&|j]3fw ErJ F#PRTۀW&Lbqy^SO˖!Cc„ODll,bbb J"jkpgv/ {=-A,*Ć3vFV<]YJBN q_5p`ڷ#ছoFxxӫ'bŗ˯`Yv+A XHJJȑ0e4+Xab _;}M.OdTRRR0|\s ޝ7^;bGEJ>޽{`|HR{ZF18ir) ,˸l\ߪE\2@@C}XB Iv܍gވ| ~;::iii;~<&O?q": <9)gd@a!2e*]|djp}a𐡈FQ##q[n×˿rwdBTw{P':VR766ь[AtIBO&􊉾\RUC'M+s!++[uIlM3of6!q:p4}?"""T'S& 7r+$8c=g~?W]@f}s``Nvb6/3 m0bH,\6nZH|n !0x555?okK_UYYO>ϙqƎ8u3K~T!cO<^p͵3V{ =0܋W΅h8b rQѱІ2*:?۪k{ <.֩rENӚ00 p`2#""3zx'p zL2l8Í3g➻ cAbbx!U5 /oNk8:&\>ٰp˭) xl޺ y= /yGBdddxIgY'M#M-kmCP?5DIYETjgYzPbN{/z9=x^ډm[t"=#yyxfíݎ{!8x ֯,aX 6 Vkuuعcg3]0]/#;y<lۺ?n^q'``n@xzUWW ף$&&b)HT)Hl:zzZ*n6E7HYYVEÉX_ Ŵ劯/dfe!7wbKi%$a||q#xދ#G /o5xn7nقł'";baY&MšߪRlٲ~ Pf? NAVf|!\yظ8haѹ^A?lS 9]OB!~A؈86 ![\tI\.,|s>^)pfV'̝A+707& _\V6?>LQ@jkjoۦ_v׬y"-,ŢEoa+TϫZw{.S ;*:0xwlٲsQ<̀sy^,L|o2g&jDG;A ۼQv1:u [=r%~NJJot* ۻ rOvUdXz߬]L.eY\6|o"BJmmEs{ fR0 n&X[~Q({UUUٝϯ465O>׫^{}Axo>QDum۫(h% y=Π %{]O$UA`4oOe4IY2 )22ՊK,#ղa_YbՁ%S!BA$ ͺ ir8U$Iqv 76zspg(Abb&N{f6֬cǍSZ:m{2q礽[WංR޶ o8dO,یͦRzts5B8eYzSҒvu_Nrr;~(sp9%Á6)++.0 }yTsrf&ګ۶ni1xG {B:PR\8e^!yP,mr30v8 VҖ&YTsINM}dL]vA*%-ӂ¨8!,Դ<ڈbh9 Fh4:y@5$Ue%@zy^Юzm"2U'ZvM<YXHe||b!QsMks"++qq};yKUM,CVIƆ&qOɽ/f?99kLY"x:92:agϤx Ńt?S\M!9>,@ePCAts\~E>j;[$!&IWVV=Ȟ |a҂Njd= ԝ>z ~XFDD`SOw+:7 \>j^Cm(|tAY $C:>,X."P<E,ϐ>X-Ҽ.8  )RQQ:($&&Cz*(D?2*Ꮂ,eKs!Z`ȑpY[rĬfz%Pl2b~yV qPQa'LM7ҵOte =vmD6}m*W | hXZmLu68c[l+wрBp+IFe5zN>͕ӧ3\yy9< K>l<^ RRRLB˥8)$ ~16^ |Y٪]׿34c3x~ ,2ɪѣp餀8:4m FL3hzAhqRl@E4- b1a!hF DY>e88J]> (*:AʲqU:o9S6OIqQ9EDDfNlFj[9|8rbC,4o/kK>^OΚz1 #!?7=<U0 Ad) ӫEq'M;Λe:.ln i >*lyea#,( X}?Х& }IJa1& D -!,tD" aIl$25냑 ӥ"[D. Zmc,JA;CO?'XZO̚+)FdY&jm~qcЮL8A¸;"X8y2κNCl̥Qkl˖z#}+v*aŋ?m[UeBB~98@| `„ wp=Hzj0ՁS#**k ohZ@jDVǵ3W.N% lCcPom!塞Fw5*wpYIy0ͅؾm+6mڄ:tL0|e'No}M,GEQ1#%Q7^oIU۫Xjvp8L> |9 \a6Vɭ+NCƜ9ؤƒ$!&: 7t#+ķ;/2^z%|XpG=*vHg !0q|?w+٭g{^1G$$$B-k4̞=[tyF$IR-a CE]<T6U 8+x)2GucЕc%Hc+ƎU}H, &O)S=!"V|<=L[oc ())rF455Kupq} AyYQvF FIDAT _e'͚U;(Ȳ,ny3돍m@WT4@߾}G 9%{\cŘsZ8mww;ify={}~ܾ %Qr{^eRԯ>5 +*iW^w[~MH֡WwmQ PQ:xvP?!%4 z_?aW+pǯl;vKubbbpw6_]7:ڻw*Պ]u0^ߟci>b3p rrsr8{=4 L& n O1b(lxrSؿ?*XZɡ͡`q, Uh:ZGi];mO}UDQ|' P=dYF4Mp:ڹy?N\mtb}7w8VTҩ>*n?gvA~w5>^v;AhBxGee%V|C`XlۺE|N[n9?,۰Au E>$YTپJ;wQ<,JUG,Ɯ9(!b,Ґ3kxݮR֞Y]c)4F-1v$u]%wʺ !HIUs6!K)CJ7(//(mH5kŊ1dP&MD#"ٴ^סn6m-C'C.qqӷX,p8e~lX~{ւq$s/\}SSNӉr{9ʕ+[uZ -YUpypxYkWFqQ`UO@Q&T/!^y/Z8]}LBߐ'.63&-19c !Qxx(]#'CRlbJWT'>^DQtMȢ YQ wpj8۬yu0AEa<WOXth5]֣['+7kcX48(E$=^xx dB\[S[$050>w*^QRdd?o88Dñ*!]oytX'6 ,vurzKOItЀj+.AMM-xp޷P(* j %9`XaXi0 Xǝt&,KnTy p8hhh@JTU׀4BPz( ,C"fAxXlV Qs4R}y/< p= 钭<) BP(A&P(  BPP(  BPP( BPP( BPP( BP(T@( yT<%IENDB`borgmatic/docs/static/zabbix.png000066400000000000000000000205741476361726000172550ustar00rootroot00000000000000PNG  IHDRiUuiCCPICC profile(}=H@_SE)3(dNvQZ"TB&~A$Qp-8XupqU?@]1ݽ;@nwpT".eR+BD 9YNw|#׻(?r6qCA<阜EVT4s .Huo  v |Rf?Iп \\5uLR<)HS3,0x ֚q pp({ݽ{rbKGD pHYs B(xtIME 0˓zIDATxyxTǿddɄMhh1(!D *V"*(Rh](?ְeO& Y'E M&39d=|yyWv8 `0xЃ`0|30  `00  `00 `0L@ `0L@ `0 & ``0 & ``0 ɣŏ{zH?+A܇y*Ə'u`@=#gDܹ~^n.ROQ4=UUG{jn r**((1? CrQ46dRϮҥ g[wm- p[0eeqsaEx8c?0a|ACCivhڻWx_ou5za?~{!?urbF}~>w쀻HT֦&Ϛn' q0PODqdx\$<'cR3m>:{s@h&ND л kBsMAMj}=0qb@Wiw˗úoFqa7q !YɧO#:7`KN_5$nU^3$P>ٓIT> Aӿ?k0>3 ߏ`@̼yy0Bf aD͞ ȥ;|P xmQQ~awމGninf̄?HDuuo"Og@U%czd+k$&L&a*//`H&*>ˀA+UD>$3F@i4}F>7ހ2:/lB'ǏÒyB>57Mv0p.!=g†Gޓqe)lY w#|4v^%YY⺺̾ʹs!7E1LYY:E u`7fdbNPAڵy,dLTPuUPa jyy\@,*BѸq9t@HB{$'C;d)MZ}~["rѕe"ɓQmm{Q0^M;uhNOĵ^ho޲p!?0`ҵ+twIq;ر{}Q/gY;, QݽPG/޳)LСCa4 ^b,A֭pWWKRDgD]w!$1m믣ᮩ x =؏Cnsa{F͛h/Q9o^@9|4/FԬX[̍?55p~1r{8hoEZza+͘޽Ѵo]TMY++.q˗GÔ].L ħ˿p$mDjo?v řM"8e *pfԼ*gNҹ`qn>2{ $kgq1ΏO?Otrr S%k>LS@?v. oIj[x1/6 )a9]S>.Ru8{/(`q$ΏIjoLOi -HA.2+P 0\g+W"$!{{(,A# ݨQDĉp>6Ґ8Ξ?$$MvCPr%2Dl(>ZNI^x~~J@L-ƨ? M}VDD0f c͚Kill}H..`yE*|OXwj\YYYVǀ(zԶj"4)sHС08^Xv-jxCn<;HEƍ؈ a2ˬ&G& LjNN&1;reW26{|0, iQHܲgK>`rbi^8ΞXc5+F3MR@v (z='|\Yf3b_yr$8yNdgp0كiHχl&w?  4o^1]C,JTDRm  u\#%XlǕ ȑot49AeA& d2W&4605J<ilD}~5$^m} V'fE)AaM$6V"SDՒ%KFϙCO<$'i4ۼ ڵk9ךf f+&6>׉F]Jsr讬_ed2zSW~̟On_9o^@Ӕ\ Ä M:H1!`~섍KzP ъ&yA% иgݕЍz'*̓u߾HX\~6X^|QpA<<=sC}Žk VsY,apRФ:0 ٕ%܆+˼r%?cT%# $} ĬÇQIvܫ0a?)+ {2{-<\Y! ]joi'^řu]IJ@dJ%?뮣jOSŔͽ"n~5f/BAN/F}vAbk˕裿cJqEܹ %! q~[R[Ӊ 8sFx"TQ^ZXaÇ70eeA= (fxֺP^j) ,"{6 } e˾?ܾlt4߂|=TRD\#ZٔŔ-bDoݦM풷I+jJKR+kW.YG!ňxm6jIzm0S@Np%QS2I[`O(둴};хU$o'f .WOB"{\b؏7#֗ҵ+n%nڻO>)ܙod$=%l_b,bLH@Sqo_tݽ5m\ Ç%-qyy!RtjR>,)HW_x MkHܺ6SDˑעI=;2MB}usZL!0^?Dά*-ED$&"iDΚE^A|\Y^%YY<Go~]h 4 585 6ʘAtZCݻ7_Ѝ;b#஭vՐIJ_?CC{z( 4T .LӦuw*}S\ӉAɰ9"gz+4r!56oFܲem&T0fdD͚﫪p΢"5*Iճ'Դ_Lu:BB:|^OTF D-[у605U\OG-&ASUt4B[2\RȔJh@㗸Pm)QL/ JJ.Djgq(:GRҾ~!Ąu:R=zDΘO>Ara!b̓"**hq$y!o}BwL@:BpF/>|#Nä=_.l1!,b\$?! f<4$me֭wd2~[ HaQ:/VOR(`8uiogGG)t0a_%4/}u GO?8y23FD= tXb^MxS!ݺ!i6r={P?SF$mu$5(1!`:>p[ծ]_`ՐPhRRՋ\ 7܀|ѣI+YTq:8An2Aa2AuЦ@=p Z-]Xe{ByL-/}"yqo*^۷}Ύ'Q.$n&?~5&Ĕ^xgYYUFGØӴiOɄv(fBB=iӠ92%mͅdπy㺺j@üv-N}Pºylha}= Ǐ]Z eL GsEGzKff)XPlNCv*Z*`ꆈ Ӊ8ճ'>1C?f SiJTݻZ.\}j*qAC 2Gf&)/7ϯ%NBv`qfrЗlF咵œ&xdZ 7$:u]]I6 Hk&ND}ٳE\gmp{q6Ӎ e|||fdg;eFܫX۴ Ƒ*'T8FT"H+c47Fj9V?4&$[GNSR~=FСP_=G؏6W82sDrx({q$5y%yD:۹a$q3|3w7sPʹsIJN++E@("13WqbB*emԿ?~[ۤjb2"gJ㺲"l?eG#g,4" AøP4f m'@aaTR%'#W h{ڇn'6Nt{!|N]FK/ǃ҇&reoʕ%~NS2v,%%`tz{wX gSvvI?ArGF<4T49m;Kr- {Pys+˅Ҝ_8Kn.b_~{ӻ7ssHY-zxQWLA<@?g٧JRO؎V: >ESGBh+BO(tBoT۸43efl;;mOKn26&55p_ ze Yʚ3 HªU\Os3&LRRyNEÎ]< uFDX3a=xYuF>+R5kvХS$qdu(6]LBF0q"ݨ{}ܷfC-T=zH>UE6&[KB㏓7IdוaUtf@D=8Dw$E, *)&Aqd\傺wH e*jR؎D?,9s64yv>K;%o_K/'sBӿDݻwKb|Yg!F3Gs PH1X&Cʕx#Oj7mAٗQs$AwJb*ށY| d*U=w+gGP%ѣ(:UOh lZ;lm{[ r +s&jzd$n+Յv)BxD61^榛#.`"S(eFz"wWW05"BtbN/[^jׯ=XQpݑ#Oս'OJt?D 8c.UQ^do_rªW^K͜_\;1ɧO$6/mu:Q|VWÇ9ifѾ/Ex8"OGCu$%{wM *qQ_ UPCZߨx)Ir]8/xjtY/lDkG/ՋZ۶Z e\,bAӞ=~ֺ8)+ fuL8>idj5F# x#))Pӡe*eMV-2* MIf%߲6HBLYjbI\ζDΚ*BC}nG\E x-ol'})\Q+VٌB]] @{1L , SQ^"v58s3\C߆ 'ۗBhq]U/%g]wN% GAOR>h˟z 3gJQGiܵ gL& >?ÇQx1v`D<aN[dĉ׋fߺ{yo@ GA7c-Y gQ!SW˗g…mEPDh uב )u $ ~JIr&X462>N_PЯ/JTuup8PIve}b#Lx_qꔨ_cRӧQ8yּ nEp9#庚6MPGmOY?'UaF# &t0;w]UYHHս;†Ggsbrv C֭-y|q,>?MwKVDyVu~=eSSI9b-BÎvL@^?SS:v2qD0eeq5ilj 88u c`;t(JۯX:pWUmYZb'`;tv<_r9/oGtGN0@iNI-]±c;tŁ0 #xa{ ``0 & ``0 & `0a0 `0a0 `0a0  `00  `00 C(af`0 _bIENDB`borgmatic/pyproject.toml000066400000000000000000000030311476361726000157520ustar00rootroot00000000000000[project] name = "borgmatic" version = "1.9.14" authors = [ { name="Dan Helfman", email="witten@torsion.org" }, ] description = "Simple, configuration-driven backup software for servers and workstations" readme = "README.md" requires-python = ">=3.9" classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: System Administrators", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python", "Topic :: Security :: Cryptography", "Topic :: System :: Archiving :: Backup", ] dependencies = [ "jsonschema", "packaging", "requests", "ruamel.yaml>0.15.0", ] [project.scripts] borgmatic = "borgmatic.commands.borgmatic:main" generate-borgmatic-config = "borgmatic.commands.generate_config:main" validate-borgmatic-config = "borgmatic.commands.validate_config:main" [project.optional-dependencies] Apprise = ["apprise"] [project.urls] Homepage = "https://torsion.org/borgmatic" [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["borgmatic*"] namespaces = false [tool.black] line-length = 100 skip-string-normalization = true [tool.pytest.ini_options] testpaths = "tests" addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end" [tool.isort] profile = "black" known_first_party = "borgmatic" line_length = 100 skip = ".tox" [tool.codespell] skip = ".git,.tox,build" borgmatic/sample/000077500000000000000000000000001476361726000143225ustar00rootroot00000000000000borgmatic/sample/cron/000077500000000000000000000000001476361726000152635ustar00rootroot00000000000000borgmatic/sample/cron/borgmatic000066400000000000000000000002671476361726000171620ustar00rootroot00000000000000# You can drop this file into /etc/cron.d/ to run borgmatic nightly. 0 3 * * * root PATH=$PATH:/usr/bin:/usr/local/bin /root/.local/bin/borgmatic --verbosity -1 --syslog-verbosity 1 borgmatic/sample/systemd/000077500000000000000000000000001476361726000160125ustar00rootroot00000000000000borgmatic/sample/systemd/borgmatic-user.service000066400000000000000000000010401476361726000223120ustar00rootroot00000000000000[Unit] Description=borgmatic backup Wants=network-online.target After=network-online.target ConditionACPower=true Documentation=https://torsion.org/borgmatic/ [Service] Type=oneshot Restart=no # Prevent rate limiting of borgmatic log events. If you are using an older version of systemd that # doesn't support this (pre-240 or so), you may have to remove this option. LogRateLimitIntervalSec=0 # Delay start to prevent backups running during boot. ExecStartPre=sleep 1m ExecStart=/root/.local/bin/borgmatic --verbosity -2 --syslog-verbosity 1 borgmatic/sample/systemd/borgmatic-user.timer000077700000000000000000000000001476361726000250032borgmatic.timerustar00rootroot00000000000000borgmatic/sample/systemd/borgmatic.service000066400000000000000000000054511476361726000213500ustar00rootroot00000000000000[Unit] Description=borgmatic backup Wants=network-online.target After=network-online.target # Prevent borgmatic from running unless the machine is plugged into power. Remove this line if you # want to allow borgmatic to run anytime. ConditionACPower=true Documentation=https://torsion.org/borgmatic/ [Service] Type=oneshot RuntimeDirectory=borgmatic StateDirectory=borgmatic # Load single encrypted credential. LoadCredentialEncrypted=borgmatic.pw # Load multiple encrypted credentials. # LoadCredentialEncrypted=borgmatic:/etc/credstore.encrypted/borgmatic/ # Security settings for systemd running as root, optional but recommended to improve security. You # can disable individual settings if they cause problems for your use case. For more details, see # the systemd manual: https://www.freedesktop.org/software/systemd/man/systemd.exec.html LockPersonality=true # Certain borgmatic features like Healthchecks integration need MemoryDenyWriteExecute to be off. # But you can try setting it to "yes" for improved security if you don't use those features. MemoryDenyWriteExecute=no NoNewPrivileges=yes PrivateDevices=yes PrivateTmp=yes ProtectClock=yes ProtectControlGroups=yes ProtectHostname=yes ProtectKernelLogs=yes ProtectKernelModules=yes ProtectKernelTunables=yes RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK RestrictNamespaces=yes RestrictRealtime=yes RestrictSUIDSGID=yes SystemCallArchitectures=native SystemCallFilter=@system-service SystemCallErrorNumber=EPERM # To restrict write access further, change "ProtectSystem" to "strict" and # uncomment "ReadWritePaths", "TemporaryFileSystem", "BindPaths" and # "BindReadOnlyPaths". Then add any local repository paths to the list of # "ReadWritePaths". This leaves most of the filesystem read-only to borgmatic. ProtectSystem=full # ReadWritePaths=-/mnt/my_backup_drive # This will mount a tmpfs on top of /root and pass through needed paths # TemporaryFileSystem=/root:ro # BindPaths=-/root/.cache/borg -/root/.config/borg -/root/.borgmatic # BindReadOnlyPaths=-/root/.ssh # May interfere with running external programs within borgmatic hooks. CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW # Lower CPU and I/O priority. Nice=19 CPUSchedulingPolicy=batch IOSchedulingClass=best-effort IOSchedulingPriority=7 IOWeight=100 Restart=no # Prevent rate limiting of borgmatic log events. If you are using an older version of systemd that # doesn't support this (pre-240 or so), you may have to remove this option. LogRateLimitIntervalSec=0 # Delay start to prevent backups running during boot. Note that systemd-inhibit requires dbus and # dbus-user-session to be installed. ExecStartPre=sleep 1m ExecStart=systemd-inhibit --who="borgmatic" --what="sleep:shutdown" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --verbosity -2 --syslog-verbosity 1 borgmatic/sample/systemd/borgmatic.timer000066400000000000000000000002121476361726000210160ustar00rootroot00000000000000[Unit] Description=Run borgmatic backup [Timer] OnCalendar=daily Persistent=true RandomizedDelaySec=3h [Install] WantedBy=timers.target borgmatic/scripts/000077500000000000000000000000001476361726000145305ustar00rootroot00000000000000borgmatic/scripts/dev-docs000077500000000000000000000004331476361726000161620ustar00rootroot00000000000000#!/bin/bash set -e USER_PODMAN_SOCKET_PATH=/run/user/$UID/podman/podman.sock if [ -e "$USER_PODMAN_SOCKET_PATH" ]; then export DOCKER_HOST="unix://$USER_PODMAN_SOCKET_PATH" fi BUILDKIT_PROGRESS=plain docker-compose --file docs/docker-compose.yaml up --build --force-recreate borgmatic/scripts/export-docs-from-image000077500000000000000000000004721476361726000207510ustar00rootroot00000000000000#!/bin/bash set -e docs_container_id=$(podman create "$IMAGE_NAME") podman cp $docs_container_id:/usr/share/nginx/html - > borgmatic-docs-dump.tar tar xf borgmatic-docs-dump.tar rm borgmatic-docs-dump.tar mv html borgmatic-docs tar cfz borgmatic-docs.tar.gz borgmatic-docs podman rm --volumes $docs_container_id borgmatic/scripts/find-unsupported-borg-options000077500000000000000000000060441476361726000224100ustar00rootroot00000000000000#!/bin/bash set -o nounset # For each Borg sub-command that borgmatic uses, print out the Borg flags that borgmatic does not # appear to support yet. This script isn't terribly robust. It's intended as a basic tool to ferret # out unsupported Borg options so that they can be considered for addition to borgmatic. # Generate a sample borgmatic configuration with all options set, and uncomment all options. generate-borgmatic-config --destination temp.yaml cat temp.yaml | sed -e 's/# \S.*$//' | sed -e 's/#//' > temp.yaml.uncommented mv temp.yaml.uncommented temp.yaml # For each sub-command (prune, create, and check), collect the Borg command-line flags that result # from running borgmatic with the generated configuration. Then, collect the full set of available # Borg flags as reported by "borg --help" for that sub-command. Finally, compare the two lists of # flags to determine which Borg flags borgmatic doesn't yet support. for sub_command in prune create check list info; do echo "********** borg $sub_command **********" for line in $(borgmatic --config temp.yaml $sub_command -v 2 2>&1 | grep "borg\w* $sub_command") ; do echo "$line" | grep '^-' >> borgmatic_borg_flags done sort borgmatic_borg_flags > borgmatic_borg_flags.sorted mv borgmatic_borg_flags.sorted borgmatic_borg_flags for word in $(borg $sub_command --help | grep '^ -') ; do # Exclude a bunch of flags that borgmatic actually supports, but don't get exercised by the # generated sample config, and also flags that don't make sense to support. echo "$word" | grep ^-- | sed -e 's/,$//' \ | grep -v '^--archives-only$' \ | grep -v '^--critical$' \ | grep -v '^--debug$' \ | grep -v '^--dry-run$' \ | grep -v '^--error$' \ | grep -v '^--help$' \ | grep -v '^--info$' \ | grep -v '^--json$' \ | grep -v '^--keep-last$' \ | grep -v '^--list$' \ | grep -v '^--bsdflags$' \ | grep -v '^--pattern$' \ | grep -v '^--progress$' \ | grep -v '^--stats$' \ | grep -v '^--read-special$' \ | grep -v '^--repository-only$' \ | grep -v '^--show-rc$' \ | grep -v '^--stats$' \ | grep -v '^--verbose$' \ | grep -v '^--warning$' \ | grep -v '^--exclude' \ | grep -v '^--exclude-from' \ | grep -v '^--first' \ | grep -v '^--format' \ | grep -v '^--glob-archives' \ | grep -v '^--match-archives' \ | grep -v '^--last' \ | grep -v '^--format' \ | grep -v '^--patterns-from' \ | grep -v '^--prefix' \ | grep -v '^--short' \ | grep -v '^--sort-by' \ | grep -v '^-h$' \ >> all_borg_flags done sort all_borg_flags > all_borg_flags.sorted mv all_borg_flags.sorted all_borg_flags comm -13 borgmatic_borg_flags all_borg_flags rm ./*_borg_flags done rm temp.yaml borgmatic/scripts/push000077500000000000000000000002111476361726000154270ustar00rootroot00000000000000#!/bin/bash set -e branch_name=$(git rev-parse --abbrev-ref HEAD) git push -u github "$branch_name" git push -u origin "$branch_name" borgmatic/scripts/release000077500000000000000000000031621476361726000161000ustar00rootroot00000000000000#!/bin/bash set -e projects_token=${1:-} github_token=${2:-} if [[ -z $github_token ]]; then echo "Usage: $0 [projects-token] [github-token]" exit 1 fi if [[ ! -f NEWS ]]; then echo "Missing NEWS file. Try running from root of repository." exit 1 fi version=$(head --lines=1 NEWS) if [[ $version =~ .*dev* ]]; then echo "Refusing to release a dev version: $version" exit 1 fi if ! git diff-index --quiet HEAD -- ; then echo "Refusing to release with local changes:" git status --porcelain exit 1 fi git tag $version git push origin $version git push github $version # Build borgmatic and publish to pypi. rm -fr dist python3 -m build twine upload -r pypi --username __token__ dist/borgmatic-*.tar.gz twine upload -r pypi --username __token__ dist/borgmatic-*-py3-none-any.whl # Set release changelogs on projects.torsion.org and GitHub. release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')" escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')" curl --silent --request POST \ "https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/releases" \ --header "Authorization: token $projects_token" \ --header "Accept: application/json" \ --header "Content-Type: application/json" \ --data "{\"body\": \"$escaped_release_changelog\", \"draft\": false, \"name\": \"borgmatic $version\", \"prerelease\": false, \"tag_name\": \"$version\"}" github-release create --token="$github_token" --owner=witten --repo=borgmatic --tag="$version" --target_commit="main" \ --name="borgmatic $version" --body="$release_changelog" borgmatic/scripts/run-end-to-end-tests000077500000000000000000000014741476361726000203600ustar00rootroot00000000000000#!/bin/sh # This script is for running end-to-end tests on a developer machine. It sets up database containers # to run tests against, runs the tests, and then tears down the containers. # # Run this script from the root directory of the borgmatic source. # # For more information, see: # https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/ set -e USER_PODMAN_SOCKET_PATH=/run/user/$UID/podman/podman.sock if [ -e "$USER_PODMAN_SOCKET_PATH" ]; then export DOCKER_HOST="unix://$USER_PODMAN_SOCKET_PATH" fi docker-compose --file tests/end-to-end/docker-compose.yaml --progress quiet up --force-recreate \ --renew-anon-volumes --detach docker-compose --file tests/end-to-end/docker-compose.yaml --progress quiet attach tests docker-compose --file tests/end-to-end/docker-compose.yaml --progress quiet down borgmatic/scripts/run-full-tests000077500000000000000000000020441476361726000173620ustar00rootroot00000000000000#!/bin/sh # This script installs test dependencies and runs all tests, including end-to-end tests. It # is designed to run inside a test container, and presumes that other test infrastructure like # databases are already running. Therefore, on a developer machine, you should not run this script # directly. Instead, run scripts/run-end-to-end-tests # # For more information, see: # https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/ set -e if [ -z "$TEST_CONTAINER" ]; then echo "This script is designed to work inside a test container and is not intended to" echo "be run manually. If you're trying to run borgmatic's end-to-end tests, execute" echo "scripts/run-end-to-end-dev-tests instead." exit 1 fi apk add --no-cache python3 py3-pip borgbackup postgresql17-client mariadb-client mongodb-tools \ py3-mongo py3-regex py3-ruamel.yaml py3-ruamel.yaml.clib py3-tox py3-yaml bash sqlite fish export COVERAGE_FILE=/tmp/.coverage tox --workdir /tmp/.tox --sitepackages tox --workdir /tmp/.tox --sitepackages -e end-to-end borgmatic/test_requirements.txt000066400000000000000000000007741476361726000173740ustar00rootroot00000000000000appdirs==1.4.4 apprise==1.8.0 attrs==23.2.0 black==24.4.2 certifi==2024.7.4 chardet==5.2.0 click==8.1.7 codespell==2.2.6 coverage==7.5.1 flake8==7.0.0 flake8-quotes==3.4.0 flake8-use-fstring==1.4 flake8-variables-names==0.0.6 flexmock==0.12.1 idna==3.7 isort==5.13.2 jsonschema==4.22.0 Markdown==3.6 mccabe==0.7.0 packaging==24.0 pathspec==0.12.1 pluggy==1.5.0 py==1.11.0 pycodestyle==2.11.1 pyflakes==3.2.0 pytest==8.2.1 pytest-cov==5.0.0 PyYAML>5.0.0 regex requests==2.32.2 ruamel.yaml>0.15.0 toml==0.10.2 borgmatic/tests/000077500000000000000000000000001476361726000142035ustar00rootroot00000000000000borgmatic/tests/__init__.py000066400000000000000000000000001476361726000163020ustar00rootroot00000000000000borgmatic/tests/end-to-end/000077500000000000000000000000001476361726000161355ustar00rootroot00000000000000borgmatic/tests/end-to-end/__init__.py000066400000000000000000000000001476361726000202340ustar00rootroot00000000000000borgmatic/tests/end-to-end/commands/000077500000000000000000000000001476361726000177365ustar00rootroot00000000000000borgmatic/tests/end-to-end/commands/__init__.py000066400000000000000000000000001476361726000220350ustar00rootroot00000000000000borgmatic/tests/end-to-end/commands/fake_btrfs.py000066400000000000000000000064621476361726000224260ustar00rootroot00000000000000import argparse import json import os import shutil import sys def parse_arguments(*unparsed_arguments): global_parser = argparse.ArgumentParser(add_help=False) action_parsers = global_parser.add_subparsers(dest='action') subvolume_parser = action_parsers.add_parser('subvolume') subvolume_subparser = subvolume_parser.add_subparsers(dest='subaction') list_parser = subvolume_subparser.add_parser('list') list_parser.add_argument('-s', dest='snapshots_only', action='store_true') list_parser.add_argument('subvolume_path') snapshot_parser = subvolume_subparser.add_parser('snapshot') snapshot_parser.add_argument('-r', dest='read_only', action='store_true') snapshot_parser.add_argument('subvolume_path') snapshot_parser.add_argument('snapshot_path') delete_parser = subvolume_subparser.add_parser('delete') delete_parser.add_argument('snapshot_path') property_parser = action_parsers.add_parser('property') property_subparser = property_parser.add_subparsers(dest='subaction') get_parser = property_subparser.add_parser('get') get_parser.add_argument('-t', dest='type') get_parser.add_argument('subvolume_path') get_parser.add_argument('property_name') return (global_parser, global_parser.parse_args(unparsed_arguments)) BUILTIN_SUBVOLUME_LIST_LINES = ( '261 gen 29 top level 5 path sub', '262 gen 29 top level 5 path other', ) SUBVOLUME_LIST_LINE_PREFIX = '263 gen 29 top level 5 path ' def load_snapshots(): try: return json.load(open('/tmp/fake_btrfs.json')) except FileNotFoundError: return [] def save_snapshots(snapshot_paths): json.dump(snapshot_paths, open('/tmp/fake_btrfs.json', 'w')) def print_subvolume_list(arguments, snapshot_paths): assert arguments.subvolume_path == '/mnt/subvolume' if not arguments.snapshots_only: for line in BUILTIN_SUBVOLUME_LIST_LINES: print(line) for snapshot_path in snapshot_paths: print( SUBVOLUME_LIST_LINE_PREFIX + snapshot_path[snapshot_path.index('.borgmatic-snapshot-') :] ) def main(): (global_parser, arguments) = parse_arguments(*sys.argv[1:]) snapshot_paths = load_snapshots() if not hasattr(arguments, 'subaction'): global_parser.print_help() sys.exit(1) if arguments.subaction == 'list': print_subvolume_list(arguments, snapshot_paths) elif arguments.subaction == 'snapshot': snapshot_paths.append(arguments.snapshot_path) save_snapshots(snapshot_paths) subdirectory = os.path.join(arguments.snapshot_path, 'subdir') os.makedirs(subdirectory, mode=0o700, exist_ok=True) test_file = open(os.path.join(subdirectory, 'file.txt'), 'w') test_file.write('contents') test_file.close() elif arguments.subaction == 'delete': subdirectory = os.path.join(arguments.snapshot_path, 'subdir') shutil.rmtree(subdirectory) snapshot_paths = [ snapshot_path for snapshot_path in snapshot_paths if snapshot_path.endswith('/' + arguments.snapshot_path) ] save_snapshots(snapshot_paths) elif arguments.action == 'property' and arguments.subaction == 'get': print(f'{arguments.property_name}=false') if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_findmnt.py000066400000000000000000000016341476361726000227410ustar00rootroot00000000000000import argparse import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-t', dest='type') parser.add_argument('--json', action='store_true') parser.add_argument('--list', action='store_true') return parser.parse_args(unparsed_arguments) BUILTIN_FILESYSTEM_MOUNT_OUTPUT = '''{ "filesystems": [ { "target": "/mnt/subvolume", "source": "/dev/loop0", "fstype": "btrfs", "options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/" } ] } ''' def print_filesystem_mounts(): print(BUILTIN_FILESYSTEM_MOUNT_OUTPUT) def main(): arguments = parse_arguments(*sys.argv[1:]) assert arguments.type == 'btrfs' assert arguments.json assert arguments.list print_filesystem_mounts() if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_keepassxc_cli.py000066400000000000000000000013301476361726000241100ustar00rootroot00000000000000import argparse import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('command') parser.add_argument('--show-protected', action='store_true') parser.add_argument('--attributes') parser.add_argument('database_path') parser.add_argument('attribute_name') return parser.parse_args(unparsed_arguments) def main(): arguments = parse_arguments(*sys.argv[1:]) assert arguments.command == 'show' assert arguments.show_protected assert arguments.attributes == 'Password' assert arguments.database_path.endswith('.kdbx') assert arguments.attribute_name print('test') if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_lsblk.py000066400000000000000000000033711476361726000224110ustar00rootroot00000000000000import argparse import json import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--output', required=True) parser.add_argument('--json', action='store_true', required=True) parser.add_argument('--list', action='store_true', required=True) return parser.parse_args(unparsed_arguments) BUILTIN_BLOCK_DEVICES = { 'blockdevices': [ {'name': 'loop0', 'path': '/dev/loop0', 'mountpoint': None, 'type': 'loop'}, {'name': 'cryptroot', 'path': '/dev/mapper/cryptroot', 'mountpoint': '/', 'type': 'crypt'}, { 'name': 'vgroup-lvolume', 'path': '/dev/mapper/vgroup-lvolume', 'mountpoint': '/mnt/lvolume', 'type': 'lvm', }, { 'name': 'vgroup-lvolume-real', 'path': '/dev/mapper/vgroup-lvolume-real', 'mountpoint': None, 'type': 'lvm', }, ] } def load_snapshots(): try: return json.load(open('/tmp/fake_lvm.json')) except FileNotFoundError: return [] def print_logical_volumes_json(arguments, snapshots): data = dict(BUILTIN_BLOCK_DEVICES) for snapshot in snapshots: data['blockdevices'].extend( { 'name': snapshot['lv_name'], 'path': snapshot['lv_path'], 'mountpoint': None, 'type': 'lvm', } for snapshot in snapshots ) print(json.dumps(data)) def main(): arguments = parse_arguments(*sys.argv[1:]) snapshots = load_snapshots() assert arguments.output == 'name,path,mountpoint,type' print_logical_volumes_json(arguments, snapshots) if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_lvcreate.py000066400000000000000000000020611476361726000231020ustar00rootroot00000000000000import argparse import json import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--snapshot', action='store_true', required=True) parser.add_argument('--extents') parser.add_argument('--size') parser.add_argument('--permission', required=True) parser.add_argument('--name', dest='snapshot_name', required=True) parser.add_argument('logical_volume_device') return parser.parse_args(unparsed_arguments) def load_snapshots(): try: return json.load(open('/tmp/fake_lvm.json')) except FileNotFoundError: return [] def save_snapshots(snapshots): json.dump(snapshots, open('/tmp/fake_lvm.json', 'w')) def main(): arguments = parse_arguments(*sys.argv[1:]) snapshots = load_snapshots() assert arguments.extents or arguments.size snapshots.append( {'lv_name': arguments.snapshot_name, 'lv_path': f'/dev/vgroup/{arguments.snapshot_name}'}, ) save_snapshots(snapshots) if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_lvremove.py000066400000000000000000000014411476361726000231350ustar00rootroot00000000000000import argparse import json import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--force', action='store_true', required=True) parser.add_argument('snapshot_device') return parser.parse_args(unparsed_arguments) def load_snapshots(): try: return json.load(open('/tmp/fake_lvm.json')) except FileNotFoundError: return [] def save_snapshots(snapshots): json.dump(snapshots, open('/tmp/fake_lvm.json', 'w')) def main(): arguments = parse_arguments(*sys.argv[1:]) snapshots = [ snapshot for snapshot in load_snapshots() if snapshot['lv_path'] != arguments.snapshot_device ] save_snapshots(snapshots) if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_lvs.py000066400000000000000000000021031476361726000220760ustar00rootroot00000000000000import argparse import json import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('--report-format', required=True) parser.add_argument('--options', required=True) parser.add_argument('--select', required=True) return parser.parse_args(unparsed_arguments) def load_snapshots(): try: return json.load(open('/tmp/fake_lvm.json')) except FileNotFoundError: return [] def print_snapshots_json(arguments, snapshots): assert arguments.report_format == 'json' assert arguments.options == 'lv_name,lv_path' assert arguments.select == 'lv_attr =~ ^s' print( json.dumps( { 'report': [ { 'lv': snapshots, } ], 'log': [], } ) ) def main(): arguments = parse_arguments(*sys.argv[1:]) snapshots = load_snapshots() print_snapshots_json(arguments, snapshots) if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_mount.py000066400000000000000000000013011476361726000224330ustar00rootroot00000000000000import argparse import os import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('-t', dest='type') parser.add_argument('-o', dest='options') parser.add_argument('snapshot_name') parser.add_argument('mount_point') return parser.parse_args(unparsed_arguments) def main(): arguments = parse_arguments(*sys.argv[1:]) assert arguments.options == 'ro' subdirectory = os.path.join(arguments.mount_point, 'subdir') os.mkdir(subdirectory) test_file = open(os.path.join(subdirectory, 'file.txt'), 'w') test_file.write('contents') test_file.close() if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_umount.py000066400000000000000000000006641476361726000226330ustar00rootroot00000000000000import argparse import os import shutil import sys def parse_arguments(*unparsed_arguments): parser = argparse.ArgumentParser(add_help=False) parser.add_argument('mount_point') return parser.parse_args(unparsed_arguments) def main(): arguments = parse_arguments(*sys.argv[1:]) subdirectory = os.path.join(arguments.mount_point, 'subdir') shutil.rmtree(subdirectory) if __name__ == '__main__': main() borgmatic/tests/end-to-end/commands/fake_zfs.py000066400000000000000000000050301476361726000220760ustar00rootroot00000000000000import argparse import json import sys def parse_arguments(*unparsed_arguments): global_parser = argparse.ArgumentParser(add_help=False) action_parsers = global_parser.add_subparsers(dest='action') list_parser = action_parsers.add_parser('list') list_parser.add_argument('-H', dest='header', action='store_false', default=True) list_parser.add_argument('-t', dest='type', default='filesystem') list_parser.add_argument('-o', dest='properties', default='name,used,avail,refer,mountpoint') snapshot_parser = action_parsers.add_parser('snapshot') snapshot_parser.add_argument('name') destroy_parser = action_parsers.add_parser('destroy') destroy_parser.add_argument('name') return global_parser.parse_args(unparsed_arguments) BUILTIN_DATASETS = ( { 'name': 'pool', 'used': '256K', 'avail': '23.7M', 'refer': '25K', 'canmount': 'on', 'mountpoint': '/pool', }, { 'name': 'pool/dataset', 'used': '256K', 'avail': '23.7M', 'refer': '25K', 'canmount': 'on', 'mountpoint': '/pool/dataset', }, ) def load_snapshots(): try: return json.load(open('/tmp/fake_zfs.json')) except FileNotFoundError: return [] def save_snapshots(snapshots): json.dump(snapshots, open('/tmp/fake_zfs.json', 'w')) def print_dataset_list(arguments, datasets, snapshots): properties = arguments.properties.split(',') data = ( (tuple(property_name.upper() for property_name in properties),) if arguments.header else () ) + tuple( tuple(dataset.get(property_name, '-') for property_name in properties) for dataset in (snapshots if arguments.type == 'snapshot' else datasets) ) if not data: return for data_row in data: print('\t'.join(data_row)) def main(): arguments = parse_arguments(*sys.argv[1:]) snapshots = load_snapshots() if arguments.action == 'list': print_dataset_list(arguments, BUILTIN_DATASETS, snapshots) elif arguments.action == 'snapshot': snapshots.append( { 'name': arguments.name, 'used': '0B', 'avail': '-', 'refer': '25K', 'mountpoint': '-', }, ) save_snapshots(snapshots) elif arguments.action == 'destroy': snapshots = [snapshot for snapshot in snapshots if snapshot['name'] != arguments.name] save_snapshots(snapshots) if __name__ == '__main__': main() borgmatic/tests/end-to-end/docker-compose.yaml000066400000000000000000000031631476361726000217360ustar00rootroot00000000000000services: postgresql: image: docker.io/postgres:17.2-alpine environment: POSTGRES_PASSWORD: test POSTGRES_DB: test postgresql2: image: docker.io/postgres:17.2-alpine environment: POSTGRES_PASSWORD: test2 POSTGRES_DB: test command: docker-entrypoint.sh -p 5433 mariadb: image: docker.io/mariadb:11.4.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test mariadb2: image: docker.io/mariadb:11.4.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test command: docker-entrypoint.sh --port=3307 not-actually-mysql: image: docker.io/mariadb:11.4.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test not-actually-mysql2: image: docker.io/mariadb:11.4.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test command: docker-entrypoint.sh --port=3307 mongodb: image: docker.io/mongo:7.0.16 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: test mongodb2: image: docker.io/mongo:7.0.16 environment: MONGO_INITDB_ROOT_USERNAME: root2 MONGO_INITDB_ROOT_PASSWORD: test2 command: docker-entrypoint.sh --port=27018 tests: image: docker.io/alpine:3.21 environment: TEST_CONTAINER: true volumes: - "../..:/app" tmpfs: - "/app/borgmatic.egg-info" - "/app/build" tty: true working_dir: /app entrypoint: /app/scripts/run-full-tests depends_on: - postgresql - postgresql2 - mariadb - mariadb2 - mongodb - mongodb2 borgmatic/tests/end-to-end/hooks/000077500000000000000000000000001476361726000172605ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/__init__.py000066400000000000000000000000001476361726000213570ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/credential/000077500000000000000000000000001476361726000213725ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/credential/__init__.py000066400000000000000000000000001476361726000234710ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/credential/test_container.py000066400000000000000000000047621476361726000247760ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path, secrets_directory): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase loaded from container secrets in the given secrets directory. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "{credential container mysecret}"' + f'\ncontainer:\n secrets_directory: {secrets_directory}' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_container_secret(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() os.chdir(temporary_directory) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, secrets_directory=temporary_directory) secret_path = os.path.join(temporary_directory, 'mysecret') with open(secret_path, 'w') as secret_file: secret_file.write('test') subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '), ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call( f'borgmatic --config {config_path}'.split(' '), ) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' '), ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/credential/test_file.py000066400000000000000000000046561476361726000237350ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path, credential_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase loaded from file at the given credential path. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "{credential file ' + credential_path + '}"' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_file_credential(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() os.chdir(temporary_directory) try: config_path = os.path.join(temporary_directory, 'test.yaml') credential_path = os.path.join(temporary_directory, 'mycredential') generate_configuration(config_path, repository_path, credential_path) with open(credential_path, 'w') as credential_file: credential_file.write('test') subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '), ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call( f'borgmatic --config {config_path}'.split(' '), ) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' '), ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/credential/test_keepassxc.py000066400000000000000000000050061476361726000247720ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase loaded from keepassxc-cli. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "{credential keepassxc keys.kdbx mypassword}"' + '\nkeepassxc:\n keepassxc_cli_command: python3 /app/tests/end-to-end/commands/fake_keepassxc_cli.py' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_keepassxc_password(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() os.chdir(temporary_directory) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) database_path = os.path.join(temporary_directory, 'keys.kdbx') with open(database_path, 'w') as database_file: database_file.write('fake KeePassXC database to pacify file existence check') subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '), ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call( f'borgmatic --config {config_path}'.split(' '), ) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' '), ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/credential/test_systemd.py000066400000000000000000000051311476361726000244730ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase loaded from systemd. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "{credential systemd mycredential}"' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_systemd_credential(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() os.chdir(temporary_directory) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) credential_path = os.path.join(temporary_directory, 'mycredential') with open(credential_path, 'w') as credential_file: credential_file.write('test') subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '), env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}), ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call( f'borgmatic --config {config_path}'.split(' '), env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}), ) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' '), env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}), ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/data_source/000077500000000000000000000000001476361726000215515ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/data_source/__init__.py000066400000000000000000000000001476361726000236500ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/data_source/test_btrfs.py000066400000000000000000000044731476361726000243120ustar00rootroot00000000000000import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing (including injecting the given repository path and tacking on an encryption passphrase). ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '- /mnt/subvolume/subdir') .replace('- /var/log/syslog*', '') + 'encryption_passphrase: "test"\n' + 'btrfs:\n' + ' btrfs_command: python3 /app/tests/end-to-end/commands/fake_btrfs.py\n' + ' findmnt_command: python3 /app/tests/end-to-end/commands/fake_findmnt.py\n' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_btrfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run a create action to exercise Btrfs snapshotting and backup. subprocess.check_call(f'borgmatic --config {config_path} create'.split(' ')) # List the resulting archive and assert that the snapshotted files are there. output = subprocess.check_output( f'borgmatic --config {config_path} list --archive latest'.split(' ') ).decode(sys.stdout.encoding) assert 'mnt/subvolume/subdir/file.txt' in output # Assert that the snapshot has been deleted. assert not subprocess.check_output( 'python3 /app/tests/end-to-end/commands/fake_btrfs.py subvolume list -s /mnt/subvolume'.split( ' ' ) ) finally: shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/data_source/test_database.py000066400000000000000000000471661476361726000247440ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile import pymongo import pytest import ruamel.yaml def write_configuration( source_directory, config_path, repository_path, user_runtime_directory, postgresql_dump_format='custom', postgresql_all_dump_format=None, mariadb_mysql_all_dump_format=None, mongodb_dump_format='archive', ): ''' Write out borgmatic configuration into a file at the config path. Set the options so as to work for testing. This includes injecting the given repository path, borgmatic source directory for storing database dumps, dump format (for PostgreSQL), and encryption passphrase. ''' postgresql_all_format_option = ( f'format: {postgresql_all_dump_format}' if postgresql_all_dump_format else '' ) mariadb_mysql_dump_format_option = ( f'format: {mariadb_mysql_all_dump_format}' if mariadb_mysql_all_dump_format else '' ) config_yaml = f''' source_directories: - {source_directory} repositories: - path: {repository_path} user_runtime_directory: {user_runtime_directory} encryption_passphrase: "test" postgresql_databases: - name: test hostname: postgresql username: postgres password: test format: {postgresql_dump_format} - name: all {postgresql_all_format_option} hostname: postgresql username: postgres password: test mariadb_databases: - name: test hostname: mariadb username: root password: test - name: all {mariadb_mysql_dump_format_option} hostname: mariadb username: root password: test mysql_databases: - name: test hostname: not-actually-mysql username: root password: test - name: all {mariadb_mysql_dump_format_option} hostname: not-actually-mysql username: root password: test mongodb_databases: - name: test hostname: mongodb username: root password: test authentication_database: admin format: {mongodb_dump_format} - name: all hostname: mongodb username: root password: test sqlite_databases: - name: sqlite_test path: /tmp/sqlite_test.db ''' with open(config_path, 'w') as config_file: config_file.write(config_yaml) return ruamel.yaml.YAML(typ='safe').load(config_yaml) @pytest.mark.parametrize( 'postgresql_all_dump_format,mariadb_mysql_all_dump_format', ( (None, None), ('custom', 'sql'), ), ) def write_custom_restore_configuration( source_directory, config_path, repository_path, user_runtime_directory, postgresql_dump_format='custom', postgresql_all_dump_format=None, mariadb_mysql_all_dump_format=None, mongodb_dump_format='archive', ): ''' Write out borgmatic configuration into a file at the config path. Set the options so as to work for testing with custom restore options. This includes a custom restore_hostname, restore_port, restore_username, restore_password and restore_path. ''' config_yaml = f''' source_directories: - {source_directory} repositories: - path: {repository_path} user_runtime_directory: {user_runtime_directory} encryption_passphrase: "test" postgresql_databases: - name: test hostname: postgresql username: postgres password: test format: {postgresql_dump_format} restore_hostname: postgresql2 restore_port: 5433 restore_password: test2 mariadb_databases: - name: test hostname: mariadb username: root password: test restore_hostname: mariadb2 restore_port: 3307 restore_username: root restore_password: test2 mysql_databases: - name: test hostname: not-actually-mysql username: root password: test restore_hostname: not-actually-mysql2 restore_port: 3307 restore_username: root restore_password: test2 mongodb_databases: - name: test hostname: mongodb username: root password: test authentication_database: admin format: {mongodb_dump_format} restore_hostname: mongodb2 restore_port: 27018 restore_username: root2 restore_password: test2 sqlite_databases: - name: sqlite_test path: /tmp/sqlite_test.db restore_path: /tmp/sqlite_test2.db ''' with open(config_path, 'w') as config_file: config_file.write(config_yaml) return ruamel.yaml.YAML(typ='safe').load(config_yaml) def write_simple_custom_restore_configuration( source_directory, config_path, repository_path, user_runtime_directory, postgresql_dump_format='custom', ): ''' Write out borgmatic configuration into a file at the config path. Set the options so as to work for testing with custom restore options, but this time using CLI arguments. This includes a custom restore_hostname, restore_port, restore_username and restore_password as we only test these options for PostgreSQL. ''' config_yaml = f''' source_directories: - {source_directory} repositories: - path: {repository_path} user_runtime_directory: {user_runtime_directory} encryption_passphrase: "test" postgresql_databases: - name: test hostname: postgresql username: postgres password: test format: {postgresql_dump_format} ''' with open(config_path, 'w') as config_file: config_file.write(config_yaml) return ruamel.yaml.YAML(typ='safe').load(config_yaml) def get_connection_params(database, use_restore_options=False): hostname = (database.get('restore_hostname') if use_restore_options else None) or database.get( 'hostname' ) port = (database.get('restore_port') if use_restore_options else None) or database.get('port') username = (database.get('restore_username') if use_restore_options else None) or database.get( 'username' ) password = (database.get('restore_password') if use_restore_options else None) or database.get( 'password' ) return (hostname, port, username, password) def run_postgresql_command(command, config, use_restore_options=False): (hostname, port, username, password) = get_connection_params( config['postgresql_databases'][0], use_restore_options ) subprocess.check_call( [ '/usr/bin/psql', f'--host={hostname}', f'--port={port or 5432}', f"--username={username or 'root'}", f'--command={command}', 'test', ], env={'PGPASSWORD': password}, ) def run_mariadb_command(command, config, use_restore_options=False, binary_name='mariadb'): (hostname, port, username, password) = get_connection_params( config[f'{binary_name}_databases'][0], use_restore_options ) subprocess.check_call( [ f'/usr/bin/{binary_name}', f'--host={hostname}', f'--port={port or 3306}', f'--user={username}', f'--execute={command}', 'test', ], env={'MYSQL_PWD': password}, ) def get_mongodb_database_client(config, use_restore_options=False): (hostname, port, username, password) = get_connection_params( config['mongodb_databases'][0], use_restore_options ) return pymongo.MongoClient(f'mongodb://{username}:{password}@{hostname}:{port or 27017}').test def run_sqlite_command(command, config, use_restore_options=False): database = config['sqlite_databases'][0] path = (database.get('restore_path') if use_restore_options else None) or database.get('path') subprocess.check_call( [ '/usr/bin/sqlite3', path, command, '.exit', ], ) DEFAULT_HOOK_NAMES = {'postgresql', 'mariadb', 'mysql', 'mongodb', 'sqlite'} def create_test_tables(config, use_restore_options=False): ''' Create test tables for borgmatic to dump and backup. ''' command = 'create table test{id} (thing int); insert into test{id} values (1);' if 'postgresql_databases' in config: run_postgresql_command(command.format(id=1), config, use_restore_options) if 'mariadb_databases' in config: run_mariadb_command(command.format(id=2), config, use_restore_options) if 'mysql_databases' in config: run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql') if 'mongodb_databases' in config: get_mongodb_database_client(config, use_restore_options)['test4'].insert_one({'thing': 1}) if 'sqlite_databases' in config: run_sqlite_command(command.format(id=5), config, use_restore_options) def drop_test_tables(config, use_restore_options=False): ''' Drop the test tables in preparation for borgmatic restoring them. ''' command = 'drop table if exists test{id};' if 'postgresql_databases' in config: run_postgresql_command(command.format(id=1), config, use_restore_options) if 'mariadb_databases' in config: run_mariadb_command(command.format(id=2), config, use_restore_options) if 'mysql_databases' in config: run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql') if 'mongodb_databases' in config: get_mongodb_database_client(config, use_restore_options)['test4'].drop() if 'sqlite_databases' in config: run_sqlite_command(command.format(id=5), config, use_restore_options) def select_test_tables(config, use_restore_options=False): ''' Select the test tables to make sure they exist. Raise if the expected tables cannot be selected, for instance if a restore hasn't worked as expected. ''' command = 'select count(*) from test{id};' if 'postgresql_databases' in config: run_postgresql_command(command.format(id=1), config, use_restore_options) if 'mariadb_databases' in config: run_mariadb_command(command.format(id=2), config, use_restore_options) if 'mysql_databases' in config: run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql') if 'mongodb_databases' in config: assert ( get_mongodb_database_client(config, use_restore_options)['test4'].count_documents( filter={} ) > 0 ) if 'sqlite_databases' in config: run_sqlite_command(command.format(id=5), config, use_restore_options) def test_database_dump_and_restore(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') # Write out a special file to ensure that it gets properly excluded and Borg doesn't hang on it. os.mkfifo(os.path.join(temporary_directory, 'special_file')) original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, config_path, repository_path, temporary_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'repo-create', '--encryption', 'repokey', ] ) # Run borgmatic to generate a backup archive including database dumps. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2']) # Get the created archive name. output = subprocess.check_output( ['borgmatic', '--config', config_path, 'list', '--json'] ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 archive_name = parsed_output[0]['archives'][0]['archive'] # Restore the databases from the archive. drop_test_tables(config) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name] ) # Ensure the test tables have actually been restored. select_test_tables(config) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) drop_test_tables(config) def test_database_dump_and_restore_with_restore_cli_flags(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_simple_custom_restore_configuration( temporary_directory, config_path, repository_path, temporary_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'repo-create', '--encryption', 'repokey', ] ) # Run borgmatic to generate a backup archive including a database dump. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2']) # Get the created archive name. output = subprocess.check_output( ['borgmatic', '--config', config_path, 'list', '--json'] ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 archive_name = parsed_output[0]['archives'][0]['archive'] # Restore the database from the archive. drop_test_tables(config) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name, '--hostname', 'postgresql2', '--port', '5433', '--password', 'test2', ] ) # Ensure the test tables have actually been restored. But first modify the config to contain # the altered restore values from the borgmatic command above. This ensures that the test # tables are selected from the correct database. database = config['postgresql_databases'][0] database['restore_hostname'] = 'postgresql2' database['restore_port'] = '5433' database['restore_password'] = 'test2' select_test_tables(config, use_restore_options=True) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) drop_test_tables(config) drop_test_tables(config, use_restore_options=True) def test_database_dump_and_restore_with_restore_configuration_options(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_custom_restore_configuration( temporary_directory, config_path, repository_path, temporary_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'repo-create', '--encryption', 'repokey', ] ) # Run borgmatic to generate a backup archive including a database dump. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2']) # Get the created archive name. output = subprocess.check_output( ['borgmatic', '--config', config_path, 'list', '--json'] ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 archive_name = parsed_output[0]['archives'][0]['archive'] # Restore the database from the archive. drop_test_tables(config) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name] ) # Ensure the test tables have actually been restored. select_test_tables(config, use_restore_options=True) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) drop_test_tables(config) drop_test_tables(config, use_restore_options=True) def test_database_dump_and_restore_with_directory_format(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, config_path, repository_path, temporary_directory, postgresql_dump_format='directory', mongodb_dump_format='directory', ) create_test_tables(config) select_test_tables(config) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'repo-create', '--encryption', 'repokey', ] ) # Run borgmatic to generate a backup archive including a database dump. subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2']) # Restore the database from the archive. drop_test_tables(config) subprocess.check_call( ['borgmatic', '--config', config_path, 'restore', '--archive', 'latest'] ) # Ensure the test tables have actually been restored. select_test_tables(config) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) drop_test_tables(config) def test_database_dump_with_error_causes_borgmatic_to_exit(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') write_configuration(temporary_directory, config_path, repository_path, temporary_directory) subprocess.check_call( [ 'borgmatic', '-v', '2', '--config', config_path, 'repo-create', '--encryption', 'repokey', ] ) # Run borgmatic with a config override such that the database dump fails. with pytest.raises(subprocess.CalledProcessError): subprocess.check_call( [ 'borgmatic', 'create', '--config', config_path, '-v', '2', '--override', "hooks.postgresql_databases=[{'name': 'nope'}]", # noqa: FS003 ] ) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/data_source/test_lvm.py000066400000000000000000000054311476361726000237630ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing (including injecting the given repository path and tacking on an encryption passphrase). ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '- /mnt/lvolume/subdir') .replace('- /var/log/syslog*', '') + 'encryption_passphrase: "test"\n' + 'lvm:\n' + ' lsblk_command: python3 /app/tests/end-to-end/commands/fake_lsblk.py\n' + ' lvcreate_command: python3 /app/tests/end-to-end/commands/fake_lvcreate.py\n' + ' lvremove_command: python3 /app/tests/end-to-end/commands/fake_lvremove.py\n' + ' lvs_command: python3 /app/tests/end-to-end/commands/fake_lvs.py\n' + ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n' + ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py\n' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_lvm_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run a create action to exercise LVM snapshotting and backup. subprocess.check_call(f'borgmatic --config {config_path} create'.split(' ')) # List the resulting archive and assert that the snapshotted files are there. output = subprocess.check_output( f'borgmatic --config {config_path} list --archive latest'.split(' ') ).decode(sys.stdout.encoding) assert 'mnt/lvolume/subdir/file.txt' in output # Assert that the snapshot has been deleted. assert not json.loads( subprocess.check_output( 'python3 /app/tests/end-to-end/commands/fake_lvs.py --report-format json --options lv_name,lv_path --select'.split( ' ' ) + ['lv_attr =~ ^s'] ) )['report'][0]['lv'] finally: shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/data_source/test_zfs.py000066400000000000000000000045241476361726000237710ustar00rootroot00000000000000import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing (including injecting the given repository path and tacking on an encryption passphrase). ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '- /pool/dataset/subdir') .replace('- /var/log/syslog*', '') + 'encryption_passphrase: "test"\n' + 'zfs:\n' + ' zfs_command: python3 /app/tests/end-to-end/commands/fake_zfs.py\n' + ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n' + ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_zfs_create_and_list(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run a create action to exercise ZFS snapshotting and backup. subprocess.check_call(f'borgmatic --config {config_path} create'.split(' ')) # List the resulting archive and assert that the snapshotted files are there. output = subprocess.check_output( f'borgmatic --config {config_path} list --archive latest'.split(' ') ).decode(sys.stdout.encoding) assert 'pool/dataset/subdir/file.txt' in output # Assert that the snapshot has been deleted. assert not subprocess.check_output( 'python3 /app/tests/end-to-end/commands/fake_zfs.py list -H -t snapshot'.split(' ') ) finally: shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/hooks/monitoring/000077500000000000000000000000001476361726000214455ustar00rootroot00000000000000borgmatic/tests/end-to-end/hooks/monitoring/test_monitoring.py000066400000000000000000000114401476361726000252430ustar00rootroot00000000000000import http.server import json import os import shutil import subprocess import sys import tempfile import threading import pytest def generate_configuration(config_path, repository_path, monitoring_hook_configuration): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "test"' + f'\n{monitoring_hook_configuration}' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() class Web_server(http.server.BaseHTTPRequestHandler): def handle_method(self): self.send_response(http.HTTPStatus.OK) self.send_header('Content-type', 'text/html') self.end_headers() self.wfile.write(''.encode('utf-8')) def do_GET(self): self.handle_method() def do_POST(self): self.handle_method() def serve_web_request(count): for index in range(0, count): with http.server.HTTPServer(('localhost', 12345), Web_server) as server: server.handle_request() class Background_web_server: def __init__(self, expected_request_count): self.expected_request_count = expected_request_count def __enter__(self): self.thread = threading.Thread( target=lambda: serve_web_request(count=self.expected_request_count) ) self.thread.start() def __exit__(self, exception, value, traceback): self.thread.join() START_AND_FINISH = 2 START_LOG_AND_FINISH = 3 @pytest.mark.parametrize( 'monitoring_hook_configuration,expected_request_count', ( ( 'cronhub:\n ping_url: http://localhost:12345/start/1f5e3410-254c-11e8-b61d-55875966d031', START_AND_FINISH, ), ( 'cronitor:\n ping_url: http://localhost:12345/d3x0c1', START_AND_FINISH, ), ( 'healthchecks:\n ping_url: http://localhost:12345/addffa72-da17-40ae-be9c-ff591afb942a', START_LOG_AND_FINISH, ), ( 'loki:\n url: http://localhost:12345/loki/api/v1/push\n labels:\n app: borgmatic', START_AND_FINISH, ), ( 'ntfy:\n topic: my-unique-topic\n server: http://localhost:12345\n states: [start, finish]', START_AND_FINISH, ), ( 'sentry:\n data_source_name_url: http://5f80ec@localhost:12345/203069\n monitor_slug: mymonitor', START_AND_FINISH, ), ( 'uptime_kuma:\n push_url: http://localhost:12345/api/push/abcd1234', START_AND_FINISH, ), ( 'zabbix:\n itemid: 1\n server: http://localhost:12345/zabbix/api_jsonrpc.php\n api_key: mykey\n states: [start, finish]', START_AND_FINISH, ), ), ) def test_borgmatic_command(monitoring_hook_configuration, expected_request_count): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') extract_path = os.path.join(temporary_directory, 'extract') original_working_directory = os.getcwd() os.mkdir(extract_path) os.chdir(extract_path) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path, monitoring_hook_configuration) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) with Background_web_server(expected_request_count): # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' ')) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' ') ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/test_borgmatic.py000066400000000000000000000110221476361726000215110ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile import pytest def generate_configuration_with_source_directories(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passphrase. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passphrase: "test"' # Disable automatic storage of config files so we can test storage and extraction manually. + '\nbootstrap:\n store_config_files: false' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def generate_configuration_with_patterns(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including adding patterns, injecting the given repository path, and tacking on an encryption passphrase. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('source_directories:', '') .replace('- /home/user/path with spaces', '') .replace('- /home', '') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + f'\npatterns: ["R {config_path}"]' + '\nencryption_passphrase: "test"' # Disable automatic storage of config files so we can test storage and extraction manually. + '\nbootstrap:\n store_config_files: false' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() @pytest.mark.parametrize( 'generate_configuration', (generate_configuration_with_source_directories, generate_configuration_with_patterns), ) def test_borgmatic_command(generate_configuration): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') extract_path = os.path.join(temporary_directory, 'extract') original_working_directory = os.getcwd() os.mkdir(extract_path) os.chdir(extract_path) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call(f'borgmatic --config {config_path}'.split(' ')) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' ') ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 archive_name = parsed_output[0]['archives'][0]['archive'] # Extract the created archive into the current (temporary) directory, and confirm that the # extracted file looks right. output = subprocess.check_output( f'borgmatic --config {config_path} extract --archive {archive_name}'.split(' '), ).decode(sys.stdout.encoding) extracted_config_path = os.path.join(extract_path, config_path) assert open(extracted_config_path).read() == open(config_path).read() # Exercise the info action. output = subprocess.check_output( f'borgmatic --config {config_path} info --json'.split(' '), ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert 'repository' in parsed_output[0] finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/test_completion.py000066400000000000000000000004141476361726000217160ustar00rootroot00000000000000import subprocess def test_bash_completion_runs_without_error(): subprocess.check_call('borgmatic --bash-completion | bash', shell=True) def test_fish_completion_runs_without_error(): subprocess.check_call('borgmatic --fish-completion | fish', shell=True) borgmatic/tests/end-to-end/test_generate_config.py000066400000000000000000000011141476361726000226620ustar00rootroot00000000000000import os import subprocess import tempfile def test_generate_borgmatic_config_with_merging_succeeds(): with tempfile.TemporaryDirectory() as temporary_directory: config_path = os.path.join(temporary_directory, 'test.yaml') new_config_path = os.path.join(temporary_directory, 'new.yaml') subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) subprocess.check_call( f'borgmatic config generate --source {config_path} --destination {new_config_path}'.split( ' ' ) ) borgmatic/tests/end-to-end/test_invalid_flag.py000066400000000000000000000005461476361726000221720ustar00rootroot00000000000000import subprocess import sys def test_borgmatic_command_with_invalid_flag_shows_error_but_not_traceback(): output = subprocess.run( 'borgmatic -v 2 --invalid'.split(' '), stdout=subprocess.PIPE, stderr=subprocess.STDOUT ).stdout.decode(sys.stdout.encoding) assert 'Unrecognized argument' in output assert 'Traceback' not in output borgmatic/tests/end-to-end/test_override.py000066400000000000000000000037011476361726000213660ustar00rootroot00000000000000import os import shutil import subprocess import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing (including injecting the given repository path and tacking on an encryption passphrase). ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- ssh://user@backupserver/./{fqdn}', '') # noqa: FS003 .replace('- /var/local/backups/local.borg', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + 'encryption_passphrase: "test"' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_override_gets_normalized(): temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run borgmatic with an override structured for an outdated config file format. If # normalization is working, it should get normalized and shouldn't error. subprocess.check_call( f'borgmatic create --config {config_path} --override hooks.healthchecks=http://localhost:8888/someuuid'.split( ' ' ) ) finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/test_passcommand.py000066400000000000000000000042771476361726000220650ustar00rootroot00000000000000import json import os import shutil import subprocess import sys import tempfile def generate_configuration(config_path, repository_path): ''' Generate borgmatic configuration into a file at the config path, and update the defaults so as to work for testing, including updating the source directories, injecting the given repository path, and tacking on an encryption passcommand. ''' subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = ( open(config_path) .read() .replace('ssh://user@backupserver/./sourcehostname.borg', repository_path) .replace('- path: /mnt/backup', '') .replace('label: local', '') .replace('- /home/user/path with spaces', '') .replace('- /home', f'- {config_path}') .replace('- /etc', '') .replace('- /var/log/syslog*', '') + '\nencryption_passcommand: "echo test"' ) config_file = open(config_path, 'w') config_file.write(config) config_file.close() def test_borgmatic_command(): # Create a Borg repository. temporary_directory = tempfile.mkdtemp() repository_path = os.path.join(temporary_directory, 'test.borg') extract_path = os.path.join(temporary_directory, 'extract') original_working_directory = os.getcwd() os.mkdir(extract_path) os.chdir(extract_path) try: config_path = os.path.join(temporary_directory, 'test.yaml') generate_configuration(config_path, repository_path) subprocess.check_call( f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ') ) # Run borgmatic to generate a backup archive, and then list it to make sure it exists. subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' ')) output = subprocess.check_output( f'borgmatic --config {config_path} list --json'.split(' ') ).decode(sys.stdout.encoding) parsed_output = json.loads(output) assert len(parsed_output) == 1 assert len(parsed_output[0]['archives']) == 1 finally: os.chdir(original_working_directory) shutil.rmtree(temporary_directory) borgmatic/tests/end-to-end/test_validate_config.py000066400000000000000000000031361476361726000226670ustar00rootroot00000000000000import os import subprocess import sys import tempfile def test_validate_config_command_with_valid_configuration_succeeds(): with tempfile.TemporaryDirectory() as temporary_directory: config_path = os.path.join(temporary_directory, 'test.yaml') subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) exit_code = subprocess.call(f'borgmatic config validate --config {config_path}'.split(' ')) assert exit_code == 0 def test_validate_config_command_with_invalid_configuration_fails(): with tempfile.TemporaryDirectory() as temporary_directory: config_path = os.path.join(temporary_directory, 'test.yaml') subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) config = open(config_path).read().replace('keep_daily: 7', 'keep_daily: "7"') config_file = open(config_path, 'w') config_file.write(config) config_file.close() exit_code = subprocess.call(f'borgmatic config validate --config {config_path}'.split(' ')) assert exit_code == 1 def test_validate_config_command_with_show_flag_displays_configuration(): with tempfile.TemporaryDirectory() as temporary_directory: config_path = os.path.join(temporary_directory, 'test.yaml') subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' ')) output = subprocess.check_output( f'borgmatic config validate --config {config_path} --show'.split(' ') ).decode(sys.stdout.encoding) assert 'repositories:' in output borgmatic/tests/integration/000077500000000000000000000000001476361726000165265ustar00rootroot00000000000000borgmatic/tests/integration/__init__.py000066400000000000000000000000001476361726000206250ustar00rootroot00000000000000borgmatic/tests/integration/actions/000077500000000000000000000000001476361726000201665ustar00rootroot00000000000000borgmatic/tests/integration/actions/__init__.py000066400000000000000000000000001476361726000222650ustar00rootroot00000000000000borgmatic/tests/integration/actions/config/000077500000000000000000000000001476361726000214335ustar00rootroot00000000000000borgmatic/tests/integration/actions/config/__init__.py000066400000000000000000000000001476361726000235320ustar00rootroot00000000000000borgmatic/tests/integration/actions/config/test_validate.py000066400000000000000000000021001476361726000246260ustar00rootroot00000000000000import argparse from flexmock import flexmock import borgmatic.logger from borgmatic.actions.config import validate as module def test_run_validate_with_show_renders_configurations(): log_lines = [] borgmatic.logger.add_custom_log_levels() def fake_logger_answer(message): log_lines.append(message) flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer) module.run_validate(argparse.Namespace(show=True), {'test.yaml': {'foo': {'bar': 'baz'}}}) assert log_lines == ['''foo:\n bar: baz\n'''] def test_run_validate_with_show_and_multiple_configs_renders_each(): log_lines = [] borgmatic.logger.add_custom_log_levels() def fake_logger_answer(message): log_lines.append(message) flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer) module.run_validate( argparse.Namespace(show=True), {'test.yaml': {'foo': {'bar': 'baz'}}, 'other.yaml': {'quux': 'value'}}, ) assert log_lines == ['---', 'foo:\n bar: baz\n', '---', 'quux: value\n'] borgmatic/tests/integration/borg/000077500000000000000000000000001476361726000174575ustar00rootroot00000000000000borgmatic/tests/integration/borg/test_commands.py000066400000000000000000000124611476361726000226750ustar00rootroot00000000000000import argparse import copy from flexmock import flexmock import borgmatic.borg.info import borgmatic.borg.list import borgmatic.borg.mount import borgmatic.borg.prune import borgmatic.borg.repo_list import borgmatic.borg.transfer import borgmatic.commands.arguments def assert_command_does_not_duplicate_flags(command, *args, **kwargs): ''' Assert that the given Borg command sequence does not contain any duplicated flags, e.g. "--match-archives" twice anywhere in the command. ''' flag_counts = {} for flag_name in command: if not flag_name.startswith('--'): continue if flag_name in flag_counts: flag_counts[flag_name] += 1 else: flag_counts[flag_name] = 1 assert flag_counts == { flag_name: 1 for flag_name in flag_counts }, f"Duplicate flags found in: {' '.join(command)}" if '--json' in command: return '{}' def fuzz_argument(arguments, argument_name): ''' Given an argparse.Namespace instance of arguments and an argument name in it, copy the arguments namespace and set the argument name in the copy with a fake value. Return the copied arguments. This is useful for "fuzzing" a unit under test by passing it each possible argument in turn, making sure it doesn't blow up or duplicate Borg arguments. ''' arguments_copy = copy.copy(arguments) value = getattr(arguments_copy, argument_name) setattr(arguments_copy, argument_name, not value if isinstance(value, bool) else 'value') return arguments_copy def test_transfer_archives_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments( 'transfer', '--source-repository', 'foo' )['transfer'] flexmock(borgmatic.borg.transfer).should_receive('execute_command').replace_with( assert_command_does_not_duplicate_flags ) for argument_name in dir(arguments): if argument_name.startswith('_'): continue borgmatic.borg.transfer.transfer_archives( False, 'repo', {}, '2.3.4', fuzz_argument(arguments, argument_name), global_arguments=flexmock(log_json=False), ) def test_prune_archives_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('prune')['prune'] flexmock(borgmatic.borg.prune).should_receive('execute_command').replace_with( assert_command_does_not_duplicate_flags ) for argument_name in dir(arguments): if argument_name.startswith('_'): continue borgmatic.borg.prune.prune_archives( False, 'repo', {}, '2.3.4', fuzz_argument(arguments, argument_name), argparse.Namespace(log_json=False), ) def test_mount_archive_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('mount', '--mount-point', 'tmp')[ 'mount' ] flexmock(borgmatic.borg.mount).should_receive('execute_command').replace_with( assert_command_does_not_duplicate_flags ) for argument_name in dir(arguments): if argument_name.startswith('_'): continue borgmatic.borg.mount.mount_archive( 'repo', 'archive', fuzz_argument(arguments, argument_name), {}, '2.3.4', argparse.Namespace(log_json=False), ) def test_make_list_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('list')['list'] for argument_name in dir(arguments): if argument_name.startswith('_'): continue command = borgmatic.borg.list.make_list_command( 'repo', {}, '2.3.4', fuzz_argument(arguments, argument_name), argparse.Namespace(log_json=False), ) assert_command_does_not_duplicate_flags(command) def test_make_repo_list_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('repo-list')['repo-list'] for argument_name in dir(arguments): if argument_name.startswith('_'): continue command = borgmatic.borg.repo_list.make_repo_list_command( 'repo', {}, '2.3.4', fuzz_argument(arguments, argument_name), global_arguments=flexmock(log_json=True), ) assert_command_does_not_duplicate_flags(command) def test_display_archives_info_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('info')['info'] flexmock(borgmatic.borg.info).should_receive('execute_command_and_capture_output').replace_with( assert_command_does_not_duplicate_flags ) flexmock(borgmatic.borg.info).should_receive('execute_command').replace_with( assert_command_does_not_duplicate_flags ) for argument_name in dir(arguments): if argument_name.startswith('_'): continue borgmatic.borg.info.display_archives_info( 'repo', {}, '2.3.4', fuzz_argument(arguments, argument_name), argparse.Namespace(log_json=False), ) borgmatic/tests/integration/borg/test_feature.py000066400000000000000000000010421476361726000225200ustar00rootroot00000000000000from borgmatic.borg import feature as module def test_available_true_for_new_enough_borg_version(): assert module.available(module.Feature.COMPACT, '1.3.7') def test_available_true_for_borg_version_introducing_feature(): assert module.available(module.Feature.COMPACT, '1.2.0a2') def test_available_true_for_borg_stable_version_introducing_feature(): assert module.available(module.Feature.COMPACT, '1.2.0') def test_available_false_for_too_old_borg_version(): assert not module.available(module.Feature.COMPACT, '1.1.5') borgmatic/tests/integration/commands/000077500000000000000000000000001476361726000203275ustar00rootroot00000000000000borgmatic/tests/integration/commands/__init__.py000066400000000000000000000000001476361726000224260ustar00rootroot00000000000000borgmatic/tests/integration/commands/completion/000077500000000000000000000000001476361726000225005ustar00rootroot00000000000000borgmatic/tests/integration/commands/completion/__init__.py000066400000000000000000000000001476361726000245770ustar00rootroot00000000000000borgmatic/tests/integration/commands/completion/test_actions.py000066400000000000000000000014531476361726000255540ustar00rootroot00000000000000import borgmatic.commands.arguments from borgmatic.commands.completion import actions as module def test_available_actions_uses_only_subactions_for_action_with_subactions(): ( unused_global_parser, action_parsers, unused_combined_parser, ) = borgmatic.commands.arguments.make_parsers() actions = module.available_actions(action_parsers, 'config') assert 'bootstrap' in actions assert 'list' not in actions def test_available_actions_omits_subactions_for_action_without_subactions(): ( unused_global_parser, action_parsers, unused_combined_parser, ) = borgmatic.commands.arguments.make_parsers() actions = module.available_actions(action_parsers, 'list') assert 'bootstrap' not in actions assert 'config' in actions borgmatic/tests/integration/commands/completion/test_bash.py000066400000000000000000000002121476361726000250210ustar00rootroot00000000000000from borgmatic.commands.completion import bash as module def test_bash_completion_does_not_raise(): assert module.bash_completion() borgmatic/tests/integration/commands/completion/test_fish.py000066400000000000000000000002121476361726000250350ustar00rootroot00000000000000from borgmatic.commands.completion import fish as module def test_fish_completion_does_not_raise(): assert module.fish_completion() borgmatic/tests/integration/commands/test_arguments.py000066400000000000000000000567611476361726000237640ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.commands import arguments as module def test_parse_arguments_with_no_arguments_uses_defaults(): config_paths = ['default'] flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths) arguments = module.parse_arguments() global_arguments = arguments['global'] assert global_arguments.config_paths == config_paths assert global_arguments.verbosity == 0 assert global_arguments.syslog_verbosity == -2 assert global_arguments.log_file_verbosity == 1 assert global_arguments.monitoring_verbosity == 1 def test_parse_arguments_with_multiple_config_flags_parses_as_list(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--config', 'myconfig', '--config', 'otherconfig') global_arguments = arguments['global'] assert global_arguments.config_paths == ['myconfig', 'otherconfig'] assert global_arguments.verbosity == 0 assert global_arguments.syslog_verbosity == -2 assert global_arguments.log_file_verbosity == 1 assert global_arguments.monitoring_verbosity == 1 def test_parse_arguments_with_action_after_config_path_omits_action(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--config', 'myconfig', 'list', '--json') global_arguments = arguments['global'] assert global_arguments.config_paths == ['myconfig'] assert 'list' in arguments assert arguments['list'].json def test_parse_arguments_with_action_after_config_path_omits_aliased_action(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey') global_arguments = arguments['global'] assert global_arguments.config_paths == ['myconfig'] assert 'repo-create' in arguments assert arguments['repo-create'].encryption_mode == 'repokey' def test_parse_arguments_with_action_and_positional_arguments_after_config_path_omits_action_and_arguments(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--config', 'myconfig', 'borg', 'key', 'export') global_arguments = arguments['global'] assert global_arguments.config_paths == ['myconfig'] assert 'borg' in arguments assert arguments['borg'].options == ['key', 'export'] def test_parse_arguments_with_verbosity_overrides_default(): config_paths = ['default'] flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths) arguments = module.parse_arguments('--verbosity', '1') global_arguments = arguments['global'] assert global_arguments.config_paths == config_paths assert global_arguments.verbosity == 1 assert global_arguments.syslog_verbosity == -2 assert global_arguments.log_file_verbosity == 1 assert global_arguments.monitoring_verbosity == 1 def test_parse_arguments_with_syslog_verbosity_overrides_default(): config_paths = ['default'] flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths) arguments = module.parse_arguments('--syslog-verbosity', '2') global_arguments = arguments['global'] assert global_arguments.config_paths == config_paths assert global_arguments.verbosity == 0 assert global_arguments.syslog_verbosity == 2 assert global_arguments.log_file_verbosity == 1 assert global_arguments.monitoring_verbosity == 1 def test_parse_arguments_with_log_file_verbosity_overrides_default(): config_paths = ['default'] flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths) arguments = module.parse_arguments('--log-file-verbosity', '-1') global_arguments = arguments['global'] assert global_arguments.config_paths == config_paths assert global_arguments.verbosity == 0 assert global_arguments.syslog_verbosity == -2 assert global_arguments.log_file_verbosity == -1 assert global_arguments.monitoring_verbosity == 1 def test_parse_arguments_with_single_override_parses(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--override', 'foo.bar=baz') global_arguments = arguments['global'] assert global_arguments.overrides == ['foo.bar=baz'] def test_parse_arguments_with_multiple_overrides_flags_parses(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments( '--override', 'foo.bar=baz', '--override', 'foo.quux=7', '--override', 'this.that=8' ) global_arguments = arguments['global'] assert global_arguments.overrides == ['foo.bar=baz', 'foo.quux=7', 'this.that=8'] def test_parse_arguments_with_list_json_overrides_default(): arguments = module.parse_arguments('list', '--json') assert 'list' in arguments assert arguments['list'].json is True def test_parse_arguments_with_no_actions_defaults_to_all_actions_enabled(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments() assert 'prune' in arguments assert 'create' in arguments assert 'check' in arguments def test_parse_arguments_with_no_actions_passes_argument_to_relevant_actions(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--stats', '--list') assert 'prune' in arguments assert arguments['prune'].stats assert arguments['prune'].list_archives assert 'create' in arguments assert arguments['create'].stats assert arguments['create'].list_files assert 'check' in arguments def test_parse_arguments_with_help_and_no_actions_shows_global_help(capsys): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit) as exit: module.parse_arguments('--help') assert exit.value.code == 0 captured = capsys.readouterr() assert 'global arguments:' in captured.out assert 'actions:' in captured.out def test_parse_arguments_with_help_and_action_shows_action_help(capsys): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit) as exit: module.parse_arguments('create', '--help') assert exit.value.code == 0 captured = capsys.readouterr() assert 'global arguments:' not in captured.out assert 'actions:' not in captured.out assert 'create arguments:' in captured.out def test_parse_arguments_with_action_before_global_options_parses_options(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('prune', '--verbosity', '2') assert 'prune' in arguments assert arguments['global'].verbosity == 2 def test_parse_arguments_with_global_options_before_action_parses_options(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('--verbosity', '2', 'prune') assert 'prune' in arguments assert arguments['global'].verbosity == 2 def test_parse_arguments_with_prune_action_leaves_other_actions_disabled(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('prune') assert 'prune' in arguments assert 'create' not in arguments assert 'check' not in arguments def test_parse_arguments_with_multiple_actions_leaves_other_action_disabled(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) arguments = module.parse_arguments('create', 'check') assert 'prune' not in arguments assert 'create' in arguments assert 'check' in arguments def test_parse_arguments_disallows_invalid_argument(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--posix-me-harder') def test_parse_arguments_disallows_encryption_mode_without_init(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--config', 'myconfig', '--encryption', 'repokey') def test_parse_arguments_allows_encryption_mode_with_init(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey') def test_parse_arguments_requires_encryption_mode_with_init(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', 'init') def test_parse_arguments_disallows_append_only_without_init(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--config', 'myconfig', '--append-only') def test_parse_arguments_disallows_storage_quota_without_init(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--config', 'myconfig', '--storage-quota', '5G') def test_parse_arguments_allows_init_and_prune(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'prune') def test_parse_arguments_allows_init_and_create(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'create') def test_parse_arguments_allows_repository_with_extract(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments( '--config', 'myconfig', 'extract', '--repository', 'test.borg', '--archive', 'test' ) def test_parse_arguments_allows_repository_with_mount(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments( '--config', 'myconfig', 'mount', '--repository', 'test.borg', '--archive', 'test', '--mount-point', '/mnt', ) def test_parse_arguments_allows_repository_with_list(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'list', '--repository', 'test.borg') def test_parse_arguments_disallows_archive_unless_action_consumes_it(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--config', 'myconfig', '--archive', 'test') def test_parse_arguments_disallows_paths_unless_action_consumes_it(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--config', 'myconfig', '--path', 'test') def test_parse_arguments_disallows_other_actions_with_config_bootstrap(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('config', 'bootstrap', '--repository', 'test.borg', 'list') def test_parse_arguments_allows_archive_with_extract(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'extract', '--archive', 'test') def test_parse_arguments_allows_archive_with_mount(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments( '--config', 'myconfig', 'mount', '--archive', 'test', '--mount-point', '/mnt' ) def test_parse_arguments_allows_archive_with_restore(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'restore', '--archive', 'test') def test_parse_arguments_allows_archive_with_list(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'list', '--archive', 'test') def test_parse_arguments_requires_archive_with_extract(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', 'extract') def test_parse_arguments_requires_archive_with_restore(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', 'restore') def test_parse_arguments_requires_mount_point_with_mount(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', 'mount', '--archive', 'test') def test_parse_arguments_requires_mount_point_with_umount(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', 'umount') def test_parse_arguments_allows_progress_before_create(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--progress', 'create', 'list') def test_parse_arguments_allows_progress_after_create(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('create', '--progress', 'list') def test_parse_arguments_allows_progress_and_extract(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--progress', 'extract', '--archive', 'test', 'list') def test_parse_arguments_disallows_progress_without_create(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--progress', 'list') def test_parse_arguments_with_stats_and_create_flags_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--stats', 'create', 'list') def test_parse_arguments_with_stats_and_prune_flags_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--stats', 'prune', 'list') def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_value_error(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--stats', 'list') def test_parse_arguments_with_list_and_create_flags_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--list', 'create') def test_parse_arguments_with_list_and_prune_flags_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--list', 'prune') def test_parse_arguments_with_list_flag_but_no_relevant_action_raises_value_error(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--list', 'repo-create') def test_parse_arguments_disallows_list_with_progress_for_create_action(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('create', '--list', '--progress') def test_parse_arguments_disallows_list_with_json_for_create_action(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('create', '--list', '--json') def test_parse_arguments_allows_json_with_list_or_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('list', '--json') module.parse_arguments('info', '--json') def test_parse_arguments_disallows_json_with_both_list_and_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('list', 'info', '--json') def test_parse_arguments_disallows_json_with_both_list_and_repo_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('list', 'repo-info', '--json') def test_parse_arguments_disallows_json_with_both_repo_info_and_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('repo-info', 'info', '--json') def test_parse_arguments_disallows_transfer_with_both_archive_and_match_archives(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments( 'transfer', '--source-repository', 'source.borg', '--archive', 'foo', '--match-archives', 'sh:*bar', ) def test_parse_arguments_disallows_list_with_both_prefix_and_match_archives(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('list', '--prefix', 'foo', '--match-archives', 'sh:*bar') def test_parse_arguments_disallows_repo_list_with_both_prefix_and_match_archives(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('repo-list', '--prefix', 'foo', '--match-archives', 'sh:*bar') def test_parse_arguments_disallows_info_with_both_archive_and_match_archives(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('info', '--archive', 'foo', '--match-archives', 'sh:*bar') def test_parse_arguments_disallows_info_with_both_archive_and_prefix(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('info', '--archive', 'foo', '--prefix', 'bar') def test_parse_arguments_disallows_info_with_both_prefix_and_match_archives(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('info', '--prefix', 'foo', '--match-archives', 'sh:*bar') def test_parse_arguments_check_only_extract_does_not_raise_extract_subparser_error(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('check', '--only', 'extract') def test_parse_arguments_extract_archive_check_does_not_raise_check_subparser_error(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('extract', '--archive', 'check') def test_parse_arguments_extract_with_check_only_extract_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('extract', '--archive', 'name', 'check', '--only', 'extract') def test_parse_arguments_bootstrap_without_config_errors(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('bootstrap') def test_parse_arguments_config_with_no_subaction_errors(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('config') def test_parse_arguments_config_with_help_shows_config_help(capsys): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit) as exit: module.parse_arguments('config', '--help') assert exit.value.code == 0 captured = capsys.readouterr() assert 'global arguments:' not in captured.out assert 'config arguments:' in captured.out assert 'config sub-actions:' in captured.out def test_parse_arguments_config_with_subaction_but_missing_flags_errors(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit) as exit: module.parse_arguments('config', 'bootstrap') assert exit.value.code == 2 def test_parse_arguments_config_with_subaction_and_help_shows_subaction_help(capsys): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit) as exit: module.parse_arguments('config', 'bootstrap', '--help') assert exit.value.code == 0 captured = capsys.readouterr() assert 'config bootstrap arguments:' in captured.out def test_parse_arguments_config_with_subaction_and_required_flags_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('config', 'bootstrap', '--repository', 'repo.borg') def test_parse_arguments_config_with_subaction_and_global_flags_at_start_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--verbosity', '1', 'config', 'bootstrap', '--repository', 'repo.borg') def test_parse_arguments_config_with_subaction_and_global_flags_at_end_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('config', 'bootstrap', '--repository', 'repo.borg', '--verbosity', '1') def test_parse_arguments_config_with_subaction_and_explicit_config_file_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments( 'config', 'bootstrap', '--repository', 'repo.borg', '--config', 'test.yaml' ) def test_parse_arguments_with_borg_action_and_dry_run_raises(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('--dry-run', 'borg', 'list') def test_parse_arguments_with_borg_action_and_no_dry_run_does_not_raise(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('borg', 'list') borgmatic/tests/integration/commands/test_borgmatic.py000066400000000000000000000006601476361726000237110ustar00rootroot00000000000000import subprocess from flexmock import flexmock from borgmatic.commands import borgmatic as module def test_borgmatic_version_matches_news_version(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) borgmatic_version = subprocess.check_output(('borgmatic', '--version')).decode('ascii') news_version = open('NEWS').readline() assert borgmatic_version == news_version borgmatic/tests/integration/commands/test_generate_config.py000066400000000000000000000003261476361726000250600ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.commands import generate_config as module def test_main_does_not_raise(): flexmock(module.borgmatic.commands.borgmatic).should_receive('main') module.main() borgmatic/tests/integration/commands/test_validate_config.py000066400000000000000000000003261476361726000250570ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.commands import validate_config as module def test_main_does_not_raise(): flexmock(module.borgmatic.commands.borgmatic).should_receive('main') module.main() borgmatic/tests/integration/config/000077500000000000000000000000001476361726000177735ustar00rootroot00000000000000borgmatic/tests/integration/config/__init__.py000066400000000000000000000000001476361726000220720ustar00rootroot00000000000000borgmatic/tests/integration/config/test_generate.py000066400000000000000000000212071476361726000232000ustar00rootroot00000000000000import os import sys from io import StringIO import pytest from flexmock import flexmock from borgmatic.config import generate as module def test_insert_newline_before_comment_does_not_raise(): field_name = 'foo' config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)]) config.yaml_set_comment_before_after_key(key=field_name, before='Comment') module.insert_newline_before_comment(config, field_name) def test_comment_out_line_skips_blank_line(): line = ' \n' assert module.comment_out_line(line) == line def test_comment_out_line_skips_already_commented_out_line(): line = ' # foo' assert module.comment_out_line(line) == line def test_comment_out_line_comments_section_name(): line = 'figgy-pudding:' assert module.comment_out_line(line) == '# ' + line def test_comment_out_line_comments_indented_option(): line = ' enabled: true' assert module.comment_out_line(line) == ' # enabled: true' def test_comment_out_line_comments_twice_indented_option(): line = ' - item' assert module.comment_out_line(line) == ' # - item' def test_comment_out_optional_configuration_comments_optional_config_only(): # The "# COMMENT_OUT" comment is a sentinel used to express that the following key is optional. # It's stripped out of the final output. flexmock(module).comment_out_line = lambda line: '# ' + line config = ''' # COMMENT_OUT foo: # COMMENT_OUT bar: - baz - quux repositories: - one - two # This comment should be kept. # COMMENT_OUT other: thing ''' # flake8: noqa expected_config = ''' # foo: # bar: # - baz # - quux repositories: - one - two # This comment should be kept. # other: thing ''' assert module.comment_out_optional_configuration(config.strip()) == expected_config.strip() def test_render_configuration_converts_configuration_to_yaml_string(): yaml_string = module.render_configuration({'foo': 'bar'}) assert yaml_string == 'foo: bar\n' def test_write_configuration_does_not_raise(): flexmock(os.path).should_receive('exists').and_return(False) flexmock(os).should_receive('makedirs') builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').and_return(StringIO()) flexmock(os).should_receive('chmod') module.write_configuration('config.yaml', 'config: yaml') def test_write_configuration_with_already_existing_file_raises(): flexmock(os.path).should_receive('exists').and_return(True) with pytest.raises(FileExistsError): module.write_configuration('config.yaml', 'config: yaml') def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise(): flexmock(os.path).should_receive('exists').and_return(True) module.write_configuration('/tmp/config.yaml', 'config: yaml', overwrite=True) def test_write_configuration_with_already_existing_directory_does_not_raise(): flexmock(os.path).should_receive('exists').and_return(False) flexmock(os).should_receive('makedirs').and_raise(FileExistsError) builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').and_return(StringIO()) flexmock(os).should_receive('chmod') module.write_configuration('config.yaml', 'config: yaml') def test_add_comments_to_configuration_sequence_of_strings_does_not_raise(): config = module.ruamel.yaml.comments.CommentedSeq(['foo', 'bar']) schema = {'type': 'array', 'items': {'type': 'string'}} module.add_comments_to_configuration_sequence(config, schema) def test_add_comments_to_configuration_sequence_of_maps_does_not_raise(): config = module.ruamel.yaml.comments.CommentedSeq( [module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])] ) schema = { 'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {'description': 'yo'}}}, } module.add_comments_to_configuration_sequence(config, schema) def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise(): config = module.ruamel.yaml.comments.CommentedSeq( [module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])] ) schema = {'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {}}}} module.add_comments_to_configuration_sequence(config, schema) def test_add_comments_to_configuration_object_does_not_raise(): # Ensure that it can deal with fields both in the schema and missing from the schema. config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)]) schema = { 'type': 'object', 'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}}, } module.add_comments_to_configuration_object(config, schema) def test_add_comments_to_configuration_object_with_skip_first_does_not_raise(): config = module.ruamel.yaml.comments.CommentedMap([('foo', 33)]) schema = {'type': 'object', 'properties': {'foo': {'description': 'Foo'}}} module.add_comments_to_configuration_object(config, schema, skip_first=True) def test_remove_commented_out_sentinel_keeps_other_comments(): field_name = 'foo' config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)]) config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT') module.remove_commented_out_sentinel(config, field_name) comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX] assert len(comments) == 1 assert comments[0].value == '# Actual comment.\n' def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments(): field_name = 'foo' config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)]) config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.') module.remove_commented_out_sentinel(config, field_name) comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX] assert len(comments) == 1 assert comments[0].value == '# Actual comment.\n' def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise(): field_name = 'foo' config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)]) config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.') module.remove_commented_out_sentinel(config, 'unknown') def test_generate_sample_configuration_does_not_raise(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('schema.yaml').and_return('') flexmock(module.ruamel.yaml).should_receive('YAML').and_return( flexmock(load=lambda filename: {}) ) flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('comment_out_optional_configuration') flexmock(module).should_receive('write_configuration') module.generate_sample_configuration(False, None, 'dest.yaml', 'schema.yaml') def test_generate_sample_configuration_with_source_filename_does_not_raise(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('schema.yaml').and_return('') flexmock(module.ruamel.yaml).should_receive('YAML').and_return( flexmock(load=lambda filename: {}) ) flexmock(module.load).should_receive('load_configuration') flexmock(module.normalize).should_receive('normalize') flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('comment_out_optional_configuration') flexmock(module).should_receive('write_configuration') module.generate_sample_configuration(False, 'source.yaml', 'dest.yaml', 'schema.yaml') def test_generate_sample_configuration_with_dry_run_does_not_write_file(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('schema.yaml').and_return('') flexmock(module.ruamel.yaml).should_receive('YAML').and_return( flexmock(load=lambda filename: {}) ) flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('comment_out_optional_configuration') flexmock(module).should_receive('write_configuration').never() module.generate_sample_configuration(True, None, 'dest.yaml', 'schema.yaml') borgmatic/tests/integration/config/test_load.py000066400000000000000000001265561476361726000223420ustar00rootroot00000000000000import io import sys import pytest from flexmock import flexmock from borgmatic.config import load as module def test_load_configuration_parses_contents(): builtins = flexmock(sys.modules['builtins']) config_file = io.StringIO('key: value') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == {'key': 'value'} assert config_paths == {'config.yaml', 'other.yaml'} def test_load_configuration_with_only_integer_value_does_not_raise(): builtins = flexmock(sys.modules['builtins']) config_file = io.StringIO('33') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == 33 assert config_paths == {'config.yaml', 'other.yaml'} def test_load_configuration_inlines_include_relative_to_current_directory(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO('value') include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO('key: !include include.yaml') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == {'key': 'value'} assert config_paths == {'config.yaml', '/tmp/include.yaml', 'other.yaml'} def test_load_configuration_inlines_include_relative_to_config_parent_directory(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True) flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True) flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False) flexmock(module.os.path).should_receive('exists').with_args('/tmp/include.yaml').and_return( False ) flexmock(module.os.path).should_receive('exists').with_args('/etc/include.yaml').and_return( True ) include_file = io.StringIO('value') include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/etc/include.yaml').and_return(include_file) config_file = io.StringIO('key: !include include.yaml') config_file.name = '/etc/config.yaml' builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('/etc/config.yaml', config_paths) == {'key': 'value'} assert config_paths == {'/etc/config.yaml', '/etc/include.yaml', 'other.yaml'} def test_load_configuration_raises_if_relative_include_does_not_exist(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True) flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True) flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(False) config_file = io.StringIO('key: !include include.yaml') config_file.name = '/etc/config.yaml' builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file) config_paths = set() with pytest.raises(FileNotFoundError): module.load_configuration('/etc/config.yaml', config_paths) def test_load_configuration_inlines_absolute_include(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(True) flexmock(module.os.path).should_receive('exists').never() include_file = io.StringIO('value') include_file.name = '/root/include.yaml' builtins.should_receive('open').with_args('/root/include.yaml').and_return(include_file) config_file = io.StringIO('key: !include /root/include.yaml') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == {'key': 'value'} assert config_paths == {'config.yaml', '/root/include.yaml', 'other.yaml'} def test_load_configuration_raises_if_absolute_include_does_not_exist(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(True) builtins.should_receive('open').with_args('/root/include.yaml').and_raise(FileNotFoundError) config_file = io.StringIO('key: !include /root/include.yaml') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(FileNotFoundError): assert module.load_configuration('config.yaml', config_paths) def test_load_configuration_inlines_multiple_file_include_as_list(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(True) flexmock(module.os.path).should_receive('exists').never() include1_file = io.StringIO('value1') include1_file.name = '/root/include1.yaml' builtins.should_receive('open').with_args('/root/include1.yaml').and_return(include1_file) include2_file = io.StringIO('value2') include2_file.name = '/root/include2.yaml' builtins.should_receive('open').with_args('/root/include2.yaml').and_return(include2_file) config_file = io.StringIO('key: !include [/root/include1.yaml, /root/include2.yaml]') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == {'key': ['value2', 'value1']} assert config_paths == { 'config.yaml', '/root/include1.yaml', '/root/include2.yaml', 'other.yaml', } def test_load_configuration_include_with_unsupported_filename_type_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(True) flexmock(module.os.path).should_receive('exists').never() config_file = io.StringIO('key: !include {path: /root/include.yaml}') config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_merges_include(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' foo: bar baz: quux ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' foo: override <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == { 'foo': 'override', 'baz': 'quux', } assert config_paths == {'config.yaml', '/tmp/include.yaml', 'other.yaml'} def test_load_configuration_merges_multiple_file_include(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include1_file = io.StringIO( ''' foo: bar baz: quux original: yes ''' ) include1_file.name = 'include1.yaml' builtins.should_receive('open').with_args('/tmp/include1.yaml').and_return(include1_file) include2_file = io.StringIO( ''' baz: second ''' ) include2_file.name = 'include2.yaml' builtins.should_receive('open').with_args('/tmp/include2.yaml').and_return(include2_file) config_file = io.StringIO( ''' foo: override <<: !include [include1.yaml, include2.yaml] ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == { 'foo': 'override', 'baz': 'second', 'original': 'yes', } assert config_paths == {'config.yaml', '/tmp/include1.yaml', '/tmp/include2.yaml', 'other.yaml'} def test_load_configuration_with_retain_tag_merges_include_but_keeps_local_values(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: foo: bar baz: quux other: a: b c: d ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: !retain foo: override other: a: override <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == { 'stuff': {'foo': 'override'}, 'other': {'a': 'override', 'c': 'd'}, } assert config_paths == {'config.yaml', '/tmp/include.yaml', 'other.yaml'} def test_load_configuration_with_retain_tag_but_without_merge_include_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: !retain foo: bar baz: quux ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: foo: override <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_with_retain_tag_on_scalar_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: foo: bar baz: quux ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: foo: !retain override <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_with_omit_tag_merges_include_and_omits_requested_values(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: - a - b - c ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: - x - !omit b - y <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == {'stuff': ['a', 'c', 'x', 'y']} assert config_paths == {'config.yaml', '/tmp/include.yaml', 'other.yaml'} def test_load_configuration_with_omit_tag_on_unknown_value_merges_include_and_does_not_raise(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: - a - b - c ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: - x - !omit q - y <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = {'other.yaml'} assert module.load_configuration('config.yaml', config_paths) == { 'stuff': ['a', 'b', 'c', 'x', 'y'] } assert config_paths == {'config.yaml', '/tmp/include.yaml', 'other.yaml'} def test_load_configuration_with_omit_tag_on_non_list_item_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: - a - b - c ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: !omit - x - y <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_with_omit_tag_on_non_scalar_list_item_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: - foo: bar baz: quux ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: - !omit foo: bar baz: quux <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_with_omit_tag_but_without_merge_raises(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' stuff: - a - !omit b - c ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' stuff: - x - y <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(ValueError): module.load_configuration('config.yaml', config_paths) def test_load_configuration_does_not_merge_include_list(): builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) include_file = io.StringIO( ''' - one - two ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config_file = io.StringIO( ''' foo: bar repositories: <<: !include include.yaml ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) config_paths = set() with pytest.raises(module.ruamel.yaml.error.YAMLError): assert module.load_configuration('config.yaml', config_paths) @pytest.mark.parametrize( 'node_class', ( module.ruamel.yaml.nodes.MappingNode, module.ruamel.yaml.nodes.SequenceNode, module.ruamel.yaml.nodes.ScalarNode, ), ) def test_raise_retain_node_error_raises(node_class): with pytest.raises(ValueError): module.raise_retain_node_error( loader=flexmock(), node=node_class(tag=flexmock(), value=flexmock()) ) def test_raise_omit_node_error_raises(): with pytest.raises(ValueError): module.raise_omit_node_error(loader=flexmock(), node=flexmock()) def test_filter_omitted_nodes_discards_values_with_omit_tag_and_also_equal_values(): nodes = [flexmock(), flexmock()] values = [ module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='b'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'), module.ruamel.yaml.nodes.ScalarNode(tag='!omit', value='b'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'), ] result = module.filter_omitted_nodes(nodes, values) assert [item.value for item in result] == ['a', 'c', 'a', 'c'] def test_filter_omitted_nodes_keeps_all_values_when_given_only_one_node(): nodes = [flexmock()] values = [ module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='b'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'), module.ruamel.yaml.nodes.ScalarNode(tag='!omit', value='b'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'), ] result = module.filter_omitted_nodes(nodes, values) assert [item.value for item in result] == ['a', 'b', 'c', 'a', 'b', 'c'] def test_merge_values_combines_mapping_values(): nodes = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_hourly' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='24' ), ), ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='25' ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_nanosecondly' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='1000' ), ), ], ), ), ] values = module.merge_values(nodes) assert len(values) == 4 assert values[0][0].value == 'keep_hourly' assert values[0][1].value == '24' assert values[1][0].value == 'keep_daily' assert values[1][1].value == '7' assert values[2][0].value == 'keep_daily' assert values[2][1].value == '25' assert values[3][0].value == 'keep_nanosecondly' assert values[3][1].value == '1000' def test_merge_values_combines_sequence_values(): nodes = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='1'), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='2'), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='3'), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='4'), ], ), ), ] values = module.merge_values(nodes) assert len(values) == 4 assert values[0].value == '1' assert values[1].value == '2' assert values[2].value == '3' assert values[3].value == '4' def test_deep_merge_nodes_replaces_colliding_scalar_values(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_hourly' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='24' ), ), ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'retention' options = section_value.value assert len(options) == 2 assert options[0][0].value == 'keep_daily' assert options[0][1].value == '5' assert options[1][0].value == 'keep_hourly' assert options[1][1].value == '24' def test_deep_merge_nodes_keeps_non_colliding_scalar_values(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_hourly' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='24' ), ), ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_minutely' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='10' ), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'retention' options = section_value.value assert len(options) == 3 assert options[0][0].value == 'keep_daily' assert options[0][1].value == '7' assert options[1][0].value == 'keep_hourly' assert options[1][1].value == '24' assert options[2][0].value == 'keep_minutely' assert options[2][1].value == '10' def test_deep_merge_nodes_keeps_deeply_nested_values(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='lock_wait' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'), ), ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='extra_borg_options' ), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='init' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='--init-option' ), ), ], ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='extra_borg_options' ), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='prune' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='--prune-option' ), ), ], ), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'storage' options = section_value.value assert len(options) == 2 assert options[0][0].value == 'extra_borg_options' assert options[1][0].value == 'lock_wait' assert options[1][1].value == '5' nested_options = options[0][1].value assert len(nested_options) == 2 assert nested_options[0][0].value == 'init' assert nested_options[0][1].value == '--init-option' assert nested_options[1][0].value == 'prune' assert nested_options[1][1].value == '--prune-option' def test_deep_merge_nodes_appends_colliding_sequence_values(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 1' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 2' ), ], ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 3' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 4' ), ], ), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'hooks' options = section_value.value assert len(options) == 1 assert options[0][0].value == 'before_backup' assert [item.value for item in options[0][1].value] == ['echo 1', 'echo 2', 'echo 3', 'echo 4'] def test_deep_merge_nodes_errors_on_colliding_values_of_different_types(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='echo oopsie daisy' ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 3' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 4' ), ], ), ), ], ), ), ] with pytest.raises(ValueError): module.deep_merge_nodes(node_values) def test_deep_merge_nodes_only_keeps_mapping_values_tagged_with_retain(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_hourly' ), module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:int', value='24' ), ), ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'), module.ruamel.yaml.nodes.MappingNode( tag='!retain', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='keep_daily' ), module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'retention' assert section_value.tag == 'tag:yaml.org,2002:map' options = section_value.value assert len(options) == 1 assert options[0][0].value == 'keep_daily' assert options[0][1].value == '5' def test_deep_merge_nodes_only_keeps_sequence_values_tagged_with_retain(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 1' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 2' ), ], ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='!retain', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 3' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 4' ), ], ), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'hooks' options = section_value.value assert len(options) == 1 assert options[0][0].value == 'before_backup' assert options[0][1].tag == 'tag:yaml.org,2002:seq' assert [item.value for item in options[0][1].value] == ['echo 3', 'echo 4'] def test_deep_merge_nodes_skips_sequence_values_tagged_with_omit(): node_values = [ ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 1' ), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 2' ), ], ), ), ], ), ), ( module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'), module.ruamel.yaml.nodes.MappingNode( tag='tag:yaml.org,2002:map', value=[ ( module.ruamel.yaml.nodes.ScalarNode( tag='tag:yaml.org,2002:str', value='before_backup' ), module.ruamel.yaml.nodes.SequenceNode( tag='tag:yaml.org,2002:seq', value=[ module.ruamel.yaml.ScalarNode(tag='!omit', value='echo 2'), module.ruamel.yaml.ScalarNode( tag='tag:yaml.org,2002:str', value='echo 3' ), ], ), ), ], ), ), ] result = module.deep_merge_nodes(node_values) assert len(result) == 1 (section_key, section_value) = result[0] assert section_key.value == 'hooks' options = section_value.value assert len(options) == 1 assert options[0][0].value == 'before_backup' assert [item.value for item in options[0][1].value] == ['echo 1', 'echo 3'] borgmatic/tests/integration/config/test_override.py000066400000000000000000000034221476361726000232240ustar00rootroot00000000000000import pytest from borgmatic.config import override as module @pytest.mark.parametrize( 'value,expected_result,option_type', ( ('thing', 'thing', 'string'), ('33', 33, 'integer'), ('33', '33', 'string'), ('33b', '33b', 'integer'), ('33b', '33b', 'string'), ('true', True, 'boolean'), ('false', False, 'boolean'), ('true', 'true', 'string'), ('[foo]', ['foo'], 'array'), ('[foo]', '[foo]', 'string'), ('[foo, bar]', ['foo', 'bar'], 'array'), ('[foo, bar]', '[foo, bar]', 'string'), ), ) def test_convert_value_type_coerces_values(value, expected_result, option_type): assert module.convert_value_type(value, option_type) == expected_result def test_apply_overrides_updates_config(): raw_overrides = [ 'section.key=value1', 'other_section.thing=value2', 'section.nested.key=value3', 'location.no_longer_in_location=value4', 'new.foo=bar', 'new.mylist=[baz]', 'new.nonlist=[quux]', ] config = { 'section': {'key': 'value', 'other': 'other_value'}, 'other_section': {'thing': 'thing_value'}, 'no_longer_in_location': 'because_location_is_deprecated', } schema = { 'properties': { 'new': {'properties': {'mylist': {'type': 'array'}, 'nonlist': {'type': 'string'}}} } } module.apply_overrides(config, schema, raw_overrides) assert config == { 'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}}, 'other_section': {'thing': 'value2'}, 'new': {'foo': 'bar', 'mylist': ['baz'], 'nonlist': '[quux]'}, 'location': {'no_longer_in_location': 'value4'}, 'no_longer_in_location': 'value4', } borgmatic/tests/integration/config/test_schema.py000066400000000000000000000022301476361726000226410ustar00rootroot00000000000000import pkgutil import borgmatic.actions import borgmatic.config.load import borgmatic.config.validate MAXIMUM_LINE_LENGTH = 80 def test_schema_line_length_stays_under_limit(): schema_file = open(borgmatic.config.validate.schema_filename()) for line in schema_file.readlines(): assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH ACTIONS_MODULE_NAMES_TO_OMIT = {'arguments', 'change_passphrase', 'export_key', 'json'} ACTIONS_MODULE_NAMES_TO_ADD = {'key', 'umount'} def test_schema_skip_actions_correspond_to_supported_actions(): ''' Ensure that the allowed actions in the schema's "skip_actions" option don't drift from borgmatic's actual supported actions. ''' schema = borgmatic.config.load.load_configuration(borgmatic.config.validate.schema_filename()) schema_skip_actions = set(schema['properties']['skip_actions']['items']['enum']) supported_actions = { module.name.replace('_', '-') for module in pkgutil.iter_modules(borgmatic.actions.__path__) if module.name not in ACTIONS_MODULE_NAMES_TO_OMIT }.union(ACTIONS_MODULE_NAMES_TO_ADD) assert schema_skip_actions == supported_actions borgmatic/tests/integration/config/test_validate.py000066400000000000000000000175321476361726000232050ustar00rootroot00000000000000import io import os import string import sys import pytest from flexmock import flexmock from borgmatic.config import validate as module def test_schema_filename_returns_plausible_path(): schema_path = module.schema_filename() assert schema_path.endswith('/schema.yaml') def mock_config_and_schema(config_yaml, schema_yaml=None): ''' Set up mocks for the given config config YAML string and the schema YAML string, or the default schema if no schema is provided. The idea is that that the code under test consumes these mocks when parsing the configuration. ''' config_stream = io.StringIO(config_yaml) config_stream.name = 'config.yaml' if schema_yaml is None: schema_stream = open(module.schema_filename()) else: schema_stream = io.StringIO(schema_yaml) schema_stream.name = 'schema.yaml' builtins = flexmock(sys.modules['builtins']) flexmock(module.os).should_receive('getcwd').and_return('/tmp') flexmock(module.os.path).should_receive('isabs').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(True) builtins.should_receive('open').with_args('/tmp/config.yaml').and_return(config_stream) builtins.should_receive('open').with_args('/tmp/schema.yaml').and_return(schema_stream) def test_parse_configuration_transforms_file_into_mapping(): mock_config_and_schema( ''' source_directories: - /home - /etc repositories: - path: hostname.borg keep_minutely: 60 keep_hourly: 24 keep_daily: 7 checks: - name: repository - name: archives ''' ) config, config_paths, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': ['/home', '/etc'], 'repositories': [{'path': 'hostname.borg'}], 'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60, 'checks': [{'name': 'repository'}, {'name': 'archives'}], 'bootstrap': {}, } assert config_paths == {'/tmp/config.yaml'} assert logs == [] def test_parse_configuration_passes_through_quoted_punctuation(): escaped_punctuation = string.punctuation.replace('\\', r'\\').replace('"', r'\"') mock_config_and_schema( f''' source_directories: - "/home/{escaped_punctuation}" repositories: - path: test.borg ''' ) config, config_paths, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': [f'/home/{string.punctuation}'], 'repositories': [{'path': 'test.borg'}], 'bootstrap': {}, } assert config_paths == {'/tmp/config.yaml'} assert logs == [] def test_parse_configuration_with_schema_lacking_examples_does_not_raise(): mock_config_and_schema( ''' source_directories: - /home repositories: - path: hostname.borg ''', ''' map: source_directories: required: true seq: - type: scalar repositories: required: true seq: - type: scalar ''', ) module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') def test_parse_configuration_inlines_include_inside_deprecated_section(): mock_config_and_schema( ''' source_directories: - /home repositories: - path: hostname.borg retention: !include include.yaml ''' ) builtins = flexmock(sys.modules['builtins']) include_file = io.StringIO( ''' keep_daily: 7 keep_hourly: 24 ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config, config_paths, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'hostname.borg'}], 'keep_daily': 7, 'keep_hourly': 24, 'bootstrap': {}, } assert config_paths == {'/tmp/include.yaml', '/tmp/config.yaml'} assert len(logs) == 1 def test_parse_configuration_merges_include(): mock_config_and_schema( ''' source_directories: - /home repositories: - path: hostname.borg keep_daily: 1 <<: !include include.yaml ''' ) builtins = flexmock(sys.modules['builtins']) include_file = io.StringIO( ''' keep_daily: 7 keep_hourly: 24 ''' ) include_file.name = 'include.yaml' builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file) config, config_paths, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'hostname.borg'}], 'keep_daily': 1, 'keep_hourly': 24, 'bootstrap': {}, } assert config_paths == {'/tmp/include.yaml', '/tmp/config.yaml'} assert logs == [] def test_parse_configuration_raises_for_missing_config_file(): with pytest.raises(FileNotFoundError): module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') def test_parse_configuration_raises_for_missing_schema_file(): mock_config_and_schema('') builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/tmp/config.yaml').and_return( io.StringIO('foo: bar') ) builtins.should_receive('open').with_args('/tmp/schema.yaml').and_raise(FileNotFoundError) with pytest.raises(FileNotFoundError): module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') def test_parse_configuration_raises_for_syntax_error(): mock_config_and_schema('foo:\nbar') with pytest.raises(ValueError): module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') def test_parse_configuration_raises_for_validation_error(): mock_config_and_schema( ''' source_directories: yes repositories: - path: hostname.borg ''' ) with pytest.raises(module.Validation_error): module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') def test_parse_configuration_applies_overrides(): mock_config_and_schema( ''' source_directories: - /home repositories: - path: hostname.borg local_path: borg1 ''' ) config, config_paths, logs = module.parse_configuration( '/tmp/config.yaml', '/tmp/schema.yaml', overrides=['local_path=borg2'] ) assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'hostname.borg'}], 'local_path': 'borg2', 'bootstrap': {}, } assert config_paths == {'/tmp/config.yaml'} assert logs == [] def test_parse_configuration_applies_normalization_after_environment_variable_interpolation(): mock_config_and_schema( ''' location: source_directories: - /home repositories: - ${NO_EXIST:-user@hostname:repo} exclude_if_present: .nobackup ''' ) flexmock(os).should_receive('getenv').replace_with(lambda variable_name, default: default) config, config_paths, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'ssh://user@hostname/./repo'}], 'exclude_if_present': ['.nobackup'], 'bootstrap': {}, } assert config_paths == {'/tmp/config.yaml'} assert logs borgmatic/tests/integration/hooks/000077500000000000000000000000001476361726000176515ustar00rootroot00000000000000borgmatic/tests/integration/hooks/__init__.py000066400000000000000000000000001476361726000217500ustar00rootroot00000000000000borgmatic/tests/integration/hooks/monitoring/000077500000000000000000000000001476361726000220365ustar00rootroot00000000000000borgmatic/tests/integration/hooks/monitoring/__init__.py000066400000000000000000000000001476361726000241350ustar00rootroot00000000000000borgmatic/tests/integration/hooks/monitoring/test_apprise.py000066400000000000000000000015401476361726000251120ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks.monitoring import apprise as module def test_destroy_monitor_removes_apprise_handler(): logger = logging.getLogger() original_handlers = list(logger.handlers) module.borgmatic.hooks.monitoring.logs.add_handler( module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler( identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1 ) ) module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock()) assert logger.handlers == original_handlers def test_destroy_monitor_without_apprise_handler_does_not_raise(): logger = logging.getLogger() original_handlers = list(logger.handlers) module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock()) assert logger.handlers == original_handlers borgmatic/tests/integration/hooks/monitoring/test_healthchecks.py000066400000000000000000000015571476361726000261050ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks.monitoring import healthchecks as module def test_destroy_monitor_removes_healthchecks_handler(): logger = logging.getLogger() original_handlers = list(logger.handlers) module.borgmatic.hooks.monitoring.logs.add_handler( module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler( identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1 ) ) module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock()) assert logger.handlers == original_handlers def test_destroy_monitor_without_healthchecks_handler_does_not_raise(): logger = logging.getLogger() original_handlers = list(logger.handlers) module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock()) assert logger.handlers == original_handlers borgmatic/tests/integration/hooks/monitoring/test_loki.py000066400000000000000000000060671476361726000244160ustar00rootroot00000000000000import logging import platform from flexmock import flexmock from borgmatic.hooks.monitoring import loki as module def test_initialize_monitor_replaces_labels(): ''' Assert that label placeholders get replaced. ''' hook_config = { 'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'hostname': '__hostname', 'config': '__config', 'config_full': '__config_path'}, } config_filename = '/mock/path/test.yaml' dry_run = True module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run) for handler in tuple(logging.getLogger().handlers): if isinstance(handler, module.Loki_log_handler): assert handler.buffer.root['streams'][0]['stream']['hostname'] == platform.node() assert handler.buffer.root['streams'][0]['stream']['config'] == 'test.yaml' assert handler.buffer.root['streams'][0]['stream']['config_full'] == config_filename return assert False def test_initialize_monitor_adds_log_handler(): ''' Assert that calling initialize_monitor adds our logger to the root logger. ''' hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}} module.initialize_monitor( hook_config, flexmock(), config_filename='test.yaml', monitoring_log_level=flexmock(), dry_run=True, ) for handler in tuple(logging.getLogger().handlers): if isinstance(handler, module.Loki_log_handler): return assert False def test_ping_monitor_adds_log_message(): ''' Assert that calling ping_monitor adds a message to our logger. ''' hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}} config_filename = 'test.yaml' dry_run = True module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run) module.ping_monitor( hook_config, flexmock(), config_filename, module.monitor.State.FINISH, flexmock(), dry_run ) for handler in tuple(logging.getLogger().handlers): if isinstance(handler, module.Loki_log_handler): assert any( map( lambda log: log == f'{module.MONITOR_STATE_TO_LOKI[module.monitor.State.FINISH]} backup', map(lambda value: value[1], handler.buffer.root['streams'][0]['values']), ) ) return assert False def test_destroy_monitor_removes_log_handler(): ''' Assert that destroy_monitor removes the logger from the root logger. ''' hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}} config_filename = 'test.yaml' dry_run = True module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run) module.destroy_monitor(hook_config, flexmock(), flexmock(), dry_run) for handler in tuple(logging.getLogger().handlers): if isinstance(handler, module.Loki_log_handler): assert False borgmatic/tests/integration/test_execute.py000066400000000000000000000325471476361726000216140ustar00rootroot00000000000000import logging import subprocess import sys import pytest from flexmock import flexmock from borgmatic import execute as module def test_log_outputs_logs_each_line_separately(): flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').once() flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) hi_process = subprocess.Popen(['echo', 'hi'], stdout=subprocess.PIPE) flexmock(module).should_receive('output_buffer_for_process').with_args( hi_process, () ).and_return(hi_process.stdout) there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE) flexmock(module).should_receive('output_buffer_for_process').with_args( there_process, () ).and_return(there_process.stdout) module.log_outputs( (hi_process, there_process), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_skips_logs_for_process_with_none_stdout(): flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').never() flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) hi_process = subprocess.Popen(['echo', 'hi'], stdout=None) flexmock(module).should_receive('output_buffer_for_process').with_args( hi_process, () ).and_return(hi_process.stdout) there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE) flexmock(module).should_receive('output_buffer_for_process').with_args( there_process, () ).and_return(there_process.stdout) module.log_outputs( (hi_process, there_process), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_returns_output_without_logging_for_output_log_level_none(): flexmock(module.logger).should_receive('log').never() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) hi_process = subprocess.Popen(['echo', 'hi'], stdout=subprocess.PIPE) flexmock(module).should_receive('output_buffer_for_process').with_args( hi_process, () ).and_return(hi_process.stdout) there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE) flexmock(module).should_receive('output_buffer_for_process').with_args( there_process, () ).and_return(there_process.stdout) captured_outputs = module.log_outputs( (hi_process, there_process), exclude_stdouts=(), output_log_level=None, borg_local_path='borg', borg_exit_codes=None, ) assert captured_outputs == {hi_process: 'hi', there_process: 'there'} def test_log_outputs_includes_error_output_in_exception(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR) flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) with pytest.raises(subprocess.CalledProcessError) as error: module.log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) assert error.value.output def test_log_outputs_logs_multiline_error_output(): ''' Make sure that all error output lines get logged, not just (for instance) the first few lines of a process' traceback. ''' flexmock(module.logger).should_receive('log') flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR) flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen( ['python', '-c', 'foopydoo'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) flexmock(module.logger).should_call('log').at_least().times(3) with pytest.raises(subprocess.CalledProcessError): module.log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_skips_error_output_in_exception_for_process_with_none_stdout(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR) flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen(['grep'], stdout=None) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) with pytest.raises(subprocess.CalledProcessError) as error: module.log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) assert error.value.returncode == 2 assert not error.value.output def test_log_outputs_kills_other_processes_and_raises_when_one_errors(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], None, 'borg', None, ).and_return(module.Exit_status.SUCCESS) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], 2, 'borg', None, ).and_return(module.Exit_status.ERROR) other_process = subprocess.Popen( ['sleep', '2'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('interpret_exit_code').with_args( ['sleep', '2'], None, 'borg', None, ).and_return(module.Exit_status.SUCCESS) flexmock(module).should_receive('output_buffer_for_process').with_args(process, ()).and_return( process.stdout ) flexmock(module).should_receive('output_buffer_for_process').with_args( other_process, () ).and_return(other_process.stdout) flexmock(other_process).should_receive('kill').once() with pytest.raises(subprocess.CalledProcessError) as error: module.log_outputs( (process, other_process), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) assert error.value.returncode == 2 assert error.value.output def test_log_outputs_kills_other_processes_and_returns_when_one_exits_with_warning(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], None, 'borg', None, ).and_return(module.Exit_status.SUCCESS) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], 2, 'borg', None, ).and_return(module.Exit_status.WARNING) other_process = subprocess.Popen( ['sleep', '2'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('interpret_exit_code').with_args( ['sleep', '2'], None, 'borg', None, ).and_return(module.Exit_status.SUCCESS) flexmock(module).should_receive('output_buffer_for_process').with_args(process, ()).and_return( process.stdout ) flexmock(module).should_receive('output_buffer_for_process').with_args( other_process, () ).and_return(other_process.stdout) flexmock(other_process).should_receive('kill').once() module.log_outputs( (process, other_process), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_vents_other_processes_when_one_exits(): ''' Execute a command to generate a longish random string and pipe it into another command that exits quickly. The test is basically to ensure we don't hang forever waiting for the exited process to read the pipe, and that the string-generating process eventually gets vented and exits. ''' flexmock(module.logger).should_receive('log') flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen( [ sys.executable, '-c', "import random, string; print(''.join(random.choice(string.ascii_letters) for _ in range(40000)))", ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) other_process = subprocess.Popen( ['true'], stdin=process.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('output_buffer_for_process').with_args( process, (process.stdout,) ).and_return(process.stderr) flexmock(module).should_receive('output_buffer_for_process').with_args( other_process, (process.stdout,) ).and_return(other_process.stdout) flexmock(process.stdout).should_call('readline').at_least().once() module.log_outputs( (process, other_process), exclude_stdouts=(process.stdout,), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_does_not_error_when_one_process_exits(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen( [ sys.executable, '-c', "import random, string; print(''.join(random.choice(string.ascii_letters) for _ in range(40000)))", ], stdout=None, # Specifically test the case of a process without stdout captured. stderr=None, ) other_process = subprocess.Popen( ['true'], stdin=process.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('output_buffer_for_process').with_args( process, (process.stdout,) ).and_return(process.stderr) flexmock(module).should_receive('output_buffer_for_process').with_args( other_process, (process.stdout,) ).and_return(other_process.stdout) module.log_outputs( (process, other_process), exclude_stdouts=(process.stdout,), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_truncates_long_error_output(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('command_for_process').and_return('grep') process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], None, 'borg', None, ).and_return(module.Exit_status.SUCCESS) flexmock(module).should_receive('interpret_exit_code').with_args( ['grep'], 2, 'borg', None, ).and_return(module.Exit_status.ERROR) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) with pytest.raises(subprocess.CalledProcessError) as error: flexmock(module, ERROR_OUTPUT_MAX_LINE_COUNT=0).log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) assert error.value.returncode == 2 assert error.value.output.startswith('...') def test_log_outputs_with_no_output_logs_nothing(): flexmock(module.logger).should_receive('log').never() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) module.log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) def test_log_outputs_with_unfinished_process_re_polls(): flexmock(module.logger).should_receive('log').never() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) flexmock(process).should_receive('poll').and_return(None).and_return(0).times(3) flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout) module.log_outputs( (process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg', borg_exit_codes=None, ) borgmatic/tests/unit/000077500000000000000000000000001476361726000151625ustar00rootroot00000000000000borgmatic/tests/unit/__init__.py000066400000000000000000000000001476361726000172610ustar00rootroot00000000000000borgmatic/tests/unit/actions/000077500000000000000000000000001476361726000166225ustar00rootroot00000000000000borgmatic/tests/unit/actions/__init__.py000066400000000000000000000000001476361726000207210ustar00rootroot00000000000000borgmatic/tests/unit/actions/config/000077500000000000000000000000001476361726000200675ustar00rootroot00000000000000borgmatic/tests/unit/actions/config/test_bootstrap.py000066400000000000000000000300421476361726000235140ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.actions.config import bootstrap as module def test_make_bootstrap_config_uses_ssh_command_argument(): ssh_command = flexmock() config = module.make_bootstrap_config(flexmock(ssh_command=ssh_command)) assert config['ssh_command'] == ssh_command assert config['relocated_repo_access_is_ok'] def test_get_config_paths_returns_list_of_config_paths(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') flexmock(module).should_receive('make_bootstrap_config').and_return({}) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command=None, local_path='borg7', remote_path='borg8', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock( read=lambda: '{"config_paths": ["/borgmatic/config.yaml"]}', ), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) assert module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) == ['/borgmatic/config.yaml'] def test_get_config_paths_probes_for_manifest(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') flexmock(module).should_receive('make_bootstrap_config').and_return({}) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command=None, local_path='borg7', remote_path='borg8', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory, ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.os.path).should_receive('join').with_args( 'borgmatic', 'bootstrap', 'manifest.json' ).and_return('borgmatic/bootstrap/manifest.json').once() flexmock(module.os.path).should_receive('join').with_args( borgmatic_runtime_directory, 'bootstrap', 'manifest.json' ).and_return('run/borgmatic/bootstrap/manifest.json').once() flexmock(module.os.path).should_receive('join').with_args( '/source', 'bootstrap', 'manifest.json' ).and_return('/source/bootstrap/manifest.json').once() manifest_missing_extract_process = flexmock( stdout=flexmock(read=lambda: None), ) manifest_found_extract_process = flexmock( stdout=flexmock( read=lambda: '{"config_paths": ["/borgmatic/config.yaml"]}', ), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( manifest_missing_extract_process ).and_return(manifest_missing_extract_process).and_return(manifest_found_extract_process) assert module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) == ['/borgmatic/config.yaml'] def test_get_config_paths_translates_ssh_command_argument_to_config(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') config = flexmock() flexmock(module).should_receive('make_bootstrap_config').and_return(config) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command='ssh -i key', local_path='borg7', remote_path='borg8', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock( read=lambda: '{"config_paths": ["/borgmatic/config.yaml"]}', ), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').with_args( False, 'repo', 'archive', object, config, object, object, extract_to_stdout=True, local_path='borg7', remote_path='borg8', ).and_return(extract_process) assert module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) == ['/borgmatic/config.yaml'] def test_get_config_paths_with_missing_manifest_raises_value_error(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') flexmock(module).should_receive('make_bootstrap_config').and_return({}) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command=None, local_path='borg7', remote_path='borg7', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.os.path).should_receive('join').and_return('run/borgmatic') extract_process = flexmock(stdout=flexmock(read=lambda: '')) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) with pytest.raises(ValueError): module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) def test_get_config_paths_with_broken_json_raises_value_error(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') flexmock(module).should_receive('make_bootstrap_config').and_return({}) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command=None, local_path='borg7', remote_path='borg7', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock(read=lambda: '{"config_paths": ["/oops'), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) with pytest.raises(ValueError): module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) def test_get_config_paths_with_json_missing_key_raises_value_error(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/source') flexmock(module).should_receive('make_bootstrap_config').and_return({}) bootstrap_arguments = flexmock( repository='repo', archive='archive', ssh_command=None, local_path='borg7', remote_path='borg7', borgmatic_source_directory=None, user_runtime_directory=None, ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock(read=lambda: '{}'), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) with pytest.raises(ValueError): module.get_config_paths( 'archive', bootstrap_arguments, global_arguments, local_borg_version ) def test_run_bootstrap_does_not_raise(): flexmock(module).should_receive('make_bootstrap_config').and_return({}) flexmock(module).should_receive('get_config_paths').and_return(['/borgmatic/config.yaml']) bootstrap_arguments = flexmock( repository='repo', archive='archive', destination='dest', strip_components=1, progress=False, user_runtime_directory='/borgmatic', ssh_command=None, local_path='borg7', remote_path='borg8', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock( read=lambda: '{"config_paths": ["borgmatic/config.yaml"]}', ), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ).once() flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( 'archive' ) module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version) def test_run_bootstrap_translates_ssh_command_argument_to_config(): config = flexmock() flexmock(module).should_receive('make_bootstrap_config').and_return(config) flexmock(module).should_receive('get_config_paths').and_return(['/borgmatic/config.yaml']) bootstrap_arguments = flexmock( repository='repo', archive='archive', destination='dest', strip_components=1, progress=False, user_runtime_directory='/borgmatic', ssh_command='ssh -i key', local_path='borg7', remote_path='borg8', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) extract_process = flexmock( stdout=flexmock( read=lambda: '{"config_paths": ["borgmatic/config.yaml"]}', ), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').with_args( False, 'repo', 'archive', object, config, object, object, extract_to_stdout=False, destination_path='dest', strip_components=1, progress=False, local_path='borg7', remote_path='borg8', ).and_return(extract_process).once() flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').with_args( 'repo', 'archive', config, object, object, local_path='borg7', remote_path='borg8', ).and_return('archive') module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version) borgmatic/tests/unit/actions/config/test_generate.py000066400000000000000000000024311476361726000232720ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions.config import generate as module def test_run_generate_does_not_raise(): generate_arguments = flexmock( source_filename=None, destination_filename='destination.yaml', overwrite=False, ) global_arguments = flexmock(dry_run=False) flexmock(module.borgmatic.config.generate).should_receive('generate_sample_configuration') module.run_generate(generate_arguments, global_arguments) def test_run_generate_with_dry_run_does_not_raise(): generate_arguments = flexmock( source_filename=None, destination_filename='destination.yaml', overwrite=False, ) global_arguments = flexmock(dry_run=True) flexmock(module.borgmatic.config.generate).should_receive('generate_sample_configuration') module.run_generate(generate_arguments, global_arguments) def test_run_generate_with_source_filename_does_not_raise(): generate_arguments = flexmock( source_filename='source.yaml', destination_filename='destination.yaml', overwrite=False, ) global_arguments = flexmock(dry_run=False) flexmock(module.borgmatic.config.generate).should_receive('generate_sample_configuration') module.run_generate(generate_arguments, global_arguments) borgmatic/tests/unit/actions/config/test_validate.py000066400000000000000000000011271476361726000232720ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions.config import validate as module def test_run_validate_does_not_raise(): validate_arguments = flexmock(show=False) flexmock(module.borgmatic.config.generate).should_receive('render_configuration') module.run_validate(validate_arguments, flexmock()) def test_run_validate_with_show_does_not_raise(): validate_arguments = flexmock(show=True) flexmock(module.borgmatic.config.generate).should_receive('render_configuration') module.run_validate(validate_arguments, {'test.yaml': flexmock(), 'other.yaml': flexmock()}) borgmatic/tests/unit/actions/test_arguments.py000066400000000000000000000006061476361726000222420ustar00rootroot00000000000000from borgmatic.actions import arguments as module def test_update_arguments_copies_and_updates_without_modifying_original(): original = module.argparse.Namespace(foo=1, bar=2, baz=3) result = module.update_arguments(original, bar=7, baz=8) assert original == module.argparse.Namespace(foo=1, bar=2, baz=3) assert result == module.argparse.Namespace(foo=1, bar=7, baz=8) borgmatic/tests/unit/actions/test_borg.py000066400000000000000000000014711476361726000211670ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import borg as module def test_run_borg_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module.borgmatic.borg.borg).should_receive('run_arbitrary_borg') borg_arguments = flexmock(repository=flexmock(), archive=flexmock(), options=flexmock()) module.run_borg( repository={'path': 'repos'}, config={}, local_borg_version=None, global_arguments=flexmock(log_json=False), borg_arguments=borg_arguments, local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_break_lock.py000066400000000000000000000012521476361726000223270ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import break_lock as module def test_run_break_lock_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.break_lock).should_receive('break_lock') break_lock_arguments = flexmock(repository=flexmock()) module.run_break_lock( repository={'path': 'repo'}, config={}, local_borg_version=None, break_lock_arguments=break_lock_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_change_passphrase.py000066400000000000000000000013421476361726000237110ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import change_passphrase as module def test_run_change_passphrase_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.change_passphrase).should_receive('change_passphrase') change_passphrase_arguments = flexmock(repository=flexmock()) module.run_change_passphrase( repository={'path': 'repo'}, config={}, local_borg_version=None, change_passphrase_arguments=change_passphrase_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_check.py000066400000000000000000001672661476361726000213320ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.actions import check as module from borgmatic.borg.pattern import Pattern def test_parse_checks_returns_them_as_tuple(): checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'bar'}]}) assert checks == ('foo', 'bar') def test_parse_checks_with_missing_value_returns_defaults(): checks = module.parse_checks({}) assert checks == ('repository', 'archives') def test_parse_checks_with_empty_list_returns_defaults(): checks = module.parse_checks({'checks': []}) assert checks == ('repository', 'archives') def test_parse_checks_with_none_value_returns_defaults(): checks = module.parse_checks({'checks': None}) assert checks == ('repository', 'archives') def test_parse_checks_with_disabled_returns_no_checks(): checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'disabled'}]}) assert checks == () def test_parse_checks_prefers_override_checks_to_configured_checks(): checks = module.parse_checks( {'checks': [{'name': 'archives'}]}, only_checks=['repository', 'extract'] ) assert checks == ('repository', 'extract') @pytest.mark.parametrize( 'frequency,expected_result', ( (None, None), ('always', None), ('1 hour', module.datetime.timedelta(hours=1)), ('2 hours', module.datetime.timedelta(hours=2)), ('1 day', module.datetime.timedelta(days=1)), ('2 days', module.datetime.timedelta(days=2)), ('1 week', module.datetime.timedelta(weeks=1)), ('2 weeks', module.datetime.timedelta(weeks=2)), ('1 month', module.datetime.timedelta(days=30)), ('2 months', module.datetime.timedelta(days=60)), ('1 year', module.datetime.timedelta(days=365)), ('2 years', module.datetime.timedelta(days=365 * 2)), ), ) def test_parse_frequency_parses_into_timedeltas(frequency, expected_result): assert module.parse_frequency(frequency) == expected_result @pytest.mark.parametrize( 'frequency', ( 'sometime', 'x days', '3 decades', ), ) def test_parse_frequency_raises_on_parse_error(frequency): with pytest.raises(ValueError): module.parse_frequency(frequency) def test_filter_checks_on_frequency_without_config_uses_default_checks(): flexmock(module).should_receive('parse_frequency').and_return( module.datetime.timedelta(weeks=4) ) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('probe_for_check_time').and_return(None) assert module.filter_checks_on_frequency( config={}, borg_repository_id='repo', checks=('repository', 'archives'), force=False, archives_check_id='1234', ) == ('repository', 'archives') def test_filter_checks_on_frequency_retains_unconfigured_check(): assert module.filter_checks_on_frequency( config={}, borg_repository_id='repo', checks=('data',), force=False, ) == ('data',) def test_filter_checks_on_frequency_retains_check_without_frequency(): flexmock(module).should_receive('parse_frequency').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives'}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', ) == ('archives',) def test_filter_checks_on_frequency_retains_check_with_empty_only_run_on(): flexmock(module).should_receive('parse_frequency').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'only_run_on': []}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', datetime_now=flexmock(weekday=lambda: 0), ) == ('archives',) def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today(): flexmock(module).should_receive('parse_frequency').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'only_run_on': [module.calendar.day_name[0]]}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', datetime_now=flexmock(weekday=lambda: 0), ) == ('archives',) def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today_via_weekday_value(): flexmock(module).should_receive('parse_frequency').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'only_run_on': ['weekday']}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', datetime_now=flexmock(weekday=lambda: 0), ) == ('archives',) def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today_via_weekend_value(): flexmock(module).should_receive('parse_frequency').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'only_run_on': ['weekend']}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', datetime_now=flexmock(weekday=lambda: 6), ) == ('archives',) def test_filter_checks_on_frequency_skips_check_with_only_run_on_not_matching_today(): flexmock(module).should_receive('parse_frequency').and_return(None) assert ( module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'only_run_on': [module.calendar.day_name[5]]}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', datetime_now=flexmock(weekday=lambda: 0), ) == () ) def test_filter_checks_on_frequency_retains_check_with_elapsed_frequency(): flexmock(module).should_receive('parse_frequency').and_return( module.datetime.timedelta(hours=1) ) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('probe_for_check_time').and_return( module.datetime.datetime(year=module.datetime.MINYEAR, month=1, day=1) ) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', ) == ('archives',) def test_filter_checks_on_frequency_retains_check_with_missing_check_time_file(): flexmock(module).should_receive('parse_frequency').and_return( module.datetime.timedelta(hours=1) ) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('probe_for_check_time').and_return(None) assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', ) == ('archives',) def test_filter_checks_on_frequency_skips_check_with_unelapsed_frequency(): flexmock(module).should_receive('parse_frequency').and_return( module.datetime.timedelta(hours=1) ) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('probe_for_check_time').and_return( module.datetime.datetime.now() ) assert ( module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]}, borg_repository_id='repo', checks=('archives',), force=False, archives_check_id='1234', ) == () ) def test_filter_checks_on_frequency_retains_check_with_unelapsed_frequency_and_force(): assert module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]}, borg_repository_id='repo', checks=('archives',), force=True, archives_check_id='1234', ) == ('archives',) def test_filter_checks_on_frequency_passes_through_empty_checks(): assert ( module.filter_checks_on_frequency( config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]}, borg_repository_id='repo', checks=(), force=False, archives_check_id='1234', ) == () ) def test_make_archives_check_id_with_flags_returns_a_value_and_does_not_raise(): assert module.make_archives_check_id(('--match-archives', 'sh:foo-*')) def test_make_archives_check_id_with_empty_flags_returns_none(): assert module.make_archives_check_id(()) is None def test_make_check_time_path_with_borgmatic_source_directory_includes_it(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return( '/home/user/.local/state/borgmatic', ) assert ( module.make_check_time_path({}, '1234', 'archives', '5678') == '/home/user/.local/state/borgmatic/checks/1234/archives/5678' ) def test_make_check_time_path_with_archives_check_and_no_archives_check_id_defaults_to_all(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return( '/home/user/.local/state/borgmatic', ) assert ( module.make_check_time_path( {}, '1234', 'archives', ) == '/home/user/.local/state/borgmatic/checks/1234/archives/all' ) def test_make_check_time_path_with_repositories_check_ignores_archives_check_id(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return( '/home/user/.local/state/borgmatic', ) assert ( module.make_check_time_path({}, '1234', 'repository', '5678') == '/home/user/.local/state/borgmatic/checks/1234/repository' ) def test_read_check_time_does_not_raise(): flexmock(module.os).should_receive('stat').and_return(flexmock(st_mtime=123)) assert module.read_check_time('/path') def test_read_check_time_on_missing_file_does_not_raise(): flexmock(module.os).should_receive('stat').and_raise(FileNotFoundError) assert module.read_check_time('/path') is None def test_probe_for_check_time_uses_maximum_of_multiple_check_times(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/5678' ).and_return('~/.borgmatic/checks/1234/archives/all') flexmock(module).should_receive('read_check_time').and_return(1).and_return(2) assert module.probe_for_check_time(flexmock(), flexmock(), flexmock(), flexmock()) == 2 def test_probe_for_check_time_deduplicates_identical_check_time_paths(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/5678' ).and_return('~/.borgmatic/checks/1234/archives/5678') flexmock(module).should_receive('read_check_time').and_return(1).once() assert module.probe_for_check_time(flexmock(), flexmock(), flexmock(), flexmock()) == 1 def test_probe_for_check_time_skips_none_check_time(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/5678' ).and_return('~/.borgmatic/checks/1234/archives/all') flexmock(module).should_receive('read_check_time').and_return(None).and_return(2) assert module.probe_for_check_time(flexmock(), flexmock(), flexmock(), flexmock()) == 2 def test_probe_for_check_time_uses_single_check_time(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/5678' ).and_return('~/.borgmatic/checks/1234/archives/all') flexmock(module).should_receive('read_check_time').and_return(1).and_return(None) assert module.probe_for_check_time(flexmock(), flexmock(), flexmock(), flexmock()) == 1 def test_probe_for_check_time_returns_none_when_no_check_time_found(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/5678' ).and_return('~/.borgmatic/checks/1234/archives/all') flexmock(module).should_receive('read_check_time').and_return(None).and_return(None) assert module.probe_for_check_time(flexmock(), flexmock(), flexmock(), flexmock()) is None def test_upgrade_check_times_moves_checks_from_borgmatic_source_directory_to_state_directory(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').with_args( '/home/user/.borgmatic/checks' ).and_return(True) flexmock(module.os.path).should_receive('exists').with_args( '/home/user/.local/state/borgmatic/checks' ).and_return(False) flexmock(module.os).should_receive('makedirs') flexmock(module.shutil).should_receive('move').with_args( '/home/user/.borgmatic/checks', '/home/user/.local/state/borgmatic/checks' ).once() flexmock(module).should_receive('make_check_time_path').and_return( '/home/user/.local/state/borgmatic/checks/1234/archives/all' ) flexmock(module.os.path).should_receive('isfile').and_return(False) flexmock(module.os).should_receive('mkdir').never() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_with_checks_already_in_borgmatic_state_directory_does_not_move_anything(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').with_args( '/home/user/.borgmatic/checks' ).and_return(True) flexmock(module.os.path).should_receive('exists').with_args( '/home/user/.local/state/borgmatic/checks' ).and_return(True) flexmock(module.os).should_receive('makedirs').never() flexmock(module.shutil).should_receive('move').never() flexmock(module).should_receive('make_check_time_path').and_return( '/home/user/.local/state/borgmatic/checks/1234/archives/all' ) flexmock(module.os.path).should_receive('isfile').and_return(False) flexmock(module.shutil).should_receive('move').never() flexmock(module.os).should_receive('mkdir').never() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_renames_old_check_paths_to_all(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').and_return(False) base_path = '/home/user/.local/state/borgmatic/checks/1234' flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'archives', 'all' ).and_return(f'{base_path}/archives/all') flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'data', 'all' ).and_return(f'{base_path}/data/all') flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/archives').and_return( True ) flexmock(module.os.path).should_receive('isfile').with_args( f'{base_path}/archives.temp' ).and_return(False) flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/data').and_return( False ) flexmock(module.os.path).should_receive('isfile').with_args( f'{base_path}/data.temp' ).and_return(False) flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/archives', f'{base_path}/archives.temp' ).once() flexmock(module.os).should_receive('mkdir').with_args(f'{base_path}/archives').once() flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/archives.temp', f'{base_path}/archives/all' ).once() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_renames_data_check_paths_when_archives_paths_are_already_upgraded(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').and_return(False) base_path = '/home/user/.local/state/borgmatic/checks/1234' flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'archives', 'all' ).and_return(f'{base_path}/archives/all') flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'data', 'all' ).and_return(f'{base_path}/data/all') flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/archives').and_return( False ) flexmock(module.os.path).should_receive('isfile').with_args( f'{base_path}/archives.temp' ).and_return(False) flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/data').and_return( True ) flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/data', f'{base_path}/data.temp' ).once() flexmock(module.os).should_receive('mkdir').with_args(f'{base_path}/data').once() flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/data.temp', f'{base_path}/data/all' ).once() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_skips_already_upgraded_check_paths(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module).should_receive('make_check_time_path').and_return( '/home/user/.local/state/borgmatic/checks/1234/archives/all' ) flexmock(module.os.path).should_receive('isfile').and_return(False) flexmock(module.shutil).should_receive('move').never() flexmock(module.os).should_receive('mkdir').never() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_renames_stale_temporary_check_path(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_state_directory' ).and_return('/home/user/.local/state/borgmatic') flexmock(module.os.path).should_receive('exists').and_return(False) base_path = '/home/borgmatic/.local/state/checks/1234' flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'archives', 'all' ).and_return(f'{base_path}/archives/all') flexmock(module).should_receive('make_check_time_path').with_args( object, object, 'data', 'all' ).and_return(f'{base_path}/data/all') flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/archives').and_return( False ) flexmock(module.os.path).should_receive('isfile').with_args( f'{base_path}/archives.temp' ).and_return(True) flexmock(module.os.path).should_receive('isfile').with_args(f'{base_path}/data').and_return( False ) flexmock(module.os.path).should_receive('isfile').with_args( f'{base_path}/data.temp' ).and_return(False) flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/archives', f'{base_path}/archives.temp' ).and_raise(FileNotFoundError) flexmock(module.os).should_receive('mkdir').with_args(f'{base_path}/archives').once() flexmock(module.shutil).should_receive('move').with_args( f'{base_path}/archives.temp', f'{base_path}/archives/all' ).once() module.upgrade_check_times(flexmock(), flexmock()) def test_collect_spot_check_source_paths_parses_borg_output(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return( {'hook1': False, 'hook2': True} ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return( [Pattern('foo'), Pattern('bar')] ) flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args( dry_run=True, repository_path='repo', config=object, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version=object, global_arguments=object, borgmatic_runtime_directory='/run/borgmatic', local_path=object, remote_path=object, list_files=True, stream_processes=True, ).and_return((('borg', 'create'), ('repo::archive',), flexmock())) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'warning: stuff\n- /etc/path\n+ /etc/other\n? /nope', ) flexmock(module.os.path).should_receive('isfile').and_return(True) assert module.collect_spot_check_source_paths( repository={'path': 'repo'}, config={'working_directory': '/'}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) == ('/etc/path', '/etc/other') def test_collect_spot_check_source_paths_passes_through_stream_processes_false(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return( {'hook1': False, 'hook2': False} ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return( [Pattern('foo'), Pattern('bar')] ) flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args( dry_run=True, repository_path='repo', config=object, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version=object, global_arguments=object, borgmatic_runtime_directory='/run/borgmatic', local_path=object, remote_path=object, list_files=True, stream_processes=False, ).and_return((('borg', 'create'), ('repo::archive',), flexmock())) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'warning: stuff\n- /etc/path\n+ /etc/other\n? /nope', ) flexmock(module.os.path).should_receive('isfile').and_return(True) assert module.collect_spot_check_source_paths( repository={'path': 'repo'}, config={'working_directory': '/'}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) == ('/etc/path', '/etc/other') def test_collect_spot_check_source_paths_without_working_directory_parses_borg_output(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return( {'hook1': False, 'hook2': True} ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return( [Pattern('foo'), Pattern('bar')] ) flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args( dry_run=True, repository_path='repo', config=object, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version=object, global_arguments=object, borgmatic_runtime_directory='/run/borgmatic', local_path=object, remote_path=object, list_files=True, stream_processes=True, ).and_return((('borg', 'create'), ('repo::archive',), flexmock())) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'warning: stuff\n- /etc/path\n+ /etc/other\n? /nope', ) flexmock(module.os.path).should_receive('isfile').and_return(True) assert module.collect_spot_check_source_paths( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) == ('/etc/path', '/etc/other') def test_collect_spot_check_source_paths_skips_directories(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return( {'hook1': False, 'hook2': True} ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return( [Pattern('foo'), Pattern('bar')] ) flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args( dry_run=True, repository_path='repo', config=object, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version=object, global_arguments=object, borgmatic_runtime_directory='/run/borgmatic', local_path=object, remote_path=object, list_files=True, stream_processes=True, ).and_return((('borg', 'create'), ('repo::archive',), flexmock())) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'warning: stuff\n- /etc/path\n+ /etc/dir\n? /nope', ) flexmock(module.os.path).should_receive('isfile').with_args('/etc/path').and_return(False) flexmock(module.os.path).should_receive('isfile').with_args('/etc/dir').and_return(False) assert ( module.collect_spot_check_source_paths( repository={'path': 'repo'}, config={'working_directory': '/'}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) == () ) def test_collect_spot_check_archive_paths_excludes_directories_and_pipes(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/home/user/.borgmatic') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ( 'f etc/path', 'p var/pipe', 'f etc/other', 'd etc/dir', ) ) assert module.collect_spot_check_archive_paths( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/user/1001/borgmatic', ) == ('etc/path', 'etc/other') def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_directory_as_stored_with_prefix_truncation(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ( 'f etc/path', 'f borgmatic/some/thing', ) ) assert module.collect_spot_check_archive_paths( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/user/0/borgmatic', ) == ('etc/path',) def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_source_directory(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ( 'f etc/path', 'f root/.borgmatic/some/thing', ) ) assert module.collect_spot_check_archive_paths( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/user/0/borgmatic', ) == ('etc/path',) def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_directory(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root.borgmatic') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ( 'f etc/path', 'f run/user/0/borgmatic/some/thing', ) ) assert module.collect_spot_check_archive_paths( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/user/0/borgmatic', ) == ('etc/path',) def test_collect_spot_check_source_paths_uses_working_directory(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return( {'hook1': False, 'hook2': True} ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return( flexmock() ) flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return( [Pattern('foo'), Pattern('bar')] ) flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args( dry_run=True, repository_path='repo', config=object, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version=object, global_arguments=object, borgmatic_runtime_directory='/run/borgmatic', local_path=object, remote_path=object, list_files=True, stream_processes=True, ).and_return((('borg', 'create'), ('repo::archive',), flexmock())) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir' ) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'warning: stuff\n- foo\n+ bar\n? /nope', ) flexmock(module.os.path).should_receive('isfile').with_args('/working/dir/foo').and_return(True) flexmock(module.os.path).should_receive('isfile').with_args('/working/dir/bar').and_return(True) assert module.collect_spot_check_source_paths( repository={'path': 'repo'}, config={'working_directory': '/working/dir'}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) == ('foo', 'bar') def test_compare_spot_check_hashes_returns_paths_having_failing_hashes(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_return( 'hash1 /foo\nhash2 /bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'nothash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, { 'name': 'spot', 'data_sample_percentage': 50, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar', '/baz', '/quux'), ) == ('/bar',) def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', 'foo', 'bar'), working_directory=None).and_return( 'hash1 foo\nhash2 bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'nothash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, { 'name': 'spot', 'data_sample_percentage': 50, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('foo', 'bar', 'baz', 'quux'), ) == ('bar',) def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_return( 'hash1 /foo\nhash2 /bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['nothash1 foo', 'nothash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, { 'name': 'spot', 'data_sample_percentage': 1000, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar'), ) == ('/foo', '/bar') def test_compare_spot_check_hashes_uses_xxh64sum_command_option(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('/usr/local/bin/xxh64sum', '/foo', '/bar'), working_directory=None).and_return( 'hash1 /foo\nhash2 /bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'nothash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'spot', 'data_sample_percentage': 50, 'xxh64sum_command': '/usr/local/bin/xxh64sum', }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar', '/baz', '/quux'), ) == ('/bar',) def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_matching(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_return( 'hash1 /foo\nhash2 /bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'spot', 'data_sample_percentage': 50, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar', '/baz', '/quux'), ) == ('/bar',) def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').with_args('/foo').and_return(True) flexmock(module.os.path).should_receive('exists').with_args('/bar').and_return(False) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/foo'), working_directory=None).and_return('hash1 /foo') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'hash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'spot', 'data_sample_percentage': 50, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar', '/baz', '/quux'), ) == ('/bar',) def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in_chunks(): flexmock(module).SAMPLE_PATHS_SUBSET_COUNT = 2 flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( None, ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_return( 'hash1 /foo\nhash2 /bar' ) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', '/baz', '/quux'), working_directory=None).and_return( 'hash3 /baz\nhash4 /quux' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'hash2 bar'] ).and_return(['hash3 baz', 'nothash4 quux']) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, { 'name': 'spot', 'data_sample_percentage': 100, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('/foo', '/bar', '/baz', '/quux'), ) == ('/quux',) def test_compare_spot_check_hashes_uses_working_directory_to_access_source_paths(): flexmock(module.random).should_receive('sample').replace_with( lambda population, count: population[:count] ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module.os.path).should_receive('exists').with_args('/working/dir/foo').and_return(True) flexmock(module.os.path).should_receive('exists').with_args('/working/dir/bar').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args(('xxh64sum', 'foo', 'bar'), working_directory='/working/dir').and_return( 'hash1 foo\nhash2 bar' ) flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( ['hash1 foo', 'nothash2 bar'] ) assert module.compare_spot_check_hashes( repository={'path': 'repo'}, archive='archive', config={ 'checks': [ { 'name': 'archives', 'frequency': '2 weeks', }, { 'name': 'spot', 'data_sample_percentage': 50, }, ], 'working_directory': '/working/dir', }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), source_paths=('foo', 'bar', 'baz', 'quux'), ) == ('bar',) def test_spot_check_without_spot_configuration_errors(): with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'archives', }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_without_any_configuration_errors(): with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_data_tolerance_percentage_greater_than_data_sample_percentage_errors(): with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'spot', 'data_tolerance_percentage': 7, 'data_sample_percentage': 5, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_with_count_delta_greater_than_count_tolerance_percentage_errors(): flexmock(module).should_receive('collect_spot_check_source_paths').and_return( ('/foo', '/bar', '/baz', '/quux') ) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( 'archive' ) flexmock(module).should_receive('collect_spot_check_archive_paths').and_return( ('/foo', '/bar') ).once() with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'spot', 'count_tolerance_percentage': 1, 'data_tolerance_percentage': 4, 'data_sample_percentage': 5, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_with_failing_percentage_greater_than_data_tolerance_percentage_errors(): flexmock(module).should_receive('collect_spot_check_source_paths').and_return( ('/foo', '/bar', '/baz', '/quux') ) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( 'archive' ) flexmock(module).should_receive('collect_spot_check_archive_paths').and_return(('/foo', '/bar')) flexmock(module).should_receive('compare_spot_check_hashes').and_return( ('/bar', '/baz', '/quux') ).once() with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'spot', 'count_tolerance_percentage': 55, 'data_tolerance_percentage': 4, 'data_sample_percentage': 5, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_with_high_enough_tolerances_does_not_raise(): flexmock(module).should_receive('collect_spot_check_source_paths').and_return( ('/foo', '/bar', '/baz', '/quux') ) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( 'archive' ) flexmock(module).should_receive('collect_spot_check_archive_paths').and_return(('/foo', '/bar')) flexmock(module).should_receive('compare_spot_check_hashes').and_return( ('/bar', '/baz', '/quux') ).once() module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'spot', 'count_tolerance_percentage': 55, 'data_tolerance_percentage': 80, 'data_sample_percentage': 80, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_spot_check_without_any_source_paths_errors(): flexmock(module).should_receive('collect_spot_check_source_paths').and_return(()) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( 'archive' ) flexmock(module).should_receive('collect_spot_check_archive_paths').and_return(('/foo', '/bar')) flexmock(module).should_receive('compare_spot_check_hashes').never() with pytest.raises(ValueError): module.spot_check( repository={'path': 'repo'}, config={ 'checks': [ { 'name': 'spot', 'count_tolerance_percentage': 10, 'data_tolerance_percentage': 40, 'data_sample_percentage': 50, }, ] }, local_borg_version=flexmock(), global_arguments=flexmock(), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_run_check_checks_archives_for_configured_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock()) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(()) flexmock(module).should_receive('make_archives_check_id').and_return(None) flexmock(module).should_receive('filter_checks_on_frequency').and_return( {'repository', 'archives'} ) flexmock(module.borgmatic.borg.check).should_receive('check_archives').once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') flexmock(module.borgmatic.borg.extract).should_receive('extract_last_archive_dry_run').never() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) check_arguments = flexmock( repository=None, progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) def test_run_check_runs_configured_extract_check(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock()) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(()) flexmock(module).should_receive('make_archives_check_id').and_return(None) flexmock(module).should_receive('filter_checks_on_frequency').and_return({'extract'}) flexmock(module.borgmatic.borg.check).should_receive('check_archives').never() flexmock(module.borgmatic.borg.extract).should_receive('extract_last_archive_dry_run').once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) check_arguments = flexmock( repository=None, progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) def test_run_check_runs_configured_spot_check(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock()) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(()) flexmock(module).should_receive('make_archives_check_id').and_return(None) flexmock(module).should_receive('filter_checks_on_frequency').and_return({'spot'}) flexmock(module.borgmatic.borg.check).should_receive('check_archives').never() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.actions.check).should_receive('spot_check').once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) check_arguments = flexmock( repository=None, progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) def test_run_check_without_checks_runs_nothing_except_hooks(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock()) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(()) flexmock(module).should_receive('make_archives_check_id').and_return(None) flexmock(module).should_receive('filter_checks_on_frequency').and_return({}) flexmock(module.borgmatic.borg.check).should_receive('check_archives').never() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time').never() flexmock(module.borgmatic.borg.extract).should_receive('extract_last_archive_dry_run').never() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) check_arguments = flexmock( repository=None, progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) def test_run_check_checks_archives_in_selected_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(True) flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock()) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(()) flexmock(module).should_receive('make_archives_check_id').and_return(None) flexmock(module).should_receive('filter_checks_on_frequency').and_return( {'repository', 'archives'} ) flexmock(module.borgmatic.borg.check).should_receive('check_archives').once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') flexmock(module.borgmatic.borg.extract).should_receive('extract_last_archive_dry_run').never() check_arguments = flexmock( repository=flexmock(), progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) def test_run_check_bails_if_repository_does_not_match(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(False) flexmock(module.borgmatic.borg.check).should_receive('check_archives').never() check_arguments = flexmock( repository=flexmock(), progress=flexmock(), repair=flexmock(), only_checks=flexmock(), force=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_check( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, check_arguments=check_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_compact.py000066400000000000000000000057511476361726000216710ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import compact as module def test_compact_actions_calls_hooks_for_configured_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) compact_arguments = flexmock( repository=None, progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock() ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_compact( config_filename='test.yaml', repository={'path': 'repo'}, config={}, hook_context={}, local_borg_version=None, compact_arguments=compact_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) def test_compact_runs_with_selected_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(True) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once() compact_arguments = flexmock( repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock() ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_compact( config_filename='test.yaml', repository={'path': 'repo'}, config={}, hook_context={}, local_borg_version=None, compact_arguments=compact_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) def test_compact_bails_if_repository_does_not_match(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(False) flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').never() compact_arguments = flexmock( repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock() ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_compact( config_filename='test.yaml', repository={'path': 'repo'}, config={}, hook_context={}, local_borg_version=None, compact_arguments=compact_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_create.py000066400000000000000000000553061476361726000215070ustar00rootroot00000000000000import io import sys import pytest from flexmock import flexmock from borgmatic.actions import create as module from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type @pytest.mark.parametrize( 'pattern_line,expected_pattern', ( ('R /foo', Pattern('/foo', source=Pattern_source.CONFIG)), ('P sh', Pattern('sh', Pattern_type.PATTERN_STYLE, source=Pattern_source.CONFIG)), ('+ /foo*', Pattern('/foo*', Pattern_type.INCLUDE, source=Pattern_source.CONFIG)), ( '+ sh:/foo*', Pattern( '/foo*', Pattern_type.INCLUDE, Pattern_style.SHELL, source=Pattern_source.CONFIG ), ), ), ) def test_parse_pattern_transforms_pattern_line_to_instance(pattern_line, expected_pattern): module.parse_pattern(pattern_line) == expected_pattern def test_parse_pattern_with_invalid_pattern_line_errors(): with pytest.raises(ValueError): module.parse_pattern('/foo') def test_collect_patterns_converts_source_directories(): assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == ( Pattern('/foo', source=Pattern_source.CONFIG), Pattern('/bar', source=Pattern_source.CONFIG), ) def test_collect_patterns_parses_config_patterns(): flexmock(module).should_receive('parse_pattern').with_args('R /foo').and_return(Pattern('/foo')) flexmock(module).should_receive('parse_pattern').with_args('# comment').never() flexmock(module).should_receive('parse_pattern').with_args('').never() flexmock(module).should_receive('parse_pattern').with_args(' ').never() flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar')) assert module.collect_patterns({'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}) == ( Pattern('/foo'), Pattern('/bar'), ) def test_collect_patterns_converts_exclude_patterns(): assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == ( Pattern( '/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.CONFIG ), Pattern( '/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.CONFIG ), Pattern( '**/baz', Pattern_type.NO_RECURSE, Pattern_style.SHELL, source=Pattern_source.CONFIG ), ) def test_collect_patterns_reads_config_patterns_from_file(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('file1.txt').and_return(io.StringIO('R /foo')) builtins.should_receive('open').with_args('file2.txt').and_return( io.StringIO('R /bar\n# comment\n\n \nR /baz') ) flexmock(module).should_receive('parse_pattern').with_args('R /foo').and_return(Pattern('/foo')) flexmock(module).should_receive('parse_pattern').with_args('# comment').never() flexmock(module).should_receive('parse_pattern').with_args('').never() flexmock(module).should_receive('parse_pattern').with_args(' ').never() flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar')) flexmock(module).should_receive('parse_pattern').with_args('R /baz').and_return(Pattern('/baz')) assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}) == ( Pattern('/foo'), Pattern('/bar'), Pattern('/baz'), ) def test_collect_patterns_errors_on_missing_config_patterns_from_file(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('file1.txt').and_raise(FileNotFoundError) flexmock(module).should_receive('parse_pattern').never() with pytest.raises(ValueError): module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}) def test_collect_patterns_reads_config_exclude_from_file(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('file1.txt').and_return(io.StringIO('/foo')) builtins.should_receive('open').with_args('file2.txt').and_return( io.StringIO('/bar\n# comment\n\n \n/baz') ) flexmock(module).should_receive('parse_pattern').with_args( '! /foo', default_style=Pattern_style.FNMATCH ).and_return(Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH)) flexmock(module).should_receive('parse_pattern').with_args( '! /bar', default_style=Pattern_style.FNMATCH ).and_return(Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH)) flexmock(module).should_receive('parse_pattern').with_args('# comment').never() flexmock(module).should_receive('parse_pattern').with_args('').never() flexmock(module).should_receive('parse_pattern').with_args(' ').never() flexmock(module).should_receive('parse_pattern').with_args( '! /baz', default_style=Pattern_style.FNMATCH ).and_return(Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH)) assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == ( Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH), Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH), Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH), ) def test_collect_patterns_errors_on_missing_config_exclude_from_file(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('file1.txt').and_raise(OSError) flexmock(module).should_receive('parse_pattern').never() with pytest.raises(ValueError): module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) def test_expand_directory_with_basic_path_passes_it_through(): flexmock(module.os.path).should_receive('expanduser').and_return('foo') flexmock(module.glob).should_receive('glob').and_return([]) paths = module.expand_directory('foo', None) assert paths == ['foo'] def test_expand_directory_with_glob_expands(): flexmock(module.os.path).should_receive('expanduser').and_return('foo*') flexmock(module.glob).should_receive('glob').and_return(['foo', 'food']) paths = module.expand_directory('foo*', None) assert paths == ['foo', 'food'] def test_expand_directory_strips_off_working_directory(): flexmock(module.os.path).should_receive('expanduser').and_return('foo') flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo').and_return([]).once() paths = module.expand_directory('foo', working_directory='/working/dir') assert paths == ['foo'] def test_expand_directory_globs_working_directory_and_strips_it_off(): flexmock(module.os.path).should_receive('expanduser').and_return('foo*') flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo*').and_return( ['/working/dir/foo', '/working/dir/food'] ).once() paths = module.expand_directory('foo*', working_directory='/working/dir') assert paths == ['foo', 'food'] def test_expand_directory_with_slashdot_hack_globs_working_directory_and_strips_it_off(): flexmock(module.os.path).should_receive('expanduser').and_return('./foo*') flexmock(module.glob).should_receive('glob').with_args('/working/dir/./foo*').and_return( ['/working/dir/./foo', '/working/dir/./food'] ).once() paths = module.expand_directory('./foo*', working_directory='/working/dir') assert paths == ['./foo', './food'] def test_expand_directory_with_working_directory_matching_start_of_directory_does_not_strip_it_off(): flexmock(module.os.path).should_receive('expanduser').and_return('/working/dir/foo') flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo').and_return( ['/working/dir/foo'] ).once() paths = module.expand_directory('/working/dir/foo', working_directory='/working/dir') assert paths == ['/working/dir/foo'] def test_expand_patterns_flattens_expanded_directories(): flexmock(module).should_receive('expand_directory').with_args('~/foo', None).and_return( ['/root/foo'] ) flexmock(module).should_receive('expand_directory').with_args('bar*', None).and_return( ['bar', 'barf'] ) paths = module.expand_patterns((Pattern('~/foo'), Pattern('bar*'))) assert paths == (Pattern('/root/foo'), Pattern('bar'), Pattern('barf')) def test_expand_patterns_with_working_directory_passes_it_through(): flexmock(module).should_receive('expand_directory').with_args('foo', '/working/dir').and_return( ['/working/dir/foo'] ) patterns = module.expand_patterns((Pattern('foo'),), working_directory='/working/dir') assert patterns == (Pattern('/working/dir/foo'),) def test_expand_patterns_does_not_expand_skip_paths(): flexmock(module).should_receive('expand_directory').with_args('/foo', None).and_return(['/foo']) flexmock(module).should_receive('expand_directory').with_args('/bar*', None).never() patterns = module.expand_patterns((Pattern('/foo'), Pattern('/bar*')), skip_paths=('/bar*',)) assert patterns == (Pattern('/foo'), Pattern('/bar*')) def test_expand_patterns_considers_none_as_no_patterns(): assert module.expand_patterns(None) == () def test_expand_patterns_expands_tildes_and_globs_in_root_patterns(): flexmock(module.os.path).should_receive('expanduser').never() flexmock(module).should_receive('expand_directory').and_return( ['/root/foo/one', '/root/foo/two'] ) paths = module.expand_patterns((Pattern('~/foo/*'),)) assert paths == (Pattern('/root/foo/one'), Pattern('/root/foo/two')) def test_expand_patterns_expands_only_tildes_in_non_root_patterns(): flexmock(module).should_receive('expand_directory').never() flexmock(module.os.path).should_receive('expanduser').and_return('/root/bar/*') paths = module.expand_patterns((Pattern('~/bar/*', Pattern_type.INCLUDE),)) assert paths == (Pattern('/root/bar/*', Pattern_type.INCLUDE),) def test_device_map_patterns_gives_device_id_per_path(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55)) flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=66)) device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar'))) assert device_map == ( Pattern('/foo', device=55), Pattern('/bar', device=66), ) def test_device_map_patterns_only_considers_root_patterns(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55)) flexmock(module.os).should_receive('stat').with_args('/bar*').never() device_map = module.device_map_patterns( (Pattern('/foo'), Pattern('/bar*', Pattern_type.INCLUDE)) ) assert device_map == ( Pattern('/foo', device=55), Pattern('/bar*', Pattern_type.INCLUDE), ) def test_device_map_patterns_with_missing_path_does_not_error(): flexmock(module.os.path).should_receive('exists').and_return(True).and_return(False) flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55)) flexmock(module.os).should_receive('stat').with_args('/bar').never() device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar'))) assert device_map == ( Pattern('/foo', device=55), Pattern('/bar'), ) def test_device_map_patterns_uses_working_directory_to_construct_path(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55)) flexmock(module.os).should_receive('stat').with_args('/working/dir/bar').and_return( flexmock(st_dev=66) ) device_map = module.device_map_patterns( (Pattern('/foo'), Pattern('bar')), working_directory='/working/dir' ) assert device_map == ( Pattern('/foo', device=55), Pattern('bar', device=66), ) def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55)) flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=100)) device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar', device=66))) assert device_map == ( Pattern('/foo', device=55), Pattern('/bar', device=66), ) @pytest.mark.parametrize( 'patterns,expected_patterns', ( ((Pattern('/', device=1), Pattern('/root', device=1)), (Pattern('/', device=1),)), ((Pattern('/', device=1), Pattern('/root/', device=1)), (Pattern('/', device=1),)), ( (Pattern('/', device=1), Pattern('/root', device=2)), (Pattern('/', device=1), Pattern('/root', device=2)), ), ((Pattern('/root', device=1), Pattern('/', device=1)), (Pattern('/', device=1),)), ( (Pattern('/root', device=1), Pattern('/root/foo', device=1)), (Pattern('/root', device=1),), ), ( (Pattern('/root/', device=1), Pattern('/root/foo', device=1)), (Pattern('/root/', device=1),), ), ( (Pattern('/root', device=1), Pattern('/root/foo/', device=1)), (Pattern('/root', device=1),), ), ( (Pattern('/root', device=1), Pattern('/root/foo', device=2)), (Pattern('/root', device=1), Pattern('/root/foo', device=2)), ), ( (Pattern('/root/foo', device=1), Pattern('/root', device=1)), (Pattern('/root', device=1),), ), ( (Pattern('/root', device=None), Pattern('/root/foo', device=None)), (Pattern('/root'), Pattern('/root/foo')), ), ( ( Pattern('/root', device=1), Pattern('/etc', device=1), Pattern('/root/foo/bar', device=1), ), (Pattern('/root', device=1), Pattern('/etc', device=1)), ), ( ( Pattern('/root', device=1), Pattern('/root/foo', device=1), Pattern('/root/foo/bar', device=1), ), (Pattern('/root', device=1),), ), ((Pattern('/dup', device=1), Pattern('/dup', device=1)), (Pattern('/dup', device=1),)), ( (Pattern('/foo', device=1), Pattern('/bar', device=1)), (Pattern('/foo', device=1), Pattern('/bar', device=1)), ), ( (Pattern('/foo', device=1), Pattern('/bar', device=2)), (Pattern('/foo', device=1), Pattern('/bar', device=2)), ), ((Pattern('/root/foo', device=1),), (Pattern('/root/foo', device=1),)), ( (Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)), (Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)), ), ( (Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)), (Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)), ), ), ) def test_deduplicate_patterns_omits_child_paths_on_the_same_filesystem(patterns, expected_patterns): assert module.deduplicate_patterns(patterns) == expected_patterns def test_process_patterns_includes_patterns(): flexmock(module).should_receive('deduplicate_patterns').and_return( (Pattern('foo'), Pattern('bar')) ) flexmock(module).should_receive('device_map_patterns').and_return({}) flexmock(module).should_receive('expand_patterns').with_args( (Pattern('foo'), Pattern('bar')), working_directory='/working', skip_paths=set(), ).and_return(()).once() assert module.process_patterns( (Pattern('foo'), Pattern('bar')), working_directory='/working', ) == [Pattern('foo'), Pattern('bar')] def test_process_patterns_skips_expand_for_requested_paths(): skip_paths = {flexmock()} flexmock(module).should_receive('deduplicate_patterns').and_return( (Pattern('foo'), Pattern('bar')) ) flexmock(module).should_receive('device_map_patterns').and_return({}) flexmock(module).should_receive('expand_patterns').with_args( (Pattern('foo'), Pattern('bar')), working_directory='/working', skip_paths=skip_paths, ).and_return(()).once() assert module.process_patterns( (Pattern('foo'), Pattern('bar')), working_directory='/working', skip_expand_paths=skip_paths, ) == [Pattern('foo'), Pattern('bar')] def test_run_create_executes_and_calls_hooks_for_configured_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.borg.create).should_receive('create_archive').once() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({}) flexmock(module.borgmatic.hooks.dispatch).should_receive( 'call_hooks_even_if_unconfigured' ).and_return({}) flexmock(module).should_receive('collect_patterns').and_return(()) flexmock(module).should_receive('process_patterns').and_return([]) flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap') create_arguments = flexmock( repository=None, progress=flexmock(), stats=flexmock(), json=False, list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={}, config_paths=['/tmp/test.yaml'], hook_context={}, local_borg_version=None, create_arguments=create_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) ) def test_run_create_runs_with_selected_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(True) flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.borg.create).should_receive('create_archive').once() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({}) flexmock(module.borgmatic.hooks.dispatch).should_receive( 'call_hooks_even_if_unconfigured' ).and_return({}) flexmock(module).should_receive('collect_patterns').and_return(()) flexmock(module).should_receive('process_patterns').and_return([]) flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap') create_arguments = flexmock( repository=flexmock(), progress=flexmock(), stats=flexmock(), json=False, list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={}, config_paths=['/tmp/test.yaml'], hook_context={}, local_borg_version=None, create_arguments=create_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) ) def test_run_create_bails_if_repository_does_not_match(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(False) flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').never() flexmock(module.borgmatic.borg.create).should_receive('create_archive').never() create_arguments = flexmock( repository=flexmock(), progress=flexmock(), stats=flexmock(), json=False, list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) list( module.run_create( config_filename='test.yaml', repository='repo', config={}, config_paths=['/tmp/test.yaml'], hook_context={}, local_borg_version=None, create_arguments=create_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) ) def test_run_create_produces_json(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(True) flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.borg.create).should_receive('create_archive').once().and_return( flexmock() ) parsed_json = flexmock() flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json) flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({}) flexmock(module.borgmatic.hooks.dispatch).should_receive( 'call_hooks_even_if_unconfigured' ).and_return({}) flexmock(module).should_receive('collect_patterns').and_return(()) flexmock(module).should_receive('process_patterns').and_return([]) flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap') create_arguments = flexmock( repository=flexmock(), progress=flexmock(), stats=flexmock(), json=True, list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) assert list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={}, config_paths=['/tmp/test.yaml'], hook_context={}, local_borg_version=None, create_arguments=create_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) ) == [parsed_json] borgmatic/tests/unit/actions/test_delete.py000066400000000000000000000031261476361726000214770ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import delete as module def test_run_delete_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name') flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.delete).should_receive('delete_archives') module.run_delete( repository={'path': 'repo'}, config={}, local_borg_version=None, delete_arguments=flexmock(repository=flexmock(), archive=flexmock()), global_arguments=flexmock(), local_path=None, remote_path=None, ) def test_run_delete_without_archive_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name') flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.delete).should_receive('delete_archives') module.run_delete( repository={'path': 'repo'}, config={}, local_borg_version=None, delete_arguments=flexmock(repository=flexmock(), archive=None), global_arguments=flexmock(), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_export_key.py000066400000000000000000000012361476361726000224260ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import export_key as module def test_run_export_key_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.export_key).should_receive('export_key') export_arguments = flexmock(repository=flexmock()) module.run_export_key( repository={'path': 'repo'}, config={}, local_borg_version=None, export_arguments=export_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_export_tar.py000066400000000000000000000017071476361726000224270ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import export_tar as module def test_run_export_tar_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.export_tar).should_receive('export_tar_archive') export_tar_arguments = flexmock( repository=flexmock(), archive=flexmock(), paths=flexmock(), destination=flexmock(), tar_filter=flexmock(), list_files=flexmock(), strip_components=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_export_tar( repository={'path': 'repo'}, config={}, local_borg_version=None, export_tar_arguments=export_tar_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_extract.py000066400000000000000000000020621476361726000217050ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import extract as module def test_run_extract_calls_hooks(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive') flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) extract_arguments = flexmock( paths=flexmock(), progress=flexmock(), destination=flexmock(), strip_components=flexmock(), archive=flexmock(), repository='repo', ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_extract( config_filename='test.yaml', repository={'path': 'repo'}, config={'repositories': ['repo']}, hook_context={}, local_borg_version=None, extract_arguments=extract_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_info.py000066400000000000000000000040551476361726000211720ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import info as module def test_run_info_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.info).should_receive('display_archives_info') info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=False) list( module.run_info( repository={'path': 'repo'}, config={}, local_borg_version=None, info_arguments=info_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) def test_run_info_produces_json(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.info).should_receive('display_archives_info').and_return( flexmock() ) parsed_json = flexmock() flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json) info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=True) assert list( module.run_info( repository={'path': 'repo'}, config={}, local_borg_version=None, info_arguments=info_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) == [parsed_json] borgmatic/tests/unit/actions/test_json.py000066400000000000000000000020641476361726000212060ustar00rootroot00000000000000import pytest from borgmatic.actions import json as module def test_parse_json_loads_json_from_string(): assert module.parse_json('{"repository": {"id": "foo"}}', label=None) == { 'repository': {'id': 'foo', 'label': ''} } def test_parse_json_skips_non_json_warnings_and_loads_subsequent_json(): assert module.parse_json( '/non/existent/path: stat: [Errno 2] No such file or directory: /non/existent/path\n{"repository":\n{"id": "foo"}}', label=None, ) == {'repository': {'id': 'foo', 'label': ''}} def test_parse_json_skips_with_invalid_json_raises(): with pytest.raises(module.json.JSONDecodeError): module.parse_json('this is not valid JSON }', label=None) def test_parse_json_injects_label_into_parsed_data(): assert module.parse_json('{"repository": {"id": "foo"}}', label='bar') == { 'repository': {'id': 'foo', 'label': 'bar'} } def test_parse_json_injects_nothing_when_repository_missing(): assert module.parse_json('{"stuff": {"id": "foo"}}', label='bar') == {'stuff': {'id': 'foo'}} borgmatic/tests/unit/actions/test_list.py000066400000000000000000000040541476361726000212110ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import list as module def test_run_list_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.list).should_receive('list_archive') list_arguments = flexmock( repository=flexmock(), archive=flexmock(), json=False, find_paths=None ) list( module.run_list( repository={'path': 'repo'}, config={}, local_borg_version=None, list_arguments=list_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) def test_run_list_produces_json(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.list).should_receive('list_archive').and_return(flexmock()) parsed_json = flexmock() flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json) list_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=True) assert list( module.run_list( repository={'path': 'repo'}, config={}, local_borg_version=None, list_arguments=list_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) == [parsed_json] borgmatic/tests/unit/actions/test_mount.py000066400000000000000000000014701476361726000213770ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import mount as module def test_run_mount_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.mount).should_receive('mount_archive') mount_arguments = flexmock( repository=flexmock(), archive=flexmock(), mount_point=flexmock(), paths=flexmock(), foreground=flexmock(), options=flexmock(), ) module.run_mount( repository={'path': 'repo'}, config={}, local_borg_version=None, mount_arguments=mount_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_prune.py000066400000000000000000000050611476361726000213660ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import prune as module def test_run_prune_calls_hooks_for_configured_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) prune_arguments = flexmock(repository=None, stats=flexmock(), list_archives=flexmock()) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_prune( config_filename='test.yaml', repository={'path': 'repo'}, config={}, hook_context={}, local_borg_version=None, prune_arguments=prune_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) def test_run_prune_runs_with_selected_repository(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(True) flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once() prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock()) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_prune( config_filename='test.yaml', repository={'path': 'repo'}, config={}, hook_context={}, local_borg_version=None, prune_arguments=prune_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) def test_run_prune_bails_if_repository_does_not_match(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive( 'repositories_match' ).once().and_return(False) flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').never() prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock()) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_prune( config_filename='test.yaml', repository='repo', config={}, hook_context={}, local_borg_version=None, prune_arguments=prune_arguments, global_arguments=global_arguments, dry_run_label='', local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_repo_create.py000066400000000000000000000033701476361726000225260ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import repo_create as module def test_run_repo_create_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_create).should_receive('create_repository') arguments = flexmock( encryption_mode=flexmock(), source_repository=flexmock(), repository=flexmock(), copy_crypt_key=flexmock(), append_only=flexmock(), storage_quota=flexmock(), make_parent_dirs=flexmock(), ) module.run_repo_create( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_create_arguments=arguments, global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, ) def test_run_repo_create_bails_if_repository_does_not_match(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return( False ) flexmock(module.borgmatic.borg.repo_create).should_receive('create_repository').never() arguments = flexmock( encryption_mode=flexmock(), source_repository=flexmock(), repository=flexmock(), copy_crypt_key=flexmock(), append_only=flexmock(), storage_quota=flexmock(), make_parent_dirs=flexmock(), ) module.run_repo_create( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_create_arguments=arguments, global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_repo_delete.py000066400000000000000000000027361476361726000225320ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import repo_delete as module def test_run_repo_delete_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository') module.run_repo_delete( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_delete_arguments=flexmock(repository=flexmock(), cache_only=False), global_arguments=flexmock(), local_path=None, remote_path=None, ) def test_run_repo_delete_with_cache_only_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return( flexmock() ) flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository') module.run_repo_delete( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_delete_arguments=flexmock(repository=flexmock(), cache_only=True), global_arguments=flexmock(), local_path=None, remote_path=None, ) borgmatic/tests/unit/actions/test_repo_info.py000066400000000000000000000031331476361726000222130ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import repo_info as module def test_run_repo_info_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_info).should_receive('display_repository_info') repo_info_arguments = flexmock(repository=flexmock(), json=False) list( module.run_repo_info( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_info_arguments=repo_info_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) def test_run_repo_info_parses_json(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_info).should_receive('display_repository_info').and_return( flexmock() ) parsed_json = flexmock() flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json) repo_info_arguments = flexmock(repository=flexmock(), json=True) list( module.run_repo_info( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_info_arguments=repo_info_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) == [parsed_json] borgmatic/tests/unit/actions/test_repo_list.py000066400000000000000000000030701476361726000222330ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import repo_list as module def test_run_repo_list_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('list_repository') repo_list_arguments = flexmock(repository=flexmock(), json=False) list( module.run_repo_list( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_list_arguments=repo_list_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) ) def test_run_repo_list_produces_json(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.borg.repo_list).should_receive('list_repository').and_return( flexmock() ) parsed_json = flexmock() flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json) repo_list_arguments = flexmock(repository=flexmock(), json=True) assert list( module.run_repo_list( repository={'path': 'repo'}, config={}, local_borg_version=None, repo_list_arguments=repo_list_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) ) == [parsed_json] borgmatic/tests/unit/actions/test_restore.py000066400000000000000000001360331476361726000217240ustar00rootroot00000000000000import pytest from flexmock import flexmock import borgmatic.actions.restore as module @pytest.mark.parametrize( 'first_dump,second_dump,default_port,expected_result', ( ( module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'foo'), None, True, ), ( module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), None, False, ), ( module.Dump('postgresql_databases', 'foo'), module.Dump('mariadb_databases', 'foo'), None, False, ), ( module.Dump('postgresql_databases', 'foo'), module.Dump(module.UNSPECIFIED, 'foo'), None, True, ), ( module.Dump('postgresql_databases', 'foo'), module.Dump(module.UNSPECIFIED, 'bar'), None, False, ), ( module.Dump('postgresql_databases', module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), None, True, ), ( module.Dump('postgresql_databases', module.UNSPECIFIED), module.Dump('mariadb_databases', 'foo'), None, False, ), ( module.Dump('postgresql_databases', 'foo', 'myhost'), module.Dump('postgresql_databases', 'foo', 'myhost'), None, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost'), module.Dump('postgresql_databases', 'foo', 'otherhost'), None, False, ), ( module.Dump('postgresql_databases', 'foo', 'myhost'), module.Dump('postgresql_databases', 'foo', module.UNSPECIFIED), None, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost'), module.Dump('postgresql_databases', 'bar', module.UNSPECIFIED), None, False, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', 1234), module.Dump('postgresql_databases', 'foo', 'myhost', 1234), None, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', 1234), module.Dump('postgresql_databases', 'foo', 'myhost', 4321), None, False, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo', 'myhost', 1234), None, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo', 'otherhost', 1234), None, False, ), ( module.Dump( module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED ), module.Dump('postgresql_databases', 'foo', 'myhost', 1234), None, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', 5432), module.Dump('postgresql_databases', 'foo', 'myhost', None), 5432, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', None), module.Dump('postgresql_databases', 'foo', 'myhost', 5432), 5432, True, ), ( module.Dump('postgresql_databases', 'foo', 'myhost', 5433), module.Dump('postgresql_databases', 'foo', 'myhost', None), 5432, False, ), ), ) def test_dumps_match_compares_two_dumps_while_respecting_unspecified_values( first_dump, second_dump, default_port, expected_result ): assert module.dumps_match(first_dump, second_dump, default_port) == expected_result @pytest.mark.parametrize( 'dump,expected_result', ( ( module.Dump('postgresql_databases', 'foo'), 'foo@localhost (postgresql_databases)', ), ( module.Dump(module.UNSPECIFIED, 'foo'), 'foo@localhost', ), ( module.Dump('postgresql_databases', module.UNSPECIFIED), 'unspecified@localhost (postgresql_databases)', ), ( module.Dump('postgresql_databases', 'foo', 'host'), 'foo@host (postgresql_databases)', ), ( module.Dump('postgresql_databases', 'foo', module.UNSPECIFIED), 'foo (postgresql_databases)', ), ( module.Dump('postgresql_databases', 'foo', 'host', 1234), 'foo@host:1234 (postgresql_databases)', ), ( module.Dump('postgresql_databases', 'foo', module.UNSPECIFIED, 1234), 'foo@:1234 (postgresql_databases)', ), ( module.Dump('postgresql_databases', 'foo', 'host', module.UNSPECIFIED), 'foo@host (postgresql_databases)', ), ( module.Dump( module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED ), 'unspecified', ), ), ) def test_render_dump_metadata_renders_dump_values_into_string(dump, expected_result): assert module.render_dump_metadata(dump) == expected_result def test_get_configured_data_source_matches_data_source_with_restore_dump(): default_port = flexmock() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(default_port) flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'bar'), module.Dump('postgresql_databases', 'bar'), default_port=default_port, ).and_return(True) assert module.get_configured_data_source( config={ 'other_databases': [{'name': 'other'}], 'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}], }, restore_dump=module.Dump('postgresql_databases', 'bar'), ) == {'name': 'bar'} def test_get_configured_data_source_matches_nothing_when_nothing_configured(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(flexmock()) flexmock(module).should_receive('dumps_match').and_return(False) assert ( module.get_configured_data_source( config={}, restore_dump=module.Dump('postgresql_databases', 'quux'), ) is None ) def test_get_configured_data_source_matches_nothing_when_restore_dump_does_not_match_configuration(): flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(flexmock()) flexmock(module).should_receive('dumps_match').and_return(False) assert ( module.get_configured_data_source( config={ 'postgresql_databases': [{'name': 'foo'}], }, restore_dump=module.Dump('postgresql_databases', 'quux'), ) is None ) def test_get_configured_data_source_with_multiple_matching_data_sources_errors(): default_port = flexmock() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(default_port) flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'bar'), module.Dump('postgresql_databases', 'bar'), default_port=default_port, ).and_return(True) flexmock(module).should_receive('render_dump_metadata').and_return('test') with pytest.raises(ValueError): module.get_configured_data_source( config={ 'other_databases': [{'name': 'other'}], 'postgresql_databases': [ {'name': 'foo'}, {'name': 'bar'}, {'name': 'bar', 'format': 'directory'}, ], }, restore_dump=module.Dump('postgresql_databases', 'bar'), ) def test_strip_path_prefix_from_extracted_dump_destination_renames_first_matching_databases_subdirectory(): flexmock(module.os).should_receive('walk').and_return( [ ('/foo', flexmock(), flexmock()), ('/foo/bar', flexmock(), flexmock()), ('/foo/bar/postgresql_databases', flexmock(), flexmock()), ('/foo/bar/mariadb_databases', flexmock(), flexmock()), ] ) flexmock(module.shutil).should_receive('move').with_args( '/foo/bar/postgresql_databases', '/run/user/0/borgmatic/postgresql_databases' ).once() flexmock(module.shutil).should_receive('move').with_args( '/foo/bar/mariadb_databases', '/run/user/0/borgmatic/mariadb_databases' ).never() module.strip_path_prefix_from_extracted_dump_destination('/foo', '/run/user/0/borgmatic') def test_restore_single_dump_extracts_and_restores_single_file_dump(): flexmock(module).should_receive('render_dump_metadata').and_return('test') flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args( 'make_data_source_dump_patterns', object, object, object, object ).and_return({'postgresql': flexmock()}) flexmock(module.tempfile).should_receive('mkdtemp').never() flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'convert_glob_patterns_to_borg_pattern' ).and_return(flexmock()) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( flexmock() ).once() flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never() flexmock(module.shutil).should_receive('rmtree').never() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( function_name='restore_data_source_dump', config=object, hook_name=object, data_source=object, dry_run=object, extract_process=object, connection_params=object, borgmatic_runtime_directory=object, ).once() module.restore_single_dump( repository={'path': 'test.borg'}, config=flexmock(), local_borg_version=flexmock(), global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, archive_name=flexmock(), hook_name='postgresql', data_source={'name': 'test', 'format': 'plain'}, connection_params=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_single_dump_extracts_and_restores_directory_dump(): flexmock(module).should_receive('render_dump_metadata').and_return('test') flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args( 'make_data_source_dump_patterns', object, object, object, object ).and_return({'postgresql': flexmock()}) flexmock(module.tempfile).should_receive('mkdtemp').once().and_return( '/run/user/0/borgmatic/tmp1234' ) flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'convert_glob_patterns_to_borg_pattern' ).and_return(flexmock()) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( flexmock() ).once() flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').once() flexmock(module.shutil).should_receive('rmtree').once() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( function_name='restore_data_source_dump', config=object, hook_name=object, data_source=object, dry_run=object, extract_process=object, connection_params=object, borgmatic_runtime_directory='/run/borgmatic', ).once() module.restore_single_dump( repository={'path': 'test.borg'}, config=flexmock(), local_borg_version=flexmock(), global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, archive_name=flexmock(), hook_name='postgresql', data_source={'name': 'test', 'format': 'directory'}, connection_params=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_single_dump_with_directory_dump_error_cleans_up_temporary_directory(): flexmock(module).should_receive('render_dump_metadata').and_return('test') flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args( 'make_data_source_dump_patterns', object, object, object, object ).and_return({'postgresql': flexmock()}) flexmock(module.tempfile).should_receive('mkdtemp').once().and_return( '/run/user/0/borgmatic/tmp1234' ) flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'convert_glob_patterns_to_borg_pattern' ).and_return(flexmock()) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_raise( ValueError ).once() flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never() flexmock(module.shutil).should_receive('rmtree').once() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( function_name='restore_data_source_dump', config=object, hook_name=object, data_source=object, dry_run=object, extract_process=object, connection_params=object, borgmatic_runtime_directory='/run/user/0/borgmatic/tmp1234', ).never() with pytest.raises(ValueError): module.restore_single_dump( repository={'path': 'test.borg'}, config=flexmock(), local_borg_version=flexmock(), global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, archive_name=flexmock(), hook_name='postgresql', data_source={'name': 'test', 'format': 'directory'}, connection_params=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_single_dump_with_directory_dump_and_dry_run_skips_directory_move_and_cleanup(): flexmock(module).should_receive('render_dump_metadata').and_return('test') flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args( 'make_data_source_dump_patterns', object, object, object, object ).and_return({'postgresql': flexmock()}) flexmock(module.tempfile).should_receive('mkdtemp').once().and_return('/run/borgmatic/tmp1234') flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'convert_glob_patterns_to_borg_pattern' ).and_return(flexmock()) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( flexmock() ).once() flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never() flexmock(module.shutil).should_receive('rmtree').never() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( function_name='restore_data_source_dump', config=object, hook_name=object, data_source=object, dry_run=object, extract_process=object, connection_params=object, borgmatic_runtime_directory='/run/borgmatic', ).once() module.restore_single_dump( repository={'path': 'test.borg'}, config=flexmock(), local_borg_version=flexmock(), global_arguments=flexmock(dry_run=True), local_path=None, remote_path=None, archive_name=flexmock(), hook_name='postgresql', data_source={'name': 'test', 'format': 'directory'}, connection_params=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) def test_collect_dumps_from_archive_parses_archive_paths(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'make_data_source_dump_path' ).and_return('') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( [ 'borgmatic/postgresql_databases/localhost/foo', 'borgmatic/postgresql_databases/host:1234/bar', 'borgmatic/mysql_databases/localhost/quux', ] ) archive_dumps = module.collect_dumps_from_archive( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) assert archive_dumps == { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar', 'host', 1234), module.Dump('mysql_databases', 'quux'), } def test_collect_dumps_from_archive_parses_archive_paths_with_different_base_directories(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'make_data_source_dump_path' ).and_return('') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( [ 'borgmatic/postgresql_databases/localhost/foo', '.borgmatic/postgresql_databases/localhost/bar', '/root/.borgmatic/postgresql_databases/localhost/baz', '/var/run/0/borgmatic/mysql_databases/localhost/quux', ] ) archive_dumps = module.collect_dumps_from_archive( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) assert archive_dumps == { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), module.Dump('postgresql_databases', 'baz'), module.Dump('mysql_databases', 'quux'), } def test_collect_dumps_from_archive_parses_directory_format_archive_paths(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'make_data_source_dump_path' ).and_return('') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( [ 'borgmatic/postgresql_databases/localhost/foo/table1', 'borgmatic/postgresql_databases/localhost/foo/table2', ] ) archive_dumps = module.collect_dumps_from_archive( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) assert archive_dumps == { module.Dump('postgresql_databases', 'foo'), } def test_collect_dumps_from_archive_skips_bad_archive_paths_or_bad_path_components(): flexmock(module.borgmatic.config.paths).should_receive( 'get_borgmatic_source_directory' ).and_return('/root/.borgmatic') flexmock(module.borgmatic.hooks.data_source.dump).should_receive( 'make_data_source_dump_path' ).and_return('') flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return( [ 'borgmatic/postgresql_databases/localhost/foo', 'borgmatic/postgresql_databases/localhost:abcd/bar', 'borgmatic/invalid', 'invalid/as/well', '', ] ) archive_dumps = module.collect_dumps_from_archive( repository={'path': 'repo'}, archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), borgmatic_runtime_directory='/run/borgmatic', ) assert archive_dumps == { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), } def test_get_dumps_to_restore_gets_requested_dumps_found_in_archive(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), module.Dump('postgresql_databases', 'baz'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'bar'), ).and_return(True) assert module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['foo', 'bar'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), } def test_get_dumps_to_restore_raises_for_requested_dumps_missing_from_archive(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('render_dump_metadata').and_return('test') with pytest.raises(ValueError): module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['foo', 'bar'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) def test_get_dumps_to_restore_without_requested_dumps_finds_all_archive_dumps(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) assert ( module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=[], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == dumps_from_archive ) def test_get_dumps_to_restore_with_all_in_requested_dumps_finds_all_archive_dumps(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'bar'), ).and_return(True) assert ( module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['all'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == dumps_from_archive ) def test_get_dumps_to_restore_with_all_in_requested_dumps_plus_additional_requested_dumps_omits_duplicates(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'bar'), ).and_return(True) assert ( module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['all', 'foo', 'bar'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == dumps_from_archive ) def test_get_dumps_to_restore_raises_for_multiple_matching_dumps_in_archive(): flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('mariadb_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('render_dump_metadata').and_return('test') with pytest.raises(ValueError): module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['foo'], original_hostname=None, original_port=None, ), dumps_from_archive={ module.Dump('postgresql_databases', 'foo'), module.Dump('mariadb_databases', 'foo'), }, ) def test_get_dumps_to_restore_raises_for_all_in_requested_dumps_and_requested_dumps_missing_from_archive(): flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) flexmock(module).should_receive('render_dump_metadata').and_return('test') with pytest.raises(ValueError): module.get_dumps_to_restore( restore_arguments=flexmock( hook=None, data_sources=['all', 'foo', 'bar'], original_hostname=None, original_port=None, ), dumps_from_archive={module.Dump('postresql_databases', 'foo')}, ) def test_get_dumps_to_restore_with_requested_hook_name_filters_dumps_found_in_archive(): dumps_from_archive = { module.Dump('mariadb_databases', 'foo'), module.Dump('postgresql_databases', 'foo'), module.Dump('sqlite_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) assert module.get_dumps_to_restore( restore_arguments=flexmock( hook='postgresql_databases', data_sources=['foo'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == { module.Dump('postgresql_databases', 'foo'), } def test_get_dumps_to_restore_with_requested_shortened_hook_name_filters_dumps_found_in_archive(): dumps_from_archive = { module.Dump('mariadb_databases', 'foo'), module.Dump('postgresql_databases', 'foo'), module.Dump('sqlite_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'foo', hostname=module.UNSPECIFIED), module.Dump('postgresql_databases', 'foo'), ).and_return(True) assert module.get_dumps_to_restore( restore_arguments=flexmock( hook='postgresql', data_sources=['foo'], original_hostname=None, original_port=None, ), dumps_from_archive=dumps_from_archive, ) == { module.Dump('postgresql_databases', 'foo'), } def test_get_dumps_to_restore_with_requested_hostname_filters_dumps_found_in_archive(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo'), module.Dump('postgresql_databases', 'foo', 'host'), module.Dump('postgresql_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'foo', 'host'), module.Dump('postgresql_databases', 'foo', 'host'), ).and_return(True) assert module.get_dumps_to_restore( restore_arguments=flexmock( hook='postgresql_databases', data_sources=['foo'], original_hostname='host', original_port=None, ), dumps_from_archive=dumps_from_archive, ) == { module.Dump('postgresql_databases', 'foo', 'host'), } def test_get_dumps_to_restore_with_requested_port_filters_dumps_found_in_archive(): dumps_from_archive = { module.Dump('postgresql_databases', 'foo', 'host'), module.Dump('postgresql_databases', 'foo', 'host', 1234), module.Dump('postgresql_databases', 'bar'), } flexmock(module).should_receive('dumps_match').and_return(False) flexmock(module).should_receive('dumps_match').with_args( module.Dump('postgresql_databases', 'foo', 'host', 1234), module.Dump('postgresql_databases', 'foo', 'host', 1234), ).and_return(True) assert module.get_dumps_to_restore( restore_arguments=flexmock( hook='postgresql_databases', data_sources=['foo'], original_hostname='host', original_port=1234, ), dumps_from_archive=dumps_from_archive, ) == { module.Dump('postgresql_databases', 'foo', 'host', 1234), } def test_ensure_requested_dumps_restored_with_all_dumps_restored_does_not_raise(): module.ensure_requested_dumps_restored( dumps_to_restore={ module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='postgresql_databases', data_source_name='bar'), }, dumps_actually_restored={ module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='postgresql_databases', data_source_name='bar'), }, ) def test_ensure_requested_dumps_restored_with_no_dumps_raises(): with pytest.raises(ValueError): module.ensure_requested_dumps_restored( dumps_to_restore={}, dumps_actually_restored={}, ) def test_ensure_requested_dumps_restored_with_missing_dumps_raises(): flexmock(module).should_receive('render_dump_metadata').and_return('test') with pytest.raises(ValueError): module.ensure_requested_dumps_restored( dumps_to_restore={ module.Dump(hook_name='postgresql_databases', data_source_name='foo') }, dumps_actually_restored={ module.Dump(hook_name='postgresql_databases', data_source_name='bar') }, ) def test_run_restore_restores_each_data_source(): dumps_to_restore = { module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='postgresql_databases', data_source_name='bar'), } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_dumps_from_archive').and_return(flexmock()) flexmock(module).should_receive('get_dumps_to_restore').and_return(dumps_to_restore) flexmock(module).should_receive('get_configured_data_source').and_return( {'name': 'foo'} ).and_return({'name': 'bar'}) flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'foo', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'bar', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('ensure_requested_dumps_restored') module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock( repository='repo', archive='archive', data_sources=flexmock(), schemas=None, hostname=None, port=None, username=None, password=None, restore_path=None, ), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) def test_run_restore_bails_for_non_matching_repository(): flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return( False ) flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive( 'call_hooks_even_if_unconfigured' ).never() flexmock(module).should_receive('restore_single_dump').never() module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock(repository='repo', archive='archive', data_sources=flexmock()), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) def test_run_restore_restores_data_source_by_falling_back_to_all_name(): dumps_to_restore = { module.Dump(hook_name='postgresql_databases', data_source_name='foo'), } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_dumps_from_archive').and_return(flexmock()) flexmock(module).should_receive('get_dumps_to_restore').and_return(dumps_to_restore) flexmock(module).should_receive('get_configured_data_source').and_return( {'name': 'foo'} ).and_return({'name': 'all'}) flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'foo', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('ensure_requested_dumps_restored') module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock( repository='repo', archive='archive', data_sources=flexmock(), schemas=None, hostname=None, port=None, username=None, password=None, restore_path=None, ), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) def test_run_restore_restores_data_source_configured_with_all_name(): dumps_to_restore = { module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='postgresql_databases', data_source_name='bar'), } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_dumps_from_archive').and_return(flexmock()) flexmock(module).should_receive('get_dumps_to_restore').and_return(dumps_to_restore) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'), ).and_return({'name': 'foo'}) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='bar'), ).and_return(None) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='all'), ).and_return({'name': 'bar'}) flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'foo', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'bar', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('ensure_requested_dumps_restored') module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock( repository='repo', archive='archive', data_sources=flexmock(), schemas=None, hostname=None, port=None, username=None, password=None, restore_path=None, ), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) def test_run_restore_skips_missing_data_source(): dumps_to_restore = { module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='postgresql_databases', data_source_name='bar'), } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_dumps_from_archive').and_return(flexmock()) flexmock(module).should_receive('get_dumps_to_restore').and_return(dumps_to_restore) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'), ).and_return({'name': 'foo'}) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='bar'), ).and_return(None) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='all'), ).and_return(None) flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'foo', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'bar', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).never() flexmock(module).should_receive('ensure_requested_dumps_restored') module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock( repository='repo', archive='archive', data_sources=flexmock(), schemas=None, hostname=None, port=None, username=None, password=None, restore_path=None, ), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) def test_run_restore_restores_data_sources_from_different_hooks(): dumps_to_restore = { module.Dump(hook_name='postgresql_databases', data_source_name='foo'), module.Dump(hook_name='mysql_databases', data_source_name='foo'), } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) borgmatic_runtime_directory = flexmock() flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return( borgmatic_runtime_directory ) flexmock(module.borgmatic.config.paths).should_receive( 'make_runtime_directory_glob' ).replace_with(lambda path: path) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_dumps_from_archive').and_return(flexmock()) flexmock(module).should_receive('get_dumps_to_restore').and_return(dumps_to_restore) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'), ).and_return({'name': 'foo'}) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, restore_dump=module.Dump(hook_name='mysql_databases', data_source_name='foo'), ).and_return({'name': 'bar'}) flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='postgresql_databases', data_source={'name': 'foo', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('restore_single_dump').with_args( repository=object, config=object, local_borg_version=object, global_arguments=object, local_path=object, remote_path=object, archive_name=object, hook_name='mysql_databases', data_source={'name': 'bar', 'schemas': None}, connection_params=object, borgmatic_runtime_directory=borgmatic_runtime_directory, ).once() flexmock(module).should_receive('ensure_requested_dumps_restored') module.run_restore( repository={'path': 'repo'}, config=flexmock(), local_borg_version=flexmock(), restore_arguments=flexmock( repository='repo', archive='archive', data_sources=flexmock(), schemas=None, hostname=None, port=None, username=None, password=None, restore_path=None, ), global_arguments=flexmock(dry_run=False), local_path=flexmock(), remote_path=flexmock(), ) borgmatic/tests/unit/actions/test_transfer.py000066400000000000000000000011661476361726000220630ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import transfer as module def test_run_transfer_does_not_raise(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.borg.transfer).should_receive('transfer_archives') transfer_arguments = flexmock() global_arguments = flexmock(monitoring_verbosity=1, dry_run=False) module.run_transfer( repository={'path': 'repo'}, config={}, local_borg_version=None, transfer_arguments=transfer_arguments, global_arguments=global_arguments, local_path=None, remote_path=None, ) borgmatic/tests/unit/borg/000077500000000000000000000000001476361726000161135ustar00rootroot00000000000000borgmatic/tests/unit/borg/__init__.py000066400000000000000000000000001476361726000202120ustar00rootroot00000000000000borgmatic/tests/unit/borg/test_borg.py000066400000000000000000000361261476361726000204650ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import borg as module from ..test_verbosity import insert_logging_mock def test_run_arbitrary_borg_calls_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], ) def test_run_arbitrary_borg_with_log_info_calls_borg_with_info_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '--info', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], ) def test_run_arbitrary_borg_with_log_debug_calls_borg_with_debug_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '--debug', '--show-rc', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.DEBUG) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], ) def test_run_arbitrary_borg_with_lock_wait_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER config = {'lock_wait': 5} flexmock(module.flags).should_receive('make_flags').and_return(()).and_return( ('--lock-wait', '5') ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '--lock-wait', '5', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config=config, local_borg_version='1.2.3', options=['break-lock', '::'], ) def test_run_arbitrary_borg_with_archive_calls_borg_with_archive_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', "'::$ARCHIVE'"), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': 'archive'}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::$ARCHIVE'], archive='archive', ) def test_run_arbitrary_borg_with_local_path_calls_borg_via_local_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg1', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg1', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], local_path='borg1', ) def test_run_arbitrary_borg_with_exit_codes_calls_borg_using_them(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') borg_exit_codes = flexmock() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) module.run_arbitrary_borg( repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', options=['break-lock', '::'], ) def test_run_arbitrary_borg_with_remote_path_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return( ('--remote-path', 'borg1') ).and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '--remote-path', 'borg1', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], remote_path='borg1', ) def test_run_arbitrary_borg_with_remote_path_injection_attack_gets_escaped(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return( ('--remote-path', 'borg1; naughty-command') ).and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '--remote-path', "'borg1; naughty-command'", '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], remote_path='borg1', ) def test_run_arbitrary_borg_passes_borg_specific_flags_to_borg(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', '--progress', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['list', '--progress', '::'], ) def test_run_arbitrary_borg_omits_dash_dash_in_flags_passed_to_borg(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['--', 'break-lock', '::'], ) def test_run_arbitrary_borg_without_borg_specific_flags_does_not_raise(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg',), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=[], ) def test_run_arbitrary_borg_passes_key_sub_command_to_borg_before_injected_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'key', 'export', '--info', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['key', 'export', '::'], ) def test_run_arbitrary_borg_passes_debug_sub_command_to_borg_before_injected_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'debug', 'dump-manifest', '--info', '::', 'path'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['debug', 'dump-manifest', '::', 'path'], ) def test_run_arbitrary_borg_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, shell=True, environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['break-lock', '::'], ) borgmatic/tests/unit/borg/test_break_lock.py000066400000000000000000000113551476361726000216250ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import break_lock as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock(command, working_directory=None, borg_exit_codes=None): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, environment=None, working_directory=working_directory, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def test_break_lock_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', 'repo')) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_break_lock_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'break-lock', 'repo')) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_break_lock_calls_borg_using_exit_codes(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'break-lock', 'repo')) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_break_lock_calls_borg_with_remote_path_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--remote-path', 'borg1', 'repo')) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_break_lock_calls_borg_with_umask_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--umask', '0770', 'repo')) module.break_lock( repository_path='repo', config={'umask': '0770'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_break_lock_calls_borg_with_log_json_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--log-json', 'repo')) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_break_lock_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--lock-wait', '5', 'repo')) module.break_lock( repository_path='repo', config={'lock_wait': '5'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_break_lock_with_log_info_calls_borg_with_info_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--info', 'repo')) insert_logging_mock(logging.INFO) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_break_lock_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', '--debug', '--show-rc', 'repo')) insert_logging_mock(logging.DEBUG) module.break_lock( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_break_lock_calls_borg_with_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'break-lock', 'repo'), working_directory='/working/dir') module.break_lock( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_change_passphrase.py000066400000000000000000000166701476361726000232140ustar00rootroot00000000000000import logging from flexmock import flexmock import borgmatic.logger from borgmatic.borg import change_passphrase as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( command, config=None, output_file=module.borgmatic.execute.DO_NOT_CAPTURE, working_directory=None, borg_exit_codes=None, ): borgmatic.logger.add_custom_log_levels() flexmock(module.environment).should_receive('make_environment').with_args(config or {}).once() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_file=output_file, output_log_level=module.logging.ANSWER, environment=None, working_directory=working_directory, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def test_change_passphrase_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'key', 'change-passphrase', 'repo')) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'key', 'change-passphrase', 'repo')) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg1', ) def test_change_passphrase_calls_borg_using_exit_codes(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) borg_exit_codes = flexmock() config = {'borg_exit_codes': borg_exit_codes} insert_execute_command_mock( ('borg', 'key', 'change-passphrase', 'repo'), config=config, borg_exit_codes=borg_exit_codes ) module.change_passphrase( repository_path='repo', config=config, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_calls_borg_with_remote_path_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock( ('borg', 'key', 'change-passphrase', '--remote-path', 'borg1', 'repo') ) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), remote_path='borg1', ) def test_change_passphrase_calls_borg_with_umask_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) config = {'umask': '0770'} insert_execute_command_mock( ('borg', 'key', 'change-passphrase', '--umask', '0770', 'repo'), config=config ) module.change_passphrase( repository_path='repo', config=config, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_calls_borg_with_log_json_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--log-json', 'repo')) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=True), ) def test_change_passphrase_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) config = {'lock_wait': '5'} insert_execute_command_mock( ('borg', 'key', 'change-passphrase', '--lock-wait', '5', 'repo'), config=config ) module.change_passphrase( repository_path='repo', config=config, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_with_log_info_calls_borg_with_info_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--info', 'repo')) insert_logging_mock(logging.INFO) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock( ('borg', 'key', 'change-passphrase', '--debug', '--show-rc', 'repo') ) insert_logging_mock(logging.DEBUG) module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_with_dry_run_skips_borg_call(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').never() module.change_passphrase( repository_path='repo', config={}, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=True, log_json=False), ) def test_change_passphrase_calls_borg_without_passphrase(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock( ('borg', 'key', 'change-passphrase', 'repo'), config={'option': 'foo'} ) module.change_passphrase( repository_path='repo', config={ 'encryption_passphrase': 'test', 'encryption_passcommand': 'getpass', 'option': 'foo', }, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_change_passphrase_calls_borg_with_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) config = {'working_directory': '/working/dir'} insert_execute_command_mock( ('borg', 'key', 'change-passphrase', 'repo'), config=config, working_directory='/working/dir', ) module.change_passphrase( repository_path='repo', config=config, local_borg_version='1.2.3', change_passphrase_arguments=flexmock(), global_arguments=flexmock(dry_run=False, log_json=False), ) borgmatic/tests/unit/borg/test_check.py000066400000000000000000001041541476361726000206060ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import check as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( command, output_file=None, working_directory=None, borg_exit_codes=None ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, output_file=output_file, environment=None, working_directory=working_directory, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def insert_execute_command_never(): flexmock(module).should_receive('execute_command').never() def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_default_flags(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo'}, ('repository', 'archives'), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:foo*') def test_make_archive_filter_flags_with_all_checks_and_prefix_returns_default_flags(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo'}, ('repository', 'archives', 'extract'), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:foo*') def test_make_archive_filter_flags_with_all_checks_and_prefix_without_borg_features_returns_glob_archives_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo'}, ('repository', 'archives', 'extract'), check_arguments=flexmock(match_archives=None), ) assert flags == ('--glob-archives', 'foo*') def test_make_archive_filter_flags_with_archives_check_and_last_includes_last_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'check_last': 3}, ('archives',), check_arguments=flexmock(match_archives=None), ) assert flags == ('--last', '3') def test_make_archive_filter_flags_with_data_check_and_last_includes_last_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'check_last': 3}, ('data',), check_arguments=flexmock(match_archives=None), ) assert flags == ('--last', '3') def test_make_archive_filter_flags_with_repository_check_and_last_omits_last_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'check_last': 3}, ('repository',), check_arguments=flexmock(match_archives=None), ) assert flags == () def test_make_archive_filter_flags_with_default_checks_and_last_includes_last_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'check_last': 3}, ('repository', 'archives'), check_arguments=flexmock(match_archives=None), ) assert flags == ('--last', '3') def test_make_archive_filter_flags_with_archives_check_and_prefix_includes_match_archives_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo-'}, ('archives',), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:foo-*') def test_make_archive_filter_flags_with_data_check_and_prefix_includes_match_archives_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo-'}, ('data',), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:foo-*') def test_make_archive_filter_flags_prefers_check_arguments_match_archives_to_config_match_archives(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'baz-*', None, '1.2.3' ).and_return(('--match-archives', 'sh:baz-*')) flags = module.make_archive_filter_flags( '1.2.3', {'match_archives': 'bar-{now}', 'prefix': ''}, # noqa: FS003 ('archives',), check_arguments=flexmock(match_archives='baz-*'), ) assert flags == ('--match-archives', 'sh:baz-*') def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_archive_name_format_instead(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '1.2.3' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')) flags = module.make_archive_filter_flags( '1.2.3', {'archive_name_format': 'bar-{now}', 'prefix': ''}, # noqa: FS003 ('archives',), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:bar-*') def test_make_archive_filter_flags_with_archives_check_and_none_prefix_omits_match_archives_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), ) assert flags == () def test_make_archive_filter_flags_with_repository_check_and_prefix_omits_match_archives_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo-'}, ('repository',), check_arguments=flexmock(match_archives=None), ) assert flags == () def test_make_archive_filter_flags_with_default_checks_and_prefix_includes_match_archives_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_archive_filter_flags( '1.2.3', {'prefix': 'foo-'}, ('repository', 'archives'), check_arguments=flexmock(match_archives=None), ) assert flags == ('--match-archives', 'sh:foo-*') def test_make_check_name_flags_with_repository_check_returns_flag(): flags = module.make_check_name_flags({'repository'}, ()) assert flags == ('--repository-only',) def test_make_check_name_flags_with_archives_check_returns_flag(): flags = module.make_check_name_flags({'archives'}, ()) assert flags == ('--archives-only',) def test_make_check_name_flags_with_archives_check_and_archive_filter_flags_includes_those_flags(): flags = module.make_check_name_flags({'archives'}, ('--match-archives', 'sh:foo-*')) assert flags == ('--archives-only', '--match-archives', 'sh:foo-*') def test_make_check_name_flags_without_archives_check_and_with_archive_filter_flags_includes_those_flags(): flags = module.make_check_name_flags({'repository'}, ('--match-archives', 'sh:foo-*')) assert flags == ('--repository-only',) def test_make_check_name_flags_with_archives_and_data_check_returns_verify_data_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_check_name_flags({'archives', 'data'}, ()) assert flags == ( '--archives-only', '--verify-data', ) def test_make_check_name_flags_with_repository_and_data_check_returns_verify_data_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_check_name_flags({'archives', 'data', 'repository'}, ()) assert flags == ('--verify-data',) def test_make_check_name_flags_with_extract_omits_extract_flag(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_check_name_flags({'extract'}, ()) assert flags == () def test_get_repository_id_with_valid_json_does_not_raise(): config = {} flexmock(module.repo_info).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) assert module.get_repository_id( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg', remote_path=None, ) def test_get_repository_id_with_json_error_raises(): config = {} flexmock(module.repo_info).should_receive('display_repository_info').and_return( '{"unexpected": {"id": "repo"}}' ) with pytest.raises(ValueError): module.get_repository_id( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg', remote_path=None, ) def test_get_repository_id_with_missing_json_keys_raises(): config = {} flexmock(module.repo_info).should_receive('display_repository_info').and_return('{invalid JSON') with pytest.raises(ValueError): module.get_repository_id( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg', remote_path=None, ) def test_check_archives_with_progress_passes_through_to_borg(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=True, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_repair_passes_through_to_borg(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=True, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_flag_passes_through_to_borg(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--max-duration', '33', 'repo'), output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=33, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_option_passes_through_to_borg(): config = {'checks': [{'name': 'repository', 'max_duration': 33}]} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--max-duration', '33', 'repo'), output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_option_and_archives_check_runs_repository_check_separately(): config = {'checks': [{'name': 'repository', 'max_duration': 33}, {'name': 'archives'}]} flexmock(module).should_receive('make_check_name_flags').with_args({'archives'}, ()).and_return( ('--archives-only',) ) flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(('--repository-only',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--archives-only', 'repo')) insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', 'repo') ) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository', 'archives'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_flag_and_archives_check_runs_repository_check_separately(): config = {'checks': [{'name': 'repository'}, {'name': 'archives'}]} flexmock(module).should_receive('make_check_name_flags').with_args({'archives'}, ()).and_return( ('--archives-only',) ) flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(('--repository-only',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--archives-only', 'repo')) insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', 'repo') ) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=33, ), global_arguments=flexmock(log_json=False), checks={'repository', 'archives'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_option_and_data_check_runs_repository_check_separately(): config = {'checks': [{'name': 'repository', 'max_duration': 33}, {'name': 'data'}]} flexmock(module).should_receive('make_check_name_flags').with_args( {'data', 'archives'}, () ).and_return(('--archives-only', '--verify-data')) flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(('--repository-only',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--archives-only', '--verify-data', 'repo')) insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', 'repo') ) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository', 'data'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_flag_and_data_check_runs_repository_check_separately(): config = {'checks': [{'name': 'repository'}, {'name': 'data'}]} flexmock(module).should_receive('make_check_name_flags').with_args( {'data', 'archives'}, () ).and_return(('--archives-only', '--verify-data')) flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(('--repository-only',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--archives-only', '--verify-data', 'repo')) insert_execute_command_mock( ('borg', 'check', '--max-duration', '33', '--repository-only', 'repo') ) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=33, ), global_arguments=flexmock(log_json=False), checks={'repository', 'data'}, archive_filter_flags=(), ) def test_check_archives_with_max_duration_flag_overrides_max_duration_option(): config = {'checks': [{'name': 'repository', 'max_duration': 33}]} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--max-duration', '44', 'repo'), output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=44, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) @pytest.mark.parametrize( 'checks', ( ('repository',), ('archives',), ('repository', 'archives'), ('repository', 'archives', 'other'), ), ) def test_check_archives_calls_borg_with_parameters(checks): config = {} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_data_check_implies_archives_check_calls_borg_with_parameters(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'data', 'archives'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'data'}, archive_filter_flags=(), ) def test_check_archives_with_log_info_passes_through_to_borg(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_logging_mock(logging.INFO) insert_execute_command_mock(('borg', 'check', '--info', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_log_debug_passes_through_to_borg(): config = {} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_logging_mock(logging.DEBUG) insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_local_path_calls_borg_via_local_path(): checks = {'repository'} config = {} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'check', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), local_path='borg1', ) def test_check_archives_with_exit_codes_calls_borg_using_them(): checks = {'repository'} borg_exit_codes = flexmock() config = {'borg_exit_codes': borg_exit_codes} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo'), borg_exit_codes=borg_exit_codes) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_remote_path_passes_through_to_borg(): checks = {'repository'} config = {} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), remote_path='borg1', ) def test_check_archives_with_umask_passes_through_to_borg(): checks = {'repository'} config = {'umask': '077'} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--umask', '077', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_log_json_passes_through_to_borg(): checks = {'repository'} config = {} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--log-json', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=True), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_lock_wait_passes_through_to_borg(): checks = {'repository'} config = {'lock_wait': 5} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--lock-wait', '5', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_retention_prefix(): checks = {'repository'} prefix = 'foo-' config = {'prefix': prefix} flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks=checks, archive_filter_flags=(), ) def test_check_archives_with_extra_borg_options_passes_through_to_borg(): config = {'extra_borg_options': {'check': '--extra --options'}} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo')) module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) def test_check_archives_with_match_archives_passes_through_to_borg(): config = {'checks': [{'name': 'archives'}]} flexmock(module).should_receive('make_check_name_flags').with_args( {'archives'}, object ).and_return(('--match-archives', 'foo-*')) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--match-archives', 'foo-*', 'repo'), output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=None, repair=None, only_checks=None, force=None, match_archives='foo-*', max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'archives'}, archive_filter_flags=('--match-archives', 'foo-*'), ) def test_check_archives_calls_borg_with_working_directory(): config = {'working_directory': '/working/dir'} flexmock(module).should_receive('make_check_name_flags').with_args( {'repository'}, () ).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) insert_execute_command_mock(('borg', 'check', 'repo'), working_directory='/working/dir') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', check_arguments=flexmock( progress=False, repair=None, only_checks=None, force=None, match_archives=None, max_duration=None, ), global_arguments=flexmock(log_json=False), checks={'repository'}, archive_filter_flags=(), ) borgmatic/tests/unit/borg/test_compact.py000066400000000000000000000174641476361726000211660ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import compact as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( compact_command, output_log_level, working_directory=None, borg_exit_codes=None ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory ) flexmock(module).should_receive('execute_command').with_args( compact_command, output_log_level=output_log_level, environment=None, working_directory=working_directory, borg_local_path=compact_command[0], borg_exit_codes=borg_exit_codes, ).once() COMPACT_COMMAND = ('borg', 'compact') def test_compact_segments_calls_borg_with_parameters(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('repo',), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_compact_segments_with_log_info_calls_borg_with_info_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--info', 'repo'), logging.INFO) insert_logging_mock(logging.INFO) module.compact_segments( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), dry_run=False, ) def test_compact_segments_with_log_debug_calls_borg_with_debug_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--debug', '--show-rc', 'repo'), logging.INFO) insert_logging_mock(logging.DEBUG) module.compact_segments( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), dry_run=False, ) def test_compact_segments_with_dry_run_skips_borg_call(): flexmock(module).should_receive('execute_command').never() module.compact_segments( repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), dry_run=True, ) def test_compact_segments_with_local_path_calls_borg_via_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1',) + COMPACT_COMMAND[1:] + ('repo',), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_compact_segments_with_exit_codes_calls_borg_using_them(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) borg_exit_codes = flexmock() insert_execute_command_mock( COMPACT_COMMAND + ('repo',), logging.INFO, borg_exit_codes=borg_exit_codes ) module.compact_segments( dry_run=False, repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_compact_segments_with_remote_path_calls_borg_with_remote_path_parameters(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--remote-path', 'borg1', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_compact_segments_with_progress_calls_borg_with_progress_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--progress', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), progress=True, ) def test_compact_segments_with_cleanup_commits_calls_borg_with_cleanup_commits_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--cleanup-commits', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), cleanup_commits=True, ) def test_compact_segments_with_threshold_calls_borg_with_threshold_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--threshold', '20', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), threshold=20, ) def test_compact_segments_with_umask_calls_borg_with_umask_parameters(): config = {'umask': '077'} flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--umask', '077', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_compact_segments_with_log_json_calls_borg_with_log_json_parameters(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--log-json', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_compact_segments_with_lock_wait_calls_borg_with_lock_wait_parameters(): config = {'lock_wait': 5} flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--lock-wait', '5', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_compact_segments_with_extra_borg_options_calls_borg_with_extra_options(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(COMPACT_COMMAND + ('--extra', '--options', 'repo'), logging.INFO) module.compact_segments( dry_run=False, repository_path='repo', config={'extra_borg_options': {'compact': '--extra --options'}}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_compact_segments_calls_borg_with_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock( COMPACT_COMMAND + ('repo',), logging.INFO, working_directory='/working/dir' ) module.compact_segments( dry_run=False, repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_create.py000066400000000000000000001767271476361726000210130ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import create as module from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type from ..test_verbosity import insert_logging_mock def test_write_patterns_file_writes_pattern_lines(): temporary_file = flexmock(name='filename', flush=lambda: None) temporary_file.should_receive('write').with_args('R /foo\n+ sh:/foo/bar') flexmock(module.tempfile).should_receive('NamedTemporaryFile').and_return(temporary_file) module.write_patterns_file( [Pattern('/foo'), Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.SHELL)], borgmatic_runtime_directory='/run/user/0', ) def test_write_patterns_file_with_empty_exclude_patterns_does_not_raise(): module.write_patterns_file([], borgmatic_runtime_directory='/run/user/0') def test_write_patterns_file_appends_to_existing(): patterns_file = flexmock(name='filename', flush=lambda: None) patterns_file.should_receive('write').with_args('\n') patterns_file.should_receive('write').with_args('R /foo\n+ /foo/bar') flexmock(module.tempfile).should_receive('NamedTemporaryFile').never() module.write_patterns_file( [Pattern('/foo'), Pattern('/foo/bar', Pattern_type.INCLUDE)], borgmatic_runtime_directory='/run/user/0', patterns_file=patterns_file, ) def test_make_exclude_flags_includes_exclude_caches_when_true_in_config(): exclude_flags = module.make_exclude_flags(config={'exclude_caches': True}) assert exclude_flags == ('--exclude-caches',) def test_make_exclude_flags_does_not_include_exclude_caches_when_false_in_config(): exclude_flags = module.make_exclude_flags(config={'exclude_caches': False}) assert exclude_flags == () def test_make_exclude_flags_includes_exclude_if_present_when_in_config(): exclude_flags = module.make_exclude_flags( config={'exclude_if_present': ['exclude_me', 'also_me']} ) assert exclude_flags == ( '--exclude-if-present', 'exclude_me', '--exclude-if-present', 'also_me', ) def test_make_exclude_flags_includes_keep_exclude_tags_when_true_in_config(): exclude_flags = module.make_exclude_flags(config={'keep_exclude_tags': True}) assert exclude_flags == ('--keep-exclude-tags',) def test_make_exclude_flags_does_not_include_keep_exclude_tags_when_false_in_config(): exclude_flags = module.make_exclude_flags(config={'keep_exclude_tags': False}) assert exclude_flags == () def test_make_exclude_flags_includes_exclude_nodump_when_true_in_config(): exclude_flags = module.make_exclude_flags(config={'exclude_nodump': True}) assert exclude_flags == ('--exclude-nodump',) def test_make_exclude_flags_does_not_include_exclude_nodump_when_false_in_config(): exclude_flags = module.make_exclude_flags(config={'exclude_nodump': False}) assert exclude_flags == () def test_make_exclude_flags_is_empty_when_config_has_no_excludes(): exclude_flags = module.make_exclude_flags(config={}) assert exclude_flags == () def test_make_list_filter_flags_with_debug_and_feature_available_includes_plus_and_minus(): flexmock(module.logger).should_receive('isEnabledFor').and_return(True) flexmock(module.feature).should_receive('available').and_return(True) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=False) == 'AME+-' def test_make_list_filter_flags_with_info_and_feature_available_omits_plus_and_minus(): flexmock(module.logger).should_receive('isEnabledFor').and_return(False) flexmock(module.feature).should_receive('available').and_return(True) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=False) == 'AME' def test_make_list_filter_flags_with_debug_and_feature_available_and_dry_run_includes_plus_and_minus(): flexmock(module.logger).should_receive('isEnabledFor').and_return(True) flexmock(module.feature).should_receive('available').and_return(True) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=True) == 'AME+-' def test_make_list_filter_flags_with_info_and_feature_available_and_dry_run_includes_plus_and_minus(): flexmock(module.logger).should_receive('isEnabledFor').and_return(False) flexmock(module.feature).should_receive('available').and_return(True) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=True) == 'AME+-' def test_make_list_filter_flags_with_debug_and_feature_not_available_includes_x(): flexmock(module.logger).should_receive('isEnabledFor').and_return(True) flexmock(module.feature).should_receive('available').and_return(False) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=False) == 'AMEx-' def test_make_list_filter_flags_with_info_and_feature_not_available_omits_x(): flexmock(module.logger).should_receive('isEnabledFor').and_return(False) flexmock(module.feature).should_receive('available').and_return(False) assert module.make_list_filter_flags(local_borg_version=flexmock(), dry_run=False) == 'AME-' @pytest.mark.parametrize( 'character_device,block_device,fifo,expected_result', ( (False, False, False, False), (True, False, False, True), (False, True, False, True), (True, True, False, True), (False, False, True, True), (False, True, True, True), (True, False, True, True), ), ) def test_special_file_looks_at_file_type(character_device, block_device, fifo, expected_result): flexmock(module.os).should_receive('stat').and_return(flexmock(st_mode=flexmock())) flexmock(module.stat).should_receive('S_ISCHR').and_return(character_device) flexmock(module.stat).should_receive('S_ISBLK').and_return(block_device) flexmock(module.stat).should_receive('S_ISFIFO').and_return(fifo) assert module.special_file('/dev/special') == expected_result def test_special_file_treats_broken_symlink_as_non_special(): flexmock(module.os).should_receive('stat').and_raise(FileNotFoundError) assert module.special_file('/broken/symlink') is False def test_special_file_prepends_relative_path_with_working_directory(): flexmock(module.os).should_receive('stat').with_args('/working/dir/relative').and_return( flexmock(st_mode=flexmock()) ) flexmock(module.stat).should_receive('S_ISCHR').and_return(False) flexmock(module.stat).should_receive('S_ISBLK').and_return(False) flexmock(module.stat).should_receive('S_ISFIFO').and_return(False) assert module.special_file('relative', '/working/dir') is False def test_any_parent_directories_treats_parents_as_match(): module.any_parent_directories('/foo/bar.txt', ('/foo', '/etc')) def test_any_parent_directories_treats_grandparents_as_match(): module.any_parent_directories('/foo/bar/baz.txt', ('/foo', '/etc')) def test_any_parent_directories_treats_unrelated_paths_as_non_match(): module.any_parent_directories('/foo/bar.txt', ('/usr', '/etc')) def test_collect_special_file_paths_parses_special_files_from_borg_dry_run_file_list(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments ) flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( lambda arguments, flag: arguments ) flexmock(module.environment).should_receive('make_environment').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return( 'Processing files ...\n- /foo\n+ /bar\n- /baz' ) flexmock(module).should_receive('special_file').and_return(True) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module).should_receive('any_parent_directories').never() assert module.collect_special_file_paths( dry_run=False, create_command=('borg', 'create'), config={}, local_path=None, working_directory=None, borgmatic_runtime_directory='/run/borgmatic', ) == ('/foo', '/bar', '/baz') def test_collect_special_file_paths_skips_borgmatic_runtime_directory(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments ) flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( lambda arguments, flag: arguments ) flexmock(module.environment).should_receive('make_environment').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return( '+ /foo\n- /run/borgmatic/bar\n- /baz' ) flexmock(module).should_receive('special_file').and_return(True) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').with_args( '/foo', ('/run/borgmatic',) ).and_return(False) flexmock(module).should_receive('any_parent_directories').with_args( '/run/borgmatic/bar', ('/run/borgmatic',) ).and_return(True) flexmock(module).should_receive('any_parent_directories').with_args( '/baz', ('/run/borgmatic',) ).and_return(False) assert module.collect_special_file_paths( dry_run=False, create_command=('borg', 'create'), config={}, local_path=None, working_directory=None, borgmatic_runtime_directory='/run/borgmatic', ) == ('/foo', '/baz') def test_collect_special_file_paths_with_borgmatic_runtime_directory_missing_from_paths_output_errors(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments ) flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( lambda arguments, flag: arguments ) flexmock(module.environment).should_receive('make_environment').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return( '+ /foo\n- /bar\n- /baz' ) flexmock(module).should_receive('special_file').and_return(True) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').and_return(False) with pytest.raises(ValueError): module.collect_special_file_paths( dry_run=False, create_command=('borg', 'create'), config={}, local_path=None, working_directory=None, borgmatic_runtime_directory='/run/borgmatic', ) def test_collect_special_file_paths_with_dry_run_and_borgmatic_runtime_directory_missing_from_paths_output_does_not_raise(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments ) flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( lambda arguments, flag: arguments ) flexmock(module.environment).should_receive('make_environment').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return( '+ /foo\n- /bar\n- /baz' ) flexmock(module).should_receive('special_file').and_return(True) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('any_parent_directories').and_return(False) assert module.collect_special_file_paths( dry_run=True, create_command=('borg', 'create'), config={}, local_path=None, working_directory=None, borgmatic_runtime_directory='/run/borgmatic', ) == ('/foo', '/bar', '/baz') def test_collect_special_file_paths_excludes_non_special_files(): flexmock(module.flags).should_receive('omit_flag').replace_with( lambda arguments, flag: arguments ) flexmock(module.flags).should_receive('omit_flag_and_value').replace_with( lambda arguments, flag: arguments ) flexmock(module.environment).should_receive('make_environment').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return( '+ /foo\n+ /bar\n+ /baz' ) flexmock(module).should_receive('special_file').and_return(True).and_return(False).and_return( True ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module).should_receive('any_parent_directories').never() assert module.collect_special_file_paths( dry_run=False, create_command=('borg', 'create'), config={}, local_path=None, working_directory=None, borgmatic_runtime_directory='/run/borgmatic', ) == ('/foo', '/baz') DEFAULT_ARCHIVE_NAME = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 REPO_ARCHIVE = (f'repo::{DEFAULT_ARCHIVE_NAME}',) def test_make_base_create_produces_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_patterns_file_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) mock_pattern_file = flexmock(name='/tmp/patterns') flexmock(module).should_receive('write_patterns_file').and_return(mock_pattern_file).and_return( None ) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) pattern_flags = ('--patterns-from', mock_pattern_file.name) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'patterns': ['pattern'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') + pattern_flags assert create_positional_arguments == (f'repo::{DEFAULT_ARCHIVE_NAME}',) assert pattern_file == mock_pattern_file def test_make_base_create_command_with_store_config_false_omits_config_files(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'store_config_files': False, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file @pytest.mark.parametrize( 'option_name,option_value,feature_available,option_flags', ( ('checkpoint_interval', 600, True, ('--checkpoint-interval', '600')), ('checkpoint_volume', 1024, True, ('--checkpoint-volume', '1024')), ('chunker_params', '1,2,3,4', True, ('--chunker-params', '1,2,3,4')), ('compression', 'rle', True, ('--compression', 'rle')), ('one_file_system', True, True, ('--one-file-system',)), ('upload_rate_limit', 100, True, ('--upload-ratelimit', '100')), ('upload_rate_limit', 100, False, ('--remote-ratelimit', '100')), ('upload_buffer_size', 160, True, ('--upload-buffer', '160')), ('numeric_ids', True, True, ('--numeric-ids',)), ('numeric_ids', True, False, ('--numeric-owner',)), ('read_special', True, True, ('--read-special',)), ('ctime', True, True, ()), ('ctime', False, True, ('--noctime',)), ('birthtime', True, True, ()), ('birthtime', False, True, ('--nobirthtime',)), ('atime', True, True, ('--atime',)), ('atime', True, False, ()), ('atime', False, True, ()), ('atime', False, False, ('--noatime',)), ('flags', True, True, ()), ('flags', True, False, ()), ('flags', False, True, ('--noflags',)), ('flags', False, False, ('--nobsdflags',)), ('files_cache', 'ctime,size', True, ('--files-cache', 'ctime,size')), ('umask', 740, True, ('--umask', '740')), ('lock_wait', 5, True, ('--lock-wait', '5')), ), ) def test_make_base_create_command_includes_configuration_option_as_command_flag( option_name, option_value, feature_available, option_flags ): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], option_name: option_value, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') + option_flags assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_dry_run_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=True, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': ['exclude'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create', '--dry-run') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_local_path_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', local_path='borg1', ) assert create_flags == ('borg1', 'create') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_remote_path_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', remote_path='borg1', ) assert create_flags == ('borg', 'create', '--remote-path', 'borg1') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_log_json_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create', '--log-json') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_list_flags_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', list_files=True, ) assert create_flags == ('borg', 'create', '--list', '--filter', 'FOO') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_with_stream_processes_ignores_read_special_false_and_excludes_special_files(): patterns = [Pattern('foo'), Pattern('bar')] patterns_file = flexmock(name='patterns') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').with_args( patterns, '/run/borgmatic' ).and_return(patterns_file) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) flexmock(module.logger).should_receive('warning').twice() flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',)).once() flexmock(module).should_receive('write_patterns_file').with_args( ( Pattern( '/dev/null', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.INTERNAL, ), ), '/run/borgmatic', patterns_file=patterns_file, ).and_return(patterns_file).once() flexmock(module).should_receive('make_exclude_flags').and_return(()) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'read_special': False, }, patterns=patterns, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', stream_processes=flexmock(), ) assert create_flags == ('borg', 'create', '--patterns-from', 'patterns', '--read-special') assert create_positional_arguments == REPO_ARCHIVE assert pattern_file def test_make_base_create_command_without_patterns_and_with_stream_processes_ignores_read_special_false_and_excludes_special_files(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').with_args( [], '/run/borgmatic' ).and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) flexmock(module.logger).should_receive('warning').twice() flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',)).once() flexmock(module).should_receive('write_patterns_file').with_args( ( Pattern( '/dev/null', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, source=Pattern_source.INTERNAL, ), ), '/run/borgmatic', patterns_file=None, ).and_return(flexmock(name='patterns')).once() flexmock(module).should_receive('make_exclude_flags').and_return(()) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': [], 'repositories': ['repo'], 'read_special': False, }, patterns=[], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', stream_processes=flexmock(), ) assert create_flags == ('borg', 'create', '--read-special', '--patterns-from', 'patterns') assert create_positional_arguments == REPO_ARCHIVE assert pattern_file def test_make_base_create_command_with_stream_processes_and_read_special_true_skips_special_files_excludes(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) flexmock(module.logger).should_receive('warning').never() flexmock(module).should_receive('collect_special_file_paths').never() (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'read_special': True, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', stream_processes=flexmock(), ) assert create_flags == ('borg', 'create', '--read-special') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_includes_archive_name_format_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::ARCHIVE_NAME',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'archive_name_format': 'ARCHIVE_NAME', }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == ('repo::ARCHIVE_NAME',) assert not pattern_file def test_make_base_create_command_includes_default_archive_name_format_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::{hostname}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == ('repo::{hostname}',) assert not pattern_file def test_make_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command(): repository_archive_pattern = 'repo::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (repository_archive_pattern,) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'archive_name_format': 'Documents_{hostname}-{now}', # noqa: FS003 }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == (repository_archive_pattern,) assert not pattern_file def test_make_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command(): repository_archive_pattern = '{fqdn}::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (repository_archive_pattern,) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='{fqdn}', # noqa: FS003 config={ 'source_directories': ['foo', 'bar'], 'repositories': ['{fqdn}'], # noqa: FS003 'archive_name_format': 'Documents_{hostname}-{now}', # noqa: FS003 }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create') assert create_positional_arguments == (repository_archive_pattern,) assert not pattern_file def test_make_base_create_command_includes_extra_borg_options_in_borg_command(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('write_patterns_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.flags).should_receive('get_default_archive_name_format').and_return( '{hostname}' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) (create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'extra_borg_options': {'create': '--extra --options'}, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) assert create_flags == ('borg', 'create', '--extra', '--options') assert create_positional_arguments == REPO_ARCHIVE assert not pattern_file def test_make_base_create_command_with_non_existent_directory_and_source_directories_must_exist_raises(): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('check_all_root_patterns_exist').and_raise(ValueError) with pytest.raises(ValueError): module.make_base_create_command( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'source_directories_must_exist': True, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/run/borgmatic', ) def test_create_archive_calls_borg_with_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_calls_borg_with_environment(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) environment = {'BORG_THINGY': 'YUP'} flexmock(module.environment).should_receive('make_environment').and_return(environment) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=environment, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--info') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) insert_logging_mock(logging.INFO) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_with_log_info_and_json_suppresses_most_borg_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE, working_directory=None, environment=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', json=True, ) def test_create_archive_with_log_debug_calls_borg_with_debug_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--debug', '--show-rc') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) insert_logging_mock(logging.DEBUG) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_with_log_debug_and_json_suppresses_most_borg_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE, working_directory=None, environment=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.DEBUG) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', json=True, ) def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats(): # --dry-run and --stats are mutually exclusive, see: # https://borgbackup.readthedocs.io/en/stable/usage/create.html#description flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create', '--dry-run'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--dry-run', '--info') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) insert_logging_mock(logging.INFO) module.create_archive( dry_run=True, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', stats=True, ) def test_create_archive_with_working_directory_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir' ) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory='/working/dir', environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'working_directory': '/working/dir', 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_with_exit_codes_calls_borg_using_them(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') borg_exit_codes = flexmock() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'borg_exit_codes': borg_exit_codes, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_create_archive_with_stats_calls_borg_with_stats_parameter_and_answer_output_log_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--stats') + REPO_ARCHIVE, output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', stats=True, ) def test_create_archive_with_files_calls_borg_with_answer_output_log_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( ( ('borg', 'create', '--list', '--filter', 'FOO'), REPO_ARCHIVE, flexmock(), ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--list', '--filter', 'FOO') + REPO_ARCHIVE, output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', list_files=True, ) def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_parameter_and_no_list(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--info', '--progress') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) insert_logging_mock(logging.INFO) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', progress=True, ) def test_create_archive_with_progress_calls_borg_with_progress_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--progress') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', progress=True, ) def test_create_archive_with_progress_and_stream_processes_calls_borg_with_progress_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER processes = flexmock() flexmock(module).should_receive('make_base_create_command').and_return( ( ('borg', 'create', '--read-special'), REPO_ARCHIVE, flexmock(), ) ) flexmock(module.environment).should_receive('make_environment') create_command = ( 'borg', 'create', '--read-special', '--progress', ) + REPO_ARCHIVE flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_with_processes').with_args( create_command + ('--dry-run', '--list'), processes=processes, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_command, processes=processes, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', borg_exit_codes=None, working_directory=None, environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', progress=True, stream_processes=processes, ) def test_create_archive_with_json_calls_borg_with_json_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE, working_directory=None, environment=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') json_output = module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', json=True, ) assert json_output == '[]' def test_create_archive_with_stats_and_json_calls_borg_without_stats_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE, working_directory=None, environment=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') json_output = module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo*'], 'repositories': ['repo'], 'exclude_patterns': None, }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', json=True, stats=True, ) assert json_output == '[]' def test_create_archive_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_base_create_command').and_return( (('borg', 'create'), REPO_ARCHIVE, flexmock()) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir' ) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', borg_exit_codes=None, working_directory='/working/dir', environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'working_directory': '/working/dir', }, patterns=[Pattern('foo'), Pattern('bar')], local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), borgmatic_runtime_directory='/borgmatic/run', ) def test_check_all_root_patterns_exist_with_existent_pattern_path_does_not_raise(): flexmock(module.os.path).should_receive('exists').and_return(True) module.check_all_root_patterns_exist([Pattern('foo')]) def test_check_all_root_patterns_exist_with_non_root_pattern_skips_existence_check(): flexmock(module.os.path).should_receive('exists').never() module.check_all_root_patterns_exist([Pattern('foo', Pattern_type.INCLUDE)]) def test_check_all_root_patterns_exist_with_non_existent_pattern_path_raises(): flexmock(module.os.path).should_receive('exists').and_return(False) with pytest.raises(ValueError): module.check_all_root_patterns_exist([Pattern('foo')]) borgmatic/tests/unit/borg/test_delete.py000066400000000000000000000404161476361726000207730ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import delete as module from ..test_verbosity import insert_logging_mock def test_make_delete_command_includes_log_info(): insert_logging_mock(logging.INFO) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--info', 'repo') def test_make_delete_command_includes_log_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--debug', '--show-rc', 'repo') def test_make_delete_command_includes_dry_run(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'dry-run', True ).and_return(('--dry-run',)) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=True, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--dry-run', 'repo') def test_make_delete_command_includes_remote_path(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'remote-path', 'borg1' ).and_return(('--remote-path', 'borg1')) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path='borg1', ) assert command == ('borg', 'delete', '--remote-path', 'borg1', 'repo') def test_make_delete_command_includes_umask(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={'umask': '077'}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--umask', '077', 'repo') def test_make_delete_command_includes_log_json(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'log-json', True ).and_return(('--log-json',)) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=True), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--log-json', 'repo') def test_make_delete_command_includes_lock_wait(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'lock-wait', 5 ).and_return(('--lock-wait', '5')) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={'lock_wait': 5}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--lock-wait', '5', 'repo') def test_make_delete_command_includes_list(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'list', True ).and_return(('--list',)) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=True, force=0, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--list', 'repo') def test_make_delete_command_includes_force(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=1, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--force', 'repo') def test_make_delete_command_includes_force_twice(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock(list_archives=False, force=2, match_archives=None, archive=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--force', '--force', 'repo') def test_make_delete_command_includes_archive(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return( ('--match-archives', 'archive') ) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock( list_archives=False, force=0, match_archives=None, archive='archive' ), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--match-archives', 'archive', 'repo') def test_make_delete_command_includes_match_archives(): flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return( ('--match-archives', 'sh:foo*') ) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', delete_arguments=flexmock( list_archives=False, force=0, match_archives='sh:foo*', archive='archive' ), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', '--match-archives', 'sh:foo*', 'repo') def test_delete_archives_with_archive_calls_borg_delete(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository').never() flexmock(module).should_receive('make_delete_command').and_return(flexmock()) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() module.delete_archives( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), delete_arguments=flexmock(archive='archive'), global_arguments=flexmock(), ) def test_delete_archives_with_match_archives_calls_borg_delete(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository').never() flexmock(module).should_receive('make_delete_command').and_return(flexmock()) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() module.delete_archives( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), delete_arguments=flexmock(match_archives='sh:foo*'), global_arguments=flexmock(), ) @pytest.mark.parametrize('argument_name', module.ARCHIVE_RELATED_ARGUMENT_NAMES[2:]) def test_delete_archives_with_archive_related_argument_calls_borg_delete(argument_name): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository').never() flexmock(module).should_receive('make_delete_command').and_return(flexmock()) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').once() module.delete_archives( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), delete_arguments=flexmock(archive='archive', **{argument_name: 'value'}), global_arguments=flexmock(), ) def test_delete_archives_without_archive_related_argument_calls_borg_repo_delete(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository').once() flexmock(module).should_receive('make_delete_command').never() flexmock(module.borgmatic.borg.environment).should_receive('make_environment').never() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').never() module.delete_archives( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), delete_arguments=flexmock( list_archives=True, force=False, cache_only=False, keep_security_info=False ), global_arguments=flexmock(), ) def test_delete_archives_calls_borg_delete_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.borgmatic.borg.repo_delete).should_receive('delete_repository').never() command = flexmock() flexmock(module).should_receive('make_delete_command').and_return(command) environment = flexmock() flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( environment ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir' ) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_log_level=logging.ANSWER, environment=environment, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ).once() module.delete_archives( repository={'path': 'repo'}, config={'working_directory': '/working/dir'}, local_borg_version=flexmock(), delete_arguments=flexmock(archive='archive'), global_arguments=flexmock(), ) borgmatic/tests/unit/borg/test_environment.py000066400000000000000000000161701476361726000220750ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.borg import environment as module def test_make_environment_with_passcommand_should_call_it_and_set_passphrase_file_descriptor_in_environment(): flexmock(module.os).should_receive('environ').and_return( {'USER': 'root', 'BORG_PASSCOMMAND': 'nope'} ) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.borgmatic.borg.passcommand).should_receive( 'get_passphrase_from_passcommand' ).and_return('passphrase') flexmock(module.os).should_receive('pipe').and_return((3, 4)) flexmock(module.os).should_receive('write') flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') environment = module.make_environment({'encryption_passcommand': 'command'}) assert environment.get('BORG_PASSPHRASE') is None assert environment.get('BORG_PASSCOMMAND') is None assert environment.get('BORG_PASSPHRASE_FD') == '3' def test_make_environment_with_passphrase_should_set_passphrase_file_descriptor_in_environment(): flexmock(module.os).should_receive('environ').and_return( {'USER': 'root', 'BORG_PASSPHRASE': 'nope', 'BORG_PASSCOMMAND': 'nope'} ) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.borg.passcommand).should_receive( 'get_passphrase_from_passcommand' ).and_return(None) flexmock(module.os).should_receive('pipe').and_return((3, 4)) flexmock(module.os).should_receive('write') flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') environment = module.make_environment({'encryption_passphrase': 'pass'}) assert environment.get('BORG_PASSPHRASE') is None assert environment.get('BORG_PASSCOMMAND') is None assert environment.get('BORG_PASSPHRASE_FD') == '3' def test_make_environment_with_credential_tag_passphrase_should_load_it_and_set_passphrase_file_descriptor_in_environment(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) config = {'encryption_passphrase': '{credential systemd pass}'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential', ).with_args('{credential systemd pass}', config).and_return('pass') flexmock(module.borgmatic.borg.passcommand).should_receive( 'get_passphrase_from_passcommand' ).never() flexmock(module.os).should_receive('pipe').and_return((3, 4)) flexmock(module.os).should_receive('write') flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') environment = module.make_environment(config) assert environment.get('BORG_PASSPHRASE') is None assert environment.get('BORG_PASSPHRASE_FD') == '3' def test_make_environment_with_ssh_command_should_set_environment(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'ssh_command': 'ssh -C'}) assert environment.get('BORG_RSH') == 'ssh -C' def test_make_environment_without_configuration_sets_certain_environment_variables(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({}) # Default environment variables. assert environment == { 'USER': 'root', 'BORG_EXIT_CODES': 'modern', 'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'no', 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'no', } def test_make_environment_without_configuration_passes_through_default_environment_variables_untouched(): flexmock(module.os).should_receive('environ').and_return( { 'USER': 'root', 'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup', 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah', } ) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({}) assert environment == { 'USER': 'root', 'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup', 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah', 'BORG_EXIT_CODES': 'modern', } def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'relocated_repo_access_is_ok': True}) assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes' def test_make_environment_with_relocated_repo_access_false_should_set_environment_no(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'relocated_repo_access_is_ok': False}) assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'no' def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'check_i_know_what_i_am_doing': True}) assert environment.get('BORG_CHECK_I_KNOW_WHAT_I_AM_DOING') == 'YES' def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'check_i_know_what_i_am_doing': False}) assert environment.get('BORG_CHECK_I_KNOW_WHAT_I_AM_DOING') == 'NO' def test_make_environment_with_integer_variable_value(): flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('pipe').never() environment = module.make_environment({'borg_files_cache_ttl': 40}) assert environment.get('BORG_FILES_CACHE_TTL') == '40' borgmatic/tests/unit/borg/test_export_key.py000066400000000000000000000260311476361726000217170ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock import borgmatic.logger from borgmatic.borg import export_key as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( command, output_file=module.DO_NOT_CAPTURE, working_directory=None, borg_exit_codes=None ): borgmatic.logger.add_custom_log_levels() flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, output_file=output_file, output_log_level=module.logging.ANSWER, environment=None, working_directory=working_directory, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def test_export_key_calls_borg_with_required_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_local_path(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg1', 'key', 'export', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg1', ) def test_export_key_calls_borg_using_exit_codes(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() borg_exit_codes = flexmock() insert_execute_command_mock(('borg', 'key', 'export', 'repo'), borg_exit_codes=borg_exit_codes) module.export_key( repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_remote_path_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--remote-path', 'borg1', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), remote_path='borg1', ) def test_export_key_calls_borg_with_umask_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--umask', '0770', 'repo')) module.export_key( repository_path='repo', config={'umask': '0770'}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_log_json_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--log-json', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=True), ) def test_export_key_calls_borg_with_lock_wait_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--lock-wait', '5', 'repo')) module.export_key( repository_path='repo', config={'lock_wait': '5'}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_with_log_info_calls_borg_with_info_parameter(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--info', 'repo')) insert_logging_mock(logging.INFO) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--debug', '--show-rc', 'repo')) insert_logging_mock(logging.DEBUG) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_paper_flags(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=True, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_paper_flag(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=True, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_qr_html_flag(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', '--qr-html', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=True, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_path_argument(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').with_args('dest').and_return(False) insert_execute_command_mock(('borg', 'key', 'export', 'repo', 'dest'), output_file=None) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path='dest'), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_with_already_existent_path_raises(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('execute_command').never() with pytest.raises(FileExistsError): module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path='dest'), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_with_stdout_path_calls_borg_without_path_argument(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo')) module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path='-'), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_with_dry_run_skips_borg_call(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() flexmock(module).should_receive('execute_command').never() module.export_key( repository_path='repo', config={}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=True, log_json=False), ) def test_export_key_calls_borg_with_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').never() insert_execute_command_mock(('borg', 'key', 'export', 'repo'), working_directory='/working/dir') module.export_key( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path=None), global_arguments=flexmock(dry_run=False, log_json=False), ) def test_export_key_calls_borg_with_path_argument_and_working_directory(): flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.os.path).should_receive('exists').with_args('/working/dir/dest').and_return( False ).once() insert_execute_command_mock( ('borg', 'key', 'export', 'repo', 'dest'), output_file=None, working_directory='/working/dir', ) module.export_key( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', export_arguments=flexmock(paper=False, qr_html=False, path='dest'), global_arguments=flexmock(dry_run=False, log_json=False), ) borgmatic/tests/unit/borg/test_export_tar.py000066400000000000000000000313041476361726000217140ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import export_tar as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( command, output_log_level=logging.INFO, working_directory=None, borg_local_path='borg', borg_exit_codes=None, capture=True, ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, output_file=None if capture else module.DO_NOT_CAPTURE, output_log_level=output_log_level, environment=None, working_directory=working_directory, borg_local_path=borg_local_path, borg_exit_codes=borg_exit_codes, ).once() def test_export_tar_archive_calls_borg_with_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', 'repo::archive', 'test.tar', 'path1', 'path2') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=['path1', 'path2'], destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_local_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg1', 'export-tar', 'repo::archive', 'test.tar'), borg_local_path='borg1' ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_export_tar_archive_calls_borg_using_exit_codes(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) borg_exit_codes = flexmock() insert_execute_command_mock( ('borg', 'export-tar', 'repo::archive', 'test.tar'), borg_exit_codes=borg_exit_codes, ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--remote-path', 'borg1', 'repo::archive', 'test.tar') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_export_tar_archive_calls_borg_with_umask_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--umask', '0770', 'repo::archive', 'test.tar') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={'umask': '0770'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_log_json_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'export-tar', '--log-json', 'repo::archive', 'test.tar')) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_export_tar_archive_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--lock-wait', '5', 'repo::archive', 'test.tar') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={'lock_wait': '5'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'export-tar', '--info', 'repo::archive', 'test.tar')) insert_logging_mock(logging.INFO) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--debug', '--show-rc', 'repo::archive', 'test.tar') ) insert_logging_mock(logging.DEBUG) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_dry_run_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module).should_receive('execute_command').never() module.export_tar_archive( dry_run=True, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_tar_filter_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--tar-filter', 'bzip2', 'repo::archive', 'test.tar') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), tar_filter='bzip2', ) def test_export_tar_archive_calls_borg_with_list_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--list', 'repo::archive', 'test.tar'), output_log_level=logging.ANSWER, ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), list_files=True, ) def test_export_tar_archive_calls_borg_with_strip_components_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', '--strip-components', '5', 'repo::archive', 'test.tar') ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), strip_components=5, ) def test_export_tar_archive_skips_abspath_for_remote_repository_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('server:repo::archive',) ) insert_execute_command_mock(('borg', 'export-tar', 'server:repo::archive', 'test.tar')) module.export_tar_archive( dry_run=False, repository_path='server:repo', archive='archive', paths=None, destination_path='test.tar', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_stdout_destination_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'export-tar', 'repo::archive', '-'), capture=False) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=None, destination_path='-', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_export_tar_archive_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'export-tar', 'repo::archive', 'test.tar'), working_directory='/working/dir', ) module.export_tar_archive( dry_run=False, repository_path='repo', archive='archive', paths=[], destination_path='test.tar', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_extract.py000066400000000000000000000657601476361726000212140ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import extract as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock(command, destination_path=None, borg_exit_codes=None): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, environment=None, working_directory=destination_path, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def test_extract_last_archive_dry_run_calls_borg_with_last_archive(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive')) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_without_any_archives_should_not_raise(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_raise(ValueError) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(('repo',)) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_with_log_info_calls_borg_with_info_parameter(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock(('borg', 'extract', '--dry-run', '--info', 'repo::archive')) insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_with_log_debug_calls_borg_with_debug_parameter(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock( ('borg', 'extract', '--dry-run', '--debug', '--show-rc', '--list', 'repo::archive') ) insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_calls_borg_via_local_path(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock(('borg1', 'extract', '--dry-run', 'repo::archive')) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, local_path='borg1', ) def test_extract_last_archive_dry_run_calls_borg_using_exit_codes(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') borg_exit_codes = flexmock() insert_execute_command_mock( ('borg', 'extract', '--dry-run', 'repo::archive'), borg_exit_codes=borg_exit_codes ) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_calls_borg_with_remote_path_flags(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock( ('borg', 'extract', '--dry-run', '--remote-path', 'borg1', 'repo::archive') ) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=None, remote_path='borg1', ) def test_extract_last_archive_dry_run_calls_borg_with_log_json_flag(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock(('borg', 'extract', '--dry-run', '--log-json', 'repo::archive')) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), repository_path='repo', lock_wait=None, ) def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_flags(): flexmock(module.repo_list).should_receive('resolve_archive_name').and_return('archive') insert_execute_command_mock( ('borg', 'extract', '--dry-run', '--lock-wait', '5', 'repo::archive') ) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_last_archive_dry_run( config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repository_path='repo', lock_wait=5, ) def test_extract_archive_calls_borg_with_path_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', 'repo::archive', 'path1', 'path2')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=['path1', 'path2'], config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_local_path(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg1', 'extract', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_extract_archive_calls_borg_with_exit_codes(): flexmock(module.os.path).should_receive('abspath').and_return('repo') borg_exit_codes = flexmock() insert_execute_command_mock( ('borg', 'extract', 'repo::archive'), borg_exit_codes=borg_exit_codes ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_remote_path_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--remote-path', 'borg1', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) @pytest.mark.parametrize( 'feature_available,option_flag', ( (True, '--numeric-ids'), (False, '--numeric-owner'), ), ) def test_extract_archive_calls_borg_with_numeric_ids_parameter(feature_available, option_flag): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', option_flag, 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'numeric_ids': True}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_umask_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--umask', '0770', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'umask': '0770'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_log_json_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--log-json', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_extract_archive_calls_borg_with_lock_wait_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--lock-wait', '5', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'lock_wait': '5'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--info', 'repo::archive')) insert_logging_mock(logging.INFO) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock( ('borg', 'extract', '--debug', '--list', '--show-rc', 'repo::archive') ) insert_logging_mock(logging.DEBUG) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_dry_run_parameter(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=True, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_calls_borg_with_destination_path(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', 'repo::archive'), destination_path='/dest') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), destination_path='/dest', ) def test_extract_archive_calls_borg_with_strip_components(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '--strip-components', '5', 'repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), strip_components=5, ) def test_extract_archive_calls_borg_with_strip_components_calculated_from_all(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock( ( 'borg', 'extract', '--strip-components', '2', 'repo::archive', 'foo/bar/baz.txt', 'foo/bar.txt', ) ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=['foo/bar/baz.txt', 'foo/bar.txt'], config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), strip_components='all', ) def test_extract_archive_calls_borg_with_strip_components_calculated_from_all_with_leading_slash(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock( ( 'borg', 'extract', '--strip-components', '2', 'repo::archive', '/foo/bar/baz.txt', '/foo/bar.txt', ) ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=['/foo/bar/baz.txt', '/foo/bar.txt'], config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), strip_components='all', ) def test_extract_archive_with_strip_components_all_and_no_paths_raises(): flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') flexmock(module).should_receive('execute_command').never() with pytest.raises(ValueError): module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), strip_components='all', ) def test_extract_archive_calls_borg_with_progress_parameter(): flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'extract', '--progress', 'repo::archive'), output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), progress=True, ) def test_extract_archive_with_progress_and_extract_to_stdout_raises(): flexmock(module).should_receive('execute_command').never() with pytest.raises(ValueError): module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), progress=True, extract_to_stdout=True, ) def test_extract_archive_calls_borg_with_stdout_parameter_and_returns_process(): flexmock(module.os.path).should_receive('abspath').and_return('repo') process = flexmock() flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'extract', '--stdout', 'repo::archive'), output_file=module.subprocess.PIPE, run_to_completion=False, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(process).once() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') assert ( module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), extract_to_stdout=True, ) == process ) def test_extract_archive_skips_abspath_for_remote_repository(): flexmock(module.os.path).should_receive('abspath').never() flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'extract', 'server:repo::archive'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('server:repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).and_return('repo') module.extract_archive( dry_run=False, repository='server:repo', archive='archive', paths=None, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_extract_archive_uses_configured_working_directory_in_repo_path_and_destination_path(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock( ('borg', 'extract', '/working/dir/repo::archive'), destination_path='/working/dir/dest' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('/working/dir/repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).with_args('/working/dir/repo').and_return('/working/dir/repo').once() module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), destination_path='dest', ) def test_extract_archive_uses_configured_working_directory_in_repo_path_when_destination_path_is_not_set(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', '/working/dir/repo::archive')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('/working/dir/repo::archive',) ) flexmock(module.borgmatic.config.validate).should_receive( 'normalize_repository_path' ).with_args('/working/dir/repo').and_return('/working/dir/repo').once() module.extract_archive( dry_run=False, repository='repo', archive='archive', paths=None, config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_flags.py000066400000000000000000000237021476361726000206240ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.borg import flags as module def test_make_flags_formats_string_value(): assert module.make_flags('foo', 'bar') == ('--foo', 'bar') def test_make_flags_formats_integer_value(): assert module.make_flags('foo', 3) == ('--foo', '3') def test_make_flags_formats_true_value(): assert module.make_flags('foo', True) == ('--foo',) def test_make_flags_omits_false_value(): assert module.make_flags('foo', False) == () def test_make_flags_formats_name_with_underscore(): assert module.make_flags('posix_me_harder', 'okay') == ('--posix-me-harder', 'okay') def test_make_flags_from_arguments_flattens_and_sorts_multiple_arguments(): flexmock(module).should_receive('make_flags').with_args('foo', 'bar').and_return(('foo', 'bar')) flexmock(module).should_receive('make_flags').with_args('baz', 'quux').and_return( ('baz', 'quux') ) arguments = flexmock(foo='bar', baz='quux') assert module.make_flags_from_arguments(arguments) == ('baz', 'quux', 'foo', 'bar') def test_make_flags_from_arguments_excludes_underscored_argument_names(): flexmock(module).should_receive('make_flags').with_args('foo', 'bar').and_return(('foo', 'bar')) arguments = flexmock(foo='bar', _baz='quux') assert module.make_flags_from_arguments(arguments) == ('foo', 'bar') def test_make_flags_from_arguments_omits_excludes(): flexmock(module).should_receive('make_flags').with_args('foo', 'bar').and_return(('foo', 'bar')) arguments = flexmock(foo='bar', baz='quux') assert module.make_flags_from_arguments(arguments, excludes=('baz', 'other')) == ('foo', 'bar') def test_make_repository_flags_with_borg_features_includes_repo_flag(): flexmock(module.feature).should_receive('available').and_return(True) assert module.make_repository_flags(repository_path='repo', local_borg_version='1.2.3') == ( '--repo', 'repo', ) def test_make_repository_flags_without_borg_features_includes_omits_flag(): flexmock(module.feature).should_receive('available').and_return(False) assert module.make_repository_flags(repository_path='repo', local_borg_version='1.2.3') == ( 'repo', ) def test_make_repository_archive_flags_with_borg_features_separates_repository_and_archive(): flexmock(module.feature).should_receive('available').and_return(True) assert module.make_repository_archive_flags( repository_path='repo', archive='archive', local_borg_version='1.2.3' ) == ( '--repo', 'repo', 'archive', ) def test_make_repository_archive_flags_with_borg_features_joins_repository_and_archive(): flexmock(module.feature).should_receive('available').and_return(False) assert module.make_repository_archive_flags( repository_path='repo', archive='archive', local_borg_version='1.2.3' ) == ('repo::archive',) def test_get_default_archive_name_format_with_archive_series_feature_uses_series_archive_name_format(): flexmock(module.feature).should_receive('available').and_return(True) assert ( module.get_default_archive_name_format(local_borg_version='1.2.3') == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES ) def test_get_default_archive_name_format_without_archive_series_feature_uses_non_series_archive_name_format(): flexmock(module.feature).should_receive('available').and_return(False) assert ( module.get_default_archive_name_format(local_borg_version='1.2.3') == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES ) @pytest.mark.parametrize( 'match_archives,archive_name_format,feature_available,expected_result', ( (None, None, True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003 (None, '', True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003 ( 're:foo-.*', '{hostname}-{now}', # noqa: FS003 True, ('--match-archives', 're:foo-.*'), ), ( 'sh:foo-*', '{hostname}-{now}', # noqa: FS003 False, ('--glob-archives', 'foo-*'), ), ( 'foo-*', '{hostname}-{now}', # noqa: FS003 False, ('--glob-archives', 'foo-*'), ), ( None, '{hostname}-docs-{now}', # noqa: FS003 True, ('--match-archives', 'sh:{hostname}-docs-*'), # noqa: FS003 ), ( None, '{utcnow}-docs-{user}', # noqa: FS003 True, ('--match-archives', 'sh:*-docs-{user}'), # noqa: FS003 ), (None, '{fqdn}-{pid}', True, ('--match-archives', 'sh:{fqdn}-*')), # noqa: FS003 ( None, 'stuff-{now:%Y-%m-%dT%H:%M:%S.%f}', # noqa: FS003 True, ('--match-archives', 'sh:stuff-*'), ), ( None, '{hostname}-docs-{now}', # noqa: FS003 False, ('--glob-archives', '{hostname}-docs-*'), # noqa: FS003 ), ( None, '{now}', # noqa: FS003 False, (), ), ( None, '{now}', # noqa: FS003 True, (), ), ( None, '{utcnow}-docs-{user}', # noqa: FS003 False, ('--glob-archives', '*-docs-{user}'), # noqa: FS003 ), ( '*', '{now}', # noqa: FS003 True, (), ), ( '*', '{now}', # noqa: FS003 False, (), ), ( 're:.*', '{now}', # noqa: FS003 True, (), ), ( 'sh:*', '{now}', # noqa: FS003 True, (), ), ( 'abcdefabcdef', None, True, ('--match-archives', 'aid:abcdefabcdef'), ), ( 'aid:abcdefabcdef', None, True, ('--match-archives', 'aid:abcdefabcdef'), ), ), ) def test_make_match_archives_flags_makes_flags_with_globs( match_archives, archive_name_format, feature_available, expected_result ): flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('get_default_archive_name_format').and_return( module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES ) assert ( module.make_match_archives_flags( match_archives, archive_name_format, local_borg_version=flexmock() ) == expected_result ) def test_make_match_archives_flags_accepts_default_archive_name_format(): flexmock(module.feature).should_receive('available').and_return(True) assert ( module.make_match_archives_flags( match_archives=None, archive_name_format=None, local_borg_version=flexmock(), default_archive_name_format='*', ) == () ) def test_warn_for_aggressive_archive_flags_without_archive_flags_bails(): flexmock(module.logger).should_receive('warning').never() module.warn_for_aggressive_archive_flags(('borg', '--do-stuff'), '{}') def test_warn_for_aggressive_archive_flags_with_glob_archives_and_zero_archives_warns(): flexmock(module.logger).should_receive('warning').twice() module.warn_for_aggressive_archive_flags( ('borg', '--glob-archives', 'foo*'), '{"archives": []}' ) def test_warn_for_aggressive_archive_flags_with_match_archives_and_zero_archives_warns(): flexmock(module.logger).should_receive('warning').twice() module.warn_for_aggressive_archive_flags( ('borg', '--match-archives', 'foo*'), '{"archives": []}' ) def test_warn_for_aggressive_archive_flags_with_glob_archives_and_one_archive_does_not_warn(): flexmock(module.logger).should_receive('warning').never() module.warn_for_aggressive_archive_flags( ('borg', '--glob-archives', 'foo*'), '{"archives": [{"name": "foo"]}' ) def test_warn_for_aggressive_archive_flags_with_match_archives_and_one_archive_does_not_warn(): flexmock(module.logger).should_receive('warning').never() module.warn_for_aggressive_archive_flags( ('borg', '--match-archives', 'foo*'), '{"archives": [{"name": "foo"]}' ) def test_warn_for_aggressive_archive_flags_with_glob_archives_and_invalid_json_does_not_warn(): flexmock(module.logger).should_receive('warning').never() module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{"archives": [}') def test_warn_for_aggressive_archive_flags_with_glob_archives_and_json_missing_archives_does_not_warn(): flexmock(module.logger).should_receive('warning').never() module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{}') def test_omit_flag_removes_flag_from_arguments(): assert module.omit_flag(('borg', 'create', '--flag', '--other'), '--flag') == ( 'borg', 'create', '--other', ) def test_omit_flag_without_flag_present_passes_through_arguments(): assert module.omit_flag(('borg', 'create', '--other'), '--flag') == ( 'borg', 'create', '--other', ) def test_omit_flag_and_value_removes_flag_and_value_from_arguments(): assert module.omit_flag_and_value( ('borg', 'create', '--flag', 'value', '--other'), '--flag' ) == ( 'borg', 'create', '--other', ) def test_omit_flag_and_value_with_equals_sign_removes_flag_and_value_from_arguments(): assert module.omit_flag_and_value(('borg', 'create', '--flag=value', '--other'), '--flag') == ( 'borg', 'create', '--other', ) def test_omit_flag_and_value_without_flag_present_passes_through_arguments(): assert module.omit_flag_and_value(('borg', 'create', '--other'), '--flag') == ( 'borg', 'create', '--other', ) borgmatic/tests/unit/borg/test_info.py000066400000000000000000000530431476361726000204640ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import info as module from ..test_verbosity import insert_logging_mock def test_make_info_command_constructs_borg_info_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--repo', 'repo') def test_make_info_command_with_log_info_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) insert_logging_mock(logging.INFO) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--info', '--repo', 'repo') def test_make_info_command_with_log_info_and_json_omits_borg_logging_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) insert_logging_mock(logging.INFO) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--json', '--repo', 'repo') def test_make_info_command_with_log_debug_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) insert_logging_mock(logging.DEBUG) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--debug', '--show-rc', '--repo', 'repo') def test_make_info_command_with_log_debug_and_json_omits_borg_logging_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--json', '--repo', 'repo') def test_make_info_command_with_json_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--json', '--repo', 'repo') def test_make_info_command_with_archive_uses_match_archives_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'archive', None, '2.3.4' ).and_return(('--match-archives', 'archive')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive='archive', json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'archive', '--repo', 'repo') def test_make_info_command_with_local_path_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg1', remote_path=None, ) command == ('borg1', 'info', '--repo', 'repo') def test_make_info_command_with_remote_path_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'remote-path', 'borg1' ).and_return(('--remote-path', 'borg1')) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path='borg1', ) assert command == ('borg', 'info', '--remote-path', 'borg1', '--repo', 'repo') def test_make_info_command_with_umask_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={'umask': '077'}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--umask', '077', '--repo', 'repo') def test_make_info_command_with_log_json_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return( ('--log-json',) ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=True), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--log-json', '--repo', 'repo') def test_make_info_command_with_lock_wait_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('lock-wait', 5).and_return( ('--lock-wait', '5') ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) config = {'lock_wait': 5} command = module.make_info_command( repository_path='repo', config=config, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--lock-wait', '5', '--repo', 'repo') def test_make_info_command_transforms_prefix_into_match_archives_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'match-archives', 'sh:foo*' ).and_return(('--match-archives', 'sh:foo*')) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix='foo'), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo') def test_make_info_command_prefers_prefix_over_archive_name_format(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'match-archives', 'sh:foo*' ).and_return(('--match-archives', 'sh:foo*')) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix='foo'), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo') def test_make_info_command_transforms_archive_name_format_into_match_archives_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) command = module.make_info_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'sh:bar-*', '--repo', 'repo') def test_make_info_command_with_match_archives_option_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'sh:foo-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') command = module.make_info_command( repository_path='repo', config={ 'archive_name_format': 'bar-{now}', # noqa: FS003 'match_archives': 'sh:foo-*', }, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo') def test_make_info_command_with_match_archives_flag_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'sh:foo-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') command = module.make_info_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives='sh:foo-*'), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo') @pytest.mark.parametrize('argument_name', ('sort_by', 'first', 'last')) def test_make_info_command_passes_arguments_through_to_command(argument_name): flag_name = f"--{argument_name.replace('_', ' ')}" flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( (flag_name, 'value') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock( archive=None, json=False, prefix=None, match_archives=None, **{argument_name: 'value'} ), local_path='borg', remote_path=None, ) assert command == ('borg', 'info', flag_name, 'value', '--repo', 'repo') def test_make_info_command_with_date_based_matching_passes_through_to_command(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '2.3.4' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( ('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) info_arguments = flexmock( archive=None, json=False, prefix=None, match_archives=None, newer='1d', newest='1y', older='1m', oldest='1w', ) command = module.make_info_command( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=info_arguments, local_path='borg', remote_path=None, ) assert command == ( 'borg', 'info', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--repo', 'repo', ) def test_display_archives_info_calls_two_commands(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_info_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').once() flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').once() module.display_archives_info( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), ) def test_display_archives_info_with_json_calls_json_command_only(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_info_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) json_output = flexmock() flexmock(module).should_receive('execute_command_and_capture_output').and_return(json_output) flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() flexmock(module).should_receive('execute_command').never() assert ( module.display_archives_info( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), ) == json_output ) def test_display_archives_info_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_info_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command_and_capture_output').with_args( full_command=object, environment=object, working_directory='/working/dir', borg_local_path=object, borg_exit_codes=object, ).once() flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( full_command=object, output_log_level=object, environment=object, working_directory='/working/dir', borg_local_path=object, borg_exit_codes=object, ).once() module.display_archives_info( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), ) borgmatic/tests/unit/borg/test_list.py000066400000000000000000000755501476361726000205130ustar00rootroot00000000000000import argparse import logging import pytest from flexmock import flexmock from borgmatic.borg import list as module from ..test_verbosity import insert_logging_mock def test_make_list_command_includes_log_info(): insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--info', 'repo') def test_make_list_command_includes_json_but_not_info(): insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=True), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_list_command_includes_log_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--debug', '--show-rc', 'repo') def test_make_list_command_includes_json_but_not_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=True), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_list_command_includes_json(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=True), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_list_command_includes_log_json(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( ('--log-json',) ) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=True), ) assert command == ('borg', 'list', '--log-json', 'repo') def test_make_list_command_includes_lock_wait(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( ('--lock-wait', '5') ) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={'lock_wait': 5}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--lock-wait', '5', 'repo') def test_make_list_command_includes_archive(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive='archive', paths=None, json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', 'repo::archive') def test_make_list_command_includes_archive_and_path(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive='archive', paths=['var/lib'], json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', 'repo::archive', 'var/lib') def test_make_list_command_includes_local_path(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), local_path='borg2', ) assert command == ('borg2', 'list', 'repo') def test_make_list_command_includes_remote_path(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'remote-path', 'borg2' ).and_return(('--remote-path', 'borg2')) flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return( ('--log-json') ) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), remote_path='borg2', ) assert command == ('borg', 'list', '--remote-path', 'borg2', 'repo') def test_make_list_command_includes_umask(): flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={'umask': '077'}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--umask', '077', 'repo') def test_make_list_command_includes_short(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--short',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock(archive=None, paths=None, json=False, short=True), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--short', 'repo') @pytest.mark.parametrize( 'argument_name', ( 'prefix', 'match_archives', 'sort_by', 'first', 'last', 'exclude', 'exclude_from', 'pattern', 'patterns_from', ), ) def test_make_list_command_includes_additional_flags(argument_name): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( (f"--{argument_name.replace('_', '-')}", 'value') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=flexmock( archive=None, paths=None, json=False, find_paths=None, format=None, **{argument_name: 'value'}, ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--' + argument_name.replace('_', '-'), 'value', 'repo') def test_make_find_paths_considers_none_as_empty_paths(): assert module.make_find_paths(None) == () def test_make_find_paths_passes_through_patterns(): find_paths = ( 'fm:*', 'sh:**/*.txt', 're:^.*$', 'pp:root/somedir', 'pf:root/foo.txt', 'R /', 'r /', 'p /', 'P /', '+ /', '- /', '! /', ) assert module.make_find_paths(find_paths) == find_paths def test_make_find_paths_adds_globs_to_path_fragments(): assert module.make_find_paths(('foo.txt',)) == ('sh:**/*foo.txt*/**',) def test_capture_archive_listing_does_not_raise(): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').and_return('') flexmock(module).should_receive('make_list_command') module.capture_archive_listing( repository_path='repo', archive='archive', config={}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), ) def test_list_archive_calls_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, ) def test_list_archive_with_archive_and_json_errors(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace(archive='archive', paths=None, json=True, find_paths=None) flexmock(module.feature).should_receive('available').and_return(False) with pytest.raises(ValueError): module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=flexmock(log_json=False), ) def test_list_archive_calls_borg_with_local_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg2', remote_path=None, ).and_return(('borg2', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg2', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg2', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg2', ) def test_list_archive_calls_borg_using_exit_codes(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(False) borg_exit_codes = flexmock() flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ).once() module.list_archive( repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, ) def test_list_archive_calls_borg_multiple_times_with_find_paths(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None glob_paths = ('**/*foo.txt*/**',) list_arguments = argparse.Namespace( archive=None, json=False, find_paths=['foo.txt'], prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.repo_list).should_receive('make_repo_list_command').and_return( ('borg', 'list', 'repo') ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('archive1\narchive2').once() flexmock(module).should_receive('make_list_command').and_return( ('borg', 'list', 'repo::archive1') ).and_return(('borg', 'list', 'repo::archive2')) flexmock(module).should_receive('make_find_paths').and_return(glob_paths) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive1') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive2') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=flexmock(log_json=False), ) def test_list_archive_calls_borg_with_archive(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, ) def test_list_archive_without_archive_delegates_to_list_repository(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive=None, short=None, format=None, json=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, find_paths=None, ) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.repo_list).should_receive('list_repository') flexmock(module.environment).should_receive('make_environment').never() flexmock(module).should_receive('execute_command').never() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=flexmock(log_json=False), ) def test_list_archive_with_borg_features_without_archive_delegates_to_list_repository(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive=None, short=None, format=None, json=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, find_paths=None, ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.repo_list).should_receive('list_repository') flexmock(module.environment).should_receive('make_environment').never() flexmock(module).should_receive('execute_command').never() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=flexmock(log_json=False), ) @pytest.mark.parametrize( 'archive_filter_flag', ( 'prefix', 'match_archives', 'sort_by', 'first', 'last', ), ) def test_list_archive_with_archive_ignores_archive_filter_flag( archive_filter_flag, ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None global_arguments = flexmock(log_json=False) default_filter_flags = { 'prefix': None, 'match_archives': None, 'sort_by': None, 'first': None, 'last': None, } altered_filter_flags = {**default_filter_flags, **{archive_filter_flag: 'foo'}} flexmock(module.feature).should_receive('available').with_args( module.feature.Feature.REPO_LIST, '1.2.3' ).and_return(False) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, **default_filter_flags ), global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, **altered_filter_flags ), global_arguments=global_arguments, ) @pytest.mark.parametrize( 'archive_filter_flag', ( 'prefix', 'match_archives', 'sort_by', 'first', 'last', ), ) def test_list_archive_with_find_paths_allows_archive_filter_flag_but_only_passes_it_to_repo_list( archive_filter_flag, ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None default_filter_flags = { 'prefix': None, 'match_archives': None, 'sort_by': None, 'first': None, 'last': None, } altered_filter_flags = {**default_filter_flags, **{archive_filter_flag: 'foo'}} glob_paths = ('**/*foo.txt*/**',) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.repo_list).should_receive('make_repo_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=argparse.Namespace( repository='repo', short=True, format=None, json=None, **altered_filter_flags ), global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'repo-list', '--repo', 'repo')) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-list', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('archive1\narchive2').once() flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=argparse.Namespace( repository='repo', archive='archive1', paths=None, short=True, format=None, json=None, find_paths=['foo.txt'], **default_filter_flags, ), global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', '--repo', 'repo', 'archive1')) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=argparse.Namespace( repository='repo', archive='archive2', paths=None, short=True, format=None, json=None, find_paths=['foo.txt'], **default_filter_flags, ), global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', '--repo', 'repo', 'archive2')) flexmock(module).should_receive('make_find_paths').and_return(glob_paths) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', '--repo', 'repo', 'archive1') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', '--repo', 'repo', 'archive2') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={}, local_borg_version='1.2.3', list_arguments=argparse.Namespace( repository='repo', archive=None, paths=None, short=True, format=None, json=None, find_paths=['foo.txt'], **altered_filter_flags, ), global_arguments=global_arguments, ) def test_list_archive_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logger).answer = lambda message: None list_arguments = argparse.Namespace( archive='archive', paths=None, json=False, find_paths=None, prefix=None, match_archives=None, sort_by=None, first=None, last=None, ) global_arguments = flexmock(log_json=False) flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_list_command').with_args( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'list', 'repo::archive')) flexmock(module).should_receive('make_find_paths').and_return(()) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ).once() module.list_archive( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', list_arguments=list_arguments, global_arguments=global_arguments, ) borgmatic/tests/unit/borg/test_mount.py000066400000000000000000000322651476361726000206760ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import mount as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock(command, working_directory=None, borg_exit_codes=None): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, environment=None, working_directory=working_directory, borg_local_path=command[0], borg_exit_codes=borg_exit_codes, ).once() def test_mount_archive_calls_borg_with_required_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'mount', 'repo', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive=None, mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_with_borg_features_calls_borg_with_repository_and_match_archives_flags(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) insert_execute_command_mock( ('borg', 'mount', '--repo', 'repo', '--match-archives', 'archive', '/mnt') ) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_without_archive_calls_borg_with_repository_flags_only(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_path_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt', 'path1', 'path2')) mount_arguments = flexmock( mount_point='/mnt', options=None, paths=['path1', 'path2'], foreground=False ) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_local_path(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg1', 'mount', 'repo::archive', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_mount_archive_calls_borg_using_exit_codes(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) borg_exit_codes = flexmock() insert_execute_command_mock( ('borg', 'mount', 'repo::archive', '/mnt'), borg_exit_codes=borg_exit_codes, ) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_remote_path_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock( ('borg', 'mount', '--remote-path', 'borg1', 'repo::archive', '/mnt') ) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_mount_archive_calls_borg_with_umask_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '--umask', '0770', 'repo::archive', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={'umask': '0770'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_log_json_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '--log-json', 'repo::archive', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_mount_archive_calls_borg_with_lock_wait_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '--lock-wait', '5', 'repo::archive', '/mnt')) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={'lock_wait': '5'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '--info', 'repo::archive', '/mnt')) insert_logging_mock(logging.INFO) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_with_log_debug_calls_borg_with_debug_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '--debug', '--show-rc', 'repo::archive', '/mnt')) insert_logging_mock(logging.DEBUG) mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_foreground_parameter(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'mount', '--foreground', 'repo::archive', '/mnt'), output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=True) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_options_flags(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::archive',) ) insert_execute_command_mock(('borg', 'mount', '-o', 'super_mount', 'repo::archive', '/mnt')) mount_arguments = flexmock( mount_point='/mnt', options='super_mount', paths=None, foreground=False ) module.mount_archive( repository_path='repo', archive='archive', mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_with_date_based_matching_calls_borg_with_date_based_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( ( '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--match-archives', None, ) ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ( 'borg', 'mount', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--match-archives', None, '--repo', 'repo', '/mnt', ), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) mount_arguments = flexmock( mount_point='/mnt', options=None, paths=None, foreground=False, newer='1d', newest='1y', older='1m', oldest='1w', ) module.mount_archive( repository_path='repo', archive=None, mount_arguments=mount_arguments, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_mount_archive_calls_borg_with_working_directory(): flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'mount', 'repo', '/mnt'), working_directory='/working/dir') mount_arguments = flexmock(mount_point='/mnt', options=None, paths=None, foreground=False) module.mount_archive( repository_path='repo', archive=None, mount_arguments=mount_arguments, config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_passcommand.py000066400000000000000000000030171476361726000220320ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.borg import passcommand as module def test_run_passcommand_does_not_raise(): module.run_passcommand.cache_clear() flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('passphrase') assert module.run_passcommand('passcommand', working_directory=None) == 'passphrase' def test_get_passphrase_from_passcommand_with_configured_passcommand_runs_it(): module.run_passcommand.cache_clear() flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working' ) flexmock(module).should_receive('run_passcommand').with_args('command', '/working').and_return( 'passphrase' ).once() assert ( module.get_passphrase_from_passcommand( {'encryption_passcommand': 'command'}, ) == 'passphrase' ) def test_get_passphrase_from_passcommand_without_configured_passcommand_bails(): flexmock(module).should_receive('run_passcommand').never() assert module.get_passphrase_from_passcommand({}) is None def test_run_passcommand_caches_passcommand_after_first_call(): module.run_passcommand.cache_clear() flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('passphrase').once() assert module.run_passcommand('passcommand', working_directory=None) == 'passphrase' assert module.run_passcommand('passcommand', working_directory=None) == 'passphrase' borgmatic/tests/unit/borg/test_prune.py000066400000000000000000000463461476361726000206720ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import prune as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( prune_command, output_log_level, working_directory=None, borg_exit_codes=None ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( prune_command, output_log_level=output_log_level, environment=None, working_directory=working_directory, borg_local_path=prune_command[0], borg_exit_codes=borg_exit_codes, ).once() BASE_PRUNE_FLAGS = ('--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly', '3') def test_make_prune_flags_returns_flags_from_config(): config = { 'keep_daily': 1, 'keep_weekly': 2, 'keep_monthly': 3, } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) assert result == BASE_PRUNE_FLAGS def test_make_prune_flags_accepts_prefix_with_placeholders(): config = { 'keep_daily': 1, 'prefix': 'Documents_{hostname}-{now}', # noqa: FS003 } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--match-archives', 'sh:Documents_{hostname}-{now}*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_with_prefix_without_borg_features_uses_glob_archives(): config = { 'keep_daily': 1, 'prefix': 'Documents_{hostname}-{now}', # noqa: FS003 } flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--glob-archives', 'Documents_{hostname}-{now}*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_prefers_prefix_to_archive_name_format(): config = { 'archive_name_format': 'bar-{now}', # noqa: FS003 'keep_daily': 1, 'prefix': 'bar-', } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').never() result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--match-archives', 'sh:bar-*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_without_prefix_uses_archive_name_format_instead(): config = { 'archive_name_format': 'bar-{now}', # noqa: FS003 'keep_daily': 1, 'prefix': None, } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '1.2.3' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')).once() result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--match-archives', 'sh:bar-*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_without_prefix_uses_match_archives_flag_instead_of_option(): config = { 'archive_name_format': 'bar-{now}', # noqa: FS003 'match_archives': 'foo*', 'keep_daily': 1, 'prefix': None, } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'baz*', 'bar-{now}', '1.2.3' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')).once() result = module.make_prune_flags( config, flexmock(match_archives='baz*'), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--match-archives', 'sh:bar-*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_without_prefix_uses_match_archives_option(): config = { 'archive_name_format': 'bar-{now}', # noqa: FS003 'match_archives': 'foo*', 'keep_daily': 1, 'prefix': None, } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'foo*', 'bar-{now}', '1.2.3' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')).once() result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) expected = ( '--keep-daily', '1', '--match-archives', 'sh:bar-*', # noqa: FS003 ) assert result == expected def test_make_prune_flags_ignores_keep_exclude_tags_in_config(): config = { 'keep_daily': 1, 'keep_exclude_tags': True, } flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) result = module.make_prune_flags( config, flexmock(match_archives=None), local_borg_version='1.2.3' ) assert result == ('--keep-daily', '1') PRUNE_COMMAND = ('borg', 'prune', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly', '3') def test_prune_archives_calls_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('repo',), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_log_info_calls_borg_with_info_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--info', 'repo'), logging.INFO) insert_logging_mock(logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( repository_path='repo', config={}, dry_run=False, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_log_debug_calls_borg_with_debug_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--debug', '--show-rc', 'repo'), logging.INFO) insert_logging_mock(logging.DEBUG) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( repository_path='repo', config={}, dry_run=False, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_dry_run_calls_borg_with_dry_run_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--dry-run', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( repository_path='repo', config={}, dry_run=True, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_local_path_calls_borg_via_local_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1',) + PRUNE_COMMAND[1:] + ('repo',), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', prune_arguments=prune_arguments, ) def test_prune_archives_with_exit_codes_calls_borg_using_them(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) borg_exit_codes = flexmock() insert_execute_command_mock( ('borg',) + PRUNE_COMMAND[1:] + ('repo',), logging.INFO, borg_exit_codes=borg_exit_codes, ) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_remote_path_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--remote-path', 'borg1', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', prune_arguments=prune_arguments, ) def test_prune_archives_with_stats_calls_borg_with_stats_flag_and_answer_output_log_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--stats', 'repo'), module.borgmatic.logger.ANSWER) prune_arguments = flexmock(stats=True, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_files_calls_borg_with_list_flag_and_answer_output_log_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--list', 'repo'), module.borgmatic.logger.ANSWER) prune_arguments = flexmock(stats=False, list_archives=True) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_umask_calls_borg_with_umask_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER config = {'umask': '077'} flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--umask', '077', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_log_json_calls_borg_with_log_json_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--log-json', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), prune_arguments=prune_arguments, ) def test_prune_archives_with_lock_wait_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER config = {'lock_wait': 5} flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--lock-wait', '5', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_extra_borg_options_calls_borg_with_extra_options(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(PRUNE_COMMAND + ('--extra', '--options', 'repo'), logging.INFO) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={'extra_borg_options': {'prune': '--extra --options'}}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_with_date_based_matching_calls_borg_with_date_based_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( ( '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--match-archives', None, ) ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ( 'borg', 'prune', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly', '3', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--match-archives', None, '--repo', 'repo', ), output_log_level=logging.INFO, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) prune_arguments = flexmock( stats=False, list_archives=False, newer='1d', newest='1y', older='1m', oldest='1w' ) module.prune_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) def test_prune_archives_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_prune_flags').and_return(BASE_PRUNE_FLAGS) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock( PRUNE_COMMAND + ('repo',), logging.INFO, working_directory='/working/dir' ) prune_arguments = flexmock(stats=False, list_archives=False) module.prune_archives( dry_run=False, repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), prune_arguments=prune_arguments, ) borgmatic/tests/unit/borg/test_repo_create.py000066400000000000000000000374151476361726000220260ustar00rootroot00000000000000import logging import subprocess import pytest from flexmock import flexmock from borgmatic.borg import repo_create as module from ..test_verbosity import insert_logging_mock REPO_INFO_SOME_UNKNOWN_EXIT_CODE = -999 REPO_CREATE_COMMAND = ('borg', 'repo-create', '--encryption', 'repokey') def insert_repo_info_command_found_mock(): flexmock(module.repo_info).should_receive('display_repository_info').and_return( '{"encryption": {"mode": "repokey"}}' ) def insert_repo_info_command_not_found_mock(): flexmock(module.repo_info).should_receive('display_repository_info').and_raise( subprocess.CalledProcessError( sorted(module.REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES)[0], [] ) ) def insert_repo_create_command_mock( repo_create_command, working_directory=None, borg_exit_codes=None, **kwargs ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( repo_create_command, output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=working_directory, borg_local_path=repo_create_command[0], borg_exit_codes=borg_exit_codes, ).once() def test_create_repository_calls_borg_with_flags(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_dry_run_skips_borg_call(): insert_repo_info_command_not_found_mock() flexmock(module).should_receive('execute_command').never() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=True, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_raises_for_borg_repo_create_error(): insert_repo_info_command_not_found_mock() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').and_raise( module.subprocess.CalledProcessError(2, 'borg repo_create') ) with pytest.raises(subprocess.CalledProcessError): module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_skips_creation_when_repository_already_exists(): insert_repo_info_command_found_mock() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_errors_when_repository_with_differing_encryption_mode_already_exists(): insert_repo_info_command_found_mock() flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) with pytest.raises(ValueError): module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey-blake2', ) def test_create_repository_raises_for_unknown_repo_info_command_error(): flexmock(module.repo_info).should_receive('display_repository_info').and_raise( subprocess.CalledProcessError(REPO_INFO_SOME_UNKNOWN_EXIT_CODE, []) ) with pytest.raises(subprocess.CalledProcessError): module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_source_repository_calls_borg_with_other_repo_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( REPO_CREATE_COMMAND + ('--other-repo', 'other.borg', '--repo', 'repo') ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', source_repository='other.borg', ) def test_create_repository_with_copy_crypt_key_calls_borg_with_copy_crypt_key_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--copy-crypt-key', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', copy_crypt_key=True, ) def test_create_repository_with_append_only_calls_borg_with_append_only_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--append-only', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', append_only=True, ) def test_create_repository_with_storage_quota_calls_borg_with_storage_quota_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( REPO_CREATE_COMMAND + ('--storage-quota', '5G', '--repo', 'repo') ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', storage_quota='5G', ) def test_create_repository_with_make_parent_dirs_calls_borg_with_make_parent_dirs_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--make-parent-dirs', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', make_parent_dirs=True, ) def test_create_repository_with_log_info_calls_borg_with_info_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--info', '--repo', 'repo')) insert_logging_mock(logging.INFO) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_log_debug_calls_borg_with_debug_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--debug', '--repo', 'repo')) insert_logging_mock(logging.DEBUG) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_log_json_calls_borg_with_log_json_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--log-json', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=True), encryption_mode='repokey', ) def test_create_repository_with_lock_wait_calls_borg_with_lock_wait_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--lock-wait', '5', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={'lock_wait': 5}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_local_path_calls_borg_via_local_path(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(('borg1',) + REPO_CREATE_COMMAND[1:] + ('--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', local_path='borg1', ) def test_create_repository_with_exit_codes_calls_borg_using_them(): borg_exit_codes = flexmock() insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( ('borg',) + REPO_CREATE_COMMAND[1:] + ('--repo', 'repo'), borg_exit_codes=borg_exit_codes ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_remote_path_calls_borg_with_remote_path_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( REPO_CREATE_COMMAND + ('--remote-path', 'borg1', '--repo', 'repo') ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', remote_path='borg1', ) def test_create_repository_with_umask_calls_borg_with_umask_flag(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock(REPO_CREATE_COMMAND + ('--umask', '077', '--repo', 'repo')) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={'umask': '077'}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_with_extra_borg_options_calls_borg_with_extra_options(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( REPO_CREATE_COMMAND + ('--extra', '--options', '--repo', 'repo') ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={'extra_borg_options': {'repo-create': '--extra --options'}}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) def test_create_repository_calls_borg_with_working_directory(): insert_repo_info_command_not_found_mock() insert_repo_create_command_mock( REPO_CREATE_COMMAND + ('--repo', 'repo'), working_directory='/working/dir' ) flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) module.create_repository( dry_run=False, repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) borgmatic/tests/unit/borg/test_repo_delete.py000066400000000000000000000366471476361726000220330ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import repo_delete as module from ..test_verbosity import insert_logging_mock def test_make_repo_delete_command_with_feature_available_runs_borg_repo_delete(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', 'repo') def test_make_repo_delete_command_without_feature_available_runs_borg_delete(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'delete', 'repo') def test_make_repo_delete_command_includes_log_info(): insert_logging_mock(logging.INFO) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--info', 'repo') def test_make_repo_delete_command_includes_log_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--debug', '--show-rc', 'repo') def test_make_repo_delete_command_includes_dry_run(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'dry-run', True ).and_return(('--dry-run',)) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=True, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--dry-run', 'repo') def test_make_repo_delete_command_includes_remote_path(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'remote-path', 'borg1' ).and_return(('--remote-path', 'borg1')) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path='borg1', ) assert command == ('borg', 'repo-delete', '--remote-path', 'borg1', 'repo') def test_make_repo_delete_command_includes_umask(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={'umask': '077'}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--umask', '077', 'repo') def test_make_repo_delete_command_includes_log_json(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'log-json', True ).and_return(('--log-json',)) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=True), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--log-json', 'repo') def test_make_repo_delete_command_includes_lock_wait(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'lock-wait', 5 ).and_return(('--lock-wait', '5')) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={'lock_wait': 5}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--lock-wait', '5', 'repo') def test_make_repo_delete_command_includes_list(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args( 'list', True ).and_return(('--list',)) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=True, force=0), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--list', 'repo') def test_make_repo_delete_command_includes_force(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=1), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--force', 'repo') def test_make_repo_delete_command_includes_force_twice(): flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( ('repo',) ) command = module.make_repo_delete_command( repository={'path': 'repo'}, config={}, local_borg_version='1.2.3', repo_delete_arguments=flexmock(list_archives=False, force=2), global_arguments=flexmock(dry_run=False, log_json=False), local_path='borg', remote_path=None, ) assert command == ('borg', 'repo-delete', '--force', '--force', 'repo') def test_delete_repository_with_defaults_does_not_capture_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') command = flexmock() flexmock(module).should_receive('make_repo_delete_command').and_return(command) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_log_level=module.logging.ANSWER, output_file=module.borgmatic.execute.DO_NOT_CAPTURE, environment=object, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.delete_repository( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), repo_delete_arguments=flexmock(force=False, cache_only=False), global_arguments=flexmock(), local_path='borg', remote_path=None, ) def test_delete_repository_with_force_captures_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') command = flexmock() flexmock(module).should_receive('make_repo_delete_command').and_return(command) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_log_level=module.logging.ANSWER, output_file=None, environment=object, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.delete_repository( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), repo_delete_arguments=flexmock(force=True, cache_only=False), global_arguments=flexmock(), local_path='borg', remote_path=None, ) def test_delete_repository_with_cache_only_captures_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') command = flexmock() flexmock(module).should_receive('make_repo_delete_command').and_return(command) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_log_level=module.logging.ANSWER, output_file=None, environment=object, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).once() module.delete_repository( repository={'path': 'repo'}, config={}, local_borg_version=flexmock(), repo_delete_arguments=flexmock(force=False, cache_only=True), global_arguments=flexmock(), local_path='borg', remote_path=None, ) def test_delete_repository_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') command = flexmock() flexmock(module).should_receive('make_repo_delete_command').and_return(command) flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( flexmock() ) flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( command, output_log_level=module.logging.ANSWER, output_file=module.borgmatic.execute.DO_NOT_CAPTURE, environment=object, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ).once() module.delete_repository( repository={'path': 'repo'}, config={'working_directory': '/working/dir'}, local_borg_version=flexmock(), repo_delete_arguments=flexmock(force=False, cache_only=False), global_arguments=flexmock(), local_path='borg', remote_path=None, ) borgmatic/tests/unit/borg/test_repo_info.py000066400000000000000000000553401476361726000215130ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import repo_info as module from ..test_verbosity import insert_logging_mock def test_display_repository_info_calls_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_without_borg_features_calls_borg_with_info_sub_command(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(False) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_with_log_info_calls_borg_with_info_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_with_log_info_and_json_suppresses_most_borg_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() insert_logging_mock(logging.INFO) json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=True), global_arguments=flexmock(log_json=False), ) assert json_output == '[]' def test_display_repository_info_with_log_debug_calls_borg_with_debug_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--debug', '--show-rc', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--debug', '--show-rc', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.DEBUG) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_with_log_debug_and_json_suppresses_most_borg_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() insert_logging_mock(logging.DEBUG) json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=True), global_arguments=flexmock(log_json=False), ) assert json_output == '[]' def test_display_repository_info_with_json_calls_borg_with_json_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=True), global_arguments=flexmock(log_json=False), ) assert json_output == '[]' def test_display_repository_info_with_local_path_calls_borg_via_local_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg1', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg1', 'repo-info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg1', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_display_repository_info_with_exit_codes_calls_borg_using_them(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) borg_exit_codes = flexmock() flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) module.display_repository_info( repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_with_remote_path_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--remote-path', 'borg1', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--remote-path', 'borg1', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_display_repository_info_with_umask_calls_borg_with_umask_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--umask', '077', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--umask', '077', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={'umask': '077'}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), remote_path=None, ) def test_display_repository_info_with_log_json_calls_borg_with_log_json_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return( ('--log-json',) ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--log-json', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--log-json', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=True), ) def test_display_repository_info_with_lock_wait_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER config = {'lock_wait': 5} flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', str(value)) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--lock-wait', '5', '--json', '--repo', 'repo'), environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--lock-wait', '5', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config=config, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) def test_display_repository_info_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'repo-info', '--json', '--repo', 'repo'), environment=None, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ).and_return('[]') flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( ('borg', 'repo-info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, environment=None, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', repo_info_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_repo_list.py000066400000000000000000000707061476361726000215360ustar00rootroot00000000000000import argparse import logging import pytest from flexmock import flexmock from borgmatic.borg import repo_list as module from ..test_verbosity import insert_logging_mock BORG_LIST_LATEST_ARGUMENTS = ( '--last', '1', '--short', 'repo', ) def test_resolve_archive_name_passes_through_non_latest_archive_name(): archive = 'myhost-2030-01-01T14:41:17.647620' assert ( module.resolve_archive_name( 'repo', archive, config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == archive ) def test_resolve_archive_name_calls_borg_with_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, borg_local_path='borg', borg_exit_codes=None, environment=None, working_directory=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_with_log_info_calls_borg_without_info_flag(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') insert_logging_mock(logging.INFO) assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_with_log_debug_calls_borg_without_debug_flag(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') insert_logging_mock(logging.DEBUG) assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_with_local_path_calls_borg_via_local_path(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg1', 'list') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg1', borg_exit_codes=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) == expected_archive ) def test_resolve_archive_name_with_exit_codes_calls_borg_using_them(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) borg_exit_codes = flexmock() flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--remote-path', 'borg1') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) == expected_archive ) def test_resolve_archive_name_with_umask_calls_borg_with_umask_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--umask', '077') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={'umask': '077'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_without_archives_raises(): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return('') with pytest.raises(ValueError): module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_resolve_archive_name_with_log_json_calls_borg_with_log_json_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--log-json') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) == expected_archive ) def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--lock-wait', 'okay') + BORG_LIST_LATEST_ARGUMENTS, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={'lock_wait': 'okay'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_resolve_archive_name_calls_borg_with_working_directory(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, borg_local_path='borg', borg_exit_codes=None, environment=None, working_directory='/working/dir', ).and_return(expected_archive + '\n') assert ( module.resolve_archive_name( 'repo', 'latest', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) == expected_archive ) def test_make_repo_list_command_includes_log_info(): insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--info', 'repo') def test_make_repo_list_command_includes_json_but_not_info(): insert_logging_mock(logging.INFO) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=True, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_repo_list_command_includes_log_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--debug', '--show-rc', 'repo') def test_make_repo_list_command_includes_json_but_not_debug(): insert_logging_mock(logging.DEBUG) flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=True, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_repo_list_command_includes_json(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=True, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--json', 'repo') def test_make_repo_list_command_includes_log_json(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return( ('--log-json',) ).and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=True), ) assert command == ('borg', 'list', '--log-json', 'repo') def test_make_repo_list_command_includes_lock_wait(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return( ('--lock-wait', '5') ).and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={'lock_wait': 5}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--lock-wait', '5', 'repo') def test_make_repo_list_command_includes_local_path(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), local_path='borg2', ) assert command == ('borg2', 'list', 'repo') def test_make_repo_list_command_includes_remote_path(): flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), remote_path='borg2', ) assert command == ('borg', 'list', '--remote-path', 'borg2', 'repo') def test_make_repo_list_command_includes_umask(): flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={'umask': '077'}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--umask', '077', 'repo') def test_make_repo_list_command_transforms_prefix_into_match_archives(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( ('--match-archives', 'sh:foo*') ) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock(archive=None, paths=None, json=False, prefix='foo'), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--match-archives', 'sh:foo*', 'repo') def test_make_repo_list_command_prefers_prefix_over_archive_name_format(): flexmock(module.flags).should_receive('make_flags').and_return(()).and_return(()).and_return( ('--match-archives', 'sh:foo*') ) flexmock(module.flags).should_receive('make_match_archives_flags').never() flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='1.2.3', repo_list_arguments=flexmock(archive=None, paths=None, json=False, prefix='foo'), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--match-archives', 'sh:foo*', 'repo') def test_make_repo_list_command_transforms_archive_name_format_into_match_archives(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '1.2.3' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--match-archives', 'sh:bar-*', 'repo') def test_make_repo_list_command_includes_short(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--short',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None, short=True ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--short', 'repo') @pytest.mark.parametrize( 'argument_name', ( 'sort_by', 'first', 'last', 'exclude', 'exclude_from', 'pattern', 'patterns_from', ), ) def test_make_repo_list_command_includes_additional_flags(argument_name): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( (f"--{argument_name.replace('_', '-')}", 'value') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None, find_paths=None, format=None, **{argument_name: 'value'}, ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--' + argument_name.replace('_', '-'), 'value', 'repo') def test_make_repo_list_command_with_match_archives_calls_borg_with_match_archives_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'foo-*', None, '1.2.3', ).and_return(('--match-archives', 'foo-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives='foo-*', find_paths=None, format=None, ), global_arguments=flexmock(log_json=False), ) assert command == ('borg', 'list', '--match-archives', 'foo-*', 'repo') def test_list_repository_calls_two_commands(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_repo_list_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command_and_capture_output').once() flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').once() module.list_repository( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=argparse.Namespace(json=False), global_arguments=flexmock(), ) def test_list_repository_with_json_calls_json_command_only(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_repo_list_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) json_output = flexmock() flexmock(module).should_receive('execute_command_and_capture_output').and_return(json_output) flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never() flexmock(module).should_receive('execute_command').never() assert ( module.list_repository( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=argparse.Namespace(json=True), global_arguments=flexmock(), ) == json_output ) def test_make_repo_list_command_with_date_based_matching_calls_borg_with_date_based_flags(): flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, None, '1.2.3' ).and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( ('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) command = module.make_repo_list_command( repository_path='repo', config={}, local_borg_version='1.2.3', repo_list_arguments=flexmock( archive=None, paths=None, json=False, prefix=None, match_archives=None, newer='1d', newest='1y', older='1m', oldest='1w', ), global_arguments=flexmock(log_json=False), ) assert command == ( 'borg', 'list', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', 'repo', ) def test_list_repository_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module).should_receive('make_repo_list_command') flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command_and_capture_output').with_args( full_command=object, environment=object, working_directory='/working/dir', borg_local_path=object, borg_exit_codes=object, ).once() flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags') flexmock(module).should_receive('execute_command').with_args( full_command=object, output_log_level=object, environment=object, working_directory='/working/dir', borg_local_path=object, borg_exit_codes=object, ).once() module.list_repository( repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='1.2.3', repo_list_arguments=argparse.Namespace(json=False), global_arguments=flexmock(), ) borgmatic/tests/unit/borg/test_transfer.py000066400000000000000000000655471476361726000213710ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import transfer as module from ..test_verbosity import insert_logging_mock def test_transfer_archives_calls_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_dry_run_calls_borg_with_dry_run_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('dry-run', True).and_return( ('--dry-run',) ) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo', '--dry-run'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=True, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_log_info_calls_borg_with_info_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.INFO) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_log_debug_calls_borg_with_debug_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--debug', '--show-rc', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) insert_logging_mock(logging.DEBUG) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_archive_calls_borg_with_match_archives_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'archive', 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'archive')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'archive', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', transfer_arguments=flexmock( archive='archive', progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_match_archives_calls_borg_with_match_archives_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 'sh:foo*', 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'sh:foo*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'sh:foo*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives='sh:foo*', source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_archive_name_format_calls_borg_with_match_archives_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').with_args( None, 'bar-{now}', '2.3.4' # noqa: FS003 ).and_return(('--match-archives', 'sh:bar-*')) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'sh:bar-*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_local_path_calls_borg_via_local_path(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg2', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg2', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), local_path='borg2', ) def test_transfer_archives_with_exit_codes_calls_borg_using_them(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) borg_exit_codes = flexmock() flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=borg_exit_codes, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'borg_exit_codes': borg_exit_codes}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_remote_path_calls_borg_with_remote_path_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args( 'remote-path', 'borg2' ).and_return(('--remote-path', 'borg2')) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--remote-path', 'borg2', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), remote_path='borg2', ) def test_transfer_archives_with_umask_calls_borg_with_umask_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').replace_with( lambda name, value: (f'--{name}', value) if value else () ) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--umask', '077', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'umask': '077'}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_log_json_calls_borg_with_log_json_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return( ('--log-json',) ) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--log-json', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=True), ) def test_transfer_archives_with_lock_wait_calls_borg_with_lock_wait_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('lock-wait', 5).and_return( ('--lock-wait', '5') ) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) config = {'lock_wait': 5} flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--lock-wait', '5', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config=config, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_progress_calls_borg_with_progress_flag(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--progress',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--progress', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=module.DO_NOT_CAPTURE, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=True, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) @pytest.mark.parametrize('argument_name', ('upgrader', 'sort_by', 'first', 'last')) def test_transfer_archives_passes_through_arguments_to_borg(argument_name): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flag_name = f"--{argument_name.replace('_', ' ')}" flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( (flag_name, 'value') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', flag_name, 'value', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None, **{argument_name: 'value'}, ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_source_repository_calls_borg_with_other_repo_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').with_args('other-repo', 'other').and_return( ('--other-repo', 'other') ) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo', '--other-repo', 'other'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository='other' ), global_arguments=flexmock(log_json=False), ) def test_transfer_archives_with_date_based_matching_calls_borg_with_date_based_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return( ('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w') ) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None) flexmock(module).should_receive('execute_command').with_args( ( 'borg', 'transfer', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--repo', 'repo', ), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory=None, borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), transfer_arguments=flexmock( archive=None, progress=None, source_repository='other', newer='1d', newest='1y', older='1m', oldest='1w', ), ) def test_transfer_archives_calls_borg_with_working_directory(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( '/working/dir', ) flexmock(module).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, environment=None, working_directory='/working/dir', borg_local_path='borg', borg_exit_codes=None, ) module.transfer_archives( dry_run=False, repository_path='repo', config={'working_directory': '/working/dir'}, local_borg_version='2.3.4', transfer_arguments=flexmock( archive=None, progress=None, match_archives=None, source_repository=None ), global_arguments=flexmock(log_json=False), ) borgmatic/tests/unit/borg/test_umount.py000066400000000000000000000040021476361726000210470ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import umount as module from ..test_verbosity import insert_logging_mock def insert_execute_command_mock( command, borg_local_path='borg', working_directory=None, borg_exit_codes=None ): flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command').with_args( command, working_directory=working_directory, borg_local_path=borg_local_path, borg_exit_codes=borg_exit_codes, ).once() def test_unmount_archive_calls_borg_with_required_parameters(): insert_execute_command_mock(('borg', 'umount', '/mnt')) module.unmount_archive(config={}, mount_point='/mnt') def test_unmount_archive_with_log_info_calls_borg_with_info_parameter(): insert_execute_command_mock(('borg', 'umount', '--info', '/mnt')) insert_logging_mock(logging.INFO) module.unmount_archive(config={}, mount_point='/mnt') def test_unmount_archive_with_log_debug_calls_borg_with_debug_parameters(): insert_execute_command_mock(('borg', 'umount', '--debug', '--show-rc', '/mnt')) insert_logging_mock(logging.DEBUG) module.unmount_archive(config={}, mount_point='/mnt') def test_unmount_archive_calls_borg_with_local_path(): insert_execute_command_mock(('borg1', 'umount', '/mnt'), borg_local_path='borg1') module.unmount_archive(config={}, mount_point='/mnt', local_path='borg1') def test_unmount_archive_calls_borg_with_exit_codes(): borg_exit_codes = flexmock() insert_execute_command_mock(('borg', 'umount', '/mnt'), borg_exit_codes=borg_exit_codes) module.unmount_archive(config={'borg_exit_codes': borg_exit_codes}, mount_point='/mnt') def test_unmount_archive_calls_borg_with_working_directory(): insert_execute_command_mock(('borg', 'umount', '/mnt'), working_directory='/working/dir') module.unmount_archive(config={'working_directory': '/working/dir'}, mount_point='/mnt') borgmatic/tests/unit/borg/test_version.py000066400000000000000000000060521476361726000212140ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import version as module from ..test_verbosity import insert_logging_mock VERSION = '1.2.3' def insert_execute_command_and_capture_output_mock( command, working_directory=None, borg_local_path='borg', borg_exit_codes=None, version_output=f'borg {VERSION}', ): flexmock(module.environment).should_receive('make_environment') flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( working_directory, ) flexmock(module).should_receive('execute_command_and_capture_output').with_args( command, environment=None, working_directory=working_directory, borg_local_path=borg_local_path, borg_exit_codes=borg_exit_codes, ).once().and_return(version_output) def test_local_borg_version_calls_borg_with_required_parameters(): insert_execute_command_and_capture_output_mock(('borg', '--version')) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}) == VERSION def test_local_borg_version_with_log_info_calls_borg_with_info_parameter(): insert_execute_command_and_capture_output_mock(('borg', '--version', '--info')) insert_logging_mock(logging.INFO) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}) == VERSION def test_local_borg_version_with_log_debug_calls_borg_with_debug_parameters(): insert_execute_command_and_capture_output_mock(('borg', '--version', '--debug', '--show-rc')) insert_logging_mock(logging.DEBUG) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}) == VERSION def test_local_borg_version_with_local_borg_path_calls_borg_with_it(): insert_execute_command_and_capture_output_mock(('borg1', '--version'), borg_local_path='borg1') flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({}, 'borg1') == VERSION def test_local_borg_version_with_borg_exit_codes_calls_using_with_them(): borg_exit_codes = flexmock() insert_execute_command_and_capture_output_mock( ('borg', '--version'), borg_exit_codes=borg_exit_codes ) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({'borg_exit_codes': borg_exit_codes}) == VERSION def test_local_borg_version_with_invalid_version_raises(): insert_execute_command_and_capture_output_mock(('borg', '--version'), version_output='wtf') flexmock(module.environment).should_receive('make_environment') with pytest.raises(ValueError): module.local_borg_version({}) def test_local_borg_version_calls_borg_with_working_directory(): insert_execute_command_and_capture_output_mock( ('borg', '--version'), working_directory='/working/dir' ) flexmock(module.environment).should_receive('make_environment') assert module.local_borg_version({'working_directory': '/working/dir'}) == VERSION borgmatic/tests/unit/commands/000077500000000000000000000000001476361726000167635ustar00rootroot00000000000000borgmatic/tests/unit/commands/__init__.py000066400000000000000000000000001476361726000210620ustar00rootroot00000000000000borgmatic/tests/unit/commands/completion/000077500000000000000000000000001476361726000211345ustar00rootroot00000000000000borgmatic/tests/unit/commands/completion/__init__.py000066400000000000000000000000001476361726000232330ustar00rootroot00000000000000borgmatic/tests/unit/commands/completion/test_actions.py000066400000000000000000000003421476361726000242040ustar00rootroot00000000000000from borgmatic.commands.completion import actions as module def test_upgrade_message_does_not_raise(): module.upgrade_message( language='English', upgrade_command='read a lot', completion_file='your brain' ) borgmatic/tests/unit/commands/completion/test_bash.py000066400000000000000000000006571476361726000234720ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.commands.completion import bash as module def test_parser_flags_flattens_and_joins_flags(): assert ( module.parser_flags( flexmock( _actions=[ flexmock(option_strings=['--foo', '--bar']), flexmock(option_strings=['--baz']), ] ) ) == '--foo --bar --baz' ) borgmatic/tests/unit/commands/completion/test_fish.py000066400000000000000000000110101476361726000234670ustar00rootroot00000000000000from argparse import Action from collections import namedtuple from typing import Tuple import pytest from flexmock import flexmock from borgmatic.commands.completion import fish as module OptionType = namedtuple('OptionType', ['file', 'choice', 'unknown_required']) TestCase = Tuple[Action, OptionType] test_data = [ (Action('--flag', 'flag'), OptionType(file=False, choice=False, unknown_required=False)), *( ( Action('--flag', 'flag', metavar=metavar), OptionType(file=True, choice=False, unknown_required=False), ) for metavar in ('FILENAME', 'PATH') ), ( Action('--flag', dest='config_paths'), OptionType(file=True, choice=False, unknown_required=False), ), ( Action('--flag', 'flag', metavar='OTHER'), OptionType(file=False, choice=False, unknown_required=False), ), ( Action('--flag', 'flag', choices=['a', 'b']), OptionType(file=False, choice=True, unknown_required=False), ), ( Action('--flag', 'flag', choices=['a', 'b'], type=str), OptionType(file=False, choice=True, unknown_required=True), ), ( Action('--flag', 'flag', choices=None), OptionType(file=False, choice=False, unknown_required=False), ), ( Action('--flag', 'flag', required=True), OptionType(file=False, choice=False, unknown_required=True), ), *( ( Action('--flag', 'flag', nargs=nargs), OptionType(file=False, choice=False, unknown_required=True), ) for nargs in ('+', '*') ), *( ( Action('--flag', 'flag', metavar=metavar), OptionType(file=False, choice=False, unknown_required=True), ) for metavar in ('PATTERN', 'KEYS', 'N') ), *( ( Action('--flag', 'flag', type=type, default=None), OptionType(file=False, choice=False, unknown_required=True), ) for type in (int, str) ), ( Action('--flag', 'flag', type=int, default=1), OptionType(file=False, choice=False, unknown_required=False), ), ( Action('--flag', 'flag', type=str, required=True, metavar='PATH'), OptionType(file=True, choice=False, unknown_required=True), ), ( Action('--flag', 'flag', type=str, required=True, metavar='PATH', default='/dev/null'), OptionType(file=True, choice=False, unknown_required=True), ), ( Action('--flag', 'flag', type=str, required=False, metavar='PATH', default='/dev/null'), OptionType(file=True, choice=False, unknown_required=False), ), ] @pytest.mark.parametrize('action, option_type', test_data) def test_has_file_options_detects_file_options(action: Action, option_type: OptionType): assert module.has_file_options(action) == option_type.file @pytest.mark.parametrize('action, option_type', test_data) def test_has_choice_options_detects_choice_options(action: Action, option_type: OptionType): assert module.has_choice_options(action) == option_type.choice @pytest.mark.parametrize('action, option_type', test_data) def test_has_unknown_required_param_options_detects_unknown_required_param_options( action: Action, option_type: OptionType ): assert module.has_unknown_required_param_options(action) == option_type.unknown_required @pytest.mark.parametrize('action, option_type', test_data) def test_has_exact_options_detects_exact_options(action: Action, option_type: OptionType): assert module.has_exact_options(action) == (True in option_type) @pytest.mark.parametrize('action, option_type', test_data) def test_exact_options_completion_produces_reasonable_completions( action: Action, option_type: OptionType ): completion = module.exact_options_completion(action) if True in option_type: assert completion.startswith('\ncomplete -c borgmatic') else: assert completion == '' def test_exact_options_completion_raises_for_unexpected_action(): flexmock(module).should_receive('has_exact_options').and_return(True) flexmock(module).should_receive('has_file_options').and_return(False) flexmock(module).should_receive('has_choice_options').and_return(False) flexmock(module).should_receive('has_unknown_required_param_options').and_return(False) with pytest.raises(ValueError): module.exact_options_completion(Action('--unknown', dest='unknown')) def test_dedent_strip_as_tuple_does_not_raise(): module.dedent_strip_as_tuple( ''' a b ''' ) borgmatic/tests/unit/commands/test_arguments.py000066400000000000000000000537231476361726000224130ustar00rootroot00000000000000import collections import pytest from flexmock import flexmock from borgmatic.commands import arguments as module def test_get_subaction_parsers_with_no_subactions_returns_empty_result(): assert module.get_subaction_parsers(flexmock(_subparsers=None)) == {} def test_get_subaction_parsers_with_subactions_returns_one_entry_per_subaction(): foo_parser = flexmock() bar_parser = flexmock() baz_parser = flexmock() assert module.get_subaction_parsers( flexmock( _subparsers=flexmock( _group_actions=( flexmock(choices={'foo': foo_parser, 'bar': bar_parser}), flexmock(choices={'baz': baz_parser}), ) ) ) ) == {'foo': foo_parser, 'bar': bar_parser, 'baz': baz_parser} def test_get_subactions_for_actions_with_no_subactions_returns_empty_result(): assert module.get_subactions_for_actions({'action': flexmock(_subparsers=None)}) == {} def test_get_subactions_for_actions_with_subactions_returns_one_entry_per_action(): assert module.get_subactions_for_actions( { 'action': flexmock( _subparsers=flexmock( _group_actions=( flexmock(choices={'foo': flexmock(), 'bar': flexmock()}), flexmock(choices={'baz': flexmock()}), ) ) ), 'other': flexmock( _subparsers=flexmock(_group_actions=(flexmock(choices={'quux': flexmock()}),)) ), } ) == {'action': ('foo', 'bar', 'baz'), 'other': ('quux',)} def test_omit_values_colliding_with_action_names_drops_action_names_that_have_been_parsed_as_values(): assert module.omit_values_colliding_with_action_names( ('check', '--only', 'extract', '--some-list', 'borg'), {'check': flexmock(only='extract', some_list=['borg'])}, ) == ('check', '--only', '--some-list') def test_omit_values_colliding_twice_with_action_names_drops_action_names_that_have_been_parsed_as_values(): assert module.omit_values_colliding_with_action_names( ('config', 'bootstrap', '--local-path', '--remote-path', 'borg'), {'bootstrap': flexmock(local_path='borg', remote_path='borg')}, ) == ('config', 'bootstrap', '--local-path', '--remote-path') def test_parse_and_record_action_arguments_without_action_name_leaves_arguments_untouched(): unparsed_arguments = ('--foo', '--bar') flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return( unparsed_arguments ) assert ( module.parse_and_record_action_arguments( unparsed_arguments, flexmock(), flexmock(), 'action' ) == unparsed_arguments ) def test_parse_and_record_action_arguments_updates_parsed_arguments_and_returns_remaining(): unparsed_arguments = ('action', '--foo', '--bar', '--verbosity', '1') other_parsed_arguments = flexmock() parsed_arguments = {'other': other_parsed_arguments} action_parsed_arguments = flexmock() flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return( unparsed_arguments ) action_parser = flexmock() flexmock(action_parser).should_receive('parse_known_args').and_return( action_parsed_arguments, ('action', '--verbosity', '1') ) assert module.parse_and_record_action_arguments( unparsed_arguments, parsed_arguments, action_parser, 'action' ) == ('--verbosity', '1') assert parsed_arguments == {'other': other_parsed_arguments, 'action': action_parsed_arguments} def test_parse_and_record_action_arguments_with_alias_updates_canonical_parsed_arguments(): unparsed_arguments = ('action', '--foo', '--bar', '--verbosity', '1') other_parsed_arguments = flexmock() parsed_arguments = {'other': other_parsed_arguments} action_parsed_arguments = flexmock() flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return( unparsed_arguments ) action_parser = flexmock() flexmock(action_parser).should_receive('parse_known_args').and_return( action_parsed_arguments, ('action', '--verbosity', '1') ) assert module.parse_and_record_action_arguments( unparsed_arguments, parsed_arguments, action_parser, 'action', canonical_name='doit' ) == ('--verbosity', '1') assert parsed_arguments == {'other': other_parsed_arguments, 'doit': action_parsed_arguments} def test_parse_and_record_action_arguments_with_borg_action_consumes_arguments_after_action_name(): unparsed_arguments = ('--verbosity', '1', 'borg', 'list') parsed_arguments = {} borg_parsed_arguments = flexmock(options=flexmock()) flexmock(module).should_receive('omit_values_colliding_with_action_names').and_return( unparsed_arguments ) borg_parser = flexmock() flexmock(borg_parser).should_receive('parse_known_args').and_return( borg_parsed_arguments, ('--verbosity', '1', 'borg', 'list') ) assert module.parse_and_record_action_arguments( unparsed_arguments, parsed_arguments, borg_parser, 'borg', ) == ('--verbosity', '1') assert parsed_arguments == {'borg': borg_parsed_arguments} assert borg_parsed_arguments.options == ('list',) @pytest.mark.parametrize( 'argument, expected', [ ('--foo', True), ('foo', False), (33, False), ], ) def test_argument_is_flag_only_for_string_starting_with_double_dash(argument, expected): assert module.argument_is_flag(argument) == expected @pytest.mark.parametrize( 'arguments, expected', [ # Ending with a valueless flag. ( ('--foo', '--bar', 33, '--baz'), ( ('--foo',), ('--bar', 33), ('--baz',), ), ), # Ending with a flag and its corresponding value. ( ('--foo', '--bar', 33, '--baz', '--quux', 'thing'), (('--foo',), ('--bar', 33), ('--baz',), ('--quux', 'thing')), ), # Starting with an action name. ( ('check', '--foo', '--bar', 33, '--baz'), ( ('check',), ('--foo',), ('--bar', 33), ('--baz',), ), ), # Action name that one could mistake for a flag value. (('--progress', 'list'), (('--progress',), ('list',))), # No arguments. ((), ()), ], ) def test_group_arguments_with_values_returns_flags_with_corresponding_values(arguments, expected): flexmock(module).should_receive('argument_is_flag').with_args('--foo').and_return(True) flexmock(module).should_receive('argument_is_flag').with_args('--bar').and_return(True) flexmock(module).should_receive('argument_is_flag').with_args('--baz').and_return(True) flexmock(module).should_receive('argument_is_flag').with_args('--quux').and_return(True) flexmock(module).should_receive('argument_is_flag').with_args('--progress').and_return(True) flexmock(module).should_receive('argument_is_flag').with_args(33).and_return(False) flexmock(module).should_receive('argument_is_flag').with_args('thing').and_return(False) flexmock(module).should_receive('argument_is_flag').with_args('check').and_return(False) flexmock(module).should_receive('argument_is_flag').with_args('list').and_return(False) assert module.group_arguments_with_values(arguments) == expected @pytest.mark.parametrize( 'arguments, grouped_arguments, expected', [ # An unparsable flag remaining from each parsed action. ( ( ('--latest', 'archive', 'prune', 'extract', 'list', '--flag'), ('--latest', 'archive', 'check', 'extract', 'list', '--flag'), ('prune', 'check', 'list', '--flag'), ('prune', 'check', 'extract', '--flag'), ), ( ( ('--latest',), ('archive',), ('prune',), ('extract',), ('list',), ('--flag',), ), ( ('--latest',), ('archive',), ('check',), ('extract',), ('list',), ('--flag',), ), (('prune',), ('check',), ('list',), ('--flag',)), (('prune',), ('check',), ('extract',), ('--flag',)), ), ('--flag',), ), # No unparsable flags remaining. ( ( ('--archive', 'archive', 'prune', 'extract', 'list'), ('--archive', 'archive', 'check', 'extract', 'list'), ('prune', 'check', 'list'), ('prune', 'check', 'extract'), ), ( ( ( '--archive', 'archive', ), ('prune',), ('extract',), ('list',), ), ( ( '--archive', 'archive', ), ('check',), ('extract',), ('list',), ), (('prune',), ('check',), ('list',)), (('prune',), ('check',), ('extract',)), ), (), ), # No unparsable flags remaining, but some values in common. ( ( ('--verbosity', '5', 'archive', 'prune', 'extract', 'list'), ('--last', '5', 'archive', 'check', 'extract', 'list'), ('prune', 'check', 'list', '--last', '5'), ('prune', 'check', '--verbosity', '5', 'extract'), ), ( (('--verbosity', '5'), ('archive',), ('prune',), ('extract',), ('list',)), ( ( '--last', '5', ), ('archive',), ('check',), ('extract',), ('list',), ), (('prune',), ('check',), ('list',), ('--last', '5')), ( ('prune',), ('check',), ( '--verbosity', '5', ), ('extract',), ), ), (), ), # No flags. ((), (), ()), ], ) def test_get_unparsable_arguments_returns_remaining_arguments_that_no_action_can_parse( arguments, grouped_arguments, expected ): for action_arguments, grouped_action_arguments in zip(arguments, grouped_arguments): flexmock(module).should_receive('group_arguments_with_values').with_args( action_arguments ).and_return(grouped_action_arguments) assert module.get_unparsable_arguments(arguments) == expected def test_parse_arguments_for_actions_consumes_action_arguments_after_action_name(): action_namespace = flexmock(foo=True) remaining = flexmock() flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace} ) or remaining ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = {'action': flexmock(), 'other': flexmock()} global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('action', '--foo', 'true'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'action': action_namespace} assert remaining_action_arguments == (remaining, ()) def test_parse_arguments_for_actions_consumes_action_arguments_with_alias(): action_namespace = flexmock(foo=True) remaining = flexmock() flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {canonical or action: action_namespace} ) or remaining ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), '-a': flexmock(), 'other': flexmock(), '-o': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) flexmock(module).ACTION_ALIASES = {'action': ['-a'], 'other': ['-o']} arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('-a', '--foo', 'true'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'action': action_namespace} assert remaining_action_arguments == (remaining, ()) def test_parse_arguments_for_actions_consumes_multiple_action_arguments(): action_namespace = flexmock(foo=True) other_namespace = flexmock(bar=3) flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace if action == 'action' else other_namespace} ) or () ).and_return(('other', '--bar', '3')).and_return('action', '--foo', 'true') flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), 'other': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('action', '--foo', 'true', 'other', '--bar', '3'), action_parsers, global_parser ) assert arguments == { 'global': global_namespace, 'action': action_namespace, 'other': other_namespace, } assert remaining_action_arguments == ((), (), ()) def test_parse_arguments_for_actions_respects_command_line_action_ordering(): other_namespace = flexmock() action_namespace = flexmock(foo=True) flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: other_namespace if action == 'other' else action_namespace} ) or () ).and_return(('action',)).and_return(('other', '--foo', 'true')) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), 'other': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('other', '--foo', 'true', 'action'), action_parsers, global_parser ) assert arguments == collections.OrderedDict( [('other', other_namespace), ('action', action_namespace), ('global', global_namespace)] ) assert remaining_action_arguments == ((), (), ()) def test_parse_arguments_for_actions_applies_default_action_parsers(): global_namespace = flexmock(config_paths=[]) namespaces = { 'global': global_namespace, 'prune': flexmock(), 'compact': flexmock(), 'create': flexmock(progress=True), 'check': flexmock(), } flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: namespaces.get(action)} ) or () ).and_return(()) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'prune': flexmock(), 'compact': flexmock(), 'create': flexmock(), 'check': flexmock(), 'other': flexmock(), } global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('--progress'), action_parsers, global_parser ) assert arguments == namespaces assert remaining_action_arguments == ((), (), (), (), ()) def test_parse_arguments_for_actions_consumes_global_arguments(): action_namespace = flexmock() flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace} ) or ('--verbosity', 'lots') ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), 'other': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('action', '--verbosity', 'lots'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'action': action_namespace} assert remaining_action_arguments == (('--verbosity', 'lots'), ()) def test_parse_arguments_for_actions_passes_through_unknown_arguments_before_action_name(): action_namespace = flexmock() flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace} ) or ('--wtf', 'yes') ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), 'other': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('--wtf', 'yes', 'action'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'action': action_namespace} assert remaining_action_arguments == (('--wtf', 'yes'), ()) def test_parse_arguments_for_actions_passes_through_unknown_arguments_after_action_name(): action_namespace = flexmock() flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace} ) or ('--wtf', 'yes') ) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'action': flexmock(), 'other': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('action', '--wtf', 'yes'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'action': action_namespace} assert remaining_action_arguments == (('--wtf', 'yes'), ()) def test_parse_arguments_for_actions_with_borg_action_skips_other_action_parsers(): action_namespace = flexmock(options=[]) flexmock(module).should_receive('get_subaction_parsers').and_return({}) flexmock(module).should_receive('parse_and_record_action_arguments').replace_with( lambda unparsed, parsed, parser, action, canonical=None: parsed.update( {action: action_namespace} ) or () ).and_return(()) flexmock(module).should_receive('get_subactions_for_actions').and_return({}) action_parsers = { 'borg': flexmock(), 'list': flexmock(), } global_namespace = flexmock(config_paths=[]) global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((global_namespace, ())) arguments, remaining_action_arguments = module.parse_arguments_for_actions( ('borg', 'list'), action_parsers, global_parser ) assert arguments == {'global': global_namespace, 'borg': action_namespace} assert remaining_action_arguments == ((), ()) def test_parse_arguments_for_actions_raises_error_when_no_action_is_specified(): flexmock(module).should_receive('get_subaction_parsers').and_return({'bootstrap': [flexmock()]}) flexmock(module).should_receive('parse_and_record_action_arguments').and_return(flexmock()) flexmock(module).should_receive('get_subactions_for_actions').and_return( {'config': ['bootstrap']} ) action_parsers = {'config': flexmock()} global_parser = flexmock() global_parser.should_receive('parse_known_args').and_return((flexmock(), ())) with pytest.raises(ValueError): module.parse_arguments_for_actions(('config',), action_parsers, global_parser) borgmatic/tests/unit/commands/test_borgmatic.py000066400000000000000000002033371476361726000223530ustar00rootroot00000000000000import logging import subprocess import time import pytest from flexmock import flexmock import borgmatic.hooks.command from borgmatic.commands import borgmatic as module @pytest.mark.parametrize( 'config,arguments,expected_actions', ( ({}, {}, []), ({'skip_actions': []}, {}, []), ({'skip_actions': ['prune', 'check']}, {}, ['prune', 'check']), ( {'skip_actions': ['prune', 'check']}, {'check': flexmock(force=False)}, ['prune', 'check'], ), ({'skip_actions': ['prune', 'check']}, {'check': flexmock(force=True)}, ['prune']), ), ) def test_get_skip_actions_uses_config_and_arguments(config, arguments, expected_actions): assert module.get_skip_actions(config, arguments) == expected_actions def test_run_configuration_runs_actions_for_each_repository(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) expected_results = [flexmock(), flexmock()] flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return( expected_results[1:] ) config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]} arguments = {'global': flexmock(monitoring_verbosity=1)} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_with_skip_actions_does_not_raise(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return(['compact']) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return(flexmock()).and_return(flexmock()) config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'skip_actions': ['compact']} arguments = {'global': flexmock(monitoring_verbosity=1)} list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) def test_run_configuration_with_invalid_borg_version_errors(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError) flexmock(module.command).should_receive('execute_hook').never() flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').never() config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()} list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) def test_run_configuration_logs_monitor_start_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return( None ).and_return(None).and_return(None) expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return(expected_results) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').never() config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_bails_for_monitor_start_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') flexmock(module.dispatch).should_receive('call_hooks').and_raise(error).and_return(None) flexmock(module).should_receive('log_error_records').never() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').never() config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [] def test_run_configuration_logs_actions_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module.dispatch).should_receive('call_hooks') expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return(expected_results) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_skips_remaining_actions_for_actions_soft_failure_but_still_runs_next_repository_actions(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').times(5) error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') log = flexmock() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').twice().and_raise(error).and_yield(log) flexmock(module).should_receive('log_error_records').never() flexmock(module.command).should_receive('considered_soft_failure').and_return(True) config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [log] def test_run_configuration_logs_monitor_log_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( None ).and_raise(OSError) expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return(expected_results) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return([]) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_still_pings_monitor_for_monitor_log_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( None ).and_raise(error).and_return(None).and_return(None).times(5) flexmock(module).should_receive('log_error_records').never() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return([]) flexmock(module.command).should_receive('considered_soft_failure').and_return(True) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [] def test_run_configuration_logs_monitor_finish_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( None ).and_return(None).and_raise(OSError) expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return(expected_results) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return([]) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_bails_for_monitor_finish_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( None ).and_raise(None).and_raise(error) flexmock(module).should_receive('log_error_records').never() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return([]) flexmock(module.command).should_receive('considered_soft_failure').and_return(True) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [] def test_run_configuration_does_not_call_monitoring_hooks_if_monitoring_hooks_are_disabled(): flexmock(module).should_receive('verbosity_to_log_level').and_return(module.DISABLED) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_return([]) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=-2, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [] def test_run_configuration_logs_on_error_hook_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook').and_raise(OSError) expected_results = [flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').and_return( expected_results[:1] ).and_return(expected_results[1:]) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_bails_for_on_error_hook_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') flexmock(module.command).should_receive('execute_hook').and_raise(error) expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return(expected_results) flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'repositories': [{'path': 'foo'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_retries_soft_error(): # Run action first fails, second passes. flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([]) flexmock(module).should_receive('log_error_records').and_return([flexmock()]).once() config = {'repositories': [{'path': 'foo'}], 'retries': 1} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == [] def test_run_configuration_retries_hard_error(): # Run action fails twice. flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]) error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, ).and_return(error_logs) config = {'repositories': [{'path': 'foo'}], 'retries': 1} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == error_logs def test_run_configuration_repos_ordered(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) expected_results = [flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(expected_results[:1]).ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(expected_results[1:]).ordered() config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == expected_results def test_run_configuration_retries_round_robin(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() foo_error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(foo_error_logs).ordered() bar_error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(bar_error_logs).ordered() config = { 'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'retries': 1, } arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == foo_error_logs + bar_error_logs def test_run_configuration_retries_one_passes(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( [] ).and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return(flexmock()).ordered() error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(error_logs).ordered() config = { 'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'retries': 1, } arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == error_logs def test_run_configuration_retry_wait(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(time).should_receive('sleep').with_args(20).and_return().ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(time).should_receive('sleep').with_args(30).and_return().ordered() error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(error_logs).ordered() config = { 'repositories': [{'path': 'foo'}], 'retries': 3, 'retry_wait': 10, } arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == error_logs def test_run_configuration_retries_timeout_multiple_repos(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( [] ).and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError, levelno=logging.WARNING, log_command_error_output=True, ).and_return([flexmock()]).ordered() # Sleep before retrying foo (and passing) flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() # Sleep before retrying bar (and failing) flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() error_logs = [flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'Error running actions for repository', OSError ).and_return(error_logs).ordered() config = { 'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'retries': 1, 'retry_wait': 10, } arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) assert results == error_logs def test_run_actions_runs_repo_create(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.repo_create).should_receive('run_repo_create').once() tuple( module.run_actions( arguments={ 'global': flexmock(dry_run=False, log_file='foo'), 'repo-create': flexmock(), }, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_adds_label_file_to_hook_context(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.create).should_receive('run_create').with_args( config_filename=object, repository={'path': 'repo', 'label': 'my repo'}, config={'repositories': []}, config_paths=[], hook_context={ 'repository_label': 'my repo', 'log_file': '', 'repositories': '', 'repository': 'repo', }, local_borg_version=object, create_arguments=object, global_arguments=object, dry_run_label='', local_path=object, remote_path=object, ).once().and_return(expected) result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file=None), 'create': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo', 'label': 'my repo'}, ) ) assert result == (expected,) def test_run_actions_adds_log_file_to_hook_context(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.create).should_receive('run_create').with_args( config_filename=object, repository={'path': 'repo'}, config={'repositories': []}, config_paths=[], hook_context={ 'repository_label': '', 'log_file': 'foo', 'repositories': '', 'repository': 'repo', }, local_borg_version=object, create_arguments=object, global_arguments=object, dry_run_label='', local_path=object, remote_path=object, ).once().and_return(expected) result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'create': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_transfer(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.transfer).should_receive('run_transfer').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'transfer': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_create(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'create': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_with_skip_actions_skips_create(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return(['create']) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.create).should_receive('run_create').never() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'create': flexmock()}, config_filename=flexmock(), config={'repositories': [], 'skip_actions': ['create']}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_prune(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.prune).should_receive('run_prune').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'prune': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_with_skip_actions_skips_prune(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return(['prune']) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.prune).should_receive('run_prune').never() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'prune': flexmock()}, config_filename=flexmock(), config={'repositories': [], 'skip_actions': ['prune']}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_compact(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.compact).should_receive('run_compact').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'compact': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_with_skip_actions_skips_compact(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return(['compact']) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.compact).should_receive('run_compact').never() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'compact': flexmock()}, config_filename=flexmock(), config={'repositories': [], 'skip_actions': ['compact']}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_check_when_repository_enabled_for_checks(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True) flexmock(borgmatic.actions.check).should_receive('run_check').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'check': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_skips_check_when_repository_not_enabled_for_checks(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(False) flexmock(borgmatic.actions.check).should_receive('run_check').never() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'check': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_with_skip_actions_skips_check(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return(['check']) flexmock(module.command).should_receive('execute_hook') flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True) flexmock(borgmatic.actions.check).should_receive('run_check').never() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'check': flexmock()}, config_filename=flexmock(), config={'repositories': [], 'skip_actions': ['check']}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_extract(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.extract).should_receive('run_extract').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'extract': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_export_tar(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.export_tar).should_receive('run_export_tar').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'export-tar': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_mount(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.mount).should_receive('run_mount').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'mount': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_restore(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.restore).should_receive('run_restore').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'restore': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_repo_list(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.repo_list).should_receive('run_repo_list').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'repo-list': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_list(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.list).should_receive('run_list').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'list': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_repo_info(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.repo_info).should_receive('run_repo_info').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'repo-info': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_info(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.info).should_receive('run_info').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'info': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_break_lock(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.break_lock).should_receive('run_break_lock').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'break-lock': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_export_key(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'export': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_change_passphrase(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.change_passphrase).should_receive('run_change_passphrase').once() tuple( module.run_actions( arguments={ 'global': flexmock(dry_run=False, log_file='foo'), 'change-passphrase': flexmock(), }, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_delete(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.delete).should_receive('run_delete').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'delete': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_repo_delete(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.repo_delete).should_receive('run_repo_delete').once() tuple( module.run_actions( arguments={ 'global': flexmock(dry_run=False, log_file='foo'), 'repo-delete': flexmock(), }, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_borg(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.borg).should_receive('run_borg').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'borg': flexmock()}, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_multiple_actions_in_argument_order(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([]) flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered() flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered() tuple( module.run_actions( arguments={ 'global': flexmock(dry_run=False, log_file='foo'), 'borg': flexmock(), 'restore': flexmock(), }, config_filename=flexmock(), config={'repositories': []}, config_paths=[], local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) @pytest.mark.parametrize( 'resolve_env', ((True, False),), ) def test_load_configurations_collects_parsed_configurations_and_logs(resolve_env): configuration = flexmock() other_configuration = flexmock() test_expected_logs = [flexmock(), flexmock()] other_expected_logs = [flexmock(), flexmock()] flexmock(module.validate).should_receive('parse_configuration').and_return( configuration, ['/tmp/test.yaml'], test_expected_logs ).and_return(other_configuration, ['/tmp/other.yaml'], other_expected_logs) configs, config_paths, logs = tuple( module.load_configurations( ('test.yaml', 'other.yaml'), resolve_env=resolve_env, ) ) assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration} assert config_paths == ['/tmp/other.yaml', '/tmp/test.yaml'] assert set(logs) >= set(test_expected_logs + other_expected_logs) def test_load_configurations_logs_warning_for_permission_error(): flexmock(module.validate).should_receive('parse_configuration').and_raise(PermissionError) configs, config_paths, logs = tuple(module.load_configurations(('test.yaml',))) assert configs == {} assert config_paths == [] assert max(log.levelno for log in logs) == logging.WARNING def test_load_configurations_logs_critical_for_parse_error(): flexmock(module.validate).should_receive('parse_configuration').and_raise(ValueError) configs, config_paths, logs = tuple(module.load_configurations(('test.yaml',))) assert configs == {} assert config_paths == [] assert max(log.levelno for log in logs) == logging.CRITICAL def test_log_record_does_not_raise(): module.log_record(levelno=1, foo='bar', baz='quux') def test_log_record_with_suppress_does_not_raise(): module.log_record(levelno=1, foo='bar', baz='quux', suppress_log=True) def test_log_error_records_generates_output_logs_for_message_only(): flexmock(module).should_receive('log_record').replace_with(dict).once() logs = tuple(module.log_error_records('Error')) assert {log['levelno'] for log in logs} == {logging.CRITICAL} def test_log_error_records_generates_output_logs_for_called_process_error_with_bytes_ouput(): flexmock(module).should_receive('log_record').replace_with(dict).times(3) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING) logs = tuple( module.log_error_records('Error', subprocess.CalledProcessError(1, 'ls', b'error output')) ) assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert any(log for log in logs if 'error output' in str(log)) def test_log_error_records_generates_output_logs_for_called_process_error_with_string_ouput(): flexmock(module).should_receive('log_record').replace_with(dict).times(3) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING) logs = tuple( module.log_error_records('Error', subprocess.CalledProcessError(1, 'ls', 'error output')) ) assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert any(log for log in logs if 'error output' in str(log)) def test_log_error_records_generates_work_around_output_logs_for_called_process_error_with_repository_access_aborted_exit_code(): flexmock(module).should_receive('log_record').replace_with(dict).times(4) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING) logs = tuple( module.log_error_records( 'Error', subprocess.CalledProcessError( module.BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE, 'ls', 'error output' ), ) ) assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert any(log for log in logs if 'error output' in str(log)) assert any(log for log in logs if 'To work around this' in str(log)) def test_log_error_records_splits_called_process_error_with_multiline_ouput_into_multiple_logs(): flexmock(module).should_receive('log_record').replace_with(dict).times(4) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING) logs = tuple( module.log_error_records( 'Error', subprocess.CalledProcessError(1, 'ls', 'error output\nanother line') ) ) assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert any(log for log in logs if 'error output' in str(log)) def test_log_error_records_generates_logs_for_value_error(): flexmock(module).should_receive('log_record').replace_with(dict).twice() logs = tuple(module.log_error_records('Error', ValueError())) assert {log['levelno'] for log in logs} == {logging.CRITICAL} def test_log_error_records_generates_logs_for_os_error(): flexmock(module).should_receive('log_record').replace_with(dict).twice() logs = tuple(module.log_error_records('Error', OSError())) assert {log['levelno'] for log in logs} == {logging.CRITICAL} def test_log_error_records_generates_nothing_for_other_error(): flexmock(module).should_receive('log_record').never() logs = tuple(module.log_error_records('Error', KeyError())) assert logs == () def test_get_local_path_uses_configuration_value(): assert module.get_local_path({'test.yaml': {'local_path': 'borg1'}}) == 'borg1' def test_get_local_path_without_local_path_defaults_to_borg(): assert module.get_local_path({'test.yaml': {}}) == 'borg' def test_collect_highlander_action_summary_logs_info_for_success_with_bootstrap(): flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borgmatic.actions.config.bootstrap).should_receive('run_bootstrap') arguments = { 'bootstrap': flexmock(repository='repo', local_path='borg7'), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.ANSWER} def test_collect_highlander_action_summary_logs_error_on_bootstrap_failure(): flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borgmatic.actions.config.bootstrap).should_receive('run_bootstrap').and_raise( ValueError ) arguments = { 'bootstrap': flexmock(repository='repo', local_path='borg7'), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_highlander_action_summary_logs_error_on_bootstrap_local_borg_version_failure(): flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError) flexmock(module.borgmatic.actions.config.bootstrap).should_receive('run_bootstrap').never() arguments = { 'bootstrap': flexmock(repository='repo', local_path='borg7'), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_highlander_action_summary_logs_info_for_success_with_generate(): flexmock(module.borgmatic.actions.config.generate).should_receive('run_generate') arguments = { 'generate': flexmock(destination='test.yaml'), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.ANSWER} def test_collect_highlander_action_summary_logs_error_on_generate_failure(): flexmock(module.borgmatic.actions.config.generate).should_receive('run_generate').and_raise( ValueError ) arguments = { 'generate': flexmock(destination='test.yaml'), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_highlander_action_summary_logs_info_for_success_with_validate(): flexmock(module.borgmatic.actions.config.validate).should_receive('run_validate') arguments = { 'validate': flexmock(), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.ANSWER} def test_collect_highlander_action_summary_logs_error_on_validate_parse_failure(): flexmock(module.borgmatic.actions.config.validate).should_receive('run_validate') arguments = { 'validate': flexmock(), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=True ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_highlander_action_summary_logs_error_on_run_validate_failure(): flexmock(module.borgmatic.actions.config.validate).should_receive('run_validate').and_raise( ValueError ) arguments = { 'validate': flexmock(), 'global': flexmock(dry_run=False), } logs = tuple( module.collect_highlander_action_summary_logs( {'test.yaml': {}}, arguments=arguments, configuration_parse_errors=False ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_configuration_run_summary_logs_info_for_success(): flexmock(module.command).should_receive('execute_hook').never() flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) arguments = {} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO} def test_collect_configuration_run_summary_executes_hooks_for_create(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO} def test_collect_configuration_run_summary_logs_info_for_success_with_extract(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'extract': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO} def test_collect_configuration_run_summary_logs_extract_with_repository_error(): flexmock(module.validate).should_receive('guard_configuration_contains_repository').and_raise( ValueError ) expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) arguments = {'extract': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_info_for_success_with_mount(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'mount': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO} def test_collect_configuration_run_summary_logs_mount_with_repository_error(): flexmock(module.validate).should_receive('guard_configuration_contains_repository').and_raise( ValueError ) expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) arguments = {'mount': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_missing_configs_error(): arguments = {'global': flexmock(config_paths=[])} expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) logs = tuple( module.collect_configuration_run_summary_logs({}, config_paths=[], arguments=arguments) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_pre_hook_error(): flexmock(module.command).should_receive('execute_hook').and_raise(ValueError) expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_post_hook_error(): flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(ValueError) flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert expected_logs[0] in logs def test_collect_configuration_run_summary_logs_for_list_with_archive_and_repository_error(): flexmock(module.validate).should_receive('guard_configuration_contains_repository').and_raise( ValueError ) expected_logs = (flexmock(),) flexmock(module).should_receive('log_error_records').and_return(expected_logs) arguments = {'list': flexmock(repository='repo', archive='test')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_info_for_success_with_list(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'list': flexmock(repository='repo', archive=None)} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO} def test_collect_configuration_run_summary_logs_run_configuration_error(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return( [logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))] ) flexmock(module).should_receive('log_error_records').and_return([]) arguments = {} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.CRITICAL} def test_collect_configuration_run_summary_logs_run_umount_error(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return([]) flexmock(module.borg_umount).should_receive('unmount_archive').and_raise(OSError) flexmock(module).should_receive('log_error_records').and_return( [logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))] ) arguments = {'umount': flexmock(mount_point='/mnt')} logs = tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}}, config_paths=['/tmp/test.yaml'], arguments=arguments ) ) assert {log.levelno for log in logs} == {logging.INFO, logging.CRITICAL} def test_collect_configuration_run_summary_logs_outputs_merged_json_results(): flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('run_configuration').and_return(['foo', 'bar']).and_return( ['baz'] ) stdout = flexmock() stdout.should_receive('write').with_args('["foo", "bar", "baz"]').once() flexmock(module.sys).stdout = stdout arguments = {} tuple( module.collect_configuration_run_summary_logs( {'test.yaml': {}, 'test2.yaml': {}}, config_paths=['/tmp/test.yaml', '/tmp/test2.yaml'], arguments=arguments, ) ) borgmatic/tests/unit/config/000077500000000000000000000000001476361726000164275ustar00rootroot00000000000000borgmatic/tests/unit/config/__init__.py000066400000000000000000000000001476361726000205260ustar00rootroot00000000000000borgmatic/tests/unit/config/test_checks.py000066400000000000000000000012661476361726000213050ustar00rootroot00000000000000from borgmatic.config import checks as module def test_repository_enabled_for_checks_defaults_to_enabled_for_all_repositories(): enabled = module.repository_enabled_for_checks('repo.borg', config={}) assert enabled def test_repository_enabled_for_checks_is_enabled_for_specified_repositories(): enabled = module.repository_enabled_for_checks( 'repo.borg', config={'check_repositories': ['repo.borg', 'other.borg']} ) assert enabled def test_repository_enabled_for_checks_is_disabled_for_other_repositories(): enabled = module.repository_enabled_for_checks( 'repo.borg', config={'check_repositories': ['other.borg']} ) assert not enabled borgmatic/tests/unit/config/test_collect.py000066400000000000000000000176251476361726000215000ustar00rootroot00000000000000import sys from flexmock import flexmock from borgmatic.config import collect as module def test_get_default_config_paths_includes_absolute_user_config_path(): flexmock(module.os, environ={'XDG_CONFIG_HOME': None, 'HOME': '/home/user'}) config_paths = module.get_default_config_paths() assert '/home/user/.config/borgmatic/config.yaml' in config_paths def test_get_default_config_paths_prefers_xdg_config_home_for_user_config_path(): flexmock(module.os, environ={'XDG_CONFIG_HOME': '/home/user/.etc', 'HOME': '/home/user'}) config_paths = module.get_default_config_paths() assert '/home/user/.etc/borgmatic/config.yaml' in config_paths def test_get_default_config_paths_does_not_expand_home_when_false(): flexmock(module.os, environ={'HOME': '/home/user'}) config_paths = module.get_default_config_paths(expand_home=False) assert '$HOME/.config/borgmatic/config.yaml' in config_paths def test_collect_config_filenames_collects_yml_file_endings(): config_paths = ('config.yaml', '/etc/borgmatic.d') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').and_return(True) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d').and_return(True) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/foo.yml').and_return(False) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) flexmock(module.os).should_receive('access').and_return(True) flexmock(module.os).should_receive('listdir') flexmock(sys.modules['builtins']).should_receive('sorted').and_return(['foo.yml']) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml', '/etc/borgmatic.d/foo.yml') def test_collect_config_filenames_collects_files_from_given_directories_and_ignores_sub_directories(): config_paths = ('config.yaml', '/etc/borgmatic.d') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').and_return(True) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d').and_return(True) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/foo.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/bar').and_return(True) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/baz.yaml').and_return(False) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) flexmock(module.os).should_receive('access').and_return(True) flexmock(module.os).should_receive('listdir') flexmock(sys.modules['builtins']).should_receive('sorted').and_return( ['foo.yaml', 'bar', 'baz.yaml'] ) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ( '/config.yaml', '/etc/borgmatic.d/foo.yaml', '/etc/borgmatic.d/baz.yaml', ) def test_collect_config_filenames_collects_files_from_given_directories_and_ignores_non_yaml_filenames(): config_paths = ('/etc/borgmatic.d',) mock_path = flexmock(module.os.path) mock_path.should_receive('exists').and_return(True) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d').and_return(True) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/foo.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/bar.yaml~').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d/baz.txt').and_return(False) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) flexmock(module.os).should_receive('access').and_return(True) flexmock(module.os).should_receive('listdir') flexmock(sys.modules['builtins']).should_receive('sorted').and_return( ['foo.yaml', 'bar.yaml~', 'baz.txt'] ) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/etc/borgmatic.d/foo.yaml',) def test_collect_config_filenames_skips_permission_denied_directories(): config_paths = ('config.yaml', '/etc/borgmatic.d') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').and_return(True) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d').and_return(True) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) flexmock(module.os).should_receive('access').and_return(False) flexmock(module.os).should_receive('listdir') flexmock(sys.modules['builtins']).should_receive('sorted').and_return(['config.yaml']) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml',) def test_collect_config_filenames_skips_etc_borgmatic_config_dot_yaml_if_it_does_not_exist(): config_paths = ('config.yaml', '/etc/borgmatic/config.yaml') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').with_args('config.yaml').and_return(True) mock_path.should_receive('exists').with_args('/etc/borgmatic/config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic/config.yaml').and_return(True) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml',) def test_collect_config_filenames_skips_etc_borgmatic_dot_d_if_it_does_not_exist(): config_paths = ('config.yaml', '/etc/borgmatic.d') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').with_args('config.yaml').and_return(True) mock_path.should_receive('exists').with_args('/etc/borgmatic.d').and_return(False) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/borgmatic.d').and_return(True) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml',) def test_collect_config_filenames_skips_non_canonical_etc_borgmatic_dot_d_if_it_does_not_exist(): config_paths = ('config.yaml', '/etc/../etc/borgmatic.d') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').with_args('config.yaml').and_return(True) mock_path.should_receive('exists').with_args('/etc/../etc/borgmatic.d').and_return(False) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/etc/../etc/borgmatic.d').and_return(True) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml',) def test_collect_config_filenames_includes_other_directory_if_it_does_not_exist(): config_paths = ('config.yaml', '/my/directory') mock_path = flexmock(module.os.path) mock_path.should_receive('exists').with_args('config.yaml').and_return(True) mock_path.should_receive('exists').with_args('/my/directory').and_return(False) mock_path.should_receive('isdir').with_args('config.yaml').and_return(False) mock_path.should_receive('isdir').with_args('/my/directory').and_return(True) mock_path.should_receive('abspath').replace_with(lambda path: module.os.path.join('/', path)) config_filenames = tuple(module.collect_config_filenames(config_paths)) assert config_filenames == ('/config.yaml', '/my/directory') borgmatic/tests/unit/config/test_constants.py000066400000000000000000000046121476361726000220570ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.config import constants as module @pytest.mark.parametrize( 'value,expected_value', ( ('3', 3), ('0', 0), ('-3', -3), ('1234', 1234), ('true', True), ('True', True), ('false', False), ('False', False), ('thing', 'thing'), ({}, {}), ({'foo': 'bar'}, {'foo': 'bar'}), ([], []), (['foo', 'bar'], ['foo', 'bar']), ), ) def test_coerce_scalar_converts_value(value, expected_value): assert module.coerce_scalar(value) == expected_value def test_apply_constants_with_empty_constants_passes_through_value(): assert module.apply_constants(value='thing', constants={}) == 'thing' @pytest.mark.parametrize( 'value,expected_value', ( (None, None), ('thing', 'thing'), ('{foo}', 'bar'), ('abc{foo}', 'abcbar'), ('{foo}xyz', 'barxyz'), ('{foo}{baz}', 'barquux'), ('{int}', '3'), ('{bool}', 'True'), (['thing', 'other'], ['thing', 'other']), (['thing', '{foo}'], ['thing', 'bar']), (['{foo}', '{baz}'], ['bar', 'quux']), ({'key': 'value'}, {'key': 'value'}), ({'key': '{foo}'}, {'key': 'bar'}), ({'key': '{inject}'}, {'key': 'echo hi; naughty-command'}), ({'before_backup': '{inject}'}, {'before_backup': "'echo hi; naughty-command'"}), ({'after_backup': '{inject}'}, {'after_backup': "'echo hi; naughty-command'"}), ({'on_error': '{inject}'}, {'on_error': "'echo hi; naughty-command'"}), ( { 'before_backup': '{env_pass}', 'postgresql_databases': [{'name': 'users', 'password': '{env_pass}'}], }, { 'before_backup': "'${PASS}'", 'postgresql_databases': [{'name': 'users', 'password': '${PASS}'}], }, ), (3, 3), (True, True), (False, False), ), ) def test_apply_constants_makes_string_substitutions(value, expected_value): flexmock(module).should_receive('coerce_scalar').replace_with(lambda value: value) constants = { 'foo': 'bar', 'baz': 'quux', 'int': 3, 'bool': True, 'inject': 'echo hi; naughty-command', 'env_pass': '${PASS}', } assert module.apply_constants(value, constants) == expected_value borgmatic/tests/unit/config/test_environment.py000066400000000000000000000060221476361726000224040ustar00rootroot00000000000000import pytest from borgmatic.config import environment as module def test_env(monkeypatch): monkeypatch.setenv('MY_CUSTOM_VALUE', 'foo') config = {'key': 'Hello $MY_CUSTOM_VALUE'} module.resolve_env_variables(config) assert config == {'key': 'Hello $MY_CUSTOM_VALUE'} def test_env_braces(monkeypatch): monkeypatch.setenv('MY_CUSTOM_VALUE', 'foo') config = {'key': 'Hello ${MY_CUSTOM_VALUE}'} # noqa: FS003 module.resolve_env_variables(config) assert config == {'key': 'Hello foo'} def test_env_multi(monkeypatch): monkeypatch.setenv('MY_CUSTOM_VALUE', 'foo') monkeypatch.setenv('MY_CUSTOM_VALUE2', 'bar') config = {'key': 'Hello ${MY_CUSTOM_VALUE}${MY_CUSTOM_VALUE2}'} # noqa: FS003 module.resolve_env_variables(config) assert config == {'key': 'Hello foobar'} def test_env_escape(monkeypatch): monkeypatch.setenv('MY_CUSTOM_VALUE', 'foo') monkeypatch.setenv('MY_CUSTOM_VALUE2', 'bar') config = {'key': r'Hello ${MY_CUSTOM_VALUE} \${MY_CUSTOM_VALUE}'} # noqa: FS003 module.resolve_env_variables(config) assert config == {'key': r'Hello foo ${MY_CUSTOM_VALUE}'} # noqa: FS003 def test_env_default_value(monkeypatch): monkeypatch.delenv('MY_CUSTOM_VALUE', raising=False) config = {'key': 'Hello ${MY_CUSTOM_VALUE:-bar}'} # noqa: FS003 module.resolve_env_variables(config) assert config == {'key': 'Hello bar'} def test_env_unknown(monkeypatch): monkeypatch.delenv('MY_CUSTOM_VALUE', raising=False) config = {'key': 'Hello ${MY_CUSTOM_VALUE}'} # noqa: FS003 with pytest.raises(ValueError): module.resolve_env_variables(config) def test_env_full(monkeypatch): monkeypatch.setenv('MY_CUSTOM_VALUE', 'foo') monkeypatch.delenv('MY_CUSTOM_VALUE2', raising=False) config = { 'key': 'Hello $MY_CUSTOM_VALUE is not resolved', 'dict': { 'key': 'value', 'anotherdict': { 'key': 'My ${MY_CUSTOM_VALUE} here', # noqa: FS003 'other': '${MY_CUSTOM_VALUE}', # noqa: FS003 'escaped': r'\${MY_CUSTOM_VALUE}', # noqa: FS003 'list': [ '/home/${MY_CUSTOM_VALUE}/.local', # noqa: FS003 '/var/log/', '/home/${MY_CUSTOM_VALUE2:-bar}/.config', # noqa: FS003 ], }, }, 'list': [ '/home/${MY_CUSTOM_VALUE}/.local', # noqa: FS003 '/var/log/', '/home/${MY_CUSTOM_VALUE2-bar}/.config', # noqa: FS003 ], } module.resolve_env_variables(config) assert config == { 'key': 'Hello $MY_CUSTOM_VALUE is not resolved', 'dict': { 'key': 'value', 'anotherdict': { 'key': 'My foo here', 'other': 'foo', 'escaped': '${MY_CUSTOM_VALUE}', # noqa: FS003 'list': ['/home/foo/.local', '/var/log/', '/home/bar/.config'], }, }, 'list': ['/home/foo/.local', '/var/log/', '/home/bar/.config'], } borgmatic/tests/unit/config/test_generate.py000066400000000000000000000157431476361726000216440ustar00rootroot00000000000000from collections import OrderedDict import pytest from flexmock import flexmock from borgmatic.config import generate as module def test_get_properties_with_simple_object(): schema = { 'type': 'object', 'properties': OrderedDict( [ ('field1', {'example': 'Example'}), ] ), } assert module.get_properties(schema) == schema['properties'] def test_get_properties_merges_one_of_list_properties(): schema = { 'type': 'object', 'oneOf': [ { 'properties': OrderedDict( [ ('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'}), ] ), }, { 'properties': OrderedDict( [ ('field2', {'example': 'Example 2'}), ('field3', {'example': 'Example 3'}), ] ), }, ], } assert module.get_properties(schema) == dict( schema['oneOf'][0]['properties'], **schema['oneOf'][1]['properties'] ) def test_schema_to_sample_configuration_generates_config_map_with_examples(): schema = { 'type': 'object', 'properties': OrderedDict( [ ('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'}), ('field3', {'example': 'Example 3'}), ] ), } flexmock(module).should_receive('get_properties').and_return(schema['properties']) flexmock(module.ruamel.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module).should_receive('add_comments_to_configuration_object') config = module.schema_to_sample_configuration(schema) assert config == OrderedDict( [ ('field1', 'Example 1'), ('field2', 'Example 2'), ('field3', 'Example 3'), ] ) def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example(): flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module).should_receive('add_comments_to_configuration_sequence') schema = {'type': 'array', 'items': {'type': 'string'}, 'example': ['hi']} config = module.schema_to_sample_configuration(schema) assert config == ['hi'] def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples(): schema = { 'type': 'array', 'items': { 'type': 'object', 'properties': OrderedDict( [('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})] ), }, } flexmock(module).should_receive('get_properties').and_return(schema['items']['properties']) flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_object') config = module.schema_to_sample_configuration(schema) assert config == [OrderedDict([('field1', 'Example 1'), ('field2', 'Example 2')])] def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_multiple_types(): schema = { 'type': 'array', 'items': { 'type': ['object', 'null'], 'properties': OrderedDict( [('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})] ), }, } flexmock(module).should_receive('get_properties').and_return(schema['items']['properties']) flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_object') config = module.schema_to_sample_configuration(schema) assert config == [OrderedDict([('field1', 'Example 1'), ('field2', 'Example 2')])] def test_schema_to_sample_configuration_with_unsupported_schema_raises(): schema = {'gobbledygook': [{'type': 'not-your'}]} with pytest.raises(ValueError): module.schema_to_sample_configuration(schema) def test_merge_source_configuration_into_destination_inserts_map_fields(): destination_config = {'foo': 'dest1', 'bar': 'dest2'} source_config = {'foo': 'source1', 'baz': 'source2'} flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list) module.merge_source_configuration_into_destination(destination_config, source_config) assert destination_config == {'foo': 'source1', 'bar': 'dest2', 'baz': 'source2'} def test_merge_source_configuration_into_destination_inserts_nested_map_fields(): destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'} source_config = {'foo': {'first': 'source1'}} flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list) module.merge_source_configuration_into_destination(destination_config, source_config) assert destination_config == {'foo': {'first': 'source1', 'second': 'dest2'}, 'bar': 'dest3'} def test_merge_source_configuration_into_destination_inserts_sequence_fields(): destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']} source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']} flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list) module.merge_source_configuration_into_destination(destination_config, source_config) assert destination_config == { 'foo': ['source1'], 'bar': ['source2', 'source3'], 'baz': ['dest4'], } def test_merge_source_configuration_into_destination_inserts_sequence_of_maps(): destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'} source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]} flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list) module.merge_source_configuration_into_destination(destination_config, source_config) assert destination_config == { 'foo': [{'first': 'source1', 'second': 'dest2'}, {'other': 'source2'}], 'bar': 'dest3', } def test_merge_source_configuration_into_destination_without_source_does_nothing(): original_destination_config = {'foo': 'dest1', 'bar': 'dest2'} destination_config = dict(original_destination_config) module.merge_source_configuration_into_destination(destination_config, None) assert destination_config == original_destination_config borgmatic/tests/unit/config/test_load.py000066400000000000000000000030631476361726000207610ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.config import load as module def test_probe_and_include_file_with_absolute_path_skips_probing(): config = flexmock() config_paths = set() flexmock(module).should_receive('load_configuration').with_args( '/etc/include.yaml', config_paths ).and_return(config).once() assert ( module.probe_and_include_file('/etc/include.yaml', ['/etc', '/var'], config_paths) == config ) def test_probe_and_include_file_with_relative_path_probes_include_directories(): config = {'foo': 'bar'} config_paths = set() flexmock(module.os.path).should_receive('exists').with_args('/etc/include.yaml').and_return( False ) flexmock(module.os.path).should_receive('exists').with_args('/var/include.yaml').and_return( True ) flexmock(module).should_receive('load_configuration').with_args( '/etc/include.yaml', config_paths ).never() flexmock(module).should_receive('load_configuration').with_args( '/var/include.yaml', config_paths ).and_return(config).once() assert module.probe_and_include_file('include.yaml', ['/etc', '/var'], config_paths) == { 'foo': 'bar', } def test_probe_and_include_file_with_relative_path_and_missing_files_raises(): flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module).should_receive('load_configuration').never() with pytest.raises(FileNotFoundError): module.probe_and_include_file('include.yaml', ['/etc', '/var'], config_paths=set()) borgmatic/tests/unit/config/test_normalize.py000066400000000000000000000174701476361726000220510ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.config import normalize as module @pytest.mark.parametrize( 'config,expected_config,produces_logs', ( ( {'location': {'foo': 'bar', 'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'retention': {'foo': 'bar', 'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'consistency': {'foo': 'bar', 'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'output': {'foo': 'bar', 'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'hooks': {'foo': 'bar', 'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'location': {'foo': 'bar'}, 'storage': {'baz': 'quux'}}, {'foo': 'bar', 'baz': 'quux'}, True, ), ( {'foo': 'bar', 'baz': 'quux'}, {'foo': 'bar', 'baz': 'quux'}, False, ), ( {'location': {'prefix': 'foo'}, 'consistency': {'prefix': 'foo'}}, {'prefix': 'foo'}, True, ), ( {'location': {'prefix': 'foo'}, 'consistency': {'prefix': 'foo'}}, {'prefix': 'foo'}, True, ), ( {'location': {'prefix': 'foo'}, 'consistency': {'bar': 'baz'}}, {'prefix': 'foo', 'bar': 'baz'}, True, ), ( {'storage': {'umask': 'foo'}, 'hooks': {'umask': 'foo'}}, {'umask': 'foo'}, True, ), ( {'storage': {'umask': 'foo'}, 'hooks': {'umask': 'foo'}}, {'umask': 'foo'}, True, ), ( {'storage': {'umask': 'foo'}, 'hooks': {'bar': 'baz'}}, {'umask': 'foo', 'bar': 'baz'}, True, ), ( {'location': {'bar': 'baz'}, 'consistency': {'prefix': 'foo'}}, {'bar': 'baz', 'prefix': 'foo'}, True, ), ( {'location': {}, 'consistency': {'prefix': 'foo'}}, {'prefix': 'foo'}, True, ), ( {}, {}, False, ), ), ) def test_normalize_sections_moves_section_options_to_global_scope( config, expected_config, produces_logs ): logs = module.normalize_sections('test.yaml', config) assert config == expected_config if produces_logs: assert logs else: assert logs == [] def test_normalize_sections_with_different_prefix_values_raises(): config = {'location': {'prefix': 'foo'}, 'consistency': {'prefix': 'bar'}} with pytest.raises(ValueError): module.normalize_sections('test.yaml', config) def test_normalize_sections_with_different_umask_values_raises(): config = {'storage': {'umask': 'foo'}, 'hooks': {'umask': 'bar'}} with pytest.raises(ValueError): module.normalize_sections('test.yaml', config) def test_normalize_sections_with_only_scalar_raises(): config = 33 with pytest.raises(ValueError): module.normalize_sections('test.yaml', config) @pytest.mark.parametrize( 'config,expected_config,produces_logs', ( ( {'exclude_if_present': '.nobackup'}, {'exclude_if_present': ['.nobackup']}, True, ), ( {'exclude_if_present': ['.nobackup']}, {'exclude_if_present': ['.nobackup']}, False, ), ( {'store_config_files': False}, {'bootstrap': {'store_config_files': False}}, True, ), ( {'source_directories': ['foo', 'bar']}, {'source_directories': ['foo', 'bar']}, False, ), ( {'compression': 'yes_please'}, {'compression': 'yes_please'}, False, ), ( {'healthchecks': 'https://example.com'}, {'healthchecks': {'ping_url': 'https://example.com'}}, True, ), ( {'cronitor': 'https://example.com'}, {'cronitor': {'ping_url': 'https://example.com'}}, True, ), ( {'pagerduty': 'https://example.com'}, {'pagerduty': {'integration_key': 'https://example.com'}}, True, ), ( {'cronhub': 'https://example.com'}, {'cronhub': {'ping_url': 'https://example.com'}}, True, ), ( {'checks': ['archives']}, {'checks': [{'name': 'archives'}]}, True, ), ( {'checks': ['archives']}, {'checks': [{'name': 'archives'}]}, True, ), ( {'numeric_owner': False}, {'numeric_ids': False}, True, ), ( {'bsd_flags': False}, {'flags': False}, True, ), ( {'remote_rate_limit': False}, {'upload_rate_limit': False}, True, ), ( {'repositories': ['foo@bar:/repo']}, {'repositories': [{'path': 'ssh://foo@bar/repo'}]}, True, ), ( {'repositories': ['foo@bar:repo']}, {'repositories': [{'path': 'ssh://foo@bar/./repo'}]}, True, ), ( {'repositories': ['foo@bar:~/repo']}, {'repositories': [{'path': 'ssh://foo@bar/~/repo'}]}, True, ), ( {'repositories': ['ssh://foo@bar:1234/repo']}, {'repositories': [{'path': 'ssh://foo@bar:1234/repo'}]}, True, ), ( {'repositories': ['sftp://foo@bar:1234/repo']}, {'repositories': [{'path': 'sftp://foo@bar:1234/repo'}]}, True, ), ( {'repositories': ['rclone:host:repo']}, {'repositories': [{'path': 'rclone:host:repo'}]}, True, ), ( {'repositories': ['file:///repo']}, {'repositories': [{'path': '/repo'}]}, True, ), ( {'repositories': [{'path': 'first'}, 'file:///repo']}, {'repositories': [{'path': 'first'}, {'path': '/repo'}]}, True, ), ( {'repositories': [{'path': 'foo@bar:/repo', 'label': 'foo'}]}, {'repositories': [{'path': 'ssh://foo@bar/repo', 'label': 'foo'}]}, True, ), ( {'repositories': [{'path': 'file:///repo', 'label': 'foo'}]}, {'repositories': [{'path': '/repo', 'label': 'foo'}]}, False, ), ( {'repositories': [{'path': '/repo', 'label': 'foo'}]}, {'repositories': [{'path': '/repo', 'label': 'foo'}]}, False, ), ( {'prefix': 'foo'}, {'prefix': 'foo'}, True, ), ), ) def test_normalize_applies_hard_coded_normalization_to_config( config, expected_config, produces_logs ): flexmock(module).should_receive('normalize_sections').and_return([]) logs = module.normalize('test.yaml', config) expected_config.setdefault('bootstrap', {}) assert config == expected_config if produces_logs: assert logs else: assert logs == [] def test_normalize_config_with_borgmatic_source_directory_warns(): flexmock(module).should_receive('normalize_sections').and_return([]) logs = module.normalize('test.yaml', {'borgmatic_source_directory': '~/.borgmatic'}) assert len(logs) == 1 assert logs[0].levelno == module.logging.WARNING assert 'borgmatic_source_directory' in logs[0].msg borgmatic/tests/unit/config/test_override.py000066400000000000000000000121571476361726000216650ustar00rootroot00000000000000import pytest import ruamel.yaml from flexmock import flexmock from borgmatic.config import override as module def test_set_values_with_empty_keys_bails(): config = {} module.set_values(config, keys=(), value='value') assert config == {} def test_set_values_with_one_key_sets_it_into_config(): config = {} module.set_values(config, keys=('key',), value='value') assert config == {'key': 'value'} def test_set_values_with_one_key_overwrites_existing_key(): config = {'key': 'old_value', 'other': 'other_value'} module.set_values(config, keys=('key',), value='value') assert config == {'key': 'value', 'other': 'other_value'} def test_set_values_with_multiple_keys_creates_hierarchy(): config = {} module.set_values(config, ('option', 'suboption'), 'value') assert config == {'option': {'suboption': 'value'}} def test_set_values_with_multiple_keys_updates_hierarchy(): config = {'option': {'other': 'other_value'}} module.set_values(config, ('option', 'key'), 'value') assert config == {'option': {'key': 'value', 'other': 'other_value'}} def test_set_values_with_key_when_list_index_expected_errors(): config = {'option': ['foo', 'bar', 'baz']} with pytest.raises(ValueError): module.set_values(config, keys=('option', 'key'), value='value') @pytest.mark.parametrize( 'schema,option_keys,expected_type', ( ({'properties': {'foo': {'type': 'array'}}}, ('foo',), 'array'), ( {'properties': {'foo': {'properties': {'bar': {'type': 'array'}}}}}, ('foo', 'bar'), 'array', ), ({'properties': {'foo': {'type': 'array'}}}, ('other',), None), ({'properties': {'foo': {'description': 'stuff'}}}, ('foo',), None), ({}, ('foo',), None), ), ) def test_type_for_option_grabs_type_if_found_in_schema(schema, option_keys, expected_type): assert module.type_for_option(schema, option_keys) == expected_type @pytest.mark.parametrize( 'key,expected_key', ( (('foo', 'bar'), ('foo', 'bar')), (('location', 'foo'), ('foo',)), (('storage', 'foo'), ('foo',)), (('retention', 'foo'), ('foo',)), (('consistency', 'foo'), ('foo',)), (('output', 'foo'), ('foo',)), (('hooks', 'foo', 'bar'), ('foo', 'bar')), (('foo', 'hooks'), ('foo', 'hooks')), ), ) def test_strip_section_names_passes_through_key_without_section_name(key, expected_key): assert module.strip_section_names(key) == expected_key def test_parse_overrides_splits_keys_and_values(): flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string') flexmock(module).should_receive('convert_value_type').replace_with( lambda value, option_type: value ) raw_overrides = ['option.my_option=value1', 'other_option=value2'] expected_result = ( (('option', 'my_option'), 'value1'), (('other_option'), 'value2'), ) module.parse_overrides(raw_overrides, schema={}) == expected_result def test_parse_overrides_allows_value_with_equal_sign(): flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string') flexmock(module).should_receive('convert_value_type').replace_with( lambda value, option_type: value ) raw_overrides = ['option=this===value'] expected_result = ((('option',), 'this===value'),) module.parse_overrides(raw_overrides, schema={}) == expected_result def test_parse_overrides_raises_on_missing_equal_sign(): flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string') flexmock(module).should_receive('convert_value_type').replace_with( lambda value, option_type: value ) raw_overrides = ['option'] with pytest.raises(ValueError): module.parse_overrides(raw_overrides, schema={}) def test_parse_overrides_raises_on_invalid_override_value(): flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string') flexmock(module).should_receive('convert_value_type').and_raise(ruamel.yaml.parser.ParserError) raw_overrides = ['option=[in valid]'] with pytest.raises(ValueError): module.parse_overrides(raw_overrides, schema={}) def test_parse_overrides_allows_value_with_single_key(): flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string') flexmock(module).should_receive('convert_value_type').replace_with( lambda value, option_type: value ) raw_overrides = ['option=value'] expected_result = ((('option',), 'value'),) module.parse_overrides(raw_overrides, schema={}) == expected_result def test_parse_overrides_handles_empty_overrides(): module.parse_overrides(raw_overrides=None, schema={}) == () borgmatic/tests/unit/config/test_paths.py000066400000000000000000000331171476361726000211640ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.config import paths as module def test_expand_user_in_path_passes_through_plain_directory(): flexmock(module.os.path).should_receive('expanduser').and_return('/home/foo') assert module.expand_user_in_path('/home/foo') == '/home/foo' def test_expand_user_in_path_expands_tildes(): flexmock(module.os.path).should_receive('expanduser').and_return('/home/foo') assert module.expand_user_in_path('~/foo') == '/home/foo' def test_expand_user_in_path_handles_empty_directory(): assert module.expand_user_in_path('') is None def test_expand_user_in_path_handles_none_directory(): assert module.expand_user_in_path(None) is None def test_expand_user_in_path_handles_incorrectly_typed_directory(): assert module.expand_user_in_path(3) is None def test_get_borgmatic_source_directory_uses_config_option(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) assert module.get_borgmatic_source_directory({'borgmatic_source_directory': '/tmp'}) == '/tmp' def test_get_borgmatic_source_directory_without_config_option_uses_default(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) assert module.get_borgmatic_source_directory({}) == '~/.borgmatic' def test_replace_temporary_subdirectory_with_glob_transforms_path(): assert ( module.replace_temporary_subdirectory_with_glob('/tmp/borgmatic-aet8kn93/borgmatic') == '/tmp/borgmatic-*/borgmatic' ) def test_replace_temporary_subdirectory_with_glob_passes_through_non_matching_path(): assert ( module.replace_temporary_subdirectory_with_glob('/tmp/foo-aet8kn93/borgmatic') == '/tmp/foo-aet8kn93/borgmatic' ) def test_replace_temporary_subdirectory_with_glob_uses_custom_temporary_directory_prefix(): assert ( module.replace_temporary_subdirectory_with_glob( '/tmp/.borgmatic-aet8kn93/borgmatic', temporary_directory_prefix='.borgmatic-' ) == '/tmp/.borgmatic-*/borgmatic' ) def test_runtime_directory_uses_config_option(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os).should_receive('makedirs') config = {'user_runtime_directory': '/run', 'borgmatic_source_directory': '/nope'} with module.Runtime_directory(config) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_uses_config_option_without_adding_duplicate_borgmatic_subdirectory(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os).should_receive('makedirs') config = {'user_runtime_directory': '/run/borgmatic', 'borgmatic_source_directory': '/nope'} with module.Runtime_directory(config) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_with_relative_config_option_errors(): flexmock(module.os).should_receive('makedirs').never() config = {'user_runtime_directory': 'run', 'borgmatic_source_directory': '/nope'} with pytest.raises(ValueError): with module.Runtime_directory(config) as borgmatic_runtime_directory: # noqa: F841 pass def test_runtime_directory_falls_back_to_xdg_runtime_dir(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return( '/run' ) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_falls_back_to_xdg_runtime_dir_without_adding_duplicate_borgmatic_subdirectory(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return( '/run/borgmatic' ) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_with_relative_xdg_runtime_dir_errors(): flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return('run') flexmock(module.os).should_receive('makedirs').never() with pytest.raises(ValueError): with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841 pass def test_runtime_directory_falls_back_to_runtime_directory(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( '/run' ) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_falls_back_to_runtime_directory_without_adding_duplicate_borgmatic_subdirectory(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( '/run/borgmatic' ) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/./borgmatic' def test_runtime_directory_with_relative_runtime_directory_errors(): flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( 'run' ) flexmock(module.os).should_receive('makedirs').never() with pytest.raises(ValueError): with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841 pass def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_that_get_cleaned_up(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return('/run') temporary_directory = flexmock(name='/run/borgmatic-1234') temporary_directory.should_receive('cleanup').once() flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args( prefix='borgmatic-', dir='/run' ).and_return(temporary_directory) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic' def test_runtime_directory_with_relative_tmpdir_errors(): flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return('run') flexmock(module.tempfile).should_receive('TemporaryDirectory').never() flexmock(module.os).should_receive('makedirs').never() with pytest.raises(ValueError): with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841 pass def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_that_get_cleaned_up(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return('/run') temporary_directory = flexmock(name='/run/borgmatic-1234') temporary_directory.should_receive('cleanup').once() flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args( prefix='borgmatic-', dir='/run' ).and_return(temporary_directory) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic' def test_runtime_directory_with_relative_temp_errors(): flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return('run') flexmock(module.tempfile).should_receive('TemporaryDirectory').never() flexmock(module.os).should_receive('makedirs') with pytest.raises(ValueError): with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841 pass def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_subdirectory_that_get_cleaned_up(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return(None) temporary_directory = flexmock(name='/tmp/borgmatic-1234') temporary_directory.should_receive('cleanup').once() flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args( prefix='borgmatic-', dir='/tmp' ).and_return(temporary_directory) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic' def test_runtime_directory_with_erroring_cleanup_does_not_raise(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return( None ) flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return(None) temporary_directory = flexmock(name='/tmp/borgmatic-1234') temporary_directory.should_receive('cleanup').and_raise(OSError).once() flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args( prefix='borgmatic-', dir='/tmp' ).and_return(temporary_directory) flexmock(module.os).should_receive('makedirs') with module.Runtime_directory({}) as borgmatic_runtime_directory: assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic' @pytest.mark.parametrize( 'borgmatic_runtime_directory,expected_glob', ( ('/foo/bar/baz/./borgmatic', 'foo/bar/baz/borgmatic'), ('/foo/borgmatic/baz/./borgmatic', 'foo/borgmatic/baz/borgmatic'), ('/foo/borgmatic-jti8idds/./borgmatic', 'foo/*/borgmatic'), ), ) def test_make_runtime_directory_glob(borgmatic_runtime_directory, expected_glob): assert module.make_runtime_directory_glob(borgmatic_runtime_directory) == expected_glob def test_get_borgmatic_state_directory_uses_config_option(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').never() assert ( module.get_borgmatic_state_directory( {'user_state_directory': '/tmp', 'borgmatic_source_directory': '/nope'} ) == '/tmp/borgmatic' ) def test_get_borgmatic_state_directory_falls_back_to_xdg_state_home(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_STATE_HOME').and_return('/tmp') assert module.get_borgmatic_state_directory({}) == '/tmp/borgmatic' def test_get_borgmatic_state_directory_falls_back_to_state_directory(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').with_args('XDG_STATE_HOME').and_return(None) flexmock(module.os.environ).should_receive('get').with_args('STATE_DIRECTORY').and_return( '/tmp' ) assert module.get_borgmatic_state_directory({}) == '/tmp/borgmatic' def test_get_borgmatic_state_directory_defaults_to_hard_coded_path(): flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path) flexmock(module.os.environ).should_receive('get').and_return(None) assert module.get_borgmatic_state_directory({}) == '~/.local/state/borgmatic' borgmatic/tests/unit/config/test_validate.py000066400000000000000000000157451476361726000216450ustar00rootroot00000000000000import os import sys from io import StringIO import pytest from flexmock import flexmock from borgmatic.config import validate as module def test_schema_filename_finds_schema_path(): schema_path = '/var/borgmatic/config/schema.yaml' flexmock(os.path).should_receive('dirname').and_return('/var/borgmatic/config') builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args(schema_path).and_return(StringIO()) assert module.schema_filename() == schema_path def test_schema_filename_raises_filenotfounderror(): schema_path = '/var/borgmatic/config/schema.yaml' flexmock(os.path).should_receive('dirname').and_return('/var/borgmatic/config') builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args(schema_path).and_raise(FileNotFoundError) with pytest.raises(FileNotFoundError): module.schema_filename() def test_format_json_error_path_element_formats_array_index(): module.format_json_error_path_element(3) == '[3]' def test_format_json_error_path_element_formats_property(): module.format_json_error_path_element('foo') == '.foo' def test_format_json_error_formats_error_including_path(): flexmock(module).format_json_error_path_element = lambda element: f'.{element}' error = flexmock(message='oops', path=['foo', 'bar']) assert module.format_json_error(error) == "At 'foo.bar': oops" def test_format_json_error_formats_error_without_path(): flexmock(module).should_receive('format_json_error_path_element').never() error = flexmock(message='oops', path=[]) assert module.format_json_error(error) == 'At the top level: oops' def test_validation_error_string_contains_errors(): flexmock(module).format_json_error = lambda error: error.message error = module.Validation_error('config.yaml', ('oops', 'uh oh')) result = str(error) assert 'config.yaml' in result assert 'oops' in result assert 'uh oh' in result def test_apply_logical_validation_raises_if_unknown_repository_in_check_repositories(): flexmock(module).should_receive('repositories_match').and_return(False) with pytest.raises(module.Validation_error): module.apply_logical_validation( 'config.yaml', { 'repositories': ['repo.borg', 'other.borg'], 'keep_secondly': 1000, 'check_repositories': ['repo.borg', 'unknown.borg'], }, ) def test_apply_logical_validation_does_not_raise_if_known_repository_in_check_repositories(): flexmock(module).should_receive('repositories_match').and_return(True) module.apply_logical_validation( 'config.yaml', { 'repositories': [{'path': 'repo.borg'}, {'path': 'other.borg'}], 'keep_secondly': 1000, 'check_repositories': ['repo.borg'], }, ) def test_normalize_repository_path_passes_through_remote_repository(): repository = 'example.org:test.borg' module.normalize_repository_path(repository) == repository def test_normalize_repository_path_passes_through_file_repository(): repository = 'file:///foo/bar/test.borg' flexmock(module.os.path).should_receive('abspath').and_return('/foo/bar/test.borg') module.normalize_repository_path(repository) == '/foo/bar/test.borg' def test_normalize_repository_path_passes_through_absolute_repository(): repository = '/foo/bar/test.borg' flexmock(module.os.path).should_receive('abspath').and_return(repository) module.normalize_repository_path(repository) == repository def test_normalize_repository_path_resolves_relative_repository(): repository = 'test.borg' absolute = '/foo/bar/test.borg' flexmock(module.os.path).should_receive('abspath').and_return(absolute) module.normalize_repository_path(repository) == absolute @pytest.mark.parametrize( 'first,second,expected_result', ( (None, None, False), ('foo', None, False), (None, 'bar', False), ('foo', 'foo', True), ('foo', 'bar', False), ('foo*', 'foof', True), ('barf', 'bar*', True), ('foo*', 'bar*', False), ), ) def test_glob_match_matches_globs(first, second, expected_result): assert module.glob_match(first=first, second=second) is expected_result def test_repositories_match_matches_on_path(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match( {'path': 'foo', 'label': 'my repo'}, {'path': 'foo', 'label': 'other repo'} ) is True def test_repositories_match_matches_on_label(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match( {'path': 'foo', 'label': 'my repo'}, {'path': 'bar', 'label': 'my repo'} ) is True def test_repositories_match_with_different_paths_and_labels_does_not_match(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match( {'path': 'foo', 'label': 'my repo'}, {'path': 'bar', 'label': 'other repo'} ) is False def test_repositories_match_matches_on_string_repository(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match('foo', 'foo') is True def test_repositories_match_with_different_string_repositories_does_not_match(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match('foo', 'bar') is False def test_repositories_match_supports_mixed_repositories(): flexmock(module).should_receive('normalize_repository_path') flexmock(module).should_receive('glob_match').replace_with( lambda first, second: first == second ) module.repositories_match({'path': 'foo', 'label': 'my foo'}, 'bar') is False def test_guard_configuration_contains_repository_does_not_raise_when_repository_matches(): flexmock(module).should_receive('repositories_match').and_return(True) module.guard_configuration_contains_repository( repository='repo', configurations={'config.yaml': {'repositories': [{'path': 'foo/bar', 'label': 'repo'}]}}, ) def test_guard_configuration_contains_repository_errors_when_repository_does_not_match(): flexmock(module).should_receive('repositories_match').and_return(False) with pytest.raises(ValueError): module.guard_configuration_contains_repository( repository='nope', configurations={'config.yaml': {'repositories': ['repo', 'repo2']}}, ) borgmatic/tests/unit/hooks/000077500000000000000000000000001476361726000163055ustar00rootroot00000000000000borgmatic/tests/unit/hooks/__init__.py000066400000000000000000000000001476361726000204040ustar00rootroot00000000000000borgmatic/tests/unit/hooks/credential/000077500000000000000000000000001476361726000204175ustar00rootroot00000000000000borgmatic/tests/unit/hooks/credential/__init__.py000066400000000000000000000000001476361726000225160ustar00rootroot00000000000000borgmatic/tests/unit/hooks/credential/test_container.py000066400000000000000000000051701476361726000240150ustar00rootroot00000000000000import io import sys import pytest from flexmock import flexmock from borgmatic.hooks.credential import container as module @pytest.mark.parametrize('credential_parameters', ((), ('foo', 'bar'))) def test_load_credential_with_invalid_credential_parameters_raises(credential_parameters): with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=credential_parameters ) def test_load_credential_with_invalid_secret_name_raises(): with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=('this is invalid',) ) def test_load_credential_reads_named_secret_from_file(): credential_stream = io.StringIO('password') credential_stream.name = '/run/secrets/mysecret' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/run/secrets/mysecret').and_return(credential_stream) assert ( module.load_credential(hook_config={}, config={}, credential_parameters=('mysecret',)) == 'password' ) def test_load_credential_with_custom_secrets_directory_looks_there_for_secret_file(): config = {'container': {'secrets_directory': '/secrets'}} credential_stream = io.StringIO('password') credential_stream.name = '/secrets/mysecret' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/secrets/mysecret').and_return(credential_stream) assert ( module.load_credential( hook_config=config['container'], config=config, credential_parameters=('mysecret',) ) == 'password' ) def test_load_credential_with_custom_secrets_directory_prefixes_it_with_working_directory(): config = {'container': {'secrets_directory': 'secrets'}, 'working_directory': '/working'} credential_stream = io.StringIO('password') credential_stream.name = '/working/secrets/mysecret' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/working/secrets/mysecret').and_return( credential_stream ) assert ( module.load_credential( hook_config=config['container'], config=config, credential_parameters=('mysecret',) ) == 'password' ) def test_load_credential_with_file_not_found_error_raises(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/run/secrets/mysecret').and_raise(FileNotFoundError) with pytest.raises(ValueError): module.load_credential(hook_config={}, config={}, credential_parameters=('mysecret',)) borgmatic/tests/unit/hooks/credential/test_file.py000066400000000000000000000042211476361726000227460ustar00rootroot00000000000000import io import sys import pytest from flexmock import flexmock from borgmatic.hooks.credential import file as module @pytest.mark.parametrize('credential_parameters', ((), ('foo', 'bar'))) def test_load_credential_with_invalid_credential_parameters_raises(credential_parameters): with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=credential_parameters ) def test_load_credential_with_invalid_credential_name_raises(): with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=('this is invalid',) ) def test_load_credential_reads_named_credential_from_file(): credential_stream = io.StringIO('password') credential_stream.name = '/credentials/mycredential' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/credentials/mycredential').and_return( credential_stream ) assert ( module.load_credential( hook_config={}, config={}, credential_parameters=('/credentials/mycredential',) ) == 'password' ) def test_load_credential_reads_named_credential_from_file_using_working_directory(): credential_stream = io.StringIO('password') credential_stream.name = '/working/credentials/mycredential' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/working/credentials/mycredential').and_return( credential_stream ) assert ( module.load_credential( hook_config={}, config={'working_directory': '/working'}, credential_parameters=('credentials/mycredential',), ) == 'password' ) def test_load_credential_with_file_not_found_error_raises(): builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/credentials/mycredential').and_raise( FileNotFoundError ) with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=('/credentials/mycredential',) ) borgmatic/tests/unit/hooks/credential/test_keepassxc.py000066400000000000000000000047601476361726000240250ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.hooks.credential import keepassxc as module @pytest.mark.parametrize('credential_parameters', ((), ('foo',), ('foo', 'bar', 'baz'))) def test_load_credential_with_invalid_credential_parameters_raises(credential_parameters): flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never() with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=credential_parameters ) def test_load_credential_with_missing_database_raises(): flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never() with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=('database.kdbx', 'mypassword') ) def test_load_credential_with_present_database_fetches_password_from_keepassxc(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args( ( 'keepassxc-cli', 'show', '--show-protected', '--attributes', 'Password', 'database.kdbx', 'mypassword', ) ).and_return( 'password' ).once() assert ( module.load_credential( hook_config={}, config={}, credential_parameters=('database.kdbx', 'mypassword') ) == 'password' ) def test_load_credential_with_custom_keepassxc_cli_command_calls_it(): config = {'keepassxc': {'keepassxc_cli_command': '/usr/local/bin/keepassxc-cli --some-option'}} flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).with_args( ( '/usr/local/bin/keepassxc-cli', '--some-option', 'show', '--show-protected', '--attributes', 'Password', 'database.kdbx', 'mypassword', ) ).and_return( 'password' ).once() assert ( module.load_credential( hook_config=config['keepassxc'], config=config, credential_parameters=('database.kdbx', 'mypassword'), ) == 'password' ) borgmatic/tests/unit/hooks/credential/test_parse.py000066400000000000000000000062621476361726000231500ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.hooks.credential import parse as module def test_hash_adapter_is_always_equal(): assert module.Hash_adapter({1: 2}) == module.Hash_adapter({3: 4}) def test_hash_adapter_alwaysh_hashes_the_same(): assert hash(module.Hash_adapter({1: 2})) == hash(module.Hash_adapter({3: 4})) def test_cache_ignoring_unhashable_arguments_caches_arguments_after_first_call(): hashable = 3 unhashable = {1, 2} calls = 0 @module.cache_ignoring_unhashable_arguments def function(first, second, third): nonlocal calls calls += 1 assert first == hashable assert second == unhashable assert third == unhashable return first assert function(hashable, unhashable, third=unhashable) == hashable assert calls == 1 assert function(hashable, unhashable, third=unhashable) == hashable assert calls == 1 def test_resolve_credential_passes_through_string_without_credential(): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never() assert module.resolve_credential('{no credentials here}', config={}) == '{no credentials here}' def test_resolve_credential_passes_through_none(): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never() assert module.resolve_credential(None, config={}) is None @pytest.mark.parametrize('invalid_value', ('{credential}', '{credential }', '{credential systemd}')) def test_resolve_credential_with_invalid_credential_raises(invalid_value): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').never() with pytest.raises(ValueError): module.resolve_credential(invalid_value, config={}) def test_resolve_credential_with_valid_credential_loads_credential(): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( 'load_credential', {}, 'systemd', ('mycredential',), ).and_return('result').once() assert module.resolve_credential('{credential systemd mycredential}', config={}) == 'result' def test_resolve_credential_with_valid_credential_and_quoted_parameters_loads_credential(): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( 'load_credential', {}, 'systemd', ('my credential',), ).and_return('result').once() assert module.resolve_credential('{credential systemd "my credential"}', config={}) == 'result' def test_resolve_credential_caches_credential_after_first_call(): module.resolve_credential.cache_clear() flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args( 'load_credential', {}, 'systemd', ('mycredential',), ).and_return('result').once() assert module.resolve_credential('{credential systemd mycredential}', config={}) == 'result' assert module.resolve_credential('{credential systemd mycredential}', config={}) == 'result' borgmatic/tests/unit/hooks/credential/test_systemd.py000066400000000000000000000042631476361726000235250ustar00rootroot00000000000000import io import sys import pytest from flexmock import flexmock from borgmatic.hooks.credential import systemd as module @pytest.mark.parametrize('credential_parameters', ((), ('foo', 'bar'))) def test_load_credential_with_invalid_credential_parameters_raises(credential_parameters): flexmock(module.os.environ).should_receive('get').never() with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=credential_parameters ) def test_load_credential_without_credentials_directory_raises(): flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( None ) with pytest.raises(ValueError): module.load_credential(hook_config={}, config={}, credential_parameters=('mycredential',)) def test_load_credential_with_invalid_credential_name_raises(): flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( '/var' ) with pytest.raises(ValueError): module.load_credential( hook_config={}, config={}, credential_parameters=('../../my!@#$credential',) ) def test_load_credential_reads_named_credential_from_file(): flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( '/var' ) credential_stream = io.StringIO('password') credential_stream.name = '/var/mycredential' builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/var/mycredential').and_return(credential_stream) assert ( module.load_credential(hook_config={}, config={}, credential_parameters=('mycredential',)) == 'password' ) def test_load_credential_with_file_not_found_error_raises(): flexmock(module.os.environ).should_receive('get').with_args('CREDENTIALS_DIRECTORY').and_return( '/var' ) builtins = flexmock(sys.modules['builtins']) builtins.should_receive('open').with_args('/var/mycredential').and_raise(FileNotFoundError) with pytest.raises(ValueError): module.load_credential(hook_config={}, config={}, credential_parameters=('mycredential',)) borgmatic/tests/unit/hooks/data_source/000077500000000000000000000000001476361726000205765ustar00rootroot00000000000000borgmatic/tests/unit/hooks/data_source/__init__.py000066400000000000000000000000001476361726000226750ustar00rootroot00000000000000borgmatic/tests/unit/hooks/data_source/test_bootstrap.py000066400000000000000000000110231476361726000242210ustar00rootroot00000000000000import sys from flexmock import flexmock from borgmatic.hooks.data_source import bootstrap as module def test_dump_data_sources_creates_manifest_file(): flexmock(module.os).should_receive('makedirs') flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0') manifest_file = flexmock( __enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None), __exit__=lambda *args: None, ) flexmock(sys.modules['builtins']).should_receive('open').with_args( '/run/borgmatic/bootstrap/manifest.json', 'w' ).and_return(manifest_file) flexmock(module.json).should_receive('dump').with_args( {'borgmatic_version': '1.0.0', 'config_paths': ('test.yaml',)}, manifest_file, ).once() module.dump_data_sources( hook_config=None, config={}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) def test_dump_data_sources_with_store_config_files_false_does_not_create_manifest_file(): flexmock(module.os).should_receive('makedirs').never() flexmock(module.json).should_receive('dump').never() hook_config = {'store_config_files': False} module.dump_data_sources( hook_config=hook_config, config={'bootstrap': hook_config}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) def test_dump_data_sources_with_dry_run_does_not_create_manifest_file(): flexmock(module.os).should_receive('makedirs').never() flexmock(module.json).should_receive('dump').never() module.dump_data_sources( hook_config=None, config={}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) def test_remove_data_source_dumps_deletes_manifest_and_parent_directory(): flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) flexmock(module.os).should_receive('remove').with_args( '/run/borgmatic/bootstrap/manifest.json' ).once() flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once() module.remove_data_source_dumps( hook_config=None, config={}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_dry_run_bails(): flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) flexmock(module.os).should_receive('remove').never() flexmock(module.os).should_receive('rmdir').never() module.remove_data_source_dumps( hook_config=None, config={}, borgmatic_runtime_directory='/run/borgmatic', dry_run=True, ) def test_remove_data_source_dumps_swallows_manifest_file_not_found_error(): flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) flexmock(module.os).should_receive('remove').with_args( '/run/borgmatic/bootstrap/manifest.json' ).and_raise(FileNotFoundError).once() flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once() module.remove_data_source_dumps( hook_config=None, config={}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_swallows_manifest_parent_directory_not_found_error(): flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) flexmock(module.os).should_receive('remove').with_args( '/run/borgmatic/bootstrap/manifest.json' ).once() flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').and_raise( FileNotFoundError ).once() module.remove_data_source_dumps( hook_config=None, config={}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) borgmatic/tests/unit/hooks/data_source/test_btrfs.py000066400000000000000000001134571476361726000233420ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type from borgmatic.hooks.data_source import btrfs as module def test_get_subvolume_mount_points_parses_findmnt_output(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( '''{ "filesystems": [ { "target": "/mnt0", "source": "/dev/loop0", "fstype": "btrfs", "options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/" }, { "target": "/mnt1", "source": "/dev/loop0", "fstype": "btrfs", "options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/" } ] } ''' ) assert module.get_subvolume_mount_points('findmnt') == ('/mnt0', '/mnt1') def test_get_subvolume_mount_points_with_invalid_findmnt_json_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('{') with pytest.raises(ValueError): module.get_subvolume_mount_points('findmnt') def test_get_subvolume_mount_points_with_findmnt_json_missing_filesystems_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('{"wtf": "something is wrong here"}') with pytest.raises(ValueError): module.get_subvolume_mount_points('findmnt') def test_get_subvolume_property_with_invalid_btrfs_output_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('invalid') with pytest.raises(ValueError): module.get_subvolume_property('btrfs', '/foo', 'ro') def test_get_subvolume_property_with_true_output_returns_true_bool(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('ro=true') assert module.get_subvolume_property('btrfs', '/foo', 'ro') is True def test_get_subvolume_property_with_false_output_returns_false_bool(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('ro=false') assert module.get_subvolume_property('btrfs', '/foo', 'ro') is False def test_get_subvolume_property_passes_through_general_value(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('thing=value') assert module.get_subvolume_property('btrfs', '/foo', 'thing') == 'value' def test_omit_read_only_subvolume_mount_points_filters_out_read_only(): flexmock(module).should_receive('get_subvolume_property').with_args( 'btrfs', '/foo', 'ro' ).and_return(False) flexmock(module).should_receive('get_subvolume_property').with_args( 'btrfs', '/bar', 'ro' ).and_return(True) flexmock(module).should_receive('get_subvolume_property').with_args( 'btrfs', '/baz', 'ro' ).and_return(False) assert module.omit_read_only_subvolume_mount_points('btrfs', ('/foo', '/bar', '/baz')) == ( '/foo', '/baz', ) def test_get_subvolumes_collects_subvolumes_matching_patterns(): flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2')) flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return( ('/mnt1', '/mnt2') ) contained_pattern = Pattern( '/mnt1', type=Pattern_type.ROOT, source=Pattern_source.CONFIG, ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt1', object).and_return((contained_pattern,)) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt2', object).and_return(()) assert module.get_subvolumes( 'btrfs', 'findmnt', patterns=[ Pattern('/mnt1'), Pattern('/mnt3'), ], ) == (module.Subvolume('/mnt1', contained_patterns=(contained_pattern,)),) def test_get_subvolumes_skips_non_root_patterns(): flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2')) flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return( ('/mnt1', '/mnt2') ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt1', object).and_return( ( Pattern( '/mnt1', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG, ), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt2', object).and_return(()) assert ( module.get_subvolumes( 'btrfs', 'findmnt', patterns=[ Pattern('/mnt1'), Pattern('/mnt3'), ], ) == () ) def test_get_subvolumes_skips_non_config_patterns(): flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2')) flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return( ('/mnt1', '/mnt2') ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt1', object).and_return( ( Pattern( '/mnt1', type=Pattern_type.ROOT, source=Pattern_source.HOOK, ), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt2', object).and_return(()) assert ( module.get_subvolumes( 'btrfs', 'findmnt', patterns=[ Pattern('/mnt1'), Pattern('/mnt3'), ], ) == () ) def test_get_subvolumes_without_patterns_collects_all_subvolumes(): flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2')) flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return( ('/mnt1', '/mnt2') ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt1', object).and_return((Pattern('/mnt1'),)) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt2', object).and_return((Pattern('/mnt2'),)) assert module.get_subvolumes('btrfs', 'findmnt') == ( module.Subvolume('/mnt1', contained_patterns=(Pattern('/mnt1'),)), module.Subvolume('/mnt2', contained_patterns=(Pattern('/mnt2'),)), ) @pytest.mark.parametrize( 'subvolume_path,expected_snapshot_path', ( ('/foo/bar', '/foo/bar/.borgmatic-snapshot-1234/foo/bar'), ('/', '/.borgmatic-snapshot-1234'), ), ) def test_make_snapshot_path_includes_stripped_subvolume_path( subvolume_path, expected_snapshot_path ): flexmock(module.os).should_receive('getpid').and_return(1234) assert module.make_snapshot_path(subvolume_path) == expected_snapshot_path @pytest.mark.parametrize( 'subvolume_path,pattern,expected_pattern', ( ( '/foo/bar', Pattern('/foo/bar/baz'), Pattern('/foo/bar/.borgmatic-snapshot-1234/./foo/bar/baz'), ), ('/foo/bar', Pattern('/foo/bar'), Pattern('/foo/bar/.borgmatic-snapshot-1234/./foo/bar')), ( '/foo/bar', Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '^/foo/bar/.borgmatic-snapshot-1234/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), ( '/foo/bar', Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '/foo/bar/.borgmatic-snapshot-1234/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), ('/', Pattern('/foo'), Pattern('/.borgmatic-snapshot-1234/./foo')), ('/', Pattern('/'), Pattern('/.borgmatic-snapshot-1234/./')), ), ) def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path( subvolume_path, pattern, expected_pattern ): flexmock(module.os).should_receive('getpid').and_return(1234) assert module.make_borg_snapshot_pattern(subvolume_path, pattern) == expected_pattern def test_dump_data_sources_snapshots_each_subvolume_and_updates_patterns(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ) flexmock(module).should_receive('snapshot_subvolume').with_args( 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).once() flexmock(module).should_receive('snapshot_subvolume').with_args( 'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).once() flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol1' ).and_return( Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol2' ).and_return( Pattern( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol1', object ).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol2', object ).and_return(Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')) assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/foo'), Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'), Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'), Pattern( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), ] assert config == { 'btrfs': {}, } def test_dump_data_sources_uses_custom_btrfs_command_in_commands(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {'btrfs_command': '/usr/local/bin/btrfs'}} flexmock(module).should_receive('get_subvolumes').and_return( (module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ) flexmock(module).should_receive('snapshot_subvolume').with_args( '/usr/local/bin/btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).once() flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol1' ).and_return( Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol1', object ).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')) assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/foo'), Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'), Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), ] assert config == { 'btrfs': { 'btrfs_command': '/usr/local/bin/btrfs', }, } def test_dump_data_sources_uses_custom_findmnt_command_in_commands(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {'findmnt_command': '/usr/local/bin/findmnt'}} flexmock(module).should_receive('get_subvolumes').with_args( 'btrfs', '/usr/local/bin/findmnt', patterns ).and_return( (module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),) ).once() flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ) flexmock(module).should_receive('snapshot_subvolume').with_args( 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).once() flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol1' ).and_return( Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol1', object ).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')) assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/foo'), Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'), Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), ] assert config == { 'btrfs': { 'findmnt_command': '/usr/local/bin/findmnt', }, } def test_dump_data_sources_with_dry_run_skips_snapshot_and_patterns_update(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( (module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ) flexmock(module).should_receive('snapshot_subvolume').never() flexmock(module).should_receive('make_snapshot_exclude_pattern').never() assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=True, ) == [] ) assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')] assert config == {'btrfs': {}} def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_patterns_update(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return(()) flexmock(module).should_receive('make_snapshot_path').never() flexmock(module).should_receive('snapshot_subvolume').never() flexmock(module).should_receive('make_snapshot_exclude_pattern').never() assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')] assert config == {'btrfs': {}} def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns(): patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')] config = {'btrfs': {}, 'exclude_patterns': ['/bar']} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ) flexmock(module).should_receive('snapshot_subvolume').with_args( 'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).once() flexmock(module).should_receive('snapshot_subvolume').with_args( 'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).once() flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol1' ).and_return( Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args( '/mnt/subvol2' ).and_return( Pattern( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ) ) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol1', object ).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( '/mnt/subvol2', object ).and_return(Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')) assert ( module.dump_data_sources( hook_config=config['btrfs'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/foo'), Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'), Pattern( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'), Pattern( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH, ), ] assert config == { 'btrfs': {}, 'exclude_patterns': ['/bar'], } def test_remove_data_source_dumps_deletes_snapshots(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ).and_return( ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1') ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ).and_return( ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2') ) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-5678/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-5678/mnt/subvol2' ).and_return(False) flexmock(module).should_receive('delete_snapshot').with_args( 'btrfs', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).once() flexmock(module).should_receive('delete_snapshot').with_args( 'btrfs', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1' ).once() flexmock(module).should_receive('delete_snapshot').with_args( 'btrfs', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).once() flexmock(module).should_receive('delete_snapshot').with_args( 'btrfs', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2' ).never() flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-1234' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-5678' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-1234' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-5678' ).and_return(True) flexmock(module.shutil).should_receive('rmtree').with_args( '/mnt/subvol1/.borgmatic-1234' ).once() flexmock(module.shutil).should_receive('rmtree').with_args( '/mnt/subvol1/.borgmatic-5678' ).once() flexmock(module.shutil).should_receive('rmtree').with_args( '/mnt/subvol2/.borgmatic-1234' ).once() flexmock(module.shutil).should_receive('rmtree').with_args( '/mnt/subvol2/.borgmatic-5678' ).never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_without_hook_configuration_bails(): flexmock(module).should_receive('get_subvolumes').never() flexmock(module).should_receive('make_snapshot_path').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=None, config={'source_directories': '/mnt/subvolume'}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_get_subvolumes_file_not_found_error_bails(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_raise(FileNotFoundError) flexmock(module).should_receive('make_snapshot_path').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_get_subvolumes_called_process_error_bails(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_raise( module.subprocess.CalledProcessError(1, 'command', 'error') ) flexmock(module).should_receive('make_snapshot_path').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_dry_run_skips_deletes(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ).and_return( ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1') ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ).and_return( ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2') ) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-5678/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-5678/mnt/subvol2' ).and_return(False) flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=True, ) def test_remove_data_source_dumps_without_subvolumes_skips_deletes(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return(()) flexmock(module).should_receive('make_snapshot_path').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_without_snapshots_skips_deletes(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ) flexmock(module.glob).should_receive('glob').and_return(()) flexmock(module.os.path).should_receive('isdir').never() flexmock(module).should_receive('delete_snapshot').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bails(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ).and_return( ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1') ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ).and_return( ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2') ) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-5678/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-5678/mnt/subvol2' ).and_return(False) flexmock(module).should_receive('delete_snapshot').and_raise(FileNotFoundError) flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bails(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( ( module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)), module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)), ) ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return( '/mnt/subvol1/.borgmatic-1234/./mnt/subvol1' ) flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return( '/mnt/subvol2/.borgmatic-1234/./mnt/subvol2' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol1/.borgmatic-*/mnt/subvol1' ).and_return( ('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1') ) flexmock(module.glob).should_receive('glob').with_args( '/mnt/subvol2/.borgmatic-*/mnt/subvol2' ).and_return( ('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2') ) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-1234/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol1/.borgmatic-5678/mnt/subvol1' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-1234/mnt/subvol2' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/mnt/subvol2/.borgmatic-5678/mnt/subvol2' ).and_return(False) flexmock(module).should_receive('delete_snapshot').and_raise( module.subprocess.CalledProcessError(1, 'command', 'error') ) flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_root_subvolume_skips_duplicate_removal(): config = {'btrfs': {}} flexmock(module).should_receive('get_subvolumes').and_return( (module.Subvolume('/', contained_patterns=(Pattern('/etc'),)),) ) flexmock(module).should_receive('make_snapshot_path').with_args('/').and_return( '/.borgmatic-1234' ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).with_args( '/.borgmatic-1234', temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX, ).and_return( '/.borgmatic-*' ) flexmock(module.glob).should_receive('glob').with_args('/.borgmatic-*').and_return( ('/.borgmatic-1234', '/.borgmatic-5678') ) flexmock(module.os.path).should_receive('isdir').with_args('/.borgmatic-1234').and_return( True ).and_return(False) flexmock(module.os.path).should_receive('isdir').with_args('/.borgmatic-5678').and_return( True ).and_return(False) flexmock(module).should_receive('delete_snapshot').with_args('btrfs', '/.borgmatic-1234').once() flexmock(module).should_receive('delete_snapshot').with_args('btrfs', '/.borgmatic-5678').once() flexmock(module.os.path).should_receive('isdir').with_args('').and_return(False) flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps( hook_config=config['btrfs'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) borgmatic/tests/unit/hooks/data_source/test_dump.py000066400000000000000000000047471476361726000231700ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.hooks.data_source import dump as module def test_make_data_source_dump_path_joins_arguments(): assert module.make_data_source_dump_path('/tmp', 'super_databases') == '/tmp/super_databases' def test_make_data_source_dump_filename_uses_name_and_hostname(): assert ( module.make_data_source_dump_filename('databases', 'test', 'hostname') == 'databases/hostname/test' ) def test_make_data_source_dump_filename_uses_name_and_hostname_and_port(): assert ( module.make_data_source_dump_filename('databases', 'test', 'hostname', 1234) == 'databases/hostname:1234/test' ) def test_make_data_source_dump_filename_without_hostname_defaults_to_localhost(): assert module.make_data_source_dump_filename('databases', 'test') == 'databases/localhost/test' def test_make_data_source_dump_filename_with_invalid_name_raises(): with pytest.raises(ValueError): module.make_data_source_dump_filename('databases', 'invalid/name') def test_create_parent_directory_for_dump_does_not_raise(): flexmock(module.os).should_receive('makedirs') module.create_parent_directory_for_dump('/path/to/parent') def test_create_named_pipe_for_dump_does_not_raise(): flexmock(module).should_receive('create_parent_directory_for_dump') flexmock(module.os).should_receive('mkfifo') module.create_named_pipe_for_dump('/path/to/pipe') def test_remove_data_source_dumps_removes_dump_path(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.shutil).should_receive('rmtree').with_args('databases').once() module.remove_data_source_dumps('databases', 'SuperDB', dry_run=False) def test_remove_data_source_dumps_with_dry_run_skips_removal(): flexmock(module.os.path).should_receive('exists').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps('databases', 'SuperDB', dry_run=True) def test_remove_data_source_dumps_without_dump_path_present_skips_removal(): flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps('databases', 'SuperDB', dry_run=False) def test_convert_glob_patterns_to_borg_pattern_makes_multipart_regular_expression(): assert ( module.convert_glob_patterns_to_borg_pattern(('/etc/foo/bar', '/bar/*/baz')) == 're:(?s:etc/foo/bar)|(?s:bar/.*/baz)' ) borgmatic/tests/unit/hooks/data_source/test_lvm.py000066400000000000000000001445031476361726000230140ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type from borgmatic.hooks.data_source import lvm as module def test_get_logical_volumes_filters_by_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "blockdevices": [ { "name": "vgroup-notmounted", "path": "/dev/mapper/vgroup-notmounted", "mountpoint": null, "type": "lvm" }, { "name": "vgroup-lvolume", "path": "/dev/mapper/vgroup-lvolume", "mountpoint": "/mnt/lvolume", "type": "lvm" }, { "name": "vgroup-other", "path": "/dev/mapper/vgroup-other", "mountpoint": "/mnt/other", "type": "lvm" }, { "name": "vgroup-notlvm", "path": "/dev/mapper/vgroup-notlvm", "mountpoint": "/mnt/notlvm", "type": "notlvm" } ] } ''' ) contained = { Pattern('/mnt/lvolume', source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG), } flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args(None, contained).never() flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt/lvolume', contained).and_return( ( Pattern('/mnt/lvolume', source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt/other', contained).and_return(()) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt/notlvm', contained).never() assert module.get_logical_volumes( 'lsblk', patterns=( Pattern('/mnt/lvolume', source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG), ), ) == ( module.Logical_volume( name='vgroup-lvolume', device_path='/dev/mapper/vgroup-lvolume', mount_point='/mnt/lvolume', contained_patterns=( Pattern('/mnt/lvolume', source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', source=Pattern_source.CONFIG), ), ), ) def test_get_logical_volumes_skips_non_root_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "blockdevices": [ { "name": "vgroup-lvolume", "path": "/dev/mapper/vgroup-lvolume", "mountpoint": "/mnt/lvolume", "type": "lvm" } ] } ''' ) contained = { Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG), } flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args(None, contained).never() flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt/lvolume', contained).and_return( ( Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG), Pattern('/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG), ) ) assert ( module.get_logical_volumes( 'lsblk', patterns=( Pattern('/mnt/lvolume', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG), Pattern( '/mnt/lvolume/subdir', type=Pattern_type.EXCLUDE, source=Pattern_source.CONFIG ), ), ) == () ) def test_get_logical_volumes_skips_non_config_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "blockdevices": [ { "name": "vgroup-lvolume", "path": "/dev/mapper/vgroup-lvolume", "mountpoint": "/mnt/lvolume", "type": "lvm" } ] } ''' ) contained = { Pattern('/mnt/lvolume', source=Pattern_source.HOOK), Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK), } flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args(None, contained).never() flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/mnt/lvolume', contained).and_return( ( Pattern('/mnt/lvolume', source=Pattern_source.HOOK), Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK), ) ) assert ( module.get_logical_volumes( 'lsblk', patterns=( Pattern('/mnt/lvolume', source=Pattern_source.HOOK), Pattern('/mnt/lvolume/subdir', source=Pattern_source.HOOK), ), ) == () ) def test_get_logical_volumes_with_invalid_lsblk_json_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('{') flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).never() with pytest.raises(ValueError): module.get_logical_volumes( 'lsblk', patterns=(Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir')) ) def test_get_logical_volumes_with_lsblk_json_missing_keys_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('{"block_devices": [{}]}') flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).never() with pytest.raises(ValueError): module.get_logical_volumes( 'lsblk', patterns=(Pattern('/mnt/lvolume'), Pattern('/mnt/lvolume/subdir')) ) def test_snapshot_logical_volume_with_percentage_snapshot_name_uses_lvcreate_extents_flag(): flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( ( 'lvcreate', '--snapshot', '--extents', '10%ORIGIN', '--permission', 'r', '--name', 'snap', '/dev/snap', ), output_log_level=object, ) module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10%ORIGIN') def test_snapshot_logical_volume_with_non_percentage_snapshot_name_uses_lvcreate_size_flag(): flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( ( 'lvcreate', '--snapshot', '--size', '10TB', '--permission', 'r', '--name', 'snap', '/dev/snap', ), output_log_level=object, ) module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10TB') @pytest.mark.parametrize( 'pattern,expected_pattern', ( ( Pattern('/foo/bar/baz'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo/bar/baz'), ), (Pattern('/foo/bar'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo/bar')), ( Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '^/run/borgmatic/lvm_snapshots/b33f/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), ( Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '/run/borgmatic/lvm_snapshots/b33f/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), (Pattern('/foo'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./foo')), (Pattern('/'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./')), ), ) def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path( pattern, expected_pattern ): flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) assert ( module.make_borg_snapshot_pattern( pattern, flexmock(mount_point='/something'), '/run/borgmatic' ) == expected_pattern ) def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns(): config = {'lvm': {}} patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] logical_volumes = ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE ).once() flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume2_borgmatic-1234', '/dev/lvolume2', module.DEFAULT_SNAPSHOT_SIZE ).once() flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),) ) flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume2_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),) ) flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).once() flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2')) assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'), ] def test_dump_data_sources_with_no_logical_volumes_skips_snapshots(): config = {'lvm': {}} patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] flexmock(module).should_receive('get_logical_volumes').and_return(()) flexmock(module).should_receive('snapshot_logical_volume').never() flexmock(module).should_receive('mount_snapshot').never() assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] def test_dump_data_sources_uses_snapshot_size_for_snapshot(): config = {'lvm': {'snapshot_size': '1000PB'}} patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] logical_volumes = ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', '1000PB', ).once() flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume2_borgmatic-1234', '/dev/lvolume2', '1000PB', ).once() flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),) ) flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume2_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),) ) flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).once() flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2')) assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'), ] def test_dump_data_sources_uses_custom_commands(): config = { 'lvm': { 'lsblk_command': '/usr/local/bin/lsblk', 'lvcreate_command': '/usr/local/bin/lvcreate', 'lvs_command': '/usr/local/bin/lvs', 'mount_command': '/usr/local/bin/mount', }, } patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] logical_volumes = ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').with_args( '/usr/local/bin/lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE, ).once() flexmock(module).should_receive('snapshot_logical_volume').with_args( '/usr/local/bin/lvcreate', 'lvolume2_borgmatic-1234', '/dev/lvolume2', module.DEFAULT_SNAPSHOT_SIZE, ).once() flexmock(module).should_receive('get_snapshots').with_args( '/usr/local/bin/lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),) ) flexmock(module).should_receive('get_snapshots').with_args( '/usr/local/bin/lvs', snapshot_name='lvolume2_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),) ) flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) flexmock(module).should_receive('mount_snapshot').with_args( '/usr/local/bin/mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).once() flexmock(module).should_receive('mount_snapshot').with_args( '/usr/local/bin/mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2')) assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'), ] def test_dump_data_sources_with_dry_run_skips_snapshots_and_does_not_touch_patterns(): config = {'lvm': {}} patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').never() flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),) ) flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume2_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),) ) flexmock(module).should_receive('mount_snapshot').never() assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=True, ) == [] ) assert patterns == [ Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2'), ] def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns(): config = {'lvm': {}} patterns = [Pattern('/hmm')] logical_volumes = ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) flexmock(module).should_receive('get_logical_volumes').and_return(logical_volumes) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE ).once() flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume2_borgmatic-1234', '/dev/lvolume2', module.DEFAULT_SNAPSHOT_SIZE ).once() flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),) ) flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume2_borgmatic-1234' ).and_return( (module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),) ) flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume1_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).once() flexmock(module).should_receive('mount_snapshot').with_args( 'mount', '/dev/lvolume2_snap', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume1/subdir'), logical_volumes[0], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir')) flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/lvolume2'), logical_volumes[1], '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2')) assert ( module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [ Pattern('/hmm'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'), Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'), ] def test_dump_data_sources_with_missing_snapshot_errors(): config = {'lvm': {}} patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')] flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume1_borgmatic-1234', '/dev/lvolume1', module.DEFAULT_SNAPSHOT_SIZE ).once() flexmock(module).should_receive('snapshot_logical_volume').with_args( 'lvcreate', 'lvolume2_borgmatic-1234', '/dev/lvolume2', module.DEFAULT_SNAPSHOT_SIZE ).never() flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume1_borgmatic-1234' ).and_return(()) flexmock(module).should_receive('get_snapshots').with_args( 'lvs', snapshot_name='lvolume2_borgmatic-1234' ).never() flexmock(module).should_receive('mount_snapshot').never() with pytest.raises(ValueError): module.dump_data_sources( hook_config=config['lvm'], config=config, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) def test_get_snapshots_lists_all_snapshots(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "report": [ { "lv": [ {"lv_name": "snap1", "lv_path": "/dev/snap1"}, {"lv_name": "snap2", "lv_path": "/dev/snap2"} ] } ], "log": [ ] } ''' ) assert module.get_snapshots('lvs') == ( module.Snapshot('snap1', '/dev/snap1'), module.Snapshot('snap2', '/dev/snap2'), ) def test_get_snapshots_with_snapshot_name_lists_just_that_snapshot(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "report": [ { "lv": [ {"lv_name": "snap1", "lv_path": "/dev/snap1"}, {"lv_name": "snap2", "lv_path": "/dev/snap2"} ] } ], "log": [ ] } ''' ) assert module.get_snapshots('lvs', snapshot_name='snap2') == ( module.Snapshot('snap2', '/dev/snap2'), ) def test_get_snapshots_with_invalid_lvs_json_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return('{') with pytest.raises(ValueError): assert module.get_snapshots('lvs') def test_get_snapshots_with_lvs_json_missing_report_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "report": [], "log": [ ] } ''' ) with pytest.raises(ValueError): assert module.get_snapshots('lvs') def test_get_snapshots_with_lvs_json_missing_keys_errors(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( ''' { "report": [ { "lv": [ {} ] } ], "log": [ ] } ''' ) with pytest.raises(ValueError): assert module.get_snapshots('lvs') def test_remove_data_source_dumps_unmounts_and_remove_snapshots(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).once() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), module.Snapshot('nonborgmatic', '/dev/nonborgmatic'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() flexmock(module).should_receive('remove_snapshot').with_args( 'nonborgmatic', '/dev/nonborgmatic' ).never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_lvm_configuration(): flexmock(module).should_receive('get_logical_volumes').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=None, config={'source_directories': '/mnt/lvolume'}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_lsblk_command(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_raise(FileNotFoundError) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_lsblk_command_error(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_raise( module.subprocess.CalledProcessError(1, 'wtf') ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_missing_snapshot_directory_skips_unmount(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f' ).and_return(False) flexmock(module.shutil).should_receive('rmtree').never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_missing_snapshot_mount_path_skips_unmount(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).and_return(False) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).never() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_empty_snapshot_mount_path_skips_unmount(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).and_return(True) flexmock(module.os).should_receive('listdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).and_return([]) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).and_return(True) flexmock(module.os).should_receive('listdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).never() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_successful_mount_point_removal_skips_unmount(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1' ).and_return(True).and_return(False) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2' ).and_return(True).and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).never() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_umount_command(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).and_raise(FileNotFoundError) flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).never() flexmock(module).should_receive('get_snapshots').never() flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_swallows_umount_command_error(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).and_raise(module.subprocess.CalledProcessError(1, 'wtf')) flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), ), ) flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_lvs_command(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).once() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_raise(FileNotFoundError) flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_lvs_command_error(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume1', ).once() flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/lvm_snapshots/b33f/mnt/lvolume2', ).once() flexmock(module).should_receive('get_snapshots').and_raise( module.subprocess.CalledProcessError(1, 'wtf') ) flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_with_dry_run_skips_snapshot_unmount_and_delete(): config = {'lvm': {}} flexmock(module).should_receive('get_logical_volumes').and_return( ( module.Logical_volume( name='lvolume1', device_path='/dev/lvolume1', mount_point='/mnt/lvolume1', contained_patterns=(Pattern('/mnt/lvolume1/subdir'),), ), module.Logical_volume( name='lvolume2', device_path='/dev/lvolume2', mount_point='/mnt/lvolume2', contained_patterns=(Pattern('/mnt/lvolume2'),), ), ) ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree').never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_snapshots').and_return( ( module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), module.Snapshot('nonborgmatic', '/dev/nonborgmatic'), ), ).once() flexmock(module).should_receive('remove_snapshot').never() module.remove_data_source_dumps( hook_config=config['lvm'], config=config, borgmatic_runtime_directory='/run/borgmatic', dry_run=True, ) borgmatic/tests/unit/hooks/data_source/test_mariadb.py000066400000000000000000001320271476361726000236130ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks.data_source import mariadb as module def test_parse_extra_options_passes_through_empty_options(): assert module.parse_extra_options('') == ((), None) def test_parse_extra_options_with_defaults_extra_file_removes_and_and_parses_out_filename(): assert module.parse_extra_options('--defaults-extra-file=extra.cnf --skip-ssl') == ( ('--skip-ssl',), 'extra.cnf', ) def test_parse_extra_options_without_defaults_extra_file_passes_through_options(): assert module.parse_extra_options('--skip-ssl --and=stuff') == ( ('--skip-ssl', '--and=stuff'), None, ) def test_make_defaults_file_pipe_without_username_or_password_bails(): flexmock(module.os).should_receive('pipe').never() assert module.make_defaults_file_options(username=None, password=None) == () def test_make_defaults_file_option_with_username_and_password_writes_them_to_file_descriptor(): read_descriptor = 99 write_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor) flexmock(module.os).should_receive('write').with_args( write_descriptor, b'[client]\nuser=root\npassword="trustsome1"' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_defaults_file_options(username='root', password='trustsome1') == ( '--defaults-extra-file=/dev/fd/99', ) def test_make_defaults_file_escapes_password_containing_backslash(): read_descriptor = 99 write_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor) flexmock(module.os).should_receive('write').with_args( write_descriptor, b'[client]\nuser=root\n' + br'password="trust\\nsome1"' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_defaults_file_options(username='root', password=r'trust\nsome1') == ( '--defaults-extra-file=/dev/fd/99', ) def test_make_defaults_file_pipe_with_only_username_writes_it_to_file_descriptor(): read_descriptor = 99 write_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor) flexmock(module.os).should_receive('write').with_args( write_descriptor, b'[client]\nuser=root' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_defaults_file_options(username='root', password=None) == ( '--defaults-extra-file=/dev/fd/99', ) def test_make_defaults_file_pipe_with_only_password_writes_it_to_file_descriptor(): read_descriptor = 99 write_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor) flexmock(module.os).should_receive('write').with_args( write_descriptor, b'[client]\npassword="trustsome1"' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_defaults_file_options(username=None, password='trustsome1') == ( '--defaults-extra-file=/dev/fd/99', ) def test_make_defaults_file_option_with_defaults_extra_filename_includes_it_in_file_descriptor(): read_descriptor = 99 write_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor) flexmock(module.os).should_receive('write').with_args( write_descriptor, b'!include extra.cnf\n[client]\nuser=root\npassword="trustsome1"' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_defaults_file_options( username='root', password='trustsome1', defaults_extra_filename='extra.cnf' ) == ('--defaults-extra-file=/dev/fd/99',) def test_make_defaults_file_option_with_only_defaults_extra_filename_uses_it_instead_of_file_descriptor(): flexmock(module.os).should_receive('pipe').never() assert module.make_defaults_file_options( username=None, password=None, defaults_extra_filename='extra.cnf' ) == ('--defaults-extra-file=extra.cnf',) def test_database_names_to_dump_passes_through_name(): environment = flexmock() names = module.database_names_to_dump( {'name': 'foo'}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo',) def test_database_names_to_dump_bails_for_dry_run(): environment = flexmock() flexmock(module).should_receive('execute_command_and_capture_output').never() names = module.database_names_to_dump( {'name': 'all'}, {}, 'root', 'trustsome1', environment, dry_run=True ) assert names == () def test_database_names_to_dump_queries_mariadb_for_database_names(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all'}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_database_names_to_dump_runs_mariadb_with_tls(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all', 'tls': True}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_database_names_to_dump_runs_mariadb_without_tls(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all', 'tls': False}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_use_streaming_true_for_any_databases(): assert module.use_streaming( databases=[flexmock(), flexmock()], config=flexmock(), ) def test_use_streaming_false_for_no_databases(): assert not module.use_streaming(databases=[], config=flexmock()) def test_dump_data_sources_dumps_each_database(): databases = [{'name': 'foo'}, {'name': 'bar'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_dump_command').with_args( database={'name': name}, config={}, username=None, password=None, dump_path=object, database_names=(name,), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_dumps_with_password(): database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'} process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module).should_receive('execute_dump_command').with_args( database=database, config={}, username='root', password='trustsome1', dump_path=object, database_names=('foo',), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources( [database], {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_dumps_all_databases_at_once(): databases = [{'name': 'all'}] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar')) flexmock(module).should_receive('execute_dump_command').with_args( database={'name': 'all'}, config={}, username=None, password=None, dump_path=object, database_names=('foo', 'bar'), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_dumps_all_databases_separately_when_format_configured(): databases = [{'name': 'all', 'format': 'sql'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar')) for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_dump_command').with_args( database={'name': name, 'format': 'sql'}, config={}, username=None, password=None, dump_path=object, database_names=(name,), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_database_names_to_dump_runs_mariadb_with_list_options(): database = {'name': 'all', 'list_options': '--defaults-extra-file=mariadb.cnf --skip-ssl'} flexmock(module).should_receive('parse_extra_options').and_return( ('--skip-ssl',), 'mariadb.cnf' ) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', 'mariadb.cnf' ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=None, ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, {}, 'root', 'trustsome1', None, '') == ( 'foo', 'bar', ) def test_database_names_to_dump_runs_non_default_mariadb_with_list_options(): database = { 'name': 'all', 'list_options': '--defaults-extra-file=mariadb.cnf --skip-ssl', 'mariadb_command': 'custom_mariadb', } flexmock(module).should_receive('parse_extra_options').and_return( ('--skip-ssl',), 'mariadb.cnf' ) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', 'mariadb.cnf' ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( environment=None, full_command=( 'custom_mariadb', # Custom MariaDB command '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, {}, 'root', 'trustsome1', None, '') == ( 'foo', 'bar', ) def test_execute_dump_command_runs_mariadb_dump(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'add_drop_database': False}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_with_hostname_and_port(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_with_tls(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--ssl', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'tls': True}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_without_tls(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--skip-ssl', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'tls': False}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_with_username_and_password(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment={}, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment={}, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mariadb_dump_with_options(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return(('--stuff=such',), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--defaults-extra-file=/dev/fd/99', '--stuff=such', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'options': '--stuff=such'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_non_default_mariadb_dump_with_options(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return(('--stuff=such',), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'custom_mariadb_dump', # Custom MariaDB dump command '--defaults-extra-file=/dev/fd/99', '--stuff=such', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={ 'name': 'foo', 'mariadb_dump_command': 'custom_mariadb_dump', 'options': '--stuff=such', }, # Custom MariaDB dump command specified config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_with_duplicate_dump_skips_mariadb_dump(): flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=True, dry_run_label='SO DRY', ) is None ) def test_execute_dump_command_with_dry_run_skips_mariadb_dump(): flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=True, dry_run_label='SO DRY', ) is None ) def test_dump_data_sources_errors_for_missing_all_databases(): databases = [{'name': 'all'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module).should_receive('database_names_to_dump').and_return(()) with pytest.raises(ValueError): assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run(): databases = [{'name': 'all'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module).should_receive('database_names_to_dump').and_return(()) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] ) def test_restore_data_source_dump_runs_mariadb_to_restore(): hook_config = [{'name': 'foo'}, {'name': 'bar'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mariadb', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mariadb_with_options(): hook_config = [{'name': 'foo', 'restore_options': '--harder'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return(('--harder',), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mariadb', '--harder', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_non_default_mariadb_with_options(): hook_config = [ {'name': 'foo', 'restore_options': '--harder', 'mariadb_command': 'custom_mariadb'} ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return(('--harder',), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('custom_mariadb', '--harder', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mariadb_with_hostname_and_port(): hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--batch', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mariadb_with_tls(): hook_config = [{'name': 'foo', 'tls': True}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--batch', '--ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mariadb_without_tls(): hook_config = [{'name': 'foo', 'tls': False}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--batch', '--skip-ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mariadb_with_username_and_password(): hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'root', 'trustsome1', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mariadb', '--defaults-extra-file=/dev/fd/99', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'root', 'password': 'trustsome1', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreusername', 'restore_password': 'restorepassword', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'cliusername', 'clipassword', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--batch', '--host', 'clihost', '--port', 'cliport', '--protocol', 'tcp', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'root', 'password': 'trustsome1', 'hostname': 'dbhost', 'port': 'dbport', 'tls': True, 'restore_username': 'restoreuser', 'restore_password': 'restorepass', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_tls': False, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( 'restoreuser', 'restorepass', None ).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--defaults-extra-file=/dev/fd/99', '--batch', '--host', 'restorehost', '--port', 'restoreport', '--protocol', 'tcp', '--skip-ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo'}] flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('parse_extra_options').and_return((), None) flexmock(module).should_receive('make_defaults_file_options').with_args( None, None, None ).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) borgmatic/tests/unit/hooks/data_source/test_mongodb.py000066400000000000000000000532351476361726000236440ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks.data_source import mongodb as module def test_use_streaming_true_for_any_non_directory_format_databases(): assert module.use_streaming( databases=[{'format': 'stuff'}, {'format': 'directory'}, {}], config=flexmock(), ) def test_use_streaming_false_for_all_directory_format_databases(): assert not module.use_streaming( databases=[{'format': 'directory'}, {'format': 'directory'}], config=flexmock(), ) def test_use_streaming_false_for_no_databases(): assert not module.use_streaming(databases=[], config=flexmock()) def test_dump_data_sources_runs_mongodump_for_each_database(): databases = [{'name': 'foo'}, {'name': 'bar'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ).and_return('databases/localhost/bar') flexmock(module.dump).should_receive('create_named_pipe_for_dump') for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_command').with_args( ('mongodump', '--db', name, '--archive', '>', f'databases/localhost/{name}'), shell=True, run_to_completion=False, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_with_dry_run_skips_mongodump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ).and_return('databases/localhost/bar') flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] ) def test_dump_data_sources_runs_mongodump_with_hostname_and_port(): databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/database.example.org/foo' ) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mongodump', '--host', 'database.example.org', '--port', '5433', '--db', 'foo', '--archive', '>', 'databases/database.example.org/foo', ), shell=True, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_mongodump_with_username_and_password(): databases = [ { 'name': 'foo', 'username': 'mongo', 'password': 'trustsome1', 'authentication_database': 'admin', } ] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_password_config_file').with_args('trustsome1').and_return( '/dev/fd/99' ) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mongodump', '--username', 'mongo', '--config', '/dev/fd/99', '--authenticationDatabase', 'admin', '--db', 'foo', '--archive', '>', 'databases/localhost/foo', ), shell=True, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_mongodump_with_directory_format(): databases = [{'name': 'foo', 'format': 'directory'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.dump).should_receive('create_parent_directory_for_dump') flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').with_args( ('mongodump', '--out', 'databases/localhost/foo', '--db', 'foo'), shell=True, ).and_return(flexmock()).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [] ) def test_dump_data_sources_runs_mongodump_with_options(): databases = [{'name': 'foo', 'options': '--stuff=such'}] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mongodump', '--db', 'foo', '--stuff=such', '--archive', '>', 'databases/localhost/foo', ), shell=True, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_mongodumpall_for_all_databases(): databases = [{'name': 'all'}] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ('mongodump', '--archive', '>', 'databases/localhost/all'), shell=True, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_make_password_config_file_writes_password_to_pipe(): read_file_descriptor = 99 write_file_descriptor = flexmock() flexmock(module.os).should_receive('pipe').and_return( (read_file_descriptor, write_file_descriptor) ) flexmock(module.os).should_receive('write').with_args( write_file_descriptor, b'password: trustsome1' ).once() flexmock(module.os).should_receive('close') flexmock(module.os).should_receive('set_inheritable') assert module.make_password_config_file('trustsome1') == '/dev/fd/99' def test_build_dump_command_with_username_injection_attack_gets_escaped(): database = {'name': 'test', 'username': 'bob; naughty-command'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) command = module.build_dump_command(database, {}, dump_filename='test', dump_format='archive') assert "'bob; naughty-command'" in command def test_restore_data_source_dump_runs_mongorestore(): hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( ['mongorestore', '--archive', '--drop'], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port(): hook_config = [ {'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'schemas': None} ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--host', 'database.example.org', '--port', '5433', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mongorestore_with_username_and_password(): hook_config = [ { 'name': 'foo', 'username': 'mongo', 'password': 'trustsome1', 'authentication_database': 'admin', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_password_config_file').with_args('trustsome1').and_return( '/dev/fd/99' ) flexmock(module).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--username', 'mongo', '--config', '/dev/fd/99', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'mongo', 'password': 'trustsome1', 'authentication_database': 'admin', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreusername', 'restore_password': 'restorepassword', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_password_config_file').with_args( 'clipassword' ).and_return('/dev/fd/99') flexmock(module).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--host', 'clihost', '--port', 'cliport', '--username', 'cliusername', '--config', '/dev/fd/99', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'mongo', 'password': 'trustsome1', 'authentication_database': 'admin', 'schemas': None, 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreuser', 'restore_password': 'restorepass', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_password_config_file').with_args( 'restorepass' ).and_return('/dev/fd/99') flexmock(module).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--host', 'restorehost', '--port', 'restoreport', '--username', 'restoreuser', '--config', '/dev/fd/99', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mongorestore_with_options(): hook_config = [{'name': 'foo', 'restore_options': '--harder', 'schemas': None}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( ['mongorestore', '--archive', '--drop', '--harder'], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_databases_dump_runs_mongorestore_with_schemas(): hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--nsInclude', 'bar', '--nsInclude', 'baz', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_psql_for_all_database_dump(): hook_config = [{'name': 'all', 'schemas': None}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( ['mongorestore', '--archive'], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo', 'schemas': None}] flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_extract_process_restores_from_disk(): hook_config = [{'name': 'foo', 'format': 'directory', 'schemas': None}] flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_with_processes').with_args( ['mongorestore', '--dir', '/dump/path', '--drop'], processes=[], output_log_level=logging.DEBUG, input_file=None, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) borgmatic/tests/unit/hooks/data_source/test_mysql.py000066400000000000000000001252171476361726000233640ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks.data_source import mysql as module def test_database_names_to_dump_passes_through_name(): environment = flexmock() names = module.database_names_to_dump( {'name': 'foo'}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo',) def test_database_names_to_dump_bails_for_dry_run(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').never() names = module.database_names_to_dump( {'name': 'all'}, {}, 'root', 'trustsome1', environment, dry_run=True ) assert names == () def test_database_names_to_dump_queries_mysql_for_database_names(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all'}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_database_names_to_dump_runs_mysql_with_tls(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all', 'tls': True}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_database_names_to_dump_runs_mysql_without_tls(): environment = flexmock() flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all', 'tls': False}, {}, 'root', 'trustsome1', environment, dry_run=False ) assert names == ('foo', 'bar') def test_use_streaming_true_for_any_databases(): assert module.use_streaming( databases=[flexmock(), flexmock()], config=flexmock(), ) def test_use_streaming_false_for_no_databases(): assert not module.use_streaming(databases=[], config=flexmock()) def test_dump_data_sources_dumps_each_database(): databases = [{'name': 'foo'}, {'name': 'bar'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_dump_command').with_args( database={'name': name}, config={}, username=None, password=None, dump_path=object, database_names=(name,), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_dumps_with_password(): database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'} process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module).should_receive('execute_dump_command').with_args( database=database, config={}, username='root', password='trustsome1', dump_path=object, database_names=('foo',), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources( [database], {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_dumps_all_databases_at_once(): databases = [{'name': 'all'}] process = flexmock() flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar')) flexmock(module).should_receive('execute_dump_command').with_args( database={'name': 'all'}, config={}, username=None, password=None, dump_path=object, database_names=('foo', 'bar'), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_dumps_all_databases_separately_when_format_configured(): databases = [{'name': 'all', 'format': 'sql'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_return(None) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar')) for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_dump_command').with_args( database={'name': name, 'format': 'sql'}, config={}, username=None, password=None, dump_path=object, database_names=(name,), environment={'USER': 'root'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_database_names_to_dump_runs_mysql_with_list_options(): database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf --skip-ssl'} flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return(('--skip-ssl',), 'my.cnf') flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', 'my.cnf').and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), environment=None, ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, {}, 'root', 'trustsome1', None, '') == ( 'foo', 'bar', ) def test_database_names_to_dump_runs_non_default_mysql_with_list_options(): database = { 'name': 'all', 'list_options': '--defaults-extra-file=my.cnf --skip-ssl', 'mysql_command': 'custom_mysql', } flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return(('--skip-ssl',), 'my.cnf') flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', 'my.cnf').and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module).should_receive('execute_command_and_capture_output').with_args( environment=None, full_command=( 'custom_mysql', # Custom MySQL command '--defaults-extra-file=/dev/fd/99', '--skip-ssl', '--skip-column-names', '--batch', '--execute', 'show schemas', ), ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, {}, 'root', 'trustsome1', None, '') == ( 'foo', 'bar', ) def test_execute_dump_command_runs_mysqldump(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_without_add_drop_database(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'add_drop_database': False}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_with_hostname_and_port(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_with_tls(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--ssl', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'tls': True}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_without_tls(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--skip-ssl', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'tls': False}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_with_username_and_password(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment={}, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment={}, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_mysqldump_with_options(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return(('--stuff=such',), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--defaults-extra-file=/dev/fd/99', '--stuff=such', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'options': '--stuff=such'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_runs_non_default_mysqldump(): process = flexmock() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'custom_mysqldump', # Custom MySQL dump command '--defaults-extra-file=/dev/fd/99', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={ 'name': 'foo', 'mysql_dump_command': 'custom_mysqldump', }, # Custom MySQL dump command specified config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=False, dry_run_label='', ) == process ) def test_execute_dump_command_with_duplicate_dump_skips_mysqldump(): flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=True, dry_run_label='SO DRY', ) is None ) def test_execute_dump_command_with_dry_run_skips_mysqldump(): flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, config={}, username='root', password='trustsome1', dump_path=flexmock(), database_names=('foo',), environment=None, dry_run=True, dry_run_label='SO DRY', ) is None ) def test_dump_data_sources_errors_for_missing_all_databases(): databases = [{'name': 'all'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module).should_receive('database_names_to_dump').and_return(()) with pytest.raises(ValueError): assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run(): databases = [{'name': 'all'}] flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module).should_receive('database_names_to_dump').and_return(()) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] ) def test_restore_data_source_dump_runs_mysql_to_restore(): hook_config = [{'name': 'foo'}, {'name': 'bar'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mysql', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mysql_with_options(): hook_config = [{'name': 'foo', 'restore_options': '--harder'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return(('--harder',), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mysql', '--harder', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_non_default_mysql_with_options(): hook_config = [{'name': 'foo', 'mysql_command': 'custom_mysql', 'restore_options': '--harder'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return(('--harder',), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('custom_mysql', '--harder', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mysql_with_hostname_and_port(): hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--batch', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mysql_with_tls(): hook_config = [{'name': 'foo', 'tls': True}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--batch', '--ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mysql_without_tls(): hook_config = [{'name': 'foo', 'tls': False}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--batch', '--skip-ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_mysql_with_username_and_password(): hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('root', 'trustsome1', None).and_return(('--defaults-extra-file=/dev/fd/99',)) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mysql', '--defaults-extra-file=/dev/fd/99', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'root', 'password': 'trustsome1', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreusername', 'restore_password': 'restorepassword', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('cliusername', 'clipassword', None).and_return( ('--defaults-extra-file=/dev/fd/99',) ) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--batch', '--host', 'clihost', '--port', 'cliport', '--protocol', 'tcp', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore(): hook_config = [ { 'name': 'foo', 'username': 'root', 'password': 'trustsome1', 'hostname': 'dbhost', 'port': 'dbport', 'tls': True, 'restore_username': 'restoreuser', 'restore_password': 'restorepass', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_tls': False, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args('restoreuser', 'restorepass', None).and_return( ('--defaults-extra-file=/dev/fd/99',) ) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--defaults-extra-file=/dev/fd/99', '--batch', '--host', 'restorehost', '--port', 'restoreport', '--protocol', 'tcp', '--skip-ssl', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'USER': 'root'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo'}] flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'parse_extra_options' ).and_return((), None) flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive( 'make_defaults_file_options' ).with_args(None, None, None).and_return(()) flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) borgmatic/tests/unit/hooks/data_source/test_postgresql.py000066400000000000000000001412721476361726000244210ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks.data_source import postgresql as module def test_make_environment_maps_options_to_environment(): database = { 'name': 'foo', 'password': 'pass', 'ssl_mode': 'require', 'ssl_cert': 'cert.crt', 'ssl_key': 'key.key', 'ssl_root_cert': 'root.crt', 'ssl_crl': 'crl.crl', } expected = { 'USER': 'root', 'PGPASSWORD': 'pass', 'PGSSLMODE': 'require', 'PGSSLCERT': 'cert.crt', 'PGSSLKEY': 'key.key', 'PGSSLROOTCERT': 'root.crt', 'PGSSLCRL': 'crl.crl', } flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) assert module.make_environment(database, {}) == expected def test_make_environment_with_cli_password_sets_correct_password(): database = {'name': 'foo', 'restore_password': 'trustsome1', 'password': 'anotherpassword'} flexmock(module.os).should_receive('environ').and_return({'USER': 'root'}) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) environment = module.make_environment( database, {}, restore_connection_params={'password': 'clipassword'} ) assert environment['PGPASSWORD'] == 'clipassword' def test_make_environment_without_cli_password_or_configured_password_does_not_set_password(): database = {'name': 'foo'} environment = module.make_environment( database, {}, restore_connection_params={'username': 'someone'} ) assert 'PGPASSWORD' not in environment def test_make_environment_without_ssl_mode_does_not_set_ssl_mode(): database = {'name': 'foo'} environment = module.make_environment(database, {}) assert 'PGSSLMODE' not in environment def test_database_names_to_dump_passes_through_individual_database_name(): database = {'name': 'foo'} assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ('foo',) def test_database_names_to_dump_passes_through_individual_database_name_with_format(): database = {'name': 'foo', 'format': 'custom'} assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ('foo',) def test_database_names_to_dump_passes_through_all_without_format(): database = {'name': 'all'} assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ('all',) def test_database_names_to_dump_with_all_and_format_and_dry_run_bails(): database = {'name': 'all', 'format': 'custom'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').never() assert module.database_names_to_dump(database, {}, flexmock(), dry_run=True) == () def test_database_names_to_dump_with_all_and_format_lists_databases(): database = {'name': 'all', 'format': 'custom'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').and_return( 'foo,test,\nbar,test,"stuff and such"' ) assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ( 'foo', 'bar', ) def test_database_names_to_dump_with_all_and_format_lists_databases_with_hostname_and_port(): database = {'name': 'all', 'format': 'custom', 'hostname': 'localhost', 'port': 1234} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--host', 'localhost', '--port', '1234', ), environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ( 'foo', 'bar', ) def test_database_names_to_dump_with_all_and_format_lists_databases_with_username(): database = {'name': 'all', 'format': 'custom', 'username': 'postgres'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--username', 'postgres', ), environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ( 'foo', 'bar', ) def test_database_names_to_dump_with_all_and_format_lists_databases_with_options(): database = {'name': 'all', 'format': 'custom', 'list_options': '--harder'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'), environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ( 'foo', 'bar', ) def test_database_names_to_dump_with_all_and_format_excludes_particular_databases(): database = {'name': 'all', 'format': 'custom'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').and_return( 'foo,test,\ntemplate0,test,blah' ) assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ('foo',) def test_database_names_to_dump_with_all_and_psql_command_uses_custom_command(): database = { 'name': 'all', 'format': 'custom', 'psql_command': 'docker exec --workdir * mycontainer psql', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'docker', 'exec', '--workdir', "'*'", # Should get shell escaped to prevent injection attacks. 'mycontainer', 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', ), environment=object, ).and_return('foo,text').once() assert module.database_names_to_dump(database, {}, flexmock(), dry_run=False) == ('foo',) def test_use_streaming_true_for_any_non_directory_format_databases(): assert module.use_streaming( databases=[{'format': 'stuff'}, {'format': 'directory'}, {}], config=flexmock(), ) def test_use_streaming_false_for_all_directory_format_databases(): assert not module.use_streaming( databases=[{'format': 'directory'}, {'format': 'directory'}], config=flexmock(), ) def test_use_streaming_false_for_no_databases(): assert not module.use_streaming(databases=[], config=flexmock()) def test_dump_data_sources_runs_pg_dump_for_each_database(): databases = [{'name': 'foo'}, {'name': 'bar'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ).and_return('databases/localhost/bar') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') for name, process in zip(('foo', 'bar'), processes): flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'custom', name, '>', f'databases/localhost/{name}', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_raises_when_no_database_names_to_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(()) with pytest.raises(ValueError): module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) def test_dump_data_sources_does_not_raise_when_no_database_names_to_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(()) module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] def test_dump_data_sources_with_duplicate_dump_skips_pg_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ).and_return('databases/localhost/bar') flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [] ) def test_dump_data_sources_with_dry_run_skips_pg_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ).and_return('databases/localhost/bar') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] ) def test_dump_data_sources_runs_pg_dump_with_hostname_and_port(): databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}] process = flexmock() flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/database.example.org/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--host', 'database.example.org', '--port', '5433', '--format', 'custom', 'foo', '>', 'databases/database.example.org/foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_pg_dump_with_username_and_password(): databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}] process = flexmock() flexmock(module).should_receive('make_environment').and_return( {'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'} ) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--username', 'postgres', '--format', 'custom', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_with_username_injection_attack_gets_escaped(): databases = [{'name': 'foo', 'username': 'postgres; naughty-command', 'password': 'trustsome1'}] process = flexmock() flexmock(module).should_receive('make_environment').and_return( {'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'} ) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--username', "'postgres; naughty-command'", '--format', 'custom', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_pg_dump_with_directory_format(): databases = [{'name': 'foo', 'format': 'directory'}] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_parent_directory_for_dump') flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'directory', '--file', 'databases/localhost/foo', 'foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, ).and_return(flexmock()).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [] ) def test_dump_data_sources_runs_pg_dump_with_string_compression(): databases = [{'name': 'foo', 'compression': 'winrar'}] processes = [flexmock()] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'custom', '--compress', 'winrar', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(processes[0]).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_runs_pg_dump_with_integer_compression(): databases = [{'name': 'foo', 'compression': 0}] processes = [flexmock()] flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'custom', '--compress', '0', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(processes[0]).once() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_runs_pg_dump_with_options(): databases = [{'name': 'foo', 'options': '--stuff=such'}] process = flexmock() flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'custom', '--stuff=such', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_pg_dumpall_for_all_databases(): databases = [{'name': 'all'}] process = flexmock() flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('all',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/all' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ('pg_dumpall', '--no-password', '--clean', '--if-exists', '>', 'databases/localhost/all'), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_dump_data_sources_runs_non_default_pg_dump(): databases = [{'name': 'foo', 'pg_dump_command': 'special_pg_dump --compress *'}] process = flexmock() flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path').and_return('') flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)) flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( 'databases/localhost/foo' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'special_pg_dump', '--compress', "'*'", # Should get shell escaped to prevent injection attacks. '--no-password', '--clean', '--if-exists', '--format', 'custom', 'foo', '>', 'databases/localhost/foo', ), shell=True, environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [process] def test_restore_data_source_dump_runs_pg_restore(): hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_pg_restore_with_hostname_and_port(): hook_config = [ {'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'schemas': None} ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '--host', 'database.example.org', '--port', '5433', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--host', 'database.example.org', '--port', '5433', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_pg_restore_with_username_and_password(): hook_config = [ {'name': 'foo', 'username': 'postgres', 'password': 'trustsome1', 'schemas': None} ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return( {'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'} ) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '--username', 'postgres', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--username', 'postgres', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore(): hook_config = [ { 'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'username': 'postgres', 'password': 'trustsome1', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreusername', 'restore_password': 'restorepassword', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return( {'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'} ) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '--host', 'clihost', '--port', 'cliport', '--username', 'cliusername', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--host', 'clihost', '--port', 'cliport', '--username', 'cliusername', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore(): hook_config = [ { 'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'username': 'postgres', 'password': 'trustsome1', 'schemas': None, 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', 'restore_username': 'restoreusername', 'restore_password': 'restorepassword', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return( {'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'} ) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '--host', 'restorehost', '--port', 'restoreport', '--username', 'restoreusername', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--host', 'restorehost', '--port', 'restoreport', '--username', 'restoreusername', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_pg_restore_with_options(): hook_config = [ { 'name': 'foo', 'restore_options': '--harder', 'analyze_options': '--smarter', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '--harder', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--smarter', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_psql_for_all_database_dump(): hook_config = [{'name': 'all', 'schemas': None}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'psql', '--no-password', '--no-psqlrc', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ('psql', '--no-password', '--no-psqlrc', '--quiet', '--command', 'ANALYZE'), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'all'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_psql_for_plain_database_dump(): hook_config = [{'name': 'foo', 'format': 'plain', 'schemas': None}] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ('psql', '--no-password', '--no-psqlrc', '--dbname', 'foo'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_runs_non_default_pg_restore_and_psql(): hook_config = [ { 'name': 'foo', 'pg_restore_command': 'docker exec --workdir * mycontainer pg_restore', 'psql_command': 'docker exec --workdir * mycontainer psql', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'docker', 'exec', '--workdir', "'*'", # Should get shell escaped to prevent injection attacks. 'mycontainer', 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'docker', 'exec', '--workdir', "'*'", # Should get shell escaped to prevent injection attacks. 'mycontainer', 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo', 'schemas': None}] flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename') flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_extract_process_restores_from_disk(): hook_config = [{'name': 'foo', 'schemas': None}] flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '/dump/path', ), processes=[], output_log_level=logging.DEBUG, input_file=None, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'foo'}, dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_schemas_restores_schemas(): hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}] flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'}) flexmock(module).should_receive('make_dump_path') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path') flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'pg_restore', '--no-password', '--if-exists', '--exit-on-error', '--clean', '--dbname', 'foo', '/dump/path', '--schema', 'bar', '--schema', 'baz', ), processes=[], output_log_level=logging.DEBUG, input_file=None, environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, borgmatic_runtime_directory='/run/borgmatic', ) borgmatic/tests/unit/hooks/data_source/test_snapshot.py000066400000000000000000000026211476361726000240470ustar00rootroot00000000000000from borgmatic.borg.pattern import Pattern from borgmatic.hooks.data_source import snapshot as module def test_get_contained_patterns_without_candidates_returns_empty(): assert module.get_contained_patterns('/mnt', {}) == () def test_get_contained_patterns_with_self_candidate_returns_self(): candidates = {Pattern('/foo'), Pattern('/mnt'), Pattern('/bar')} assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt'),) assert candidates == {Pattern('/foo'), Pattern('/bar')} def test_get_contained_patterns_with_self_candidate_and_caret_prefix_returns_self(): candidates = {Pattern('^/foo'), Pattern('^/mnt'), Pattern('^/bar')} assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt'),) assert candidates == {Pattern('^/foo'), Pattern('^/bar')} def test_get_contained_patterns_with_child_candidate_returns_child(): candidates = {Pattern('/foo'), Pattern('/mnt/subdir'), Pattern('/bar')} assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/subdir'),) assert candidates == {Pattern('/foo'), Pattern('/bar')} def test_get_contained_patterns_with_grandchild_candidate_returns_child(): candidates = {Pattern('/foo'), Pattern('/mnt/sub/dir'), Pattern('/bar')} assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/sub/dir'),) assert candidates == {Pattern('/foo'), Pattern('/bar')} borgmatic/tests/unit/hooks/data_source/test_sqlite.py000066400000000000000000000223421476361726000235130ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks.data_source import sqlite as module def test_use_streaming_true_for_any_databases(): assert module.use_streaming( databases=[flexmock(), flexmock()], config=flexmock(), ) def test_use_streaming_false_for_no_databases(): assert not module.use_streaming(databases=[], config=flexmock()) def test_dump_data_sources_logs_and_skips_if_dump_already_exists(): databases = [{'path': '/path/to/database', 'name': 'database'}] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic/database' ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == [] ) def test_dump_data_sources_dumps_each_database(): databases = [ {'path': '/path/to/database1', 'name': 'database1'}, {'path': '/path/to/database2', 'name': 'database2'}, ] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]).and_return( processes[1] ) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_with_path_injection_attack_gets_escaped(): databases = [ {'path': '/path/to/database1; naughty-command', 'name': 'database1'}, ] processes = [flexmock()] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'sqlite3', "'/path/to/database1; naughty-command'", '.dump', '>', '/run/borgmatic/database', ), shell=True, run_to_completion=False, ).and_return(processes[0]) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_with_non_existent_path_warns_and_dumps_database(): databases = [ {'path': '/path/to/database1', 'name': 'database1'}, ] processes = [flexmock()] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.logger).should_receive('warning').once() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_with_name_all_warns_and_dumps_all_databases(): databases = [ {'path': '/path/to/database1', 'name': 'all'}, ] processes = [flexmock()] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.logger).should_receive( 'warning' ).twice() # once for the name=all, once for the non-existent path flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]) assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=False, ) == processes ) def test_dump_data_sources_does_not_dump_if_dry_run(): databases = [{'path': '/path/to/database', 'name': 'database'}] flexmock(module).should_receive('make_dump_path').and_return('/run/borgmatic') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/run/borgmatic' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.dump_data_sources( databases, {}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=[], dry_run=True, ) == [] ) def test_restore_data_source_dump_restores_database(): hook_config = [{'path': '/path/to/database', 'name': 'database'}, {'name': 'other'}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'sqlite3', '/path/to/database', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() flexmock(module.os).should_receive('remove').once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={'restore_path': None}, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore(): hook_config = [ {'path': '/path/to/database', 'name': 'database', 'restore_path': 'config/path/to/database'} ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'sqlite3', 'cli/path/to/database', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() flexmock(module.os).should_receive('remove').once() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'database'}, dry_run=False, extract_process=extract_process, connection_params={'restore_path': 'cli/path/to/database'}, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore(): hook_config = [ {'path': '/path/to/database', 'name': 'database', 'restore_path': 'config/path/to/database'} ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'sqlite3', 'config/path/to/database', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() flexmock(module.os).should_receive('remove').once() module.restore_data_source_dump( hook_config, {}, data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={'restore_path': None}, borgmatic_runtime_directory='/run/borgmatic', ) def test_restore_data_source_dump_does_not_restore_database_if_dry_run(): hook_config = [{'path': '/path/to/database', 'name': 'database'}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').never() flexmock(module.os).should_receive('remove').never() module.restore_data_source_dump( hook_config, {}, data_source={'name': 'database'}, dry_run=True, extract_process=extract_process, connection_params={'restore_path': None}, borgmatic_runtime_directory='/run/borgmatic', ) borgmatic/tests/unit/hooks/data_source/test_zfs.py000066400000000000000000000720071476361726000230170ustar00rootroot00000000000000import os import pytest from flexmock import flexmock from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Pattern_type from borgmatic.hooks.data_source import zfs as module def test_get_datasets_to_backup_filters_datasets_by_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset\t/dataset\ton\t-\nother\t/other\ton\t-', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/dataset', object).and_return( ( Pattern( '/dataset', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/other', object).and_return(()) assert module.get_datasets_to_backup( 'zfs', patterns=( Pattern( '/foo', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), Pattern( '/dataset', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), Pattern( '/bar', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), ), ) == ( module.Dataset( name='dataset', mount_point='/dataset', contained_patterns=( Pattern( '/dataset', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), ), ), ) def test_get_datasets_to_backup_skips_non_root_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset\t/dataset\ton\t-\nother\t/other\ton\t-', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/dataset', object).and_return( ( Pattern( '/dataset', Pattern_type.EXCLUDE, source=Pattern_source.CONFIG, ), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/other', object).and_return(()) assert ( module.get_datasets_to_backup( 'zfs', patterns=( Pattern( '/foo', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), Pattern( '/dataset', Pattern_type.EXCLUDE, source=Pattern_source.CONFIG, ), Pattern( '/bar', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), ), ) == () ) def test_get_datasets_to_backup_skips_non_config_patterns(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset\t/dataset\ton\t-\nother\t/other\ton\t-', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/dataset', object).and_return( ( Pattern( '/dataset', Pattern_type.ROOT, source=Pattern_source.HOOK, ), ) ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/other', object).and_return(()) assert ( module.get_datasets_to_backup( 'zfs', patterns=( Pattern( '/foo', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), Pattern( '/dataset', Pattern_type.ROOT, source=Pattern_source.HOOK, ), Pattern( '/bar', Pattern_type.ROOT, source=Pattern_source.CONFIG, ), ), ) == () ) def test_get_datasets_to_backup_filters_datasets_by_user_property(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset\t/dataset\ton\tauto\nother\t/other\ton\t-', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/dataset', object).and_return(()) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/other', object).and_return(()) assert module.get_datasets_to_backup( 'zfs', patterns=(Pattern('/foo'), Pattern('/bar')), ) == ( module.Dataset( name='dataset', mount_point='/dataset', auto_backup=True, contained_patterns=(Pattern('/dataset', source=Pattern_source.HOOK),), ), ) def test_get_datasets_to_backup_filters_datasets_by_canmount_property(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset\t/dataset\toff\t-\nother\t/other\ton\t-', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/dataset', object).and_return((Pattern('/dataset'),)) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).with_args('/other', object).and_return(()) assert ( module.get_datasets_to_backup( 'zfs', patterns=( Pattern('/foo'), Pattern('/dataset'), Pattern('/bar'), ), ) == () ) def test_get_datasets_to_backup_with_invalid_list_output_raises(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).never() with pytest.raises(ValueError, match='zfs'): module.get_datasets_to_backup('zfs', patterns=(Pattern('/foo'), Pattern('/bar'))) def test_get_all_dataset_mount_points_omits_none(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( '/dataset\nnone\n/other', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).and_return((Pattern('/dataset'),)) assert module.get_all_dataset_mount_points('zfs') == ( ('/dataset'), ('/other'), ) def test_get_all_dataset_mount_points_omits_duplicates(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( '/dataset\n/other\n/dataset\n/other', ) flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive( 'get_contained_patterns' ).and_return((Pattern('/dataset'),)) assert module.get_all_dataset_mount_points('zfs') == ( ('/dataset'), ('/other'), ) @pytest.mark.parametrize( 'pattern,expected_pattern', ( ( Pattern('/foo/bar/baz'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo/bar/baz'), ), (Pattern('/foo/bar'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo/bar')), ( Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '^/run/borgmatic/zfs_snapshots/b33f/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), ( Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION), Pattern( '/run/borgmatic/zfs_snapshots/b33f/./foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION, ), ), (Pattern('/foo'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./foo')), (Pattern('/'), Pattern('/run/borgmatic/zfs_snapshots/b33f/./')), ), ) def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path( pattern, expected_pattern ): flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) assert ( module.make_borg_snapshot_pattern( pattern, flexmock(mount_point='/something'), '/run/borgmatic' ) == expected_pattern ) def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns(): dataset = flexmock( name='dataset', mount_point='/mnt/dataset', contained_patterns=(Pattern('/mnt/dataset/subdir'),), ) flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,)) flexmock(module.os).should_receive('getpid').and_return(1234) full_snapshot_name = 'dataset@borgmatic-1234' flexmock(module).should_receive('snapshot_dataset').with_args( 'zfs', full_snapshot_name, ).once() flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset' flexmock(module).should_receive('mount_snapshot').with_args( 'mount', full_snapshot_name, module.os.path.normpath(snapshot_mount_path), ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir')) patterns = [Pattern('/mnt/dataset/subdir')] assert ( module.dump_data_sources( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern(os.path.join(snapshot_mount_path, 'subdir'))] def test_dump_data_sources_with_no_datasets_skips_snapshots(): flexmock(module).should_receive('get_datasets_to_backup').and_return(()) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_dataset').never() flexmock(module).should_receive('mount_snapshot').never() patterns = [Pattern('/mnt/dataset')] assert ( module.dump_data_sources( hook_config={}, config={'patterns': flexmock(), 'zfs': {}}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern('/mnt/dataset')] def test_dump_data_sources_uses_custom_commands(): dataset = flexmock( name='dataset', mount_point='/mnt/dataset', contained_patterns=(Pattern('/mnt/dataset/subdir'),), ) flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,)) flexmock(module.os).should_receive('getpid').and_return(1234) full_snapshot_name = 'dataset@borgmatic-1234' flexmock(module).should_receive('snapshot_dataset').with_args( '/usr/local/bin/zfs', full_snapshot_name, ).once() flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset' flexmock(module).should_receive('mount_snapshot').with_args( '/usr/local/bin/mount', full_snapshot_name, module.os.path.normpath(snapshot_mount_path), ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir')) patterns = [Pattern('/mnt/dataset/subdir')] hook_config = { 'zfs_command': '/usr/local/bin/zfs', 'mount_command': '/usr/local/bin/mount', } assert ( module.dump_data_sources( hook_config=hook_config, config={ 'patterns': flexmock(), 'zfs': hook_config, }, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern(os.path.join(snapshot_mount_path, 'subdir'))] def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_patterns(): flexmock(module).should_receive('get_datasets_to_backup').and_return( (flexmock(name='dataset', mount_point='/mnt/dataset'),) ) flexmock(module.os).should_receive('getpid').and_return(1234) flexmock(module).should_receive('snapshot_dataset').never() flexmock(module).should_receive('mount_snapshot').never() patterns = [Pattern('/mnt/dataset')] assert ( module.dump_data_sources( hook_config={}, config={'patterns': ('R /mnt/dataset',), 'zfs': {}}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=True, ) == [] ) assert patterns == [Pattern('/mnt/dataset')] def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns(): dataset = flexmock( name='dataset', mount_point='/mnt/dataset', contained_patterns=(Pattern('/mnt/dataset/subdir'),), ) flexmock(module).should_receive('get_datasets_to_backup').and_return((dataset,)) flexmock(module.os).should_receive('getpid').and_return(1234) full_snapshot_name = 'dataset@borgmatic-1234' flexmock(module).should_receive('snapshot_dataset').with_args( 'zfs', full_snapshot_name, ).once() flexmock(module.hashlib).should_receive('shake_256').and_return( flexmock(hexdigest=lambda length: 'b33f') ) snapshot_mount_path = '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset' flexmock(module).should_receive('mount_snapshot').with_args( 'mount', full_snapshot_name, module.os.path.normpath(snapshot_mount_path), ).once() flexmock(module).should_receive('make_borg_snapshot_pattern').with_args( Pattern('/mnt/dataset/subdir'), dataset, '/run/borgmatic' ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir')) patterns = [Pattern('/hmm')] assert ( module.dump_data_sources( hook_config={}, config={'patterns': ('R /mnt/dataset',), 'zfs': {}}, config_paths=('test.yaml',), borgmatic_runtime_directory='/run/borgmatic', patterns=patterns, dry_run=False, ) == [] ) assert patterns == [Pattern('/hmm'), Pattern(os.path.join(snapshot_mount_path, 'subdir'))] def test_get_all_snapshots_parses_list_output(): flexmock(module.borgmatic.execute).should_receive( 'execute_command_and_capture_output' ).and_return( 'dataset1@borgmatic-1234\ndataset2@borgmatic-4567', ) assert module.get_all_snapshots('zfs') == ('dataset1@borgmatic-1234', 'dataset2@borgmatic-4567') def test_remove_data_source_dumps_unmounts_and_destroys_snapshots(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( 'umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).once() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( 'zfs', 'dataset@borgmatic-1234' ).once() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_use_custom_commands(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).once() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( '/usr/local/bin/zfs', 'dataset@borgmatic-1234' ).once() hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'} module.remove_data_source_dumps( hook_config=hook_config, config={'source_directories': '/mnt/dataset', 'zfs': hook_config}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_hook_configuration(): flexmock(module).should_receive('get_all_dataset_mount_points').never() flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() module.remove_data_source_dumps( hook_config=None, config={'source_directories': '/mnt/dataset'}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_zfs_command(): flexmock(module).should_receive('get_all_dataset_mount_points').and_raise(FileNotFoundError) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() hook_config = {'zfs_command': 'wtf'} module.remove_data_source_dumps( hook_config=hook_config, config={'source_directories': '/mnt/dataset', 'zfs': hook_config}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_zfs_command_error(): flexmock(module).should_receive('get_all_dataset_mount_points').and_raise( module.subprocess.CalledProcessError(1, 'wtf') ) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).never() hook_config = {'zfs_command': 'wtf'} module.remove_data_source_dumps( hook_config=hook_config, config={'source_directories': '/mnt/dataset', 'zfs': hook_config}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_bails_for_missing_umount_command(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_raise(FileNotFoundError) flexmock(module).should_receive('get_all_snapshots').never() flexmock(module).should_receive('destroy_snapshot').never() hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'} module.remove_data_source_dumps( hook_config=hook_config, config={'source_directories': '/mnt/dataset', 'zfs': hook_config}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_swallows_umount_command_error(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').with_args( '/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_raise(module.subprocess.CalledProcessError(1, 'wtf')) flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( '/usr/local/bin/zfs', 'dataset@borgmatic-1234' ).once() hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'} module.remove_data_source_dumps( hook_config=hook_config, config={'source_directories': '/mnt/dataset', 'zfs': hook_config}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_not_actually_directories(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(False) flexmock(module.shutil).should_receive('rmtree').never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( 'zfs', 'dataset@borgmatic-1234' ).once() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_not_actually_directories(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_return(False) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( 'zfs', 'dataset@borgmatic-1234' ).once() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_empty(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_return(True) flexmock(module.os).should_receive('listdir').with_args( '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_return([]) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( 'zfs', 'dataset@borgmatic-1234' ).once() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_after_rmtree_succeeds(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f' ).and_return(True) flexmock(module.os.path).should_receive('isdir').with_args( '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset' ).and_return(True).and_return(False) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree') flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').with_args( 'zfs', 'dataset@borgmatic-1234' ).once() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=False, ) def test_remove_data_source_dumps_with_dry_run_skips_unmount_and_destroy(): flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',)) flexmock(module.borgmatic.config.paths).should_receive( 'replace_temporary_subdirectory_with_glob' ).and_return('/run/borgmatic') flexmock(module.glob).should_receive('glob').replace_with( lambda path: [path.replace('*', 'b33f')] ) flexmock(module.os.path).should_receive('isdir').and_return(True) flexmock(module.os).should_receive('listdir').and_return(['file.txt']) flexmock(module.shutil).should_receive('rmtree').never() flexmock(module).should_receive('unmount_snapshot').never() flexmock(module).should_receive('get_all_snapshots').and_return( ('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid') ) flexmock(module).should_receive('destroy_snapshot').never() module.remove_data_source_dumps( hook_config={}, config={'source_directories': '/mnt/dataset', 'zfs': {}}, borgmatic_runtime_directory='/run/borgmatic', dry_run=True, ) borgmatic/tests/unit/hooks/monitoring/000077500000000000000000000000001476361726000204725ustar00rootroot00000000000000borgmatic/tests/unit/hooks/monitoring/__init__.py000066400000000000000000000000001476361726000225710ustar00rootroot00000000000000borgmatic/tests/unit/hooks/monitoring/test_apprise.py000066400000000000000000000307621476361726000235560ustar00rootroot00000000000000import apprise from apprise import NotifyFormat, NotifyType from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import apprise as module TOPIC = 'borgmatic-unit-testing' def mock_apprise(): apprise_mock = flexmock( add=lambda servers: None, notify=lambda title, body, body_format, notify_type: None ) flexmock(apprise.Apprise).new_instances(apprise_mock) return apprise_mock def test_initialize_monitor_with_send_logs_false_does_not_add_handler(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').never() module.initialize_monitor( hook_config={'send_logs': False}, config={}, config_filename='test.yaml', monitoring_log_level=1, dry_run=False, ) def test_initialize_monitor_with_send_logs_true_adds_handler_with_default_log_size_limit(): truncation_indicator_length = 4 flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).with_args( module.HANDLER_IDENTIFIER, module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length, 1, ).once() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once() module.initialize_monitor( hook_config={'send_logs': True}, config={}, config_filename='test.yaml', monitoring_log_level=1, dry_run=False, ) def test_initialize_monitor_without_send_logs_adds_handler_with_default_log_size_limit(): truncation_indicator_length = 4 flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).with_args( module.HANDLER_IDENTIFIER, module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length, 1, ).once() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once() module.initialize_monitor( hook_config={}, config={}, config_filename='test.yaml', monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_respects_dry_run(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy log') mock_apprise().should_receive('notify').never() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_no_states_does_not_notify(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).never() mock_apprise().should_receive('notify').never() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_notifies_fail_by_default(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='A borgmatic FAIL event happened', body='A borgmatic FAIL event happened', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() for state in borgmatic.hooks.monitoring.monitor.State: module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', state, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_logs_appends_logs_to_body(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy log') mock_apprise().should_receive('notify').with_args( title='A borgmatic FAIL event happened', body='A borgmatic FAIL event happened\n\nloggy log', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() for state in borgmatic.hooks.monitoring.monitor.State: module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', state, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_finish_default_config_notifies(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='A borgmatic FINISH event happened', body='A borgmatic FINISH event happened', body_format=NotifyFormat.TEXT, notify_type=NotifyType.SUCCESS, ).once() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_start_default_config_notifies(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).never() mock_apprise().should_receive('notify').with_args( title='A borgmatic START event happened', body='A borgmatic START event happened', body_format=NotifyFormat.TEXT, notify_type=NotifyType.INFO, ).once() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_fail_default_config_notifies(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='A borgmatic FAIL event happened', body='A borgmatic FAIL event happened', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_log_default_config_notifies(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='A borgmatic LOG event happened', body='A borgmatic LOG event happened', body_format=NotifyFormat.TEXT, notify_type=NotifyType.INFO, ).once() module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.LOG, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_passes_through_custom_message_title(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='foo', body='bar', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() module.ping_monitor( { 'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail'], 'fail': {'title': 'foo', 'body': 'bar'}, }, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_passes_through_custom_message_body(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').with_args( title='', body='baz', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() module.ping_monitor( { 'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail'], 'fail': {'body': 'baz'}, }, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_passes_through_custom_message_body_and_appends_logs(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy log') mock_apprise().should_receive('notify').with_args( title='', body='baz\n\nloggy log', body_format=NotifyFormat.TEXT, notify_type=NotifyType.FAILURE, ).once() module.ping_monitor( { 'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail'], 'fail': {'body': 'baz'}, }, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_pings_multiple_services(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once() module.ping_monitor( { 'services': [ {'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}, {'url': f'ntfy://{TOPIC}', 'label': 'ntfy'}, ] }, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_logs_info_for_no_services(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).never() flexmock(module.logger).should_receive('info').once() module.ping_monitor( {'services': []}, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_logs_warning_when_notify_fails(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('') mock_apprise().should_receive('notify').and_return(False) flexmock(module.logger).should_receive('warning').once() for state in borgmatic.hooks.monitoring.monitor.State: module.ping_monitor( {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]}, {}, 'config.yaml', state, monitoring_log_level=1, dry_run=False, ) def test_destroy_monitor_does_not_raise(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('remove_handler') module.destroy_monitor( hook_config={}, config={}, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_cronhub.py000066400000000000000000000072311476361726000235460ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks.monitoring import cronhub as module def test_ping_monitor_rewrites_ping_url_for_start_state(): hook_config = {'ping_url': 'https://example.com/start/abcdef'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/start/abcdef' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_rewrites_ping_url_and_state_for_start_state(): hook_config = {'ping_url': 'https://example.com/ping/abcdef'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/start/abcdef' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_rewrites_ping_url_for_finish_state(): hook_config = {'ping_url': 'https://example.com/start/abcdef'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/finish/abcdef' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_rewrites_ping_url_for_fail_state(): hook_config = {'ping_url': 'https://example.com/start/abcdef'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/fail/abcdef' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_connection_error_logs_warning(): hook_config = {'ping_url': 'https://example.com/start/abcdef'} flexmock(module.requests).should_receive('get').and_raise( module.requests.exceptions.ConnectionError ) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, (), 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): hook_config = {'ping_url': 'https://example.com/start/abcdef'} response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('get').with_args( 'https://example.com/start/abcdef' ).and_return(response) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_unsupported_monitoring_state_bails(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_cronitor.py000066400000000000000000000061451476361726000237500ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks.monitoring import cronitor as module def test_ping_monitor_hits_ping_url_for_start_state(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return( flexmock(ok=True) ) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_ping_url_for_finish_state(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/complete' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_ping_url_for_fail_state(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').with_args( 'https://example.com/fail' ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_connection_error_logs_warning(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').and_raise( module.requests.exceptions.ConnectionError ) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): hook_config = {'ping_url': 'https://example.com'} response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return( response ) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_unsupported_monitoring_state_bails(): hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_healthchecks.py000066400000000000000000000313361476361726000245370ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks.monitoring import healthchecks as module def mock_logger(): logger = flexmock() logger.should_receive('addHandler') logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(logger) def test_initialize_monitor_creates_log_handler_with_ping_body_limit(): ping_body_limit = 100 monitoring_log_level = 1 mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).with_args( module.HANDLER_IDENTIFIER, ping_body_limit - len(module.borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR), monitoring_log_level, ).once() module.initialize_monitor( {'ping_body_limit': ping_body_limit}, {}, 'test.yaml', monitoring_log_level, dry_run=False ) def test_initialize_monitor_creates_log_handler_with_default_ping_body_limit(): monitoring_log_level = 1 mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).with_args( module.HANDLER_IDENTIFIER, module.DEFAULT_PING_BODY_LIMIT_BYTES - len(module.borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR), monitoring_log_level, ).once() module.initialize_monitor({}, {}, 'test.yaml', monitoring_log_level, dry_run=False) def test_initialize_monitor_creates_log_handler_with_zero_ping_body_limit(): ping_body_limit = 0 monitoring_log_level = 1 mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).with_args(module.HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level).once() module.initialize_monitor( {'ping_body_limit': ping_body_limit}, {}, 'test.yaml', monitoring_log_level, dry_run=False ) def test_initialize_monitor_creates_log_handler_when_send_logs_true(): mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).once() module.initialize_monitor( {'send_logs': True}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False ) def test_initialize_monitor_bails_when_send_logs_false(): mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() module.initialize_monitor( {'send_logs': False}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False ) def test_ping_monitor_hits_ping_url_for_start_state(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start', data=''.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_ping_url_for_finish_state(): hook_config = {'ping_url': 'https://example.com'} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( 'https://example.com', data=payload.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_ping_url_for_fail_state(): hook_config = {'ping_url': 'https://example.com'} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( 'https://example.com/fail', data=payload.encode('utf'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_ping_url_for_log_state(): hook_config = {'ping_url': 'https://example.com'} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( 'https://example.com/log', data=payload.encode('utf'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.LOG, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_ping_uuid_hits_corresponding_url(): hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( f"https://hc-ping.com/{hook_config['ping_url']}", data=payload.encode('utf-8'), verify=True, ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_skips_ssl_verification_when_verify_tls_false(): hook_config = {'ping_url': 'https://example.com', 'verify_tls': False} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( 'https://example.com', data=payload.encode('utf-8'), verify=False ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_executes_ssl_verification_when_verify_tls_true(): hook_config = {'ping_url': 'https://example.com', 'verify_tls': True} payload = 'data' flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler') flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return(payload) flexmock(module.requests).should_receive('post').with_args( 'https://example.com', data=payload.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_does_not_hit_ping_url_when_states_not_matching(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com', 'states': ['finish']} flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_hits_ping_url_when_states_matching(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com', 'states': ['start', 'finish']} flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start', data=''.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_adds_create_query_parameter_when_create_slug_true(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com', 'create_slug': True} flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_does_not_add_create_query_parameter_when_create_slug_false(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com', 'create_slug': False} flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start', data=''.encode('utf-8'), verify=True ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_does_not_add_create_query_parameter_when_ping_url_is_uuid(): hook_config = {'ping_url': 'b3611b24-df9c-4d36-9203-fa292820bf2a', 'create_slug': True} flexmock(module.requests).should_receive('post').with_args( f"https://hc-ping.com/{hook_config['ping_url']}", data=''.encode('utf-8'), verify=True, ).and_return(flexmock(ok=True)) module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_issues_warning_when_ping_url_is_uuid_and_create_slug_true(): hook_config = {'ping_url': 'b3611b24-df9c-4d36-9203-fa292820bf2a', 'create_slug': True} flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_connection_error_logs_warning(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com'} flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start', data=''.encode('utf-8'), verify=True ).and_raise(module.requests.exceptions.ConnectionError) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() hook_config = {'ping_url': 'https://example.com'} response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('post').with_args( 'https://example.com/start', data=''.encode('utf-8'), verify=True ).and_return(response) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', state=module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_logs.py000066400000000000000000000072331476361726000230540ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.hooks.monitoring import logs as module def test_forgetful_buffering_handler_emit_collects_log_records(): handler = module.Forgetful_buffering_handler(identifier='test', byte_capacity=100, log_level=1) handler.emit(flexmock(getMessage=lambda: 'foo')) handler.emit(flexmock(getMessage=lambda: 'bar')) assert handler.buffer == ['foo\n', 'bar\n'] assert not handler.forgot def test_forgetful_buffering_handler_emit_collects_log_records_with_zero_byte_capacity(): handler = module.Forgetful_buffering_handler(identifier='test', byte_capacity=0, log_level=1) handler.emit(flexmock(getMessage=lambda: 'foo')) handler.emit(flexmock(getMessage=lambda: 'bar')) assert handler.buffer == ['foo\n', 'bar\n'] assert not handler.forgot def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached(): handler = module.Forgetful_buffering_handler( identifier='test', byte_capacity=len('foo\nbar\n'), log_level=1 ) handler.emit(flexmock(getMessage=lambda: 'foo')) assert handler.buffer == ['foo\n'] handler.emit(flexmock(getMessage=lambda: 'bar')) assert handler.buffer == ['foo\n', 'bar\n'] handler.emit(flexmock(getMessage=lambda: 'baz')) assert handler.buffer == ['bar\n', 'baz\n'] handler.emit(flexmock(getMessage=lambda: 'quux')) assert handler.buffer == ['quux\n'] assert handler.forgot def test_get_handler_matches_by_identifier(): handlers = [ flexmock(), flexmock(), module.Forgetful_buffering_handler(identifier='other', byte_capacity=100, log_level=1), module.Forgetful_buffering_handler(identifier='test', byte_capacity=100, log_level=1), flexmock(), ] flexmock(module.logging.getLogger(), handlers=handlers) assert module.get_handler('test') == handlers[3] def test_get_handler_without_match_raises(): handlers = [ flexmock(), module.Forgetful_buffering_handler(identifier='other', byte_capacity=100, log_level=1), ] flexmock(module.logging.getLogger(), handlers=handlers) with pytest.raises(ValueError): assert module.get_handler('test') def test_format_buffered_logs_for_payload_flattens_log_buffer(): handler = module.Forgetful_buffering_handler(identifier='test', byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] flexmock(module).should_receive('get_handler').and_return(handler) payload = module.format_buffered_logs_for_payload(identifier='test') assert payload == 'foo\nbar\n' def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten(): handler = module.Forgetful_buffering_handler(identifier='test', byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] handler.forgot = True flexmock(module).should_receive('get_handler').and_return(handler) payload = module.format_buffered_logs_for_payload(identifier='test') assert payload == '...\nfoo\nbar\n' def test_format_buffered_logs_for_payload_without_handler_produces_empty_payload(): flexmock(module).should_receive('get_handler').and_raise(ValueError) payload = module.format_buffered_logs_for_payload(identifier='test') assert payload == '' def test_remove_handler_with_matching_handler_does_not_raise(): flexmock(module).should_receive('get_handler').and_return(flexmock()) flexmock(module.logging.getLogger()).should_receive('removeHandler') module.remove_handler('test') def test_remove_handler_without_matching_handler_does_not_raise(): flexmock(module).should_receive('get_handler').and_raise(ValueError) module.remove_handler('test') borgmatic/tests/unit/hooks/monitoring/test_loki.py000066400000000000000000000062031476361726000230420ustar00rootroot00000000000000import json import requests from flexmock import flexmock from borgmatic.hooks.monitoring import loki as module def test_loki_log_buffer_add_value_gets_raw(): ''' Assert that adding values to the log buffer increases it's length. ''' buffer = module.Loki_log_buffer(flexmock(), False) assert len(buffer) == 0 buffer.add_value('Some test log line') assert len(buffer) == 1 buffer.add_value('Another test log line') assert len(buffer) == 2 def test_loki_log_buffer_json_serializes_empty_buffer(): ''' Assert that the buffer correctly serializes when empty. ''' buffer = module.Loki_log_buffer(flexmock(), False) assert json.loads(buffer.to_request()) == json.loads('{"streams":[{"stream":{},"values":[]}]}') def test_loki_log_buffer_json_serializes_labels(): ''' Assert that the buffer correctly serializes with labels. ''' buffer = module.Loki_log_buffer(flexmock(), False) buffer.add_label('test', 'label') assert json.loads(buffer.to_request()) == json.loads( '{"streams":[{"stream":{"test": "label"},"values":[]}]}' ) def test_loki_log_buffer_json_serializes_log_lines(): ''' Assert that log lines end up in the correct place in the log buffer. ''' buffer = module.Loki_log_buffer(flexmock(), False) buffer.add_value('Some test log line') assert json.loads(buffer.to_request())['streams'][0]['values'][0][1] == 'Some test log line' def test_loki_log_handler_add_label_gets_labels(): ''' Assert that adding labels works. ''' buffer = module.Loki_log_buffer(flexmock(), False) buffer.add_label('test', 'label') assert buffer.root['streams'][0]['stream']['test'] == 'label' buffer.add_label('test2', 'label2') assert buffer.root['streams'][0]['stream']['test2'] == 'label2' def test_loki_log_handler_emit_gets_log_messages(): ''' Assert that adding log records works. ''' handler = module.Loki_log_handler(flexmock(), False) handler.emit(flexmock(getMessage=lambda: 'Some test log line')) assert len(handler.buffer) == 1 def test_loki_log_handler_raw_posts_to_server(): ''' Assert that the flush function sends a post request after a certain limit. ''' handler = module.Loki_log_handler(flexmock(), False) flexmock(module.requests).should_receive('post').and_return( flexmock(raise_for_status=lambda: '') ).once() for num in range(int(module.MAX_BUFFER_LINES * 1.5)): handler.raw(num) def test_loki_log_handler_raw_post_failure_does_not_raise(): ''' Assert that the flush function catches request exceptions. ''' handler = module.Loki_log_handler(flexmock(), False) flexmock(module.requests).should_receive('post').and_return( flexmock(raise_for_status=lambda: (_ for _ in ()).throw(requests.RequestException())) ).once() for num in range(int(module.MAX_BUFFER_LINES * 1.5)): handler.raw(num) def test_loki_log_handler_flush_with_empty_buffer_does_not_raise(): ''' Test that flushing an empty buffer does indeed nothing. ''' handler = module.Loki_log_handler(flexmock(), False) handler.flush() borgmatic/tests/unit/hooks/monitoring/test_ntfy.py000066400000000000000000000267401476361726000230740ustar00rootroot00000000000000from enum import Enum from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import ntfy as module default_base_url = 'https://ntfy.sh' custom_base_url = 'https://ntfy.example.com' topic = 'borgmatic-unit-testing' custom_message_config = { 'title': 'borgmatic unit testing', 'message': 'borgmatic unit testing', 'priority': 'min', 'tags': '+1', } custom_message_headers = { 'X-Title': custom_message_config['title'], 'X-Message': custom_message_config['message'], 'X-Priority': custom_message_config['priority'], 'X-Tags': custom_message_config['tags'], } def return_default_message_headers(state=Enum): headers = { 'X-Title': f'A borgmatic {state.name} event happened', 'X-Message': f'A borgmatic {state.name} event happened', 'X-Priority': 'default', 'X-Tags': 'borgmatic', } return headers def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail(): hook_config = { 'topic': topic, 'access_token': 'abc123', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=module.requests.auth.HTTPBasicAuth('', 'abc123'), ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_username_password_and_access_token_ignores_username_password(): hook_config = { 'topic': topic, 'username': 'testuser', 'password': 'fakepassword', 'access_token': 'abc123', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=module.requests.auth.HTTPBasicAuth('', 'abc123'), ).and_return(flexmock(ok=True)).once() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail(): hook_config = { 'topic': topic, 'username': 'testuser', 'password': 'fakepassword', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'), ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_password_but_no_username_warns(): hook_config = {'topic': topic, 'password': 'fakepassword'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_username_but_no_password_warns(): hook_config = {'topic': topic, 'username': 'testuser'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail(): hook_config = {'topic': topic, 'server': custom_base_url} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{custom_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail(): hook_config = {'topic': topic, 'fail': custom_message_config} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start(): hook_config = {'topic': topic, 'states': ['start', 'fail']} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.START), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_connection_error_logs_warning(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_raise(module.requests.exceptions.ConnectionError) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_credential_error_logs_warning(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_raise(ValueError) flexmock(module.requests).should_receive('post').never() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): hook_config = {'topic': topic} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL), auth=None, ).and_return(response) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_pagerduty.py000066400000000000000000000125011476361726000241060ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks.monitoring import pagerduty as module def mock_logger(): logger = flexmock() logger.should_receive('addHandler') logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(logger) def test_initialize_monitor_creates_log_handler(): monitoring_log_level = 1 mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).once() module.initialize_monitor({}, {}, 'test.yaml', monitoring_log_level, dry_run=False) def test_initialize_monitor_creates_log_handler_when_send_logs_true(): mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).once() module.initialize_monitor( {'send_logs': True}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False ) def test_initialize_monitor_bails_when_send_logs_false(): mock_logger() flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'Forgetful_buffering_handler' ).never() module.initialize_monitor( {'send_logs': False}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False ) def test_ping_monitor_ignores_start_state(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').never() module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_ignores_finish_state(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.requests).should_receive('post').never() module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_calls_api_for_fail_state(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy\nlogs') flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)) module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_dry_run_does_not_call_api(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy\nlogs') flexmock(module.requests).should_receive('post').never() module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_connection_error_logs_warning(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy\nlogs') flexmock(module.requests).should_receive('post').and_raise( module.requests.exceptions.ConnectionError ) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_credential_error_logs_warning(): flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_raise(ValueError) flexmock(module.requests).should_receive('post').never() flexmock(module.logger).should_receive('warning') module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): response = flexmock(ok=False) flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.borgmatic.hooks.monitoring.logs).should_receive( 'format_buffered_logs_for_payload' ).and_return('loggy\nlogs') response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('post').and_return(response) flexmock(module.logger).should_receive('warning') module.ping_monitor( {'integration_key': 'abc123'}, {}, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_pushover.py000066400000000000000000000505501476361726000237630ustar00rootroot00000000000000import pytest from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import pushover as module def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_send_to_pushover(): ''' This test should be the minimum working configuration. The "message" should be auto populated with the default value which is the state name. ''' hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'fail', }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_minimum_config_start_state_backup_not_send_to_pushover_exit_early(): ''' This test should exit early since the hook config does not specify the 'start' state. Only the 'fail' state is enabled by default. ''' hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_default_message_successfully_send_to_pushover(): ''' This test should send a notification to Pushover on backup start since the state has been configured. It should default to sending the name of the state as the 'message' since it is not explicitly declared in the state config. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'start', }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_custom_message_successfully_send_to_pushover(): ''' This test should send a notification to Pushover on backup start since the state has been configured. It should send a custom 'message' since it is explicitly declared in the state config. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': {'message': 'custom start message'}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'custom start message', }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_default_message_with_priority_emergency_uses_expire_and_retry_defaults(): ''' This simulates priority level 2 being set but expiry and retry are not declared. This should set retry and expiry to their defaults. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': {'priority': 2}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'start', 'priority': 2, 'retry': 30, 'expire': 600, }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_default_message_with_priority_emergency_declared_with_expire_no_retry_success(): ''' This simulates priority level 2 and expiry being set but retry is not declared. This should set retry to the default. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': {'priority': 2, 'expire': 600}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'start', 'priority': 2, 'retry': 30, 'expire': 600, }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_default_message_with_priority_emergency_declared_no_expire_with_retry_success(): ''' This simulates priority level 2 and retry being set but expire is not declared. This should set expire to the default. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': {'priority': 2, 'retry': 30}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'start', 'priority': 2, 'retry': 30, 'expire': 600, }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_default_message_with_priority_high_declared_expire_and_retry_raises(): ''' This simulates priority level 1, retry and expiry being set. Since expire and retry are only used for priority level 2, they should not be included in the request sent to Pushover. This test verifies that a ValueError is raised. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': {'priority': 1, 'expire': 30, 'retry': 30}, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').never() with pytest.raises(ValueError): module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_start_state_backup_based_on_documentation_advanced_example_success(): ''' Here is a test of what is provided in the monitor-your-backups.md file as an 'advanced example'. This test runs the start state. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': { 'message': 'Backup Started', 'priority': -2, 'title': 'Backup Started', 'html': 1, 'ttl': 10, }, 'fail': { 'message': 'Backup Failed', 'priority': 2, 'expire': 600, 'retry': 30, 'device': 'pixel8', 'title': 'Backup Failed', 'html': 1, 'sound': 'siren', 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, 'finish': { 'message': 'Backup Finished', 'priority': 0, 'title': 'Backup Finished', 'html': 1, 'ttl': 60, 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'Backup Started', 'priority': -2, 'title': 'Backup Started', 'html': 1, 'ttl': 10, }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_fail_state_backup_based_on_documentation_advanced_example_success(): ''' Here is a test of what is provided in the monitor-your-backups.md file as an 'advanced example'. This test runs the fail state. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': { 'message': 'Backup Started', 'priority': -2, 'title': 'Backup Started', 'html': 1, 'ttl': 10, }, 'fail': { 'message': 'Backup Failed', 'priority': 2, 'expire': 600, 'retry': 30, 'device': 'pixel8', 'title': 'Backup Failed', 'html': 1, 'sound': 'siren', 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, 'finish': { 'message': 'Backup Finished', 'priority': 0, 'title': 'Backup Finished', 'html': 1, 'ttl': 60, 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'Backup Failed', 'priority': 2, 'expire': 600, 'retry': 30, 'device': 'pixel8', 'title': 'Backup Failed', 'html': 1, 'sound': 'siren', 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_finish_state_backup_based_on_documentation_advanced_example_success(): ''' Here is a test of what is provided in the monitor-your-backups.md file as an 'advanced example'. This test runs the finish state. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'states': {'start', 'fail', 'finish'}, 'start': { 'message': 'Backup Started', 'priority': -2, 'title': 'Backup Started', 'html': 1, 'ttl': 10, }, 'fail': { 'message': 'Backup Failed', 'priority': 2, 'expire': 600, 'retry': 30, 'device': 'pixel8', 'title': 'Backup Failed', 'html': 1, 'sound': 'siren', 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, 'finish': { 'message': 'Backup Finished', 'priority': 0, 'title': 'Backup Finished', 'html': 1, 'ttl': 60, 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'Backup Finished', 'priority': 0, 'title': 'Backup Finished', 'html': 1, 'ttl': 60, 'url': 'https://ticketing-system.example.com/login', 'url_title': 'Login to ticketing system', }, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_send_to_pushover_dry_run(): ''' This test should be the minimum working configuration. The "message" should be auto populated with the default value which is the state name. ''' hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_config_incorrect_state_exit_early(): ''' This test should exit early since the start state is not declared in the configuration. ''' hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').never() flexmock(module.requests).should_receive('post').and_return(flexmock(ok=True)).never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_push_post_error_bails(): ''' This test simulates the Pushover servers not responding with a 200 OK. We should raise for status and warn then exit. ''' hook_config = hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) push_response = flexmock(ok=False) push_response.should_receive('raise_for_status').and_raise( module.requests.ConnectionError ).once() flexmock(module.requests).should_receive('post').with_args( 'https://api.pushover.net/1/messages.json', headers={'Content-type': 'application/x-www-form-urlencoded'}, data={ 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', 'message': 'fail', }, ).and_return(push_response).once() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_credential_error_bails(): hook_config = hook_config = { 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui', } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_raise(ValueError) flexmock(module.requests).should_receive('post').never() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_sentry.py000066400000000000000000000104251476361726000234310ustar00rootroot00000000000000import pytest from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import sentry as module @pytest.mark.parametrize( 'state,configured_states,expected_status', ( (borgmatic.hooks.monitoring.monitor.State.START, ['start'], 'in_progress'), ( borgmatic.hooks.monitoring.monitor.State.START, ['start', 'finish', 'fail'], 'in_progress', ), (borgmatic.hooks.monitoring.monitor.State.START, None, 'in_progress'), (borgmatic.hooks.monitoring.monitor.State.FINISH, ['finish'], 'ok'), (borgmatic.hooks.monitoring.monitor.State.FAIL, ['fail'], 'error'), ), ) def test_ping_monitor_constructs_cron_url_and_pings_it(state, configured_states, expected_status): hook_config = { 'data_source_name_url': 'https://5f80ec@o294220.ingest.us.sentry.io/203069', 'monitor_slug': 'test', } if configured_states: hook_config['states'] = configured_states flexmock(module.requests).should_receive('post').with_args( f'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status={expected_status}' ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', state, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_unconfigured_state_bails(): hook_config = { 'data_source_name_url': 'https://5f80ec@o294220.ingest.us.sentry.io/203069', 'monitor_slug': 'test', 'states': ['fail'], } flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) @pytest.mark.parametrize( 'data_source_name_url', ( '5f80ec@o294220.ingest.us.sentry.io/203069', 'https://o294220.ingest.us.sentry.io/203069', 'https://5f80ec@/203069', 'https://5f80ec@o294220.ingest.us.sentry.io', ), ) def test_ping_monitor_with_invalid_data_source_name_url_bails(data_source_name_url): hook_config = { 'data_source_name_url': data_source_name_url, 'monitor_slug': 'test', } flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_invalid_sentry_state_bails(): hook_config = { 'data_source_name_url': 'https://5f80ec@o294220.ingest.us.sentry.io/203069', 'monitor_slug': 'test', # This should never actually happen in practice, because the config schema prevents it. 'states': ['log'], } flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.LOG, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_dry_run_bails(): hook_config = { 'data_source_name_url': 'https://5f80ec@o294220.ingest.us.sentry.io/203069', 'monitor_slug': 'test', } flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_network_error_does_not_raise(): hook_config = { 'data_source_name_url': 'https://5f80ec@o294220.ingest.us.sentry.io/203069', 'monitor_slug': 'test', } response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.ConnectionError ) flexmock(module.requests).should_receive('post').with_args( 'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status=in_progress' ).and_return(response).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_uptimekuma.py000066400000000000000000000133531476361726000242710ustar00rootroot00000000000000from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import uptime_kuma as module DEFAULT_PUSH_URL = 'https://example.uptime.kuma/api/push/abcd1234' CUSTOM_PUSH_URL = 'https://uptime.example.com/api/push/efgh5678' def test_ping_monitor_hits_default_uptimekuma_on_fail(): hook_config = {} flexmock(module.requests).should_receive('get').with_args( f'{DEFAULT_PUSH_URL}?status=down&msg=fail', verify=True ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_hits_custom_uptimekuma_on_fail(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_custom_uptimekuma_on_start(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=up&msg=start', verify=True ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_custom_uptimekuma_on_finish(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=up&msg=finish', verify=True ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FINISH, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_does_not_hit_custom_uptimekuma_on_fail_dry_run(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_does_not_hit_custom_uptimekuma_on_start_dry_run(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_does_not_hit_custom_uptimekuma_on_finish_dry_run(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FINISH, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_with_connection_error_logs_warning(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True ).and_raise(module.requests.exceptions.ConnectionError) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): hook_config = {'push_url': CUSTOM_PUSH_URL} response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True ).and_return(response) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_invalid_run_state(): hook_config = {'push_url': CUSTOM_PUSH_URL} flexmock(module.requests).should_receive('get').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.LOG, monitoring_log_level=1, dry_run=True, ) def test_ping_monitor_skips_ssl_verification_when_verify_tls_false(): hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': False} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=False ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_executes_ssl_verification_when_verify_tls_true(): hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': True} flexmock(module.requests).should_receive('get').with_args( f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/monitoring/test_zabbix.py000066400000000000000000000456541476361726000234000ustar00rootroot00000000000000from flexmock import flexmock import borgmatic.hooks.monitoring.monitor from borgmatic.hooks.monitoring import zabbix as module SERVER = 'https://zabbix.com/zabbix/api_jsonrpc.php' ITEMID = 55105 USERNAME = 'testuser' PASSWORD = 'fakepassword' API_KEY = 'fakekey' HOST = 'borg-server' KEY = 'borg.status' VALUE = 'fail' DATA_HOST_KEY = { 'jsonrpc': '2.0', 'method': 'history.push', 'params': {'host': HOST, 'key': KEY, 'value': VALUE}, 'id': 1, } DATA_HOST_KEY_WITH_KEY_VALUE = { 'jsonrpc': '2.0', 'method': 'history.push', 'params': {'host': HOST, 'key': KEY, 'value': VALUE}, 'id': 1, } DATA_ITEMID = { 'jsonrpc': '2.0', 'method': 'history.push', 'params': {'itemid': ITEMID, 'value': VALUE}, 'id': 1, } DATA_HOST_KEY_WITH_ITEMID = { 'jsonrpc': '2.0', 'method': 'history.push', 'params': {'itemid': ITEMID, 'value': VALUE}, 'id': 1, } DATA_USER_LOGIN = { 'jsonrpc': '2.0', 'method': 'user.login', 'params': {'username': USERNAME, 'password': PASSWORD}, 'id': 1, } DATA_USER_LOGOUT = { 'jsonrpc': '2.0', 'method': 'user.logout', 'params': [], 'id': 1, } AUTH_HEADERS_LOGIN = { 'Content-Type': 'application/json-rpc', } AUTH_HEADERS = { 'Content-Type': 'application/json-rpc', 'Authorization': f'Bearer {API_KEY}', } def test_send_zabbix_request_with_post_error_bails(): server = flexmock() headers = flexmock() data = {'method': 'do.stuff'} response = flexmock(ok=False) response.should_receive('raise_for_status').and_raise( module.requests.exceptions.RequestException ) flexmock(module.requests).should_receive('post').with_args( server, headers=headers, json=data ).and_return(response) assert module.send_zabbix_request(server, headers, data) is None def test_send_zabbix_request_with_invalid_json_response_bails(): server = flexmock() headers = flexmock() data = {'method': 'do.stuff'} flexmock(module.requests.exceptions.JSONDecodeError).should_receive('__init__') response = flexmock(ok=True) response.should_receive('json').and_raise(module.requests.exceptions.JSONDecodeError) flexmock(module.requests).should_receive('post').with_args( server, headers=headers, json=data ).and_return(response) assert module.send_zabbix_request(server, headers, data) is None def test_send_zabbix_request_with_success_returns_response_result(): server = flexmock() headers = flexmock() data = {'method': 'do.stuff'} response = flexmock(ok=True) response.should_receive('json').and_return({'result': {'foo': 'bar'}}) flexmock(module.requests).should_receive('post').with_args( server, headers=headers, json=data ).and_return(response) assert module.send_zabbix_request(server, headers, data) == {'foo': 'bar'} def test_send_zabbix_request_with_success_passes_through_missing_result(): server = flexmock() headers = flexmock() data = {'method': 'do.stuff'} response = flexmock(ok=True) response.should_receive('json').and_return({}) flexmock(module.requests).should_receive('post').with_args( server, headers=headers, json=data ).and_return(response) assert module.send_zabbix_request(server, headers, data) is None def test_send_zabbix_request_with_error_bails(): server = flexmock() headers = flexmock() data = {'method': 'do.stuff'} response = flexmock(ok=True) response.should_receive('json').and_return({'result': {'data': [{'error': 'oops'}]}}) flexmock(module.requests).should_receive('post').with_args( server, headers=headers, json=data ).and_return(response) assert module.send_zabbix_request(server, headers, data) is None def test_ping_monitor_with_non_matching_state_bails(): hook_config = {'api_key': API_KEY} flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.START, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_api_key_only_bails(): # This test should exit early since only providing an API KEY is not enough # for the hook to work hook_config = {'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_host_only_bails(): # This test should exit early since only providing a HOST is not enough # for the hook to work hook_config = {'host': HOST} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_key_only_bails(): # This test should exit early since only providing a KEY is not enough # for the hook to work hook_config = {'key': KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_with_server_only_bails(): # This test should exit early since only providing a SERVER is not enough # for the hook to work hook_config = {'server': SERVER} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_user_password_no_zabbix_data_bails(): # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to hook_config = {'server': SERVER, 'username': USERNAME, 'password': PASSWORD} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_api_key_no_zabbix_data_bails(): # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to hook_config = {'server': SERVER, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_itemid_no_auth_data_bails(): # This test should exit early since there is no authentication provided # and Zabbix requires authentication to use it's API hook_config = {'server': SERVER, 'itemid': ITEMID} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_no_auth_data_bails(): # This test should exit early since there is no authentication provided # and Zabbix requires authentication to use it's API hook_config = {'server': SERVER, 'host': HOST, 'key': KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY # to authenticate and HOST/KEY to know which item to populate in Zabbix. hook_config = {'server': SERVER, 'host': HOST, 'key': KEY, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_HOST_KEY, ).once() flexmock(module.logger).should_receive('warning').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_adds_missing_api_endpoint_to_server_url(): # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY # to authenticate and HOST/KEY to know which item to populate in Zabbix. hook_config = {'server': SERVER, 'host': HOST, 'key': KEY, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_HOST_KEY, ).once() flexmock(module.logger).should_receive('warning').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_missing_key_bails(): hook_config = {'server': SERVER, 'host': HOST, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_key_and_missing_host_bails(): hook_config = {'server': SERVER, 'key': KEY, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_with_username_password_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD # to authenticate and HOST/KEY to know which item to populate in Zabbix. hook_config = { 'server': SERVER, 'host': HOST, 'key': KEY, 'username': USERNAME, 'password': PASSWORD, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS_LOGIN, data=DATA_USER_LOGIN, ).and_return('fakekey').once() flexmock(module.logger).should_receive('warning').never() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_HOST_KEY_WITH_KEY_VALUE, ).once() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_USER_LOGOUT, ).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_auth_post_error_bails(): hook_config = { 'server': SERVER, 'host': HOST, 'key': KEY, 'username': USERNAME, 'password': PASSWORD, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS_LOGIN, data=DATA_USER_LOGIN, ).and_return(None).once() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_HOST_KEY_WITH_KEY_VALUE, ).never() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_USER_LOGOUT, ).never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_with_username_and_missing_password_bails(): hook_config = { 'server': SERVER, 'host': HOST, 'key': KEY, 'username': USERNAME, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_host_and_key_with_password_and_missing_username_bails(): hook_config = { 'server': SERVER, 'host': HOST, 'key': KEY, 'password': PASSWORD, } flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module.logger).should_receive('warning').once() flexmock(module).should_receive('send_zabbix_request').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_itemid_with_api_key_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY # to authenticate and HOST/KEY to know which item to populate in Zabbix. hook_config = {'server': SERVER, 'itemid': ITEMID, 'api_key': API_KEY} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_ITEMID, ).once() flexmock(module.logger).should_receive('warning').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_config_itemid_with_username_password_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD # to authenticate and HOST/KEY to know which item to populate in Zabbix. hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).replace_with(lambda value, config: value) flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS_LOGIN, data=DATA_USER_LOGIN, ).and_return('fakekey').once() flexmock(module.logger).should_receive('warning').never() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_HOST_KEY_WITH_ITEMID, ).once() flexmock(module).should_receive('send_zabbix_request').with_args( f'{SERVER}', headers=AUTH_HEADERS, data=DATA_USER_LOGOUT, ).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_credential_error_bails(): hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD} flexmock(module.borgmatic.hooks.credential.parse).should_receive( 'resolve_credential' ).and_raise(ValueError) flexmock(module).should_receive('send_zabbix_request').never() flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitoring.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic/tests/unit/hooks/test_command.py000066400000000000000000000123371476361726000213420ustar00rootroot00000000000000import logging import subprocess from flexmock import flexmock from borgmatic.hooks import command as module def test_interpolate_context_passes_through_command_without_variable(): assert module.interpolate_context('pre-backup', 'ls', {'foo': 'bar'}) == 'ls' def test_interpolate_context_passes_through_command_with_unknown_variable(): command = 'ls {baz}' # noqa: FS003 assert module.interpolate_context('pre-backup', command, {'foo': 'bar'}) == command def test_interpolate_context_interpolates_variables(): command = 'ls {foo}{baz} {baz}' # noqa: FS003 context = {'foo': 'bar', 'baz': 'quux'} assert module.interpolate_context('pre-backup', command, context) == 'ls barquux quux' def test_interpolate_context_escapes_interpolated_variables(): command = 'ls {foo} {inject}' # noqa: FS003 context = {'foo': 'bar', 'inject': 'hi; naughty-command'} assert ( module.interpolate_context('pre-backup', command, context) == "ls bar 'hi; naughty-command'" ) def test_make_environment_without_pyinstaller_does_not_touch_environment(): assert module.make_environment({}, sys_module=flexmock()) == {} def test_make_environment_with_pyinstaller_clears_LD_LIBRARY_PATH(): assert module.make_environment({}, sys_module=flexmock(frozen=True, _MEIPASS='yup')) == { 'LD_LIBRARY_PATH': '' } def test_make_environment_with_pyinstaller_and_LD_LIBRARY_PATH_ORIG_copies_it_into_LD_LIBRARY_PATH(): assert module.make_environment( {'LD_LIBRARY_PATH_ORIG': '/lib/lib/lib'}, sys_module=flexmock(frozen=True, _MEIPASS='yup') ) == {'LD_LIBRARY_PATH_ORIG': '/lib/lib/lib', 'LD_LIBRARY_PATH': '/lib/lib/lib'} def test_execute_hook_invokes_each_command(): flexmock(module).should_receive('interpolate_context').replace_with( lambda hook_description, command, context: command ) flexmock(module).should_receive('make_environment').and_return({}) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True, environment={}, ).once() module.execute_hook([':'], None, 'config.yaml', 'pre-backup', dry_run=False) def test_execute_hook_with_multiple_commands_invokes_each_command(): flexmock(module).should_receive('interpolate_context').replace_with( lambda hook_description, command, context: command ) flexmock(module).should_receive('make_environment').and_return({}) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True, environment={}, ).once() flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( ['true'], output_log_level=logging.WARNING, shell=True, environment={}, ).once() module.execute_hook([':', 'true'], None, 'config.yaml', 'pre-backup', dry_run=False) def test_execute_hook_with_umask_sets_that_umask(): flexmock(module).should_receive('interpolate_context').replace_with( lambda hook_description, command, context: command ) flexmock(module.os).should_receive('umask').with_args(0o77).and_return(0o22).once() flexmock(module.os).should_receive('umask').with_args(0o22).once() flexmock(module).should_receive('make_environment').and_return({}) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True, environment={}, ) module.execute_hook([':'], 77, 'config.yaml', 'pre-backup', dry_run=False) def test_execute_hook_with_dry_run_skips_commands(): flexmock(module).should_receive('interpolate_context').replace_with( lambda hook_description, command, context: command ) flexmock(module).should_receive('make_environment').and_return({}) flexmock(module.borgmatic.execute).should_receive('execute_command').never() module.execute_hook([':', 'true'], None, 'config.yaml', 'pre-backup', dry_run=True) def test_execute_hook_with_empty_commands_does_not_raise(): module.execute_hook([], None, 'config.yaml', 'post-backup', dry_run=False) def test_execute_hook_on_error_logs_as_error(): flexmock(module).should_receive('interpolate_context').replace_with( lambda hook_description, command, context: command ) flexmock(module).should_receive('make_environment').and_return({}) flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.ERROR, shell=True, environment={}, ).once() module.execute_hook([':'], None, 'config.yaml', 'on-error', dry_run=False) def test_considered_soft_failure_treats_soft_fail_exit_code_as_soft_fail(): error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again') assert module.considered_soft_failure(error) def test_considered_soft_failure_does_not_treat_other_exit_code_as_soft_fail(): error = subprocess.CalledProcessError(1, 'error') assert not module.considered_soft_failure(error) def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail(): assert not module.considered_soft_failure(Exception()) borgmatic/tests/unit/hooks/test_dispatch.py000066400000000000000000000266001476361726000215210ustar00rootroot00000000000000import sys import pytest from flexmock import flexmock from borgmatic.hooks import dispatch as module def hook_function(hook_config, config, thing, value): ''' This test function gets mocked out below. ''' pass def test_call_hook_invokes_module_function_with_arguments_and_returns_value(): config = {'super_hook': flexmock(), 'other_hook': flexmock()} expected_return_value = flexmock() test_module = sys.modules[__name__] flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'third_hook']) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.super_hook' ).and_return(test_module) flexmock(test_module).should_receive('hook_function').with_args( config['super_hook'], config, 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'super_hook', 55, value=66) assert return_value == expected_return_value def test_call_hook_probes_config_with_databases_suffix(): config = {'super_hook_databases': flexmock(), 'other_hook': flexmock()} expected_return_value = flexmock() test_module = sys.modules[__name__] flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'third_hook']) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.super_hook' ).and_return(test_module) flexmock(test_module).should_receive('hook_function').with_args( config['super_hook_databases'], config, 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'super_hook', 55, value=66) assert return_value == expected_return_value def test_call_hook_strips_databases_suffix_from_hook_name(): config = {'super_hook_databases': flexmock(), 'other_hook_databases': flexmock()} expected_return_value = flexmock() test_module = sys.modules[__name__] flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'third_hook']) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.super_hook' ).and_return(test_module) flexmock(test_module).should_receive('hook_function').with_args( config['super_hook_databases'], config, 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'super_hook_databases', 55, value=66) assert return_value == expected_return_value def test_call_hook_without_hook_config_invokes_module_function_with_arguments_and_returns_value(): config = {'other_hook': flexmock()} expected_return_value = flexmock() test_module = sys.modules[__name__] flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'third_hook']) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.super_hook' ).and_return(test_module) flexmock(test_module).should_receive('hook_function').with_args( None, config, 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'super_hook', 55, value=66) assert return_value == expected_return_value def test_call_hook_without_corresponding_module_raises(): config = {'super_hook': flexmock(), 'other_hook': flexmock()} test_module = sys.modules[__name__] flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['some_hook']) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.super_hook' ).and_return(test_module) flexmock(test_module).should_receive('hook_function').never() with pytest.raises(ValueError): module.call_hook('hook_function', config, 'super_hook', 55, value=66) def test_call_hook_skips_non_hook_modules(): config = {'not_a_hook': flexmock(), 'other_hook': flexmock()} flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.credential ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.data_source ).and_return(['other_hook']) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['not_a_hook', 'third_hook']) not_a_hook_module = flexmock(IS_A_HOOK=False) flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring.not_a_hook' ).and_return(not_a_hook_module) return_value = module.call_hook('hook_function', config, 'not_a_hook', 55, value=66) assert return_value is None def test_call_hooks_calls_each_hook_and_collects_return_values(): config = {'super_hook': flexmock(), 'other_hook': flexmock()} expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return( expected_return_values['super_hook'] ).and_return(expected_return_values['other_hook']) return_values = module.call_hooks('do_stuff', config, module.Hook_type.MONITORING, 55) assert return_values == expected_return_values def test_call_hooks_calls_skips_return_values_for_unconfigured_hooks(): config = {'super_hook': flexmock()} expected_return_values = {'super_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook']) return_values = module.call_hooks('do_stuff', config, module.Hook_type.MONITORING, 55) assert return_values == expected_return_values def test_call_hooks_calls_treats_null_hook_as_optionless(): config = {'super_hook': flexmock(), 'other_hook': None} expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return( expected_return_values['super_hook'] ).and_return(expected_return_values['other_hook']) return_values = module.call_hooks('do_stuff', config, module.Hook_type.MONITORING, 55) assert return_values == expected_return_values def test_call_hooks_calls_looks_up_databases_suffix_in_config(): config = {'super_hook_databases': flexmock(), 'other_hook': flexmock()} expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return( expected_return_values['super_hook'] ).and_return(expected_return_values['other_hook']) return_values = module.call_hooks('do_stuff', config, module.Hook_type.MONITORING, 55) assert return_values == expected_return_values def test_call_hooks_even_if_unconfigured_calls_each_hook_and_collects_return_values(): config = {'super_hook': flexmock(), 'other_hook': flexmock()} expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return( expected_return_values['super_hook'] ).and_return(expected_return_values['other_hook']) return_values = module.call_hooks_even_if_unconfigured( 'do_stuff', config, module.Hook_type.MONITORING, 55 ) assert return_values == expected_return_values def test_call_hooks_even_if_unconfigured_calls_each_hook_configured_or_not_and_collects_return_values(): config = {'other_hook': flexmock()} expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()} flexmock(module.importlib).should_receive('import_module').with_args( 'borgmatic.hooks.monitoring' ).and_return(module.borgmatic.hooks.monitoring) flexmock(module).should_receive('get_submodule_names').with_args( module.borgmatic.hooks.monitoring ).and_return(['super_hook', 'other_hook']) flexmock(module).should_receive('call_hook').and_return( expected_return_values['super_hook'] ).and_return(expected_return_values['other_hook']) return_values = module.call_hooks_even_if_unconfigured( 'do_stuff', config, module.Hook_type.MONITORING, 55 ) assert return_values == expected_return_values borgmatic/tests/unit/test_execute.py000066400000000000000000000600511476361726000202370ustar00rootroot00000000000000import subprocess import pytest from flexmock import flexmock from borgmatic import execute as module @pytest.mark.parametrize( 'command,exit_code,borg_local_path,borg_exit_codes,expected_result', ( (['grep'], 2, None, None, module.Exit_status.ERROR), (['grep'], 2, 'borg', None, module.Exit_status.ERROR), (['borg'], 2, 'borg', None, module.Exit_status.ERROR), (['borg1'], 2, 'borg1', None, module.Exit_status.ERROR), (['grep'], 1, None, None, module.Exit_status.ERROR), (['grep'], 1, 'borg', None, module.Exit_status.ERROR), (['borg'], 1, 'borg', None, module.Exit_status.WARNING), (['borg1'], 1, 'borg1', None, module.Exit_status.WARNING), (['grep'], 100, None, None, module.Exit_status.ERROR), (['grep'], 100, 'borg', None, module.Exit_status.ERROR), (['borg'], 100, 'borg', None, module.Exit_status.WARNING), (['borg1'], 100, 'borg1', None, module.Exit_status.WARNING), (['grep'], 0, None, None, module.Exit_status.SUCCESS), (['grep'], 0, 'borg', None, module.Exit_status.SUCCESS), (['borg'], 0, 'borg', None, module.Exit_status.SUCCESS), (['borg1'], 0, 'borg1', None, module.Exit_status.SUCCESS), # -9 exit code occurs when child process get SIGKILLed. (['grep'], -9, None, None, module.Exit_status.ERROR), (['grep'], -9, 'borg', None, module.Exit_status.ERROR), (['borg'], -9, 'borg', None, module.Exit_status.ERROR), (['borg1'], -9, 'borg1', None, module.Exit_status.ERROR), (['borg'], None, None, None, module.Exit_status.STILL_RUNNING), (['borg'], 1, 'borg', [], module.Exit_status.WARNING), (['borg'], 1, 'borg', [{}], module.Exit_status.WARNING), (['borg'], 1, 'borg', [{'code': 1}], module.Exit_status.WARNING), (['grep'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR), (['borg'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING), (['borg'], 1, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR), (['borg'], 2, 'borg', [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR), (['borg'], 2, 'borg', [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING), (['borg'], 100, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING), (['borg'], 100, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR), ), ) def test_interpret_exit_code_respects_exit_code_and_borg_local_path( command, exit_code, borg_local_path, borg_exit_codes, expected_result ): assert ( module.interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes) is expected_result ) def test_command_for_process_converts_sequence_command_to_string(): process = flexmock(args=['foo', 'bar', 'baz']) assert module.command_for_process(process) == 'foo bar baz' def test_command_for_process_passes_through_string_command(): process = flexmock(args='foo bar baz') assert module.command_for_process(process) == 'foo bar baz' def test_output_buffer_for_process_returns_stderr_when_stdout_excluded(): stdout = flexmock() stderr = flexmock() process = flexmock(stdout=stdout, stderr=stderr) assert module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), stdout]) == stderr def test_output_buffer_for_process_returns_stdout_when_not_excluded(): stdout = flexmock() process = flexmock(stdout=stdout) assert ( module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), flexmock()]) == stdout ) def test_append_last_lines_under_max_line_count_appends(): last_lines = ['last'] flexmock(module.logger).should_receive('log').once() module.append_last_lines( last_lines, captured_output=flexmock(), line='line', output_log_level=flexmock() ) assert last_lines == ['last', 'line'] def test_append_last_lines_over_max_line_count_trims_and_appends(): original_last_lines = [str(number) for number in range(0, module.ERROR_OUTPUT_MAX_LINE_COUNT)] last_lines = list(original_last_lines) flexmock(module.logger).should_receive('log').once() module.append_last_lines( last_lines, captured_output=flexmock(), line='line', output_log_level=flexmock() ) assert last_lines == original_last_lines[1:] + ['line'] def test_append_last_lines_with_output_log_level_none_appends_captured_output(): last_lines = ['last'] captured_output = ['captured'] flexmock(module.logger).should_receive('log').never() module.append_last_lines( last_lines, captured_output=captured_output, line='line', output_log_level=None ) assert captured_output == ['captured', 'line'] def test_mask_command_secrets_masks_password_flag_value(): assert module.mask_command_secrets(('cooldb', '--username', 'bob', '--password', 'pass')) == ( 'cooldb', '--username', 'bob', '--password', '***', ) def test_mask_command_secrets_passes_through_other_commands(): assert module.mask_command_secrets(('cooldb', '--username', 'bob')) == ( 'cooldb', '--username', 'bob', ) @pytest.mark.parametrize( 'full_command,input_file,output_file,environment,expected_result', ( (('foo', 'bar'), None, None, None, 'foo bar'), (('foo', 'bar'), flexmock(name='input'), None, None, 'foo bar < input'), (('foo', 'bar'), None, flexmock(name='output'), None, 'foo bar > output'), ( ('A',) * module.MAX_LOGGED_COMMAND_LENGTH, None, None, None, 'A ' * (module.MAX_LOGGED_COMMAND_LENGTH // 2 - 2) + '...', ), ( ('foo', 'bar'), flexmock(name='input'), flexmock(name='output'), None, 'foo bar < input > output', ), ( ('foo', 'bar'), None, None, {'UNKNOWN': 'secret', 'OTHER': 'thing'}, 'foo bar', ), ( ('foo', 'bar'), None, None, {'PGTHING': 'secret', 'BORG_OTHER': 'thing'}, 'PGTHING=*** BORG_OTHER=*** foo bar', ), ), ) def test_log_command_logs_command_constructed_from_arguments( full_command, input_file, output_file, environment, expected_result ): flexmock(module).should_receive('mask_command_secrets').replace_with(lambda command: command) flexmock(module.logger).should_receive('debug').with_args(expected_result).once() module.log_command(full_command, input_file, output_file, environment) def test_execute_command_calls_full_command(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command) assert output is None def test_execute_command_calls_full_command_with_output_file(): full_command = ['foo', 'bar'] output_file = flexmock(name='test') flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=output_file, stderr=module.subprocess.PIPE, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stderr=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, output_file=output_file) assert output is None def test_execute_command_calls_full_command_without_capturing_output(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(wait=lambda: 0)).once() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, output_file=module.DO_NOT_CAPTURE) assert output is None def test_execute_command_calls_full_command_with_input_file(): full_command = ['foo', 'bar'] input_file = flexmock(name='test') flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=input_file, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, input_file=input_file) assert output is None def test_execute_command_calls_full_command_with_shell(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( ' '.join(full_command), stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=True, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, shell=True) assert output is None def test_execute_command_calls_full_command_with_environment(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env={'a': 'b'}, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, environment={'a': 'b'}) assert output is None def test_execute_command_calls_full_command_with_working_directory(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd='/working', close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, working_directory='/working') assert output is None def test_execute_command_without_run_to_completion_returns_process(): full_command = ['foo', 'bar'] process = flexmock() flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(process).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') assert module.execute_command(full_command, run_to_completion=False) == process def test_execute_command_and_capture_output_returns_stdout(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=None, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output(full_command) assert output == expected_output def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output(full_command, capture_stderr=True) assert output == expected_output def test_execute_command_and_capture_output_returns_output_when_process_error_is_not_considered_an_error(): full_command = ['foo', 'bar'] expected_output = '[]' err_output = b'[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=None, shell=False, env=None, cwd=None, close_fds=False, ).and_raise(subprocess.CalledProcessError(1, full_command, err_output)).once() flexmock(module).should_receive('interpret_exit_code').and_return( module.Exit_status.SUCCESS ).once() output = module.execute_command_and_capture_output(full_command) assert output == expected_output def test_execute_command_and_capture_output_raises_when_command_errors(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=None, shell=False, env=None, cwd=None, close_fds=False, ).and_raise(subprocess.CalledProcessError(2, full_command, expected_output)).once() flexmock(module).should_receive('interpret_exit_code').and_return( module.Exit_status.ERROR ).once() with pytest.raises(subprocess.CalledProcessError): module.execute_command_and_capture_output(full_command) def test_execute_command_and_capture_output_returns_output_with_shell(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( 'foo bar', stdin=None, stderr=None, shell=True, env=None, cwd=None, close_fds=False, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output(full_command, shell=True) assert output == expected_output def test_execute_command_and_capture_output_returns_output_with_environment(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=None, shell=False, env={'a': 'b'}, cwd=None, close_fds=False, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output( full_command, shell=False, environment={'a': 'b'} ) assert output == expected_output def test_execute_command_and_capture_output_returns_output_with_working_directory(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stdin=None, stderr=None, shell=False, env=None, cwd='/working', close_fds=False, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output( full_command, shell=False, working_directory='/working' ) assert output == expected_output def test_execute_command_with_processes_calls_full_command(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes(full_command, processes) assert output is None def test_execute_command_with_processes_returns_output_with_output_log_level_none(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') process = flexmock(stdout=None) flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(process).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs').and_return({process: 'out'}) output = module.execute_command_with_processes(full_command, processes, output_log_level=None) assert output == 'out' def test_execute_command_with_processes_calls_full_command_with_output_file(): full_command = ['foo', 'bar'] processes = (flexmock(),) output_file = flexmock(name='test') flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=output_file, stderr=module.subprocess.PIPE, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stderr=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes(full_command, processes, output_file=output_file) assert output is None def test_execute_command_with_processes_calls_full_command_without_capturing_output(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(wait=lambda: 0)).once() flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS) flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes( full_command, processes, output_file=module.DO_NOT_CAPTURE ) assert output is None def test_execute_command_with_processes_calls_full_command_with_input_file(): full_command = ['foo', 'bar'] processes = (flexmock(),) input_file = flexmock(name='test') flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=input_file, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes(full_command, processes, input_file=input_file) assert output is None def test_execute_command_with_processes_calls_full_command_with_shell(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( ' '.join(full_command), stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=True, env=None, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes(full_command, processes, shell=True) assert output is None def test_execute_command_with_processes_calls_full_command_with_environment(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env={'a': 'b'}, cwd=None, close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes(full_command, processes, environment={'a': 'b'}) assert output is None def test_execute_command_with_processes_calls_full_command_with_working_directory(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module).should_receive('log_command') flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd='/working', close_fds=False, ).and_return(flexmock(stdout=None)).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes( full_command, processes, working_directory='/working' ) assert output is None def test_execute_command_with_processes_kills_processes_on_error(): full_command = ['foo', 'bar'] flexmock(module).should_receive('log_command') process = flexmock(stdout=flexmock(read=lambda count: None)) process.should_receive('poll') process.should_receive('kill').once() processes = (process,) flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=module.subprocess.PIPE, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None, close_fds=False, ).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once() flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock()) flexmock(module).should_receive('log_outputs').never() with pytest.raises(subprocess.CalledProcessError): module.execute_command_with_processes(full_command, processes) borgmatic/tests/unit/test_logger.py000066400000000000000000000713371476361726000200650ustar00rootroot00000000000000import logging import sys import pytest from flexmock import flexmock from borgmatic import logger as module @pytest.mark.parametrize('bool_val', (True, 'yes', 'on', '1', 'true', 'True', 1)) def test_to_bool_parses_true_values(bool_val): assert module.to_bool(bool_val) @pytest.mark.parametrize('bool_val', (False, 'no', 'off', '0', 'false', 'False', 0)) def test_to_bool_parses_false_values(bool_val): assert not module.to_bool(bool_val) def test_to_bool_passes_none_through(): assert module.to_bool(None) is None def test_interactive_console_false_when_not_isatty(capsys): with capsys.disabled(): flexmock(module.sys.stderr).should_receive('isatty').and_return(False) assert module.interactive_console() is False def test_interactive_console_false_when_TERM_is_dumb(capsys): with capsys.disabled(): flexmock(module.sys.stderr).should_receive('isatty').and_return(True) flexmock(module.os.environ).should_receive('get').with_args('TERM').and_return('dumb') assert module.interactive_console() is False def test_interactive_console_true_when_isatty_and_TERM_is_not_dumb(capsys): with capsys.disabled(): flexmock(module.sys.stderr).should_receive('isatty').and_return(True) flexmock(module.os.environ).should_receive('get').with_args('TERM').and_return('smart') assert module.interactive_console() is True def test_should_do_markup_respects_no_color_value(): flexmock(module.os.environ).should_receive('get').and_return(None) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=True, configs={}) is False def test_should_do_markup_respects_config_value(): flexmock(module.os.environ).should_receive('get').and_return(None) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': False}}) is False flexmock(module).should_receive('interactive_console').and_return(True).once() assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': True}}) is True def test_should_do_markup_prefers_any_false_config_value(): flexmock(module.os.environ).should_receive('get').and_return(None) flexmock(module).should_receive('interactive_console').never() assert ( module.should_do_markup( no_color=False, configs={ 'foo.yaml': {'color': True}, 'bar.yaml': {'color': False}, }, ) is False ) def test_should_do_markup_respects_PY_COLORS_environment_variable(): flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return( 'True' ) flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return(None) flexmock(module).should_receive('to_bool').and_return(True) assert module.should_do_markup(no_color=False, configs={}) is True def test_should_do_markup_prefers_no_color_value_to_config_value(): flexmock(module.os.environ).should_receive('get').and_return(None) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=True, configs={'foo.yaml': {'color': True}}) is False def test_should_do_markup_prefers_config_value_to_environment_variables(): flexmock(module.os.environ).should_receive('get').and_return('True') flexmock(module).should_receive('to_bool').and_return(True) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': False}}) is False def test_should_do_markup_prefers_no_color_value_to_environment_variables(): flexmock(module.os.environ).should_receive('get').and_return('True') flexmock(module).should_receive('to_bool').and_return(True) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=True, configs={}) is False def test_should_do_markup_respects_interactive_console_value(): flexmock(module.os.environ).should_receive('get').and_return(None) flexmock(module).should_receive('interactive_console').and_return(True) assert module.should_do_markup(no_color=False, configs={}) is True def test_should_do_markup_prefers_PY_COLORS_to_interactive_console_value(): flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return( 'True' ) flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return(None) flexmock(module).should_receive('to_bool').and_return(True) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={}) is True def test_should_do_markup_prefers_NO_COLOR_to_interactive_console_value(): flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return(None) flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return('True') flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={}) is False def test_should_do_markup_respects_NO_COLOR_environment_variable(): flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return('True') flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return(None) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={}) is False def test_should_do_markup_ignores_empty_NO_COLOR_environment_variable(): flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return('') flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return(None) flexmock(module).should_receive('interactive_console').and_return(True) assert module.should_do_markup(no_color=False, configs={}) is True def test_should_do_markup_prefers_NO_COLOR_to_PY_COLORS(): flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return( 'True' ) flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return( 'SomeValue' ) flexmock(module).should_receive('interactive_console').never() assert module.should_do_markup(no_color=False, configs={}) is False def test_multi_stream_handler_logs_to_handler_for_log_level(): error_handler = flexmock() error_handler.should_receive('emit').once() info_handler = flexmock() multi_handler = module.Multi_stream_handler( {module.logging.ERROR: error_handler, module.logging.INFO: info_handler} ) multi_handler.emit(flexmock(levelno=module.logging.ERROR)) def test_console_color_formatter_format_includes_log_message(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER plain_message = 'uh oh' flexmock(module.logging.Formatter).should_receive('format').and_return(plain_message) record = flexmock(levelno=logging.CRITICAL) colored_message = module.Console_color_formatter().format(record) assert colored_message != plain_message assert plain_message in colored_message def test_color_text_does_not_raise(): flexmock(module).should_receive('ansi_escape_code').and_return('blah') module.color_text(module.Color.RED, 'hi') def test_color_text_without_color_does_not_raise(): flexmock(module).should_receive('ansi_escape_code').and_return('blah') module.color_text(None, 'hi') def test_add_logging_level_adds_level_name_and_sets_global_attributes_and_methods(): logger = flexmock() flexmock(module.logging).should_receive('getLoggerClass').and_return(logger) flexmock(module.logging).should_receive('addLevelName').with_args(99, 'PLAID') builtins = flexmock(sys.modules['builtins']) builtins.should_call('setattr') builtins.should_receive('setattr').with_args(module.logging, 'PLAID', 99).once() builtins.should_receive('setattr').with_args(logger, 'plaid', object).once() builtins.should_receive('setattr').with_args(logging, 'plaid', object).once() module.add_logging_level('PLAID', 99) def test_add_logging_level_skips_global_setting_if_already_set(): logger = flexmock() flexmock(module.logging).should_receive('getLoggerClass').and_return(logger) flexmock(module.logging).PLAID = 99 flexmock(logger).plaid = flexmock() flexmock(logging).plaid = flexmock() flexmock(module.logging).should_receive('addLevelName').never() builtins = flexmock(sys.modules['builtins']) builtins.should_call('setattr') builtins.should_receive('setattr').with_args(module.logging, 'PLAID', 99).never() builtins.should_receive('setattr').with_args(logger, 'plaid', object).never() builtins.should_receive('setattr').with_args(logging, 'plaid', object).never() module.add_logging_level('PLAID', 99) def test_get_log_prefix_gets_prefix_from_first_handler_formatter_with_prefix(): flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[ flexmock(formatter=flexmock()), flexmock(formatter=flexmock(prefix='myprefix')), ], removeHandler=lambda handler: None, ) ) assert module.get_log_prefix() == 'myprefix' def test_get_log_prefix_with_no_handlers_does_not_raise(): flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[], removeHandler=lambda handler: None, ) ) assert module.get_log_prefix() is None def test_get_log_prefix_with_no_formatters_does_not_raise(): flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[ flexmock(formatter=None), flexmock(formatter=None), ], removeHandler=lambda handler: None, ) ) assert module.get_log_prefix() is None def test_get_log_prefix_with_no_prefix_does_not_raise(): flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[ flexmock( formatter=flexmock(), ), ], removeHandler=lambda handler: None, ) ) assert module.get_log_prefix() is None def test_set_log_prefix_updates_all_handler_formatters(): formatters = ( flexmock(prefix=None), flexmock(prefix=None), ) flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[ flexmock( formatter=formatters[0], ), flexmock( formatter=formatters[1], ), ], removeHandler=lambda handler: None, ) ) module.set_log_prefix('myprefix') for formatter in formatters: assert formatter.prefix == 'myprefix' def test_set_log_prefix_skips_handlers_without_a_formatter(): formatter = flexmock(prefix=None) flexmock(module.logging).should_receive('getLogger').and_return( flexmock( handlers=[ flexmock( formatter=None, ), flexmock( formatter=formatter, ), ], removeHandler=lambda handler: None, ) ) module.set_log_prefix('myprefix') assert formatter.prefix == 'myprefix' def test_log_prefix_sets_prefix_and_then_restores_no_prefix_after(): flexmock(module).should_receive('get_log_prefix').and_return(None) flexmock(module).should_receive('set_log_prefix').with_args('myprefix').once() flexmock(module).should_receive('set_log_prefix').with_args(None).once() with module.Log_prefix('myprefix'): pass def test_log_prefix_sets_prefix_and_then_restores_original_prefix_after(): flexmock(module).should_receive('get_log_prefix').and_return('original') flexmock(module).should_receive('set_log_prefix').with_args('myprefix').once() flexmock(module).should_receive('set_log_prefix').with_args('original').once() with module.Log_prefix('myprefix'): pass def test_delayed_logging_handler_should_flush_without_targets_returns_false(): handler = module.Delayed_logging_handler() assert handler.shouldFlush(flexmock()) is False def test_delayed_logging_handler_should_flush_with_targets_returns_true(): handler = module.Delayed_logging_handler() handler.targets = [flexmock()] assert handler.shouldFlush(flexmock()) is True def test_delayed_logging_handler_flush_without_targets_does_not_raise(): handler = module.Delayed_logging_handler() flexmock(handler).should_receive('acquire') flexmock(handler).should_receive('release') handler.flush() def test_delayed_logging_handler_flush_with_empty_buffer_does_not_raise(): handler = module.Delayed_logging_handler() flexmock(handler).should_receive('acquire') flexmock(handler).should_receive('release') handler.targets = [flexmock()] handler.flush() def test_delayed_logging_handler_flush_forwards_each_record_to_each_target(): handler = module.Delayed_logging_handler() flexmock(handler).should_receive('acquire') flexmock(handler).should_receive('release') handler.targets = [flexmock(level=logging.DEBUG), flexmock(level=logging.DEBUG)] handler.buffer = [flexmock(levelno=logging.DEBUG), flexmock(levelno=logging.DEBUG)] handler.targets[0].should_receive('handle').with_args(handler.buffer[0]).once() handler.targets[1].should_receive('handle').with_args(handler.buffer[0]).once() handler.targets[0].should_receive('handle').with_args(handler.buffer[1]).once() handler.targets[1].should_receive('handle').with_args(handler.buffer[1]).once() handler.flush() assert handler.buffer == [] def test_delayed_logging_handler_flush_skips_forwarding_when_log_record_is_too_low_for_target(): handler = module.Delayed_logging_handler() flexmock(handler).should_receive('acquire') flexmock(handler).should_receive('release') handler.targets = [flexmock(level=logging.INFO), flexmock(level=logging.DEBUG)] handler.buffer = [flexmock(levelno=logging.DEBUG), flexmock(levelno=logging.INFO)] handler.targets[0].should_receive('handle').with_args(handler.buffer[0]).never() handler.targets[1].should_receive('handle').with_args(handler.buffer[0]).once() handler.targets[0].should_receive('handle').with_args(handler.buffer[1]).once() handler.targets[1].should_receive('handle').with_args(handler.buffer[1]).once() handler.flush() assert handler.buffer == [] def test_flush_delayed_logging_without_handlers_does_not_raise(): root_logger = flexmock(handlers=[]) root_logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(root_logger) module.flush_delayed_logging([flexmock()]) def test_flush_delayed_logging_without_delayed_logging_handler_does_not_raise(): root_logger = flexmock(handlers=[flexmock()]) root_logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(root_logger) module.flush_delayed_logging([flexmock()]) def test_flush_delayed_logging_flushes_delayed_logging_handler(): delayed_logging_handler = module.Delayed_logging_handler() root_logger = flexmock(handlers=[delayed_logging_handler]) flexmock(module.logging).should_receive('getLogger').and_return(root_logger) flexmock(delayed_logging_handler).should_receive('flush').once() root_logger.should_receive('removeHandler') module.flush_delayed_logging([flexmock()]) def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_linux(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True) syslog_handler = logging.handlers.SysLogHandler() flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args( address='/dev/log' ).and_return(syslog_handler).once() module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG) def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_macos(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(False) flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(True) syslog_handler = logging.handlers.SysLogHandler() flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args( address='/var/run/syslog' ).and_return(syslog_handler).once() module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG) def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_freebsd(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(False) flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(False) flexmock(module.os.path).should_receive('exists').with_args('/var/run/log').and_return(True) syslog_handler = logging.handlers.SysLogHandler() flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args( address='/var/run/log' ).and_return(syslog_handler).once() module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG) def test_configure_logging_without_syslog_log_level_skips_syslog(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.INFO, handlers=list ) flexmock(module.os.path).should_receive('exists').never() flexmock(module.logging.handlers).should_receive('SysLogHandler').never() module.configure_logging(console_log_level=logging.INFO) def test_configure_logging_skips_syslog_if_not_found(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.INFO, handlers=list ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.logging.handlers).should_receive('SysLogHandler').never() module.configure_logging(console_log_level=logging.INFO, syslog_log_level=logging.DEBUG) def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).DISABLED = module.DISABLED fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.INFO, handlers=list ) flexmock(module.os.path).should_receive('exists').never() flexmock(module.logging.handlers).should_receive('SysLogHandler').never() flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never() module.configure_logging( console_log_level=logging.INFO, log_file_log_level=logging.DISABLED, log_file='/tmp/logfile' ) def test_configure_logging_to_log_file_instead_of_syslog(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').never() flexmock(module.logging.handlers).should_receive('SysLogHandler').never() file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile') flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args( '/tmp/logfile' ).and_return(file_handler).once() module.configure_logging( console_log_level=logging.INFO, syslog_log_level=logging.DISABLED, log_file_log_level=logging.DEBUG, log_file='/tmp/logfile', ) def test_configure_logging_to_both_log_file_and_syslog(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True) syslog_handler = logging.handlers.SysLogHandler() flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args( address='/dev/log' ).and_return(syslog_handler).once() file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile') flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args( '/tmp/logfile' ).and_return(file_handler).once() module.configure_logging( console_log_level=logging.INFO, syslog_log_level=logging.DEBUG, log_file_log_level=logging.DEBUG, log_file='/tmp/logfile', ) def test_configure_logging_to_log_file_formats_with_custom_log_format(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER flexmock(module).should_receive('Log_prefix_formatter').with_args( '{message}', # noqa: FS003 ).once() fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.DEBUG, handlers=list ) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True) flexmock(module.logging.handlers).should_receive('SysLogHandler').never() file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile') flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args( '/tmp/logfile' ).and_return(file_handler).once() module.configure_logging( console_log_level=logging.INFO, log_file_log_level=logging.DEBUG, log_file='/tmp/logfile', log_file_format='{message}', # noqa: FS003 ) def test_configure_logging_skips_log_file_if_argument_is_none(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.INFO, handlers=list ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never() module.configure_logging(console_log_level=logging.INFO, log_file=None) def test_configure_logging_uses_console_no_color_formatter_if_color_disabled(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.ANSWER fake_formatter = flexmock() flexmock(module).should_receive('Console_color_formatter').never() flexmock(module).should_receive('Log_prefix_formatter').and_return(fake_formatter) multi_stream_handler = flexmock(setLevel=lambda level: None, level=logging.INFO) multi_stream_handler.should_receive('setFormatter').with_args(fake_formatter).once() flexmock(module).should_receive('Multi_stream_handler').and_return(multi_stream_handler) flexmock(module).should_receive('flush_delayed_logging') flexmock(module.logging).should_receive('basicConfig').with_args( level=logging.INFO, handlers=list ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never() module.configure_logging(console_log_level=logging.INFO, log_file=None, color_enabled=False) borgmatic/tests/unit/test_signals.py000066400000000000000000000037621476361726000202430ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic import signals as module def test_handle_signal_forwards_to_subprocesses(): signal_number = 100 frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something'))) process_group = flexmock() flexmock(module.os).should_receive('getpgrp').and_return(process_group) flexmock(module.os).should_receive('killpg').with_args(process_group, signal_number).once() module.handle_signal(signal_number, frame) def test_handle_signal_bails_on_recursion(): signal_number = 100 frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='handle_signal'))) flexmock(module.os).should_receive('getpgrp').never() flexmock(module.os).should_receive('killpg').never() module.handle_signal(signal_number, frame) def test_handle_signal_exits_on_sigterm(): signal_number = module.signal.SIGTERM frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something'))) flexmock(module.os).should_receive('getpgrp').and_return(flexmock()) flexmock(module.os).should_receive('killpg') flexmock(module.sys).should_receive('exit').with_args( module.EXIT_CODE_FROM_SIGNAL + signal_number ).once() module.handle_signal(signal_number, frame) def test_handle_signal_raises_on_sigint(): signal_number = module.signal.SIGINT frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something'))) process_group = flexmock() flexmock(module.os).should_receive('getpgrp').and_return(process_group) flexmock(module.os).should_receive('killpg').with_args(process_group, module.signal.SIGINT) flexmock(module.os).should_receive('killpg').with_args(process_group, module.signal.SIGTERM) flexmock(module.sys).should_receive('exit').never() with pytest.raises(KeyboardInterrupt): module.handle_signal(signal_number, frame) def test_configure_signals_installs_signal_handlers(): flexmock(module.signal).should_receive('signal').at_least().once() module.configure_signals() borgmatic/tests/unit/test_verbosity.py000066400000000000000000000026221476361726000206230ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic import verbosity as module def insert_logging_mock(log_level): ''' Mock the isEnabledFor from Python logging. ''' logging = flexmock(module.logging.Logger) logging.should_receive('isEnabledFor').replace_with(lambda level: level >= log_level) logging.should_receive('getEffectiveLevel').replace_with(lambda: log_level) def test_verbosity_to_log_level_maps_known_verbosity_to_log_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module.logging).DISABLED = module.borgmatic.logger.DISABLED assert module.verbosity_to_log_level(module.VERBOSITY_ERROR) == logging.ERROR assert module.verbosity_to_log_level(module.VERBOSITY_ANSWER) == module.borgmatic.logger.ANSWER assert module.verbosity_to_log_level(module.VERBOSITY_SOME) == logging.INFO assert module.verbosity_to_log_level(module.VERBOSITY_LOTS) == logging.DEBUG assert module.verbosity_to_log_level(module.VERBOSITY_DISABLED) == logging.DISABLED def test_verbosity_to_log_level_maps_unknown_verbosity_to_warning_level(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER assert module.verbosity_to_log_level('my pants') == logging.WARNING borgmatic/tox.ini000066400000000000000000000016151476361726000143570ustar00rootroot00000000000000[tox] env_list = py39,py310,py311,py312,py313 skip_missing_interpreters = True package = editable min_version = 4.0 [testenv] deps = -r test_requirements.txt whitelist_externals = find sh passenv = COVERAGE_FILE commands = pytest {posargs} black --check . isort --check-only . flake8 borgmatic tests codespell [testenv:black] commands = black {posargs} . [testenv:test] commands = pytest {posargs} [testenv:end-to-end] package = editable system_site_packages = True deps = -r test_requirements.txt . pass_env = COVERAGE_FILE commands = pytest {posargs} --no-cov tests/end-to-end [testenv:isort] deps = {[testenv]deps} commands = isort . [testenv:codespell] deps = {[testenv]deps} commands = codespell --write-changes [flake8] max-line-length = 100 extend-ignore = E203,E501,W503 exclude = *.*/* multiline-quotes = ''' docstring-quotes = '''