pax_global_header00006660000000000000000000000064145061033660014516gustar00rootroot0000000000000052 comment=cacb81f0862ba1bb44641e167439123cf73e5f6b borgmatic-1.8.3/000077500000000000000000000000001450610336600134765ustar00rootroot00000000000000borgmatic-1.8.3/.dockerignore000066400000000000000000000000121450610336600161430ustar00rootroot00000000000000.git .tox borgmatic-1.8.3/.drone.yml000066400000000000000000000042771450610336600154200ustar00rootroot00000000000000--- kind: pipeline name: python-3-8-alpine-3-13 services: - name: postgresql image: docker.io/postgres:13.1-alpine environment: POSTGRES_PASSWORD: test POSTGRES_DB: test - name: postgresql2 image: docker.io/postgres:13.1-alpine environment: POSTGRES_PASSWORD: test2 POSTGRES_DB: test commands: - docker-entrypoint.sh -p 5433 - name: mariadb image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test - name: mariadb2 image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test commands: - docker-entrypoint.sh --port=3307 - name: not-actually-mysql image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test - name: not-actually-mysql2 image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test commands: - docker-entrypoint.sh --port=3307 - name: mongodb image: docker.io/mongo:5.0.5 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: test - name: mongodb2 image: docker.io/mongo:5.0.5 environment: MONGO_INITDB_ROOT_USERNAME: root2 MONGO_INITDB_ROOT_PASSWORD: test2 commands: - docker-entrypoint.sh --port=27018 clone: skip_verify: true steps: - name: build image: docker.io/alpine:3.13 environment: TEST_CONTAINER: true pull: always commands: - scripts/run-full-tests --- kind: pipeline name: documentation type: exec platform: os: linux arch: amd64 clone: skip_verify: true steps: - name: build environment: USERNAME: from_secret: docker_username PASSWORD: from_secret: docker_password IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs commands: - podman login --username "$USERNAME" --password "$PASSWORD" projects.torsion.org - podman build --tag "$IMAGE_NAME" --file docs/Dockerfile --storage-opt "overlay.mount_program=/usr/bin/fuse-overlayfs" . - podman push "$IMAGE_NAME" trigger: repo: - borgmatic-collective/borgmatic branch: - main borgmatic-1.8.3/.eleventy.js000066400000000000000000000031301450610336600157420ustar00rootroot00000000000000const 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-1.8.3/.flake8000066400000000000000000000000141450610336600146440ustar00rootroot00000000000000select = Q0 borgmatic-1.8.3/.gitea/000077500000000000000000000000001450610336600146455ustar00rootroot00000000000000borgmatic-1.8.3/.gitea/issue_template/000077500000000000000000000000001450610336600176705ustar00rootroot00000000000000borgmatic-1.8.3/.gitea/issue_template/bug_template.yaml000066400000000000000000000041011450610336600232200ustar00rootroot00000000000000name: "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-1.8.3/.gitea/issue_template/config.yaml000066400000000000000000000000331450610336600220150ustar00rootroot00000000000000blank_issues_enabled: true borgmatic-1.8.3/.gitea/issue_template/feature_template.yaml000066400000000000000000000005161450610336600241040ustar00rootroot00000000000000name: "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-1.8.3/.gitignore000066400000000000000000000001511450610336600154630ustar00rootroot00000000000000*.egg-info *.pyc *.swp .cache .coverage* .pytest_cache .tox __pycache__ build/ dist/ pip-wheel-metadata/ borgmatic-1.8.3/AUTHORS000066400000000000000000000011671450610336600145530ustar00rootroot00000000000000Dan 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-1.8.3/LICENSE000066400000000000000000001044621450610336600145120ustar00rootroot00000000000000GNU 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-1.8.3/MANIFEST.in000066400000000000000000000000721450610336600152330ustar00rootroot00000000000000include borgmatic/config/schema.yaml graft sample/systemd borgmatic-1.8.3/NEWS000066400000000000000000002021451450610336600142010ustar00rootroot000000000000001.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 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-1.8.3/README.md000066400000000000000000000147721450610336600147700ustar00rootroot00000000000000--- 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: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c ``` borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). ## Integrations PostgreSQL      MySQL      MariaDB      MongoDB      SQLite      Healthchecks      Cronitor      Cronhub      PagerDuty      ntfy      BorgBase      ## 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. ![Build Status](https://build.torsion.org/api/badges/borgmatic-collective/borgmatic/status.svg?ref=refs/heads/main) borgmatic-1.8.3/SECURITY.md000066400000000000000000000012221450610336600152640ustar00rootroot00000000000000--- 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-1.8.3/borgmatic/000077500000000000000000000000001450610336600154455ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/__init__.py000066400000000000000000000000001450610336600175440ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/actions/000077500000000000000000000000001450610336600171055ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/actions/__init__.py000066400000000000000000000000001450610336600212040ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/actions/arguments.py000066400000000000000000000005071450610336600214660ustar00rootroot00000000000000import 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-1.8.3/borgmatic/actions/borg.py000066400000000000000000000022461450610336600204140ustar00rootroot00000000000000import logging import borgmatic.borg.borg import borgmatic.borg.rlist 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( f'{repository.get("label", repository["path"])}: Running arbitrary Borg command' ) archive_name = borgmatic.borg.rlist.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-1.8.3/borgmatic/actions/break_lock.py000066400000000000000000000015561450610336600215620ustar00rootroot00000000000000import 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( f'{repository.get("label", repository["path"])}: 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-1.8.3/borgmatic/actions/check.py000066400000000000000000000026621450610336600205420ustar00rootroot00000000000000import logging import borgmatic.borg.check import borgmatic.config.validate import borgmatic.hooks.command logger = logging.getLogger(__name__) 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. ''' 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(f'{repository.get("label", repository["path"])}: Running consistency checks') borgmatic.borg.check.check_archives( repository['path'], config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, progress=check_arguments.progress, repair=check_arguments.repair, only_checks=check_arguments.only, force=check_arguments.force, ) borgmatic.hooks.command.execute_hook( config.get('after_check'), config.get('umask'), config_filename, 'post-check', global_arguments.dry_run, **hook_context, ) borgmatic-1.8.3/borgmatic/actions/compact.py000066400000000000000000000035721450610336600211140ustar00rootroot00000000000000import 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'{repository.get("label", repository["path"])}: 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( f'{repository.get("label", repository["path"])}: 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-1.8.3/borgmatic/actions/config/000077500000000000000000000000001450610336600203525ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/actions/config/__init__.py000066400000000000000000000000001450610336600224510ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/actions/config/bootstrap.py000066400000000000000000000067051450610336600227510ustar00rootroot00000000000000import json import logging import os import borgmatic.borg.extract import borgmatic.borg.rlist import borgmatic.config.validate import borgmatic.hooks.command from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY logger = logging.getLogger(__name__) def get_config_paths(bootstrap_arguments, global_arguments, local_borg_version): ''' Given: The bootstrap arguments, which include the repository and archive name, borgmatic source directory, destination directory, and whether to strip components. The global arguments, which include the dry run flag and the local borg version, Return: The config paths from the manifest.json file in the borgmatic source directory after extracting it from the repository. Raise ValueError if the manifest JSON is missing, can't be decoded, or doesn't contain the expected configuration path data. ''' borgmatic_source_directory = ( bootstrap_arguments.borgmatic_source_directory or DEFAULT_BORGMATIC_SOURCE_DIRECTORY ) borgmatic_manifest_path = os.path.expanduser( os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json') ) extract_process = borgmatic.borg.extract.extract_archive( global_arguments.dry_run, bootstrap_arguments.repository, borgmatic.borg.rlist.resolve_archive_name( bootstrap_arguments.repository, bootstrap_arguments.archive, {}, local_borg_version, global_arguments, ), [borgmatic_manifest_path], {}, local_borg_version, global_arguments, extract_to_stdout=True, ) manifest_json = extract_process.stdout.read() if not manifest_json: 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. ''' manifest_config_paths = get_config_paths( 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, borgmatic.borg.rlist.resolve_archive_name( bootstrap_arguments.repository, bootstrap_arguments.archive, {}, local_borg_version, global_arguments, ), [config_path.lstrip(os.path.sep) for config_path in manifest_config_paths], {}, local_borg_version, global_arguments, extract_to_stdout=False, destination_path=bootstrap_arguments.destination, strip_components=bootstrap_arguments.strip_components, progress=bootstrap_arguments.progress, ) borgmatic-1.8.3/borgmatic/actions/config/generate.py000066400000000000000000000031261450610336600225200ustar00rootroot00000000000000import 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-1.8.3/borgmatic/actions/config/validate.py000066400000000000000000000015321450610336600225160ustar00rootroot00000000000000import 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-1.8.3/borgmatic/actions/create.py000066400000000000000000000074441450610336600207330ustar00rootroot00000000000000import json import logging import os try: import importlib_metadata except ModuleNotFoundError: # pragma: nocover import importlib.metadata as importlib_metadata import borgmatic.borg.create import borgmatic.borg.state import borgmatic.config.validate import borgmatic.hooks.command import borgmatic.hooks.dispatch import borgmatic.hooks.dump logger = logging.getLogger(__name__) def create_borgmatic_manifest(config, config_paths, dry_run): ''' Create a borgmatic manifest file to store the paths to the configuration files used to create the archive. ''' if dry_run: return borgmatic_source_directory = config.get( 'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY ) borgmatic_manifest_path = os.path.expanduser( os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json') ) if not os.path.exists(borgmatic_manifest_path): os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True) with open(borgmatic_manifest_path, 'w') as config_list_file: json.dump( { 'borgmatic_version': importlib_metadata.version('borgmatic'), 'config_paths': config_paths, }, config_list_file, ) def run_create( config_filename, repository, config, 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'{repository.get("label", repository["path"])}: Creating archive{dry_run_label}') borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, repository['path'], borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, global_arguments.dry_run, ) active_dumps = borgmatic.hooks.dispatch.call_hooks( 'dump_data_sources', config, repository['path'], borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, global_arguments.dry_run, ) if config.get('store_config_files', True): create_borgmatic_manifest( config, global_arguments.used_config_paths, global_arguments.dry_run ) stream_processes = [process for processes in active_dumps.values() for process in processes] json_output = borgmatic.borg.create.create_archive( global_arguments.dry_run, repository['path'], config, local_borg_version, global_arguments, local_path=local_path, remote_path=remote_path, progress=create_arguments.progress, stats=create_arguments.stats, json=create_arguments.json, list_files=create_arguments.list_files, stream_processes=stream_processes, ) if json_output: # pragma: nocover yield json.loads(json_output) borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, config_filename, borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, global_arguments.dry_run, ) borgmatic.hooks.command.execute_hook( config.get('after_backup'), config.get('umask'), config_filename, 'post-backup', global_arguments.dry_run, **hook_context, ) borgmatic-1.8.3/borgmatic/actions/export_key.py000066400000000000000000000015371450610336600216560ustar00rootroot00000000000000import 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(f'{repository.get("label", repository["path"])}: 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-1.8.3/borgmatic/actions/export_tar.py000066400000000000000000000027611450610336600216540ustar00rootroot00000000000000import logging import borgmatic.borg.export_tar import borgmatic.borg.rlist 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'{repository["path"]}: Exporting archive {export_tar_arguments.archive} as tar file' ) borgmatic.borg.export_tar.export_tar_archive( global_arguments.dry_run, repository['path'], borgmatic.borg.rlist.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-1.8.3/borgmatic/actions/extract.py000066400000000000000000000036521450610336600211370ustar00rootroot00000000000000import logging import borgmatic.borg.extract import borgmatic.borg.rlist 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'{repository.get("label", repository["path"])}: Extracting archive {extract_arguments.archive}' ) borgmatic.borg.extract.extract_archive( global_arguments.dry_run, repository['path'], borgmatic.borg.rlist.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-1.8.3/borgmatic/actions/info.py000066400000000000000000000030071450610336600204120ustar00rootroot00000000000000import json import logging import borgmatic.actions.arguments import borgmatic.borg.info import borgmatic.borg.rlist 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: # pragma: nocover logger.answer( f'{repository.get("label", repository["path"])}: Displaying archive summary information' ) archive_name = borgmatic.borg.rlist.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: # pragma: nocover yield json.loads(json_output) borgmatic-1.8.3/borgmatic/actions/list.py000066400000000000000000000031511450610336600204320ustar00rootroot00000000000000import json import logging import borgmatic.actions.arguments 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: # pragma: nocover if list_arguments.find_paths: logger.answer(f'{repository.get("label", repository["path"])}: Searching archives') elif not list_arguments.archive: logger.answer(f'{repository.get("label", repository["path"])}: Listing archives') archive_name = borgmatic.borg.rlist.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: # pragma: nocover yield json.loads(json_output) borgmatic-1.8.3/borgmatic/actions/mount.py000066400000000000000000000025511450610336600206240ustar00rootroot00000000000000import logging import borgmatic.borg.mount import borgmatic.borg.rlist 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'{repository.get("label", repository["path"])}: Mounting archive {mount_arguments.archive}' ) else: # pragma: nocover logger.info(f'{repository.get("label", repository["path"])}: Mounting repository') borgmatic.borg.mount.mount_archive( repository['path'], borgmatic.borg.rlist.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-1.8.3/borgmatic/actions/prune.py000066400000000000000000000025441450610336600206150ustar00rootroot00000000000000import 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'{repository.get("label", repository["path"])}: 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-1.8.3/borgmatic/actions/rcreate.py000066400000000000000000000021031450610336600211000ustar00rootroot00000000000000import logging import borgmatic.borg.rcreate import borgmatic.config.validate logger = logging.getLogger(__name__) def run_rcreate( repository, config, local_borg_version, rcreate_arguments, global_arguments, local_path, remote_path, ): ''' Run the "rcreate" action for the given repository. ''' if rcreate_arguments.repository and not borgmatic.config.validate.repositories_match( repository, rcreate_arguments.repository ): return logger.info(f'{repository.get("label", repository["path"])}: Creating repository') borgmatic.borg.rcreate.create_repository( global_arguments.dry_run, repository['path'], config, local_borg_version, global_arguments, rcreate_arguments.encryption_mode, rcreate_arguments.source_repository, rcreate_arguments.copy_crypt_key, rcreate_arguments.append_only, rcreate_arguments.storage_quota, rcreate_arguments.make_parent_dirs, local_path=local_path, remote_path=remote_path, ) borgmatic-1.8.3/borgmatic/actions/restore.py000066400000000000000000000323031450610336600211430ustar00rootroot00000000000000import copy import logging import os import borgmatic.borg.extract import borgmatic.borg.list import borgmatic.borg.mount import borgmatic.borg.rlist import borgmatic.borg.state import borgmatic.config.validate import borgmatic.hooks.dispatch import borgmatic.hooks.dump logger = logging.getLogger(__name__) UNSPECIFIED_HOOK = object() def get_configured_data_source( config, archive_data_source_names, hook_name, data_source_name, configuration_data_source_name=None, ): ''' Find the first data source with the given hook name and data source name in the configuration dict and the given archive data source names dict (from hook name to data source names contained in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data source hooks for the named data source. If a configuration data source name is given, use that instead of the data source name to lookup the data source in the given hooks configuration. Return the found data source as a tuple of (found hook name, data source configuration dict) or (None, None) if not found. ''' if not configuration_data_source_name: configuration_data_source_name = data_source_name if hook_name == UNSPECIFIED_HOOK: hooks_to_search = { hook_name: value for (hook_name, value) in config.items() if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES } else: try: hooks_to_search = {hook_name: config[hook_name]} except KeyError: return (None, None) return next( ( (name, hook_data_source) for (name, hook) in hooks_to_search.items() for hook_data_source in hook if hook_data_source['name'] == configuration_data_source_name and data_source_name in archive_data_source_names.get(name, []) ), (None, None), ) def restore_single_data_source( repository, config, local_borg_version, global_arguments, local_path, remote_path, archive_name, hook_name, data_source, connection_params, ): # pragma: no cover ''' Given (among other things) an archive name, a data source hook name, the hostname, port, username/password as connection params, and a configured data source configuration dict, restore that data source from the archive. ''' logger.info( f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}' ) dump_pattern = borgmatic.hooks.dispatch.call_hooks( 'make_data_source_dump_pattern', config, repository['path'], borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, data_source['name'], )[hook_name] # Kick off a single data source extract to stdout. extract_process = borgmatic.borg.extract.extract_archive( dry_run=global_arguments.dry_run, repository=repository['path'], archive=archive_name, paths=borgmatic.hooks.dump.convert_glob_patterns_to_borg_patterns([dump_pattern]), config=config, local_borg_version=local_borg_version, global_arguments=global_arguments, local_path=local_path, remote_path=remote_path, destination_path='/', # A directory format dump isn't a single file, and therefore can't extract # to stdout. In this case, the extract_process return value is None. extract_to_stdout=bool(data_source.get('format') != 'directory'), ) # Run a single data source restore, consuming the extract stdout (if any). borgmatic.hooks.dispatch.call_hooks( function_name='restore_data_source_dump', config=config, log_prefix=repository['path'], hook_names=[hook_name], data_source=data_source, dry_run=global_arguments.dry_run, extract_process=extract_process, connection_params=connection_params, ) def collect_archive_data_source_names( repository, archive, config, local_borg_version, global_arguments, local_path, remote_path, ): ''' Given a local or remote repository path, a resolved archive name, a configuration dict, the local Borg version, global_arguments an argparse.Namespace, and local and remote Borg paths, query the archive for the names of data sources it contains as dumps and return them as a dict from hook name to a sequence of data source names. ''' borgmatic_source_directory = os.path.expanduser( config.get( 'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY ) ).lstrip('/') dump_paths = borgmatic.borg.list.capture_archive_listing( repository, archive, config, local_borg_version, global_arguments, list_paths=[ os.path.expanduser( borgmatic.hooks.dump.make_data_source_dump_path(borgmatic_source_directory, pattern) ) for pattern in ('*_databases/*/*',) ], local_path=local_path, remote_path=remote_path, ) # Determine the data source names corresponding to the dumps found in the archive and # add them to restore_names. archive_data_source_names = {} for dump_path in dump_paths: try: (hook_name, _, data_source_name) = dump_path.split( borgmatic_source_directory + os.path.sep, 1 )[1].split(os.path.sep)[0:3] except (ValueError, IndexError): logger.warning( f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}' ) else: if data_source_name not in archive_data_source_names.get(hook_name, []): archive_data_source_names.setdefault(hook_name, []).extend([data_source_name]) return archive_data_source_names def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names): ''' Given a sequence of requested data source names to restore and a dict of hook name to the names of data sources found in an archive, return an expanded sequence of data source names to restore, replacing "all" with actual data source names as appropriate. Raise ValueError if any of the requested data source names cannot be found in the archive. ''' # A map from data source hook name to the data source names to restore for that hook. restore_names = ( {UNSPECIFIED_HOOK: requested_data_source_names} if requested_data_source_names else {UNSPECIFIED_HOOK: ['all']} ) # If "all" is in restore_names, then replace it with the names of dumps found within the # archive. if 'all' in restore_names[UNSPECIFIED_HOOK]: restore_names[UNSPECIFIED_HOOK].remove('all') for hook_name, data_source_names in archive_data_source_names.items(): restore_names.setdefault(hook_name, []).extend(data_source_names) # If a data source is to be restored as part of "all", then remove it from restore names # so it doesn't get restored twice. for data_source_name in data_source_names: if data_source_name in restore_names[UNSPECIFIED_HOOK]: restore_names[UNSPECIFIED_HOOK].remove(data_source_name) if not restore_names[UNSPECIFIED_HOOK]: restore_names.pop(UNSPECIFIED_HOOK) combined_restore_names = set( name for data_source_names in restore_names.values() for name in data_source_names ) combined_archive_data_source_names = set( name for data_source_names in archive_data_source_names.values() for name in data_source_names ) missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names) if missing_names: joined_names = ', '.join(f'"{name}"' for name in missing_names) raise ValueError( f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive" ) return restore_names def ensure_data_sources_found(restore_names, remaining_restore_names, found_names): ''' Given a dict from hook name to data source names to restore, a dict from hook name to remaining data source names to restore, and a sequence of found (actually restored) data source names, raise ValueError if requested data source to restore were missing from the archive and/or configuration. ''' combined_restore_names = set( name for data_source_names in tuple(restore_names.values()) + tuple(remaining_restore_names.values()) for name in data_source_names ) if not combined_restore_names and not found_names: raise ValueError('No data sources were found to restore') missing_names = sorted(set(combined_restore_names) - set(found_names)) if missing_names: joined_names = ', '.join(f'"{name}"' for name in missing_names) raise ValueError( f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration" ) 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. ''' if restore_arguments.repository and not borgmatic.config.validate.repositories_match( repository, restore_arguments.repository ): return logger.info( f'{repository.get("label", repository["path"])}: Restoring data sources from archive {restore_arguments.archive}' ) borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, repository['path'], borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, global_arguments.dry_run, ) archive_name = borgmatic.borg.rlist.resolve_archive_name( repository['path'], restore_arguments.archive, config, local_borg_version, global_arguments, local_path, remote_path, ) archive_data_source_names = collect_archive_data_source_names( repository['path'], archive_name, config, local_borg_version, global_arguments, local_path, remote_path, ) restore_names = find_data_sources_to_restore( restore_arguments.data_sources, archive_data_source_names ) found_names = set() remaining_restore_names = {} connection_params = { 'hostname': restore_arguments.hostname, 'port': restore_arguments.port, 'username': restore_arguments.username, 'password': restore_arguments.password, 'restore_path': restore_arguments.restore_path, } for hook_name, data_source_names in restore_names.items(): for data_source_name in data_source_names: found_hook_name, found_data_source = get_configured_data_source( config, archive_data_source_names, hook_name, data_source_name ) if not found_data_source: remaining_restore_names.setdefault(found_hook_name or hook_name, []).append( data_source_name ) continue found_names.add(data_source_name) restore_single_data_source( repository, config, local_borg_version, global_arguments, local_path, remote_path, archive_name, found_hook_name or hook_name, dict(found_data_source, **{'schemas': restore_arguments.schemas}), connection_params, ) # For any data sources that weren't found via exact matches in the configuration, try to # fallback to "all" entries. for hook_name, data_source_names in remaining_restore_names.items(): for data_source_name in data_source_names: found_hook_name, found_data_source = get_configured_data_source( config, archive_data_source_names, hook_name, data_source_name, 'all' ) if not found_data_source: continue found_names.add(data_source_name) data_source = copy.copy(found_data_source) data_source['name'] = data_source_name restore_single_data_source( repository, config, local_borg_version, global_arguments, local_path, remote_path, archive_name, found_hook_name or hook_name, dict(data_source, **{'schemas': restore_arguments.schemas}), connection_params, ) borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( 'remove_data_source_dumps', config, repository['path'], borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES, global_arguments.dry_run, ) ensure_data_sources_found(restore_names, remaining_restore_names, found_names) borgmatic-1.8.3/borgmatic/actions/rinfo.py000066400000000000000000000022601450610336600205740ustar00rootroot00000000000000import json import logging import borgmatic.borg.rinfo import borgmatic.config.validate logger = logging.getLogger(__name__) def run_rinfo( repository, config, local_borg_version, rinfo_arguments, global_arguments, local_path, remote_path, ): ''' Run the "rinfo" action for the given repository. If rinfo_arguments.json is True, yield the JSON output from the info for the repository. ''' if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, rinfo_arguments.repository ): if not rinfo_arguments.json: # pragma: nocover logger.answer( f'{repository.get("label", repository["path"])}: Displaying repository summary information' ) json_output = borgmatic.borg.rinfo.display_repository_info( repository['path'], config, local_borg_version, rinfo_arguments=rinfo_arguments, global_arguments=global_arguments, local_path=local_path, remote_path=remote_path, ) if json_output: # pragma: nocover yield json.loads(json_output) borgmatic-1.8.3/borgmatic/actions/rlist.py000066400000000000000000000021561450610336600206200ustar00rootroot00000000000000import json import logging import borgmatic.borg.rlist import borgmatic.config.validate logger = logging.getLogger(__name__) def run_rlist( repository, config, local_borg_version, rlist_arguments, global_arguments, local_path, remote_path, ): ''' Run the "rlist" action for the given repository. If rlist_arguments.json is True, yield the JSON output from listing the repository. ''' if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match( repository, rlist_arguments.repository ): if not rlist_arguments.json: # pragma: nocover logger.answer(f'{repository.get("label", repository["path"])}: Listing repository') json_output = borgmatic.borg.rlist.list_repository( repository['path'], config, local_borg_version, rlist_arguments=rlist_arguments, global_arguments=global_arguments, local_path=local_path, remote_path=remote_path, ) if json_output: # pragma: nocover yield json.loads(json_output) borgmatic-1.8.3/borgmatic/actions/transfer.py000066400000000000000000000013011450610336600212760ustar00rootroot00000000000000import 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( f'{repository.get("label", repository["path"])}: 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-1.8.3/borgmatic/borg/000077500000000000000000000000001450610336600163765ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/borg/__init__.py000066400000000000000000000000001450610336600204750ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/borg/borg.py000066400000000000000000000044701450610336600177060ustar00rootroot00000000000000import logging import borgmatic.commands.arguments 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( full_command, output_file=DO_NOT_CAPTURE, borg_local_path=local_path, shell=True, extra_environment=dict( (environment.make_environment(config) or {}), **{ 'BORG_REPO': repository_path, 'ARCHIVE': archive if archive else '', }, ), ) borgmatic-1.8.3/borgmatic/borg/break_lock.py000066400000000000000000000025041450610336600210450ustar00rootroot00000000000000import logging 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) ) borg_environment = environment.make_environment(config) execute_command(full_command, borg_local_path=local_path, extra_environment=borg_environment) borgmatic-1.8.3/borgmatic/borg/check.py000066400000000000000000000356521450610336600200400ustar00rootroot00000000000000import argparse import datetime import hashlib import itertools import json import logging import os import pathlib from borgmatic.borg import environment, extract, feature, flags, rinfo, state from borgmatic.execute import DO_NOT_CAPTURE, execute_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: 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}'") def filter_checks_on_frequency( config, borg_repository_id, checks, force, archives_check_id=None, ): ''' 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. ''' 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 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.datetime.now() < check_time + frequency_delta: remaining = check_time + frequency_delta - datetime.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_archive_filter_flags(local_borg_version, config, checks, check_last=None, prefix=None): ''' Given the local Borg version, a configuration dict, a parsed sequence of checks, the check last value, and a consistency check prefix, transform the checks into tuple of command-line flags for filtering archives in a check command. If a check_last value is given and "archives" is in checks, then include a "--last" flag. And if a prefix value is given and "archives" is in checks, then include a "--match-archives" flag. ''' 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( 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_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_flags(checks, archive_filter_flags): ''' Given a parsed sequence of checks and a sequence of flags to filter archives, transform the checks into tuple of command-line check flags. 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 them from the returned flags because Borg does both checks by default. If "data" is in checks, that implies "archives". ''' if 'data' in checks: data_flags = ('--verify-data',) checks += ('archives',) else: data_flags = () common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags if {'repository', 'archives'}.issubset(set(checks)): return common_flags return ( tuple(f'--{check}-only' for check in checks if check in ('repository', 'archives')) + common_flags ) 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_source_directory = os.path.expanduser( config.get('borgmatic_source_directory', state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY) ) if check_type in ('archives', 'data'): return os.path.join( borgmatic_source_directory, 'checks', borg_repository_id, check_type, archives_check_id if archives_check_id else 'all', ) return os.path.join( borgmatic_source_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 a 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. Currently, the only upgrade performed is renaming an archive or data check path that looks like: ~/.borgmatic/checks/1234567890/archives to: ~/.borgmatic/checks/1234567890/archives/all ''' 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 from {old_path} to {new_path}') try: os.rename(old_path, temporary_path) except FileNotFoundError: pass os.mkdir(old_path) os.rename(temporary_path, new_path) def check_archives( repository_path, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, progress=None, repair=None, only_checks=None, force=None, ): ''' Given a local or remote repository path, a configuration dict, local/remote commands to run, whether to include progress information, whether to attempt a repair, and an optional list of checks to use instead of configured checks, check the contained Borg archives for consistency. If there are no consistency checks to run, skip running them. Raises ValueError if the Borg repository ID cannot be determined. ''' try: borg_repository_id = json.loads( rinfo.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}') upgrade_check_times(config, borg_repository_id) check_last = config.get('check_last', None) prefix = config.get('prefix') configured_checks = parse_checks(config, only_checks) lock_wait = None extra_borg_options = config.get('extra_borg_options', {}).get('check', '') archive_filter_flags = make_archive_filter_flags( local_borg_version, config, configured_checks, check_last, prefix ) archives_check_id = make_archives_check_id(archive_filter_flags) checks = filter_checks_on_frequency( config, borg_repository_id, configured_checks, force, archives_check_id, ) if set(checks).intersection({'repository', 'archives', 'data'}): lock_wait = config.get('lock_wait') verbosity_flags = () if logger.isEnabledFor(logging.INFO): verbosity_flags = ('--info',) if logger.isEnabledFor(logging.DEBUG): verbosity_flags = ('--debug', '--show-rc') full_command = ( (local_path, 'check') + (('--repair',) if repair else ()) + make_check_flags(checks, archive_filter_flags) + (('--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 + (('--progress',) if progress else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) borg_environment = environment.make_environment(config) # The Borg repair option triggers an interactive prompt, which won't work when output is # captured. And progress messes with the terminal directly. if repair or progress: execute_command( full_command, output_file=DO_NOT_CAPTURE, extra_environment=borg_environment ) else: execute_command(full_command, extra_environment=borg_environment) for check in checks: write_check_time( make_check_time_path(config, borg_repository_id, check, archives_check_id) ) if 'extract' in checks: extract.extract_last_archive_dry_run( config, local_borg_version, global_arguments, repository_path, lock_wait, local_path, remote_path, ) write_check_time(make_check_time_path(config, borg_repository_id, 'extract')) borgmatic-1.8.3/borgmatic/borg/compact.py000066400000000000000000000034101450610336600203740ustar00rootroot00000000000000import logging 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(f'{repository_path}: Skipping compact (dry run)') return execute_command( full_command, output_log_level=logging.INFO, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/create.py000066400000000000000000000466461450610336600202330ustar00rootroot00000000000000import glob import itertools import logging import os import pathlib import stat import tempfile import borgmatic.logger from borgmatic.borg import environment, feature, flags, state from borgmatic.execute import ( DO_NOT_CAPTURE, execute_command, execute_command_and_capture_output, execute_command_with_processes, ) logger = logging.getLogger(__name__) def expand_directory(directory): ''' Given a directory path, expand any tilde (representing a user's home directory) and any globs therein. Return a list of one or more resulting paths. ''' expanded_directory = os.path.expanduser(directory) return glob.glob(expanded_directory) or [expanded_directory] def expand_directories(directories): ''' Given a sequence of directory paths, expand tildes and globs in each one. Return all the resulting directories as a single flattened tuple. ''' if directories is None: return () return tuple( itertools.chain.from_iterable(expand_directory(directory) for directory in directories) ) def expand_home_directories(directories): ''' Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing. Return the results as a tuple. ''' if directories is None: return () return tuple(os.path.expanduser(directory) for directory in directories) def map_directories_to_devices(directories): ''' Given a sequence of directories, return a map from directory to an identifier for the device on which that directory resides or None if the path doesn't exist. This is handy for determining whether two different directories are on the same filesystem (have the same device identifier). ''' return { directory: os.stat(directory).st_dev if os.path.exists(directory) else None for directory in directories } def deduplicate_directories(directory_devices, additional_directory_devices): ''' Given a map from directory to the identifier for the device on which that directory resides, return the directories as a sorted tuple with all duplicate child directories removed. For instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',) The one exception to this rule is if two paths are on different filesystems (devices). In that case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the location.one_file_system option is true). The idea is that if Borg is given a parent directory, then it doesn't also need to be given child directories, because it will naturally spider the contents of the parent directory. And there are cases where Borg coming across the same file twice will result in duplicate reads and even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to Borg. If any additional directory devices are given, also deduplicate against them, but don't include them in the returned directories. ''' deduplicated = set() directories = sorted(directory_devices.keys()) additional_directories = sorted(additional_directory_devices.keys()) all_devices = {**directory_devices, **additional_directory_devices} for directory in directories: deduplicated.add(directory) parents = pathlib.PurePath(directory).parents # If another directory in the given list (or the additional list) is a parent of current # directory (even n levels up) and both are on the same filesystem, then the current # directory is a duplicate. for other_directory in directories + additional_directories: for parent in parents: if ( pathlib.PurePath(other_directory) == parent and all_devices[directory] is not None and all_devices[other_directory] == all_devices[directory] ): if directory in deduplicated: deduplicated.remove(directory) break return tuple(sorted(deduplicated)) def write_pattern_file(patterns=None, sources=None, pattern_file=None): ''' Given a sequence of patterns and an optional sequence of source directories, write them to a named temporary file (with the source directories as additional roots) and return the file. If an optional open pattern file is given, overwrite it instead of making a new temporary file. Return None if no patterns are provided. ''' if not patterns and not sources: return None if pattern_file is None: pattern_file = tempfile.NamedTemporaryFile('w') else: pattern_file.seek(0) pattern_file.write( '\n'.join(tuple(patterns or ()) + tuple(f'R {source}' for source in (sources or []))) ) pattern_file.flush() return pattern_file def ensure_files_readable(*filename_lists): ''' Given a sequence of filename sequences, ensure that each filename is openable. This prevents unreadable files from being passed to Borg, which in certain situations only warns instead of erroring. ''' for file_object in itertools.chain.from_iterable( filename_list for filename_list in filename_lists if filename_list ): open(file_object).close() def make_pattern_flags(config, pattern_filename=None): ''' Given a configuration dict with a potential patterns_from option, and a filename containing any additional patterns, return the corresponding Borg flags for those files as a tuple. ''' pattern_filenames = tuple(config.get('patterns_from') or ()) + ( (pattern_filename,) if pattern_filename else () ) return tuple( itertools.chain.from_iterable( ('--patterns-from', pattern_filename) for pattern_filename in pattern_filenames ) ) def make_exclude_flags(config, exclude_filename=None): ''' Given a configuration dict with various exclude options, and a filename containing any exclude patterns, return the corresponding Borg flags as a tuple. ''' exclude_filenames = tuple(config.get('exclude_from') or ()) + ( (exclude_filename,) if exclude_filename else () ) exclude_from_flags = tuple( itertools.chain.from_iterable( ('--exclude-from', exclude_filename) for exclude_filename in exclude_filenames ) ) 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 ( exclude_from_flags + 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 collect_borgmatic_source_directories(borgmatic_source_directory): ''' Return a list of borgmatic-specific source directories used for state like database backups. ''' if not borgmatic_source_directory: borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY return ( [borgmatic_source_directory] if os.path.exists(os.path.expanduser(borgmatic_source_directory)) else [] ) ROOT_PATTERN_PREFIX = 'R ' def pattern_root_directories(patterns=None): ''' Given a sequence of patterns, parse out and return just the root directories. ''' if not patterns: return [] return [ pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1] for pattern in patterns if pattern.startswith(ROOT_PATTERN_PREFIX) ] def special_file(path): ''' Return whether the given path is a special file (character device, block device, or named pipe / FIFO). ''' try: mode = os.stat(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( create_command, local_path, working_directory, borg_environment, skip_directories ): ''' Given a Borg create command as a tuple, a local Borg path, a working directory, a dict of environment variables to pass to Borg, and a sequence of parent directories to skip, collect the paths for any special files (character devices, block devices, and named pipes / FIFOs) that Borg would encounter during a create. These are all paths that could cause Borg to hang if its --read-special flag is used. ''' # Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open # files including any named pipe we've created. paths_output = execute_command_and_capture_output( tuple(argument for argument in create_command if argument != '--exclude-nodump') + ('--dry-run', '--list'), capture_stderr=True, working_directory=working_directory, extra_environment=borg_environment, borg_local_path=local_path, ) 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('+ ') ) return tuple( path for path in paths if special_file(path) and not any_parent_directories(path, skip_directories) ) def check_all_source_directories_exist(source_directories): ''' Given a sequence of source directories, check that they all exist. If any do not, raise an exception. ''' missing_directories = [ source_directory for source_directory in source_directories if not all([os.path.exists(directory) for directory in expand_directory(source_directory)]) ] if missing_directories: raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}") def create_archive( dry_run, repository_path, config, local_borg_version, global_arguments, local_path='borg', remote_path=None, progress=False, stats=False, json=False, list_files=False, stream_processes=None, ): ''' Given vebosity/dry-run flags, a local or remote repository path, and a configuration dict, 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() borgmatic_source_directories = expand_directories( collect_borgmatic_source_directories(config.get('borgmatic_source_directory')) ) if config.get('source_directories_must_exist', False): check_all_source_directories_exist(config.get('source_directories')) sources = deduplicate_directories( map_directories_to_devices( expand_directories( tuple(config.get('source_directories', ())) + borgmatic_source_directories + tuple( global_arguments.used_config_paths if config.get('store_config_files', True) else () ) ) ), additional_directory_devices=map_directories_to_devices( expand_directories(pattern_root_directories(config.get('patterns'))) ), ) ensure_files_readable(config.get('patterns_from'), config.get('exclude_from')) try: working_directory = os.path.expanduser(config.get('working_directory')) except TypeError: working_directory = None pattern_file = ( write_pattern_file(config.get('patterns'), sources) if config.get('patterns') or config.get('patterns_from') else None ) exclude_file = write_pattern_file(expand_home_directories(config.get('exclude_patterns'))) 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) umask = config.get('umask', None) lock_wait = config.get('lock_wait', None) list_filter_flags = make_list_filter_flags(local_borg_version, dry_run) files_cache = config.get('files_cache') archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT) 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 () ) if stream_processes and config.get('read_special') is False: logger.warning( f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.' ) create_flags = ( tuple(local_path.split(' ')) + ('create',) + make_pattern_flags(config, pattern_file.name if pattern_file else None) + make_exclude_flags(config, exclude_file.name if exclude_file else None) + (('--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 + (('--one-file-system',) if config.get('one_file_system') or stream_processes 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 ) + (sources if not pattern_file else ()) 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 borg_environment = environment.make_environment(config) # 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.debug(f'{repository_path}: Collecting special file paths') special_file_paths = collect_special_file_paths( create_flags + create_positional_arguments, local_path, working_directory, borg_environment, skip_directories=borgmatic_source_directories, ) if special_file_paths: logger.warning( f'{repository_path}: Excluding special files to prevent Borg from hanging: {", ".join(special_file_paths)}' ) exclude_file = write_pattern_file( expand_home_directories( tuple(config.get('exclude_patterns') or ()) + special_file_paths ), pattern_file=exclude_file, ) create_flags += make_exclude_flags(config, exclude_file.name) 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 ()) ) if stream_processes: return execute_command_with_processes( create_flags + create_positional_arguments, stream_processes, output_log_level, output_file, borg_local_path=local_path, working_directory=working_directory, extra_environment=borg_environment, ) elif output_log_level is None: return execute_command_and_capture_output( create_flags + create_positional_arguments, working_directory=working_directory, extra_environment=borg_environment, borg_local_path=local_path, ) else: execute_command( create_flags + create_positional_arguments, output_log_level, output_file, borg_local_path=local_path, working_directory=working_directory, extra_environment=borg_environment, ) borgmatic-1.8.3/borgmatic/borg/environment.py000066400000000000000000000034551450610336600213230ustar00rootroot00000000000000OPTION_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', 'encryption_passcommand': 'BORG_PASSCOMMAND', 'encryption_passphrase': 'BORG_PASSPHRASE', '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, return its options converted to a Borg environment variable dict. ''' environment = {} for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items(): value = config.get(option_name) if value: environment[environment_variable_name] = str(value) for ( option_name, environment_variable_name, ) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items(): value = config.get(option_name) if value is not None: 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' return environment borgmatic-1.8.3/borgmatic/borg/export_key.py000066400000000000000000000044651450610336600211520ustar00rootroot00000000000000import logging import os 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, 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) if export_arguments.path and export_arguments.path != '-': if os.path.exists(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: logging.info(f'{repository_path}: Skipping key export (dry run)') return execute_command( full_command, output_file=output_file, output_log_level=logging.ANSWER, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/export_tar.py000066400000000000000000000050151450610336600211400ustar00rootroot00000000000000import logging 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(f'{repository_path}: 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, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/extract.py000066400000000000000000000126321450610336600204260ustar00rootroot00000000000000import logging import os import subprocess import borgmatic.config.validate from borgmatic.borg import environment, feature, flags, rlist 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 = rlist.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 () borg_environment = environment.make_environment(config) 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, working_directory=None, extra_environment=borg_environment ) 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. strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths)) 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 working directory changes below don't # prevent Borg from finding the repo. borgmatic.config.validate.normalize_repository_path(repository), archive, local_borg_version, ) + (tuple(paths) if paths else ()) ) borg_environment = environment.make_environment(config) # 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, working_directory=destination_path, extra_environment=borg_environment, ) return None if extract_to_stdout: return execute_command( full_command, output_file=subprocess.PIPE, working_directory=destination_path, run_to_completion=False, extra_environment=borg_environment, ) # 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, working_directory=destination_path, extra_environment=borg_environment ) borgmatic-1.8.3/borgmatic/borg/feature.py000066400000000000000000000025301450610336600204030ustar00rootroot00000000000000from 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 RCREATE = 7 RLIST = 8 RINFO = 9 MATCH_ARCHIVES = 10 EXCLUDED_FILES_MINUS = 11 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.RCREATE: parse('2.0.0a2'), # borg rcreate Feature.RLIST: parse('2.0.0a2'), # borg rlist Feature.RINFO: parse('2.0.0a2'), # borg rinfo Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes } 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-1.8.3/borgmatic/borg/flags.py000066400000000000000000000057051450610336600200530ustar00rootroot00000000000000import itertools import re from borgmatic.borg import feature 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,) 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, archive) if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version) else (f'{repository_path}::{archive}',) ) DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 def make_match_archives_flags(match_archives, archive_name_format, local_borg_version): ''' 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 feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version): 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 ) 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}') borgmatic-1.8.3/borgmatic/borg/info.py000066400000000000000000000051561450610336600177120ustar00rootroot00000000000000import logging 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_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, global arguments as an argparse.Namespace, and the arguments to the info action, display summary information for Borg archives in the repository or return JSON summary information. ''' borgmatic.logger.add_custom_log_levels() lock_wait = config.get('lock_wait', None) full_command = ( (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('log-json', global_arguments.log_json) + flags.make_flags('lock-wait', 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) ) if info_arguments.json: return execute_command_and_capture_output( full_command, extra_environment=environment.make_environment(config), borg_local_path=local_path, ) else: execute_command( full_command, output_log_level=logging.ANSWER, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/list.py000066400000000000000000000205021450610336600177220ustar00rootroot00000000000000import argparse import copy import logging import re import borgmatic.logger from borgmatic.borg import environment, feature, flags, rlist from borgmatic.execute import execute_command, execute_command_and_capture_output logger = logging.getLogger(__name__) ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last') MAKE_FLAGS_EXCLUDES = ( 'repository', 'archive', 'paths', 'find_paths', ) + ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST 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. ''' lock_wait = config.get('lock_wait', None) 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('log-json', global_arguments.log_json) + flags.make_flags('lock-wait', 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, 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 in which to list files, and local and remote Borg paths, capture the output of listing that archive and return it as a list of file paths. ''' borg_environment = environment.make_environment(config) return tuple( execute_command_and_capture_output( make_list_command( repository_path, config, local_borg_version, argparse.Namespace( repository=repository_path, archive=archive, paths=[f'sh:{path}' for path in list_paths] if list_paths else None, find_paths=None, json=None, format='{path}{NL}', # noqa: FS003 ), global_arguments, local_path, remote_path, ), extra_environment=borg_environment, borg_local_path=local_path, ) .strip('\n') .split('\n') ) 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, global arguments as an argparse.Namespace, the arguments to the list action as an argparse.Namespace, and local and remote Borg paths, display the output of listing the files of a Borg archive (or return JSON output). If list_arguments.find_paths are given, list the files by searching across multiple archives. If neither find_paths nor archive name are given, instead list the archives in the given repository. ''' borgmatic.logger.add_custom_log_levels() if not list_arguments.archive and not list_arguments.find_paths: if feature.available(feature.Feature.RLIST, local_borg_version): logger.warning( 'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the rlist action instead.' ) rlist_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 rlist.list_repository( repository_path, config, local_borg_version, rlist_arguments, global_arguments, local_path, remote_path, ) if list_arguments.archive: for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST: 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_environment = environment.make_environment(config) # If there are any paths to find (and there's not a single archive already selected), start by # getting a list of archives to search. if list_arguments.find_paths and not list_arguments.archive: rlist_arguments = argparse.Namespace( 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( rlist.make_rlist_command( repository_path, config, local_borg_version, rlist_arguments, global_arguments, local_path, remote_path, ), extra_environment=borg_environment, borg_local_path=local_path, ) .strip('\n') .split('\n') ) 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'{repository_path}: 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_RLIST: 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, borg_local_path=local_path, extra_environment=borg_environment, ) borgmatic-1.8.3/borgmatic/borg/mount.py000066400000000000000000000054431450610336600201200ustar00rootroot00000000000000import logging 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 ()) ) borg_environment = environment.make_environment(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, borg_local_path=local_path, extra_environment=borg_environment, ) return execute_command(full_command, borg_local_path=local_path, extra_environment=borg_environment) borgmatic-1.8.3/borgmatic/borg/prune.py000066400000000000000000000063611450610336600201070ustar00rootroot00000000000000import logging 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, local_borg_version): ''' Given a configuration dict mapping from option name to value, transform it into an 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( 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, 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', '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, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/rcreate.py000066400000000000000000000057601450610336600204050ustar00rootroot00000000000000import argparse import logging import subprocess from borgmatic.borg import environment, feature, flags, rinfo from borgmatic.execute import DO_NOT_CAPTURE, execute_command logger = logging.getLogger(__name__) RINFO_REPOSITORY_NOT_FOUND_EXIT_CODE = 2 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. ''' try: rinfo.display_repository_info( repository_path, config, local_borg_version, argparse.Namespace(json=True), global_arguments, local_path, remote_path, ) logger.info(f'{repository_path}: Repository already exists. Skipping creation.') return except subprocess.CalledProcessError as error: if error.returncode != RINFO_REPOSITORY_NOT_FOUND_EXIT_CODE: raise lock_wait = config.get('lock_wait') extra_borg_options = config.get('extra_borg_options', {}).get('rcreate', '') rcreate_command = ( (local_path,) + ( ('rcreate',) if feature.available(feature.Feature.RCREATE, 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 ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) if dry_run: logging.info(f'{repository_path}: Skipping repository creation (dry run)') return # Do not capture output here, so as to support interactive prompts. execute_command( rcreate_command, output_file=DO_NOT_CAPTURE, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/rinfo.py000066400000000000000000000040071450610336600200660ustar00rootroot00000000000000import logging 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, rinfo_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, the arguments to the rinfo action, and global arguments as an argparse.Namespace, display summary 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,) + ( ('rinfo',) if feature.available(feature.Feature.RINFO, local_borg_version) else ('info',) ) + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not rinfo_arguments.json else () ) + ( ('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not rinfo_arguments.json else () ) + flags.make_flags('remote-path', remote_path) + flags.make_flags('log-json', global_arguments.log_json) + flags.make_flags('lock-wait', lock_wait) + (('--json',) if rinfo_arguments.json else ()) + flags.make_repository_flags(repository_path, local_borg_version) ) extra_environment = environment.make_environment(config) if rinfo_arguments.json: return execute_command_and_capture_output( full_command, extra_environment=extra_environment, borg_local_path=local_path, ) else: execute_command( full_command, output_log_level=logging.ANSWER, borg_local_path=local_path, extra_environment=extra_environment, ) borgmatic-1.8.3/borgmatic/borg/rlist.py000066400000000000000000000116061450610336600201110ustar00rootroot00000000000000import logging 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, 'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list', ) + flags.make_flags('remote-path', remote_path) + 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, extra_environment=environment.make_environment(config), borg_local_path=local_path, ) try: latest_archive = output.strip().splitlines()[-1] except IndexError: raise ValueError('No archives found in the repository') logger.debug(f'{repository_path}: Latest archive is {latest_archive}') return latest_archive MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives') def make_rlist_command( repository_path, config, local_borg_version, rlist_arguments, global_arguments, local_path='borg', remote_path=None, ): ''' Given a local or remote repository path, a configuration dict, the local Borg version, the arguments to the rlist action, global arguments as an argparse.Namespace instance, and local and remote Borg paths, return a command as a tuple to list archives with a repository. ''' return ( ( local_path, 'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list', ) + ( ('--info',) if logger.getEffectiveLevel() == logging.INFO and not rlist_arguments.json else () ) + ( ('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not rlist_arguments.json else () ) + flags.make_flags('remote-path', remote_path) + flags.make_flags('log-json', global_arguments.log_json) + flags.make_flags('lock-wait', config.get('lock_wait')) + ( ( flags.make_flags('match-archives', f'sh:{rlist_arguments.prefix}*') if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version) else flags.make_flags('glob-archives', f'{rlist_arguments.prefix}*') ) if rlist_arguments.prefix else ( flags.make_match_archives_flags( rlist_arguments.match_archives or config.get('match_archives'), config.get('archive_name_format'), local_borg_version, ) ) ) + flags.make_flags_from_arguments(rlist_arguments, excludes=MAKE_FLAGS_EXCLUDES) + flags.make_repository_flags(repository_path, local_borg_version) ) def list_repository( repository_path, config, local_borg_version, rlist_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() borg_environment = environment.make_environment(config) main_command = make_rlist_command( repository_path, config, local_borg_version, rlist_arguments, global_arguments, local_path, remote_path, ) if rlist_arguments.json: return execute_command_and_capture_output( main_command, extra_environment=borg_environment, borg_local_path=local_path ) else: execute_command( main_command, output_log_level=logging.ANSWER, borg_local_path=local_path, extra_environment=borg_environment, ) borgmatic-1.8.3/borgmatic/borg/state.py000066400000000000000000000000641450610336600200700ustar00rootroot00000000000000DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic' borgmatic-1.8.3/borgmatic/borg/transfer.py000066400000000000000000000041301450610336600205720ustar00rootroot00000000000000import logging 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('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, borg_local_path=local_path, extra_environment=environment.make_environment(config), ) borgmatic-1.8.3/borgmatic/borg/umount.py000066400000000000000000000011001450610336600202670ustar00rootroot00000000000000import logging from borgmatic.execute import execute_command logger = logging.getLogger(__name__) def unmount_archive(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) borgmatic-1.8.3/borgmatic/borg/version.py000066400000000000000000000017371450610336600204450ustar00rootroot00000000000000import logging 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 binary 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, extra_environment=environment.make_environment(config), borg_local_path=local_path, ) try: return output.split(' ')[1].strip() except IndexError: raise ValueError('Could not parse Borg version string') borgmatic-1.8.3/borgmatic/commands/000077500000000000000000000000001450610336600172465ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/commands/__init__.py000066400000000000000000000000001450610336600213450ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/commands/arguments.py000066400000000000000000001502011450610336600216240ustar00rootroot00000000000000import collections import itertools import sys from argparse import ArgumentParser from borgmatic.config import collect ACTION_ALIASES = { 'rcreate': ['init', '-I'], 'prune': ['-p'], 'compact': [], 'create': ['-C'], 'check': ['-k'], 'config': [], 'extract': ['-x'], 'export-tar': [], 'mount': ['-m'], 'umount': ['-u'], 'restore': ['-r'], 'rlist': [], 'list': ['-l'], '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(): remaining_arguments.remove(value) elif isinstance(value, list): for item in value: if item in ACTION_ALIASES.keys(): 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 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 () return tuple( argument for argument in dict.fromkeys( itertools.chain.from_iterable(remaining_action_arguments) ).keys() if all(argument in action_arguments for action_arguments in remaining_action_arguments) ) 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: {' '.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='Log format string used for log messages written to the log file', ) global_group.add_argument( '--log-json', action='store_true', help='Write 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, 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:', ) rcreate_parser = action_parsers.add_parser( 'rcreate', aliases=ACTION_ALIASES['rcreate'], help='Create a new, empty Borg repository', description='Create a new, empty Borg repository', add_help=False, ) rcreate_group = rcreate_parser.add_argument_group('rcreate arguments') rcreate_group.add_argument( '-e', '--encryption', dest='encryption_mode', help='Borg repository encryption mode', required=True, ) rcreate_group.add_argument( '--source-repository', '--other-repo', metavar='KEY_REPOSITORY', help='Path to an existing Borg repository whose key material should be reused [Borg 2.x+ only]', ) rcreate_group.add_argument( '--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', ) rcreate_group.add_argument( '--copy-crypt-key', action='store_true', help='Copy the crypt key used for authenticated encryption from the source repository, defaults to a new random key [Borg 2.x+ only]', ) rcreate_group.add_argument( '--append-only', action='store_true', help='Create an append-only repository', ) rcreate_group.add_argument( '--storage-quota', help='Create a repository with a fixed storage quota', ) rcreate_group.add_argument( '--make-parent-dirs', action='store_true', help='Create any missing parent directories of the repository directory', ) rcreate_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', ) 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 of 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 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+, run compact afterwards to actually free space)', description='Prune archives according to the retention policy (with Borg 1.2+, 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)', ) prune_group.add_argument( '--stats', dest='stats', default=False, action='store_true', help='Display statistics of 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)', ) 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)', ) 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)', ) 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( '--only', metavar='CHECK', choices=('repository', 'archives', 'data', 'extract'), dest='only', action='append', help='Run a particular consistency check (repository, archives, data, or extract) 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') 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', ) extract_group.add_argument( '--archive', help='Name of 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', required=True, ) config_bootstrap_group.add_argument( '--borgmatic-source-directory', help='Path that stores the config files used to create an archive and additional source files used for temporary internal state like borgmatic database dumps. Defaults to ~/.borgmatic', ) config_bootstrap_group.add_argument( '--archive', help='Name of 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( '-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', ) export_tar_group.add_argument( '--archive', help='Name of 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', ) mount_group.add_argument('--archive', help='Name of 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') 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', ) restore_group.add_argument( '--archive', help='Name of 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 archive, must be defined in borgmatic's configuration, can specify 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( '-h', '--help', action='help', help='Show this help message and exit' ) rlist_parser = action_parsers.add_parser( 'rlist', aliases=ACTION_ALIASES['rlist'], help='List repository', description='List the archives in a repository', add_help=False, ) rlist_group = rlist_parser.add_argument_group('rlist arguments') rlist_group.add_argument( '--repository', help='Path of repository to list, defaults to the configured repositories', ) rlist_group.add_argument( '--short', default=False, action='store_true', help='Output only archive names' ) rlist_group.add_argument('--format', help='Format for archive listing') rlist_group.add_argument( '--json', default=False, action='store_true', help='Output results as JSON' ) rlist_group.add_argument( '-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix' ) rlist_group.add_argument( '-a', '--match-archives', '--glob-archives', metavar='PATTERN', help='Only list archive names matching this pattern', ) rlist_group.add_argument( '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' ) rlist_group.add_argument( '--first', metavar='N', help='List first N archives after other filters are applied' ) rlist_group.add_argument( '--last', metavar='N', help='List last N archives after other filters are applied' ) rlist_group.add_argument( '--oldest', metavar='TIMESPAN', help='List archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]', ) rlist_group.add_argument( '--newest', metavar='TIMESPAN', help='List archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]', ) rlist_group.add_argument( '--older', metavar='TIMESPAN', help='List archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) rlist_group.add_argument( '--newer', metavar='TIMESPAN', help='List archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]', ) rlist_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') 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', ) list_group.add_argument('--archive', help='Name of the 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') rinfo_parser = action_parsers.add_parser( 'rinfo', aliases=ACTION_ALIASES['rinfo'], help='Show repository summary information such as disk space used', description='Show repository summary information such as disk space used', add_help=False, ) rinfo_group = rinfo_parser.add_argument_group('rinfo arguments') rinfo_group.add_argument( '--repository', help='Path of repository to show info for, defaults to the configured repository if there is only one', ) rinfo_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) rinfo_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') 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', ) info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")') info_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) 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 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', ) 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', ) 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' ) 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', ) borg_group.add_argument('--archive', help='Name of archive 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 'rinfo' in arguments and arguments['list'].json) or ('list' in arguments and 'info' in arguments and arguments['list'].json) or ('rinfo' in arguments and 'info' in arguments and arguments['rinfo'].json) ): 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 'rlist' in arguments and (arguments['rlist'].prefix and arguments['rlist'].match_archives): raise ValueError( 'With the rlist 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.' ) return arguments borgmatic-1.8.3/borgmatic/commands/borgmatic.py000066400000000000000000000756601450610336600216050ustar00rootroot00000000000000import collections import json import logging import os import sys import time from queue import Queue from subprocess import CalledProcessError import colorama try: import importlib_metadata except ModuleNotFoundError: # pragma: nocover import importlib.metadata as importlib_metadata import borgmatic.actions.borg import borgmatic.actions.break_lock 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.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.rcreate import borgmatic.actions.restore import borgmatic.actions.rinfo import borgmatic.actions.rlist import borgmatic.actions.transfer import borgmatic.commands.completion.bash import borgmatic.commands.completion.fish from borgmatic.borg import umount as borg_umount from borgmatic.borg import version as borg_version from borgmatic.commands.arguments import parse_arguments from borgmatic.config import checks, collect, validate from borgmatic.hooks import command, dispatch, monitor from borgmatic.logger import DISABLED, add_custom_log_levels, configure_logging, should_do_markup from borgmatic.signals import configure_signals from borgmatic.verbosity import verbosity_to_log_level logger = logging.getLogger(__name__) def run_configuration(config_filename, config, arguments): ''' Given a config filename, the corresponding parsed config dict, 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 try: local_borg_version = borg_version.local_borg_version(config, local_path) 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, config_filename, monitor.MONITOR_HOOK_NAMES, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'ping_monitor', config, config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.START, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(config_filename, 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() logger.debug( f'{repository.get("label", repository["path"])}: Running actions for repository' ) timeout = retry_num * retry_wait if timeout: logger.warning( f'{repository.get("label", repository["path"])}: Sleeping {timeout}s before next retry' ) time.sleep(timeout) try: yield from run_actions( arguments=arguments, config_filename=config_filename, config=config, 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( f'{repository.get("label", repository["path"])}: Error running actions for repository', error, levelno=logging.WARNING, log_command_error_output=True, ) ) logger.warning( f'{repository.get("label", repository["path"])}: Retrying... attempt {retry_num + 1}/{retries}' ) continue if command.considered_soft_failure(config_filename, error): return yield from log_error_records( f'{repository.get("label", repository["path"])}: 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, config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.LOG, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(config_filename, error): return encountered_error = error yield from log_error_records(f'{repository["path"]}: Error pinging monitor', error) if not encountered_error: try: if monitoring_hooks_are_activated: dispatch.call_hooks( 'ping_monitor', config, config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.FINISH, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'destroy_monitor', config, config_filename, monitor.MONITOR_HOOK_NAMES, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(config_filename, 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, config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.FAIL, monitoring_log_level, global_arguments.dry_run, ) dispatch.call_hooks( 'destroy_monitor', config, config_filename, monitor.MONITOR_HOOK_NAMES, monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(config_filename, error): return yield from log_error_records(f'{config_filename}: Error running on-error hook', error) def run_actions( *, arguments, config_filename, config, local_path, remote_path, local_borg_version, repository, ): ''' Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration filename, several different configuration dicts, 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': repository_path, # Deprecated: For backwards compatibility with borgmatic < 1.6.0. 'repositories': ','.join([repo['path'] for repo in config['repositories']]), 'log_file': global_arguments.log_file if global_arguments.log_file else '', } 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 == 'rcreate': borgmatic.actions.rcreate.run_rcreate( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'transfer': borgmatic.actions.transfer.run_transfer( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'create': yield from borgmatic.actions.create.run_create( config_filename, repository, config, hook_context, local_borg_version, action_arguments, global_arguments, dry_run_label, local_path, remote_path, ) elif action_name == 'prune': 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': 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': 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': 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': borgmatic.actions.export_tar.run_export_tar( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'mount': borgmatic.actions.mount.run_mount( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'restore': borgmatic.actions.restore.run_restore( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'rlist': yield from borgmatic.actions.rlist.run_rlist( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'list': yield from borgmatic.actions.list.run_list( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'rinfo': yield from borgmatic.actions.rinfo.run_rinfo( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'info': 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': borgmatic.actions.break_lock.run_break_lock( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'export': borgmatic.actions.export_key.run_export_key( repository, config, local_borg_version, action_arguments, global_arguments, local_path, remote_path, ) elif action_name == 'borg': 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, load and validate each configuration file. Return the results as a tuple of: dict of configuration filename to corresponding parsed configuration, and 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! ''' # Dict mapping from config filename to corresponding parsed config dict. configs = collections.OrderedDict() 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], parse_logs = validate.parse_configuration( config_filename, validate.schema_filename(), overrides, resolve_env ) 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=error) ), ] ) return (configs, 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 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=message) return try: raise error except CalledProcessError as error: yield log_record(levelno=levelno, levelname=level_name, msg=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=error) except (ValueError, OSError) as error: yield log_record(levelno=levelno, levelname=level_name, msg=message) yield log_record(levelno=levelno, levelname=level_name, msg=error) 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({}, 'borg') 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, arguments): ''' Given a dict of configuration filename to corresponding parsed configuration 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: if 'extract' in arguments or 'mount' in arguments: validate.guard_single_repository_selected(repository, configs) validate.guard_configuration_contains_repository(repository, configs) except ValueError as error: yield from log_error_records(str(error)) 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(): results = list(run_configuration(config_filename, config, arguments)) error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord)) if error_logs: yield from log_error_records(f'{config_filename}: An error occurred') yield from error_logs else: yield logging.makeLogRecord( dict( levelno=logging.INFO, levelname='INFO', msg=f'{config_filename}: 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( 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() 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) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) global_arguments.used_config_paths = list(config_filenames) configs, parse_logs = load_configurations( config_filenames, global_arguments.overrides, global_arguments.resolve_env ) 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() ) colorama.init( autoreset=True, strip=not 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, ) 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, 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-1.8.3/borgmatic/commands/completion/000077500000000000000000000000001450610336600214175ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/commands/completion/__init__.py000066400000000000000000000000001450610336600235160ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/commands/completion/actions.py000066400000000000000000000025461450610336600234400ustar00rootroot00000000000000import 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-1.8.3/borgmatic/commands/completion/bash.py000066400000000000000000000045261450610336600227150ustar00rootroot00000000000000import 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-1.8.3/borgmatic/commands/completion/fish.py000066400000000000000000000152011450610336600227210ustar00rootroot00000000000000import 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-1.8.3/borgmatic/commands/generate_config.py000066400000000000000000000007501450610336600227410ustar00rootroot00000000000000import 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-1.8.3/borgmatic/commands/validate_config.py000066400000000000000000000007501450610336600227400ustar00rootroot00000000000000import 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-1.8.3/borgmatic/config/000077500000000000000000000000001450610336600167125ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/config/__init__.py000066400000000000000000000000001450610336600210110ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/config/checks.py000066400000000000000000000005351450610336600205270ustar00rootroot00000000000000def repository_enabled_for_checks(repository, consistency): ''' Given a repository name and a consistency configuration dict, return whether the repository is enabled to have consistency checks run. ''' if not consistency.get('check_repositories'): return True return repository in consistency['check_repositories'] borgmatic-1.8.3/borgmatic/config/collect.py000066400000000000000000000042421450610336600207130ustar00rootroot00000000000000import 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-1.8.3/borgmatic/config/environment.py000066400000000000000000000030061450610336600216270ustar00rootroot00000000000000import os import re _VARIABLE_PATTERN = re.compile( r'(?P\\)?(?P\$\{(?P[A-Za-z0-9_]+)((:?-)(?P[^}]+))?\})' ) def _resolve_string(matcher): ''' Get the value from environment given a matcher containing a name and an optional default value. If the variable is not defined in environment and no default value is provided, an Error is raised. ''' if matcher.group('escape') is not None: # in case of escaped envvar, unescape it return matcher.group('variable') # resolve the env var 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" If any variable is missing in environment and no default value is provided, an Error is raised. ''' if isinstance(item, str): return _VARIABLE_PATTERN.sub(_resolve_string, item) if isinstance(item, list): for i, subitem in enumerate(item): item[i] = resolve_env_variables(subitem) if isinstance(item, dict): for key, value in item.items(): item[key] = resolve_env_variables(value) return item borgmatic-1.8.3/borgmatic/config/generate.py000066400000000000000000000252021450610336600210570ustar00rootroot00000000000000import collections import io import os import re from ruamel import 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, yaml.tokens.CommentToken('\n', yaml.error.CommentMark(0), None) ) 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': config = 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': config = yaml.comments.CommentedMap( [ (field_name, schema_to_sample_configuration(sub_schema, level + 1)) for field_name, sub_schema in schema['properties'].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 = yaml.YAML() 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 = schema['items']['properties'].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 = schema['properties'].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(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] = 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 = yaml.round_trip_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-1.8.3/borgmatic/config/load.py000066400000000000000000000362631450610336600202150ustar00rootroot00000000000000import functools import itertools import json import logging import operator import os import ruamel.yaml logger = logging.getLogger(__name__) def probe_and_include_file(filename, include_directories): ''' Given a filename to include and a list of include directories to search for matching files, probe for the file, load it, and return the loaded configuration as a data structure of nested dicts, lists, etc. 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) 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) raise FileNotFoundError( f'Could not find include {filename} at {" or ".join(candidate_filenames)}' ) def include_configuration(loader, filename_node, include_directory): ''' Given a ruamel.yaml.loader.Loader, a ruamel.yaml.nodes.ScalarNode containing the included filename (or a list containing multiple such filenames), and an include directory path to search for matching files, 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. 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) 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) for node in reversed(filename_node.value) ] raise ValueError( '!include value is not supported; use a single filename or a list of filenames' ) 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): super(Include_constructor, self).__init__(preserve_quotes, loader) self.add_constructor( '!include', functools.partial(include_configuration, include_directory=include_directory), ) # 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): ''' Load the given configuration file and return its contents as a data structure of nested dicts and lists. Also, replace any "{constant}" strings with the value of the "constant" key in the "constants" option of the configuration file. Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError if there are too many recursive includes. ''' # Use an embedded derived class for the include constructor so as to capture the filename # value. (functools.partial doesn't work for this use case because yaml.Constructor has to be # an actual class.) class Include_constructor_with_include_directory(Include_constructor): def __init__(self, preserve_quotes=None, loader=None): super(Include_constructor_with_include_directory, self).__init__( preserve_quotes, loader, include_directory=os.path.dirname(filename) ) yaml = ruamel.yaml.YAML(typ='safe') yaml.Constructor = Include_constructor_with_include_directory with open(filename) as file: file_contents = file.read() config = yaml.load(file_contents) try: has_constants = bool(config and 'constants' in config) except TypeError: has_constants = False if has_constants: for key, value in config['constants'].items(): value = json.dumps(value) file_contents = file_contents.replace(f'{{{key}}}', value.strip('"')) config = yaml.load(file_contents) del config['constants'] return config 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-1.8.3/borgmatic/config/normalize.py000066400000000000000000000257131450610336600212740ustar00rootroot00000000000000import 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: 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) # 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] # 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 mapping value. 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 isinstance(repositories[0], str): 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} 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://'): 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:// 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-1.8.3/borgmatic/config/override.py000066400000000000000000000056501450610336600211110ustar00rootroot00000000000000import 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: 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): ''' Given a string value, determine its logical type (string, boolean, integer, etc.), and return it converted to that type. Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML. ''' 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 parse_overrides(raw_overrides): ''' Given a sequence of configuration file override strings in the form of "option.suboption=value", 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) parsed_overrides.append( ( strip_section_names(tuple(raw_keys.split('.'))), convert_value_type(value), ) ) 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, raw_overrides): ''' Given a configuration dict and a sequence of configuration file override strings in the form of "option.suboption=value", parse each override and set it the configuration dict. ''' overrides = parse_overrides(raw_overrides) for keys, value in overrides: set_values(config, keys, value) borgmatic-1.8.3/borgmatic/config/schema.yaml000066400000000000000000001701051450610336600210420ustar00rootroot00000000000000type: object required: - repositories additionalProperties: false properties: constants: type: object description: | Constants to use in the configuration file. All occurrences of the constant name within curly braces will be replaced with the value. For example, if you have a constant named "hostname" with the value "myhostname", then the string "{hostname}" will be replaced with "myhostname" in the configuration file. example: hostname: myhostname prefix: myprefix 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 for the "borg create" command. Tildes are expanded. Useful for backing up using relative paths. See http://borgbackup.readthedocs.io/en/stable/usage/create.html for details. Defaults to not set. example: /path/to/working/directory one_file_system: type: boolean description: | Stay in same file system; do not cross mount points beyond the given source directories. Defaults to false. But when a database hook is used, the setting here is ignored and one_file_system is considered true. 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. Note that only one of "patterns" and "source_directories" may be used. 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. Note that Borg considers this option experimental. 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: | Path for additional source files used for temporary internal state like borgmatic database dumps. Note that changing this path prevents "borgmatic restore" from finding any database dumps created before the change. Defaults to ~/.borgmatic example: /tmp/borgmatic store_config_files: type: boolean description: | Store configuration files used to create a backup in the backup itself. Defaults to true. Changing this to false prevents "borgmatic bootstrap" from extracting configuration files from the backup. example: true source_directories_must_exist: type: boolean description: | If true, then source directories must exist, otherwise 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. Defaults to not set. 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. 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. 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 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 as a form of backoff. Defaults to 0 (no wait). example: 10 temporary_directory: type: string description: | Directory where temporary files are stored. Defaults to $TMPDIR. 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 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. Borg placeholders can be used. See the output of "borg help placeholders" for details. Defaults to "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". When running actions like rlist, info, or check, borgmatic automatically tries to match only 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. 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 required: ['name'] additionalProperties: false properties: name: type: string enum: - repository - archives - data - extract - disabled description: | Name of consistency check to run: "repository", "archives", "data", and/or "extract". Set to "disabled" to disable all consistency checks. "repository" checks the consistency of the repository, "archives" checks all of the archives, "data" verifies the integrity of the data within the archives, and "extract" does an extraction dry-run of the most recent archive. Note that "data" implies "archives". 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 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 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." 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 both read_special and one_file_system (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". example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. 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. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. 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 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). 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 both read_special and one_file_system (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. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. 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. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. example: trustsome1 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 both read_special and one_file_system (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. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. 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. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. example: trustsome1 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 both read_special and one_file_system (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 both read_special and one_file_system (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. example: dbuser restore_username: type: string description: | Username with which to restore the database. Defaults to the "username" option. example: dbuser password: type: string description: | Password with which to connect to the database. Skip it if no authentication is needed. example: trustsome1 restore_password: type: string description: | Password with which to connect to the restore database. Defaults to the "password" option. 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. example: testuser password: type: string description: | The password used for authentication. example: fakepassword 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 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 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. 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. example: a177cad45bd374409f78906a810a3074 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 Crunhub. 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 just 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. borgmatic-1.8.3/borgmatic/config/validate.py000066400000000000000000000161341450610336600210620ustar00rootroot00000000000000import os import jsonschema import ruamel.yaml import borgmatic.config from borgmatic.config import 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", 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 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. ''' try: config = load.load_configuration(config_filename) 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, overrides) logs = normalize.normalize(config_filename, config) if resolve_env: environment.resolve_env_variables(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, 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 repositories_match(first, second): ''' Given two repository dicts with keys 'path' (relative and/or absolute), and 'label', or two repository paths, return whether they match. ''' if isinstance(first, str): first = {'path': first, 'label': first} if isinstance(second, str): second = {'path': second, 'label': second} return (first.get('label') == second.get('label')) or ( normalize_repository_path(first.get('path')) == normalize_repository_path(second.get('path')) ) 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 exactly once in all of the configurations. If no repository is given, skip this check. Raise ValueError if the repository is not found in a configuration, or is declared multiple times. ''' 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') if count > 1: raise ValueError(f'Repository {repository} found in multiple configuration files') def guard_single_repository_selected(repository, configurations): ''' Given a repository path and a dict mapping from config filename to corresponding parsed config dict, ensure either a single repository exists across all configuration files or a repository path was given. ''' if repository: return count = len( tuple( config_repository for config in configurations.values() for config_repository in config['repositories'] ) ) if count != 1: raise ValueError( "Can't determine which repository to use. Use --repository to disambiguate" ) borgmatic-1.8.3/borgmatic/execute.py000066400000000000000000000332071450610336600174660ustar00rootroot00000000000000import collections import logging import os import select import subprocess logger = logging.getLogger(__name__) ERROR_OUTPUT_MAX_LINE_COUNT = 25 BORG_ERROR_EXIT_CODE = 2 def exit_code_indicates_error(command, exit_code, borg_local_path=None): ''' Return True if the given exit code from running a command corresponds to an error. If a Borg local path is given and matches the process' command, then treat exit code 1 as a warning instead of an error. ''' if exit_code is None: return False if borg_local_path and command[0] == borg_local_path: return bool(exit_code < 0 or exit_code >= BORG_ERROR_EXIT_CODE) return bool(exit_code != 0) 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): ''' 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. 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 # If any process errors, then raise accordingly. if exit_code_indicates_error(command, exit_code, borg_local_path): # 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() raise subprocess.CalledProcessError( exit_code, command_for_process(process), '\n'.join(last_lines) ) if captured_outputs: return { process: '\n'.join(output_lines) for process, output_lines in captured_outputs.items() } def log_command(full_command, input_file=None, output_file=None): ''' Log the given command (a sequence of command/argument strings), along with its input/output file paths. ''' logger.debug( ' '.join(full_command) + (f" < {getattr(input_file, 'name', '')}" if input_file else '') + (f" > {getattr(output_file, 'name', '')}" if output_file else '') ) # An 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, extra_environment=None, working_directory=None, borg_local_path=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 extra environment dict is given, then use it to augment the current environment, and pass the result 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. 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 = {**os.environ, **extra_environment} if extra_environment else None 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, ) if not run_to_completion: return process log_outputs( (process,), (input_file, output_file), output_log_level, borg_local_path=borg_local_path ) def execute_command_and_capture_output( full_command, capture_stderr=False, shell=False, extra_environment=None, working_directory=None, borg_local_path=None, ): ''' Execute the given command (a sequence of command/argument strings), capturing and returning its output (stdout). 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 extra environment dict is given, then use it to augment the current environment, and pass the result 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. Raise subprocesses.CalledProcessError if an error occurs while running the command. ''' log_command(full_command) environment = {**os.environ, **extra_environment} if extra_environment else None command = ' '.join(full_command) if shell else full_command try: output = subprocess.check_output( command, stderr=subprocess.STDOUT if capture_stderr else None, shell=shell, env=environment, cwd=working_directory, ) except subprocess.CalledProcessError as error: if exit_code_indicates_error(command, error.returncode, borg_local_path): 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, extra_environment=None, working_directory=None, borg_local_path=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 extra environment dict is given, then use it to augment the current environment, and pass the result 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. 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 = {**os.environ, **extra_environment} if extra_environment else None 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, ) 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 captured_outputs = log_outputs( tuple(processes) + (command_process,), (input_file, output_file), output_log_level, borg_local_path=borg_local_path, ) if output_log_level is None: return captured_outputs.get(command_process) borgmatic-1.8.3/borgmatic/hooks/000077500000000000000000000000001450610336600165705ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/hooks/__init__.py000066400000000000000000000000001450610336600206670ustar00rootroot00000000000000borgmatic-1.8.3/borgmatic/hooks/command.py000066400000000000000000000063141450610336600205640ustar00rootroot00000000000000import logging import os import re from borgmatic import execute logger = logging.getLogger(__name__) SOFT_FAIL_EXIT_CODE = 75 def interpolate_context(config_filename, 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}}}', str(value)) for unsupported_variable in re.findall(r'{\w+}', command): logger.warning( f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook" ) return command 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'{config_filename}: 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(config_filename, description, command, context) for command in commands ] if len(commands) == 1: logger.info(f'{config_filename}: Running command for {description} hook{dry_run_label}') else: logger.info( f'{config_filename}: Running {len(commands)} commands for {description} hook{dry_run_label}', ) if umask: parsed_umask = int(str(umask), 8) logger.debug(f'{config_filename}: Set hook umask to {oct(parsed_umask)}') original_umask = os.umask(parsed_umask) else: original_umask = None try: for command in commands: if not dry_run: execute.execute_command( [command], output_log_level=logging.ERROR if description == 'on-error' else logging.WARNING, shell=True, ) finally: if original_umask: os.umask(original_umask) def considered_soft_failure(config_filename, 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'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining actions', ) return True return False borgmatic-1.8.3/borgmatic/hooks/cronhub.py000066400000000000000000000036111450610336600206030ustar00rootroot00000000000000import logging import requests from borgmatic.hooks 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'{config_filename}: 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'{config_filename}: Pinging Cronhub {state.name.lower()}{dry_run_label}') logger.debug(f'{config_filename}: 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'{config_filename}: Cronhub error: {error}') def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic-1.8.3/borgmatic/hooks/cronitor.py000066400000000000000000000034221450610336600210020ustar00rootroot00000000000000import logging import requests from borgmatic.hooks 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'{config_filename}: 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'{config_filename}: Pinging Cronitor {state.name.lower()}{dry_run_label}') logger.debug(f'{config_filename}: 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'{config_filename}: Cronitor error: {error}') def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic-1.8.3/borgmatic/hooks/dispatch.py000066400000000000000000000063161450610336600207470ustar00rootroot00000000000000import logging from borgmatic.hooks import ( cronhub, cronitor, healthchecks, loki, mariadb, mongodb, mysql, ntfy, pagerduty, postgresql, sqlite, ) logger = logging.getLogger(__name__) HOOK_NAME_TO_MODULE = { 'cronhub': cronhub, 'cronitor': cronitor, 'healthchecks': healthchecks, 'mariadb_databases': mariadb, 'mongodb_databases': mongodb, 'mysql_databases': mysql, 'ntfy': ntfy, 'pagerduty': pagerduty, 'postgresql_databases': postgresql, 'sqlite_databases': sqlite, 'loki': loki, } def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs): ''' Given a configuration dict and a prefix to use in log entries, call the requested function of the Python module corresponding to the given hook name. Supply that call with the configuration for this hook (if any), the log prefix, and any given args and kwargs. Return any return value. Raise ValueError if the hook name is unknown. Raise AttributeError if the function name is not found in the module. Raise anything else that the called function raises. ''' hook_config = config.get(hook_name, {}) try: module = HOOK_NAME_TO_MODULE[hook_name] except KeyError: raise ValueError(f'Unknown hook name: {hook_name}') logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}') return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs) def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs): ''' Given a configuration dict and a prefix to use in log entries, call the requested function of the Python module corresponding to each given hook name. Supply each call with the configuration for that hook, the log prefix, and any given args and kwargs. Collect any return values into a dict from hook name to return value. 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, log_prefix, hook_name, *args, **kwargs) for hook_name in hook_names if config.get(hook_name) } def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs): ''' Given a configuration dict and a prefix to use in log entries, call the requested function of the Python module corresponding to each given hook name. Supply each call with the configuration for that hook, the log prefix, and any given args and kwargs. Collect any return values into a dict from hook name to return value. Raise AttributeError if the function name is not found in the module. Raise anything else that a called function raises. An error stops calls to subsequent functions. ''' return { hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs) for hook_name in hook_names } borgmatic-1.8.3/borgmatic/hooks/dump.py000066400000000000000000000046341450610336600201160ustar00rootroot00000000000000import logging import os import shutil from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY logger = logging.getLogger(__name__) DATA_SOURCE_HOOK_NAMES = ( 'mariadb_databases', 'mysql_databases', 'mongodb_databases', 'postgresql_databases', 'sqlite_databases', ) def make_data_source_dump_path(borgmatic_source_directory, data_source_hook_name): ''' Given a borgmatic source directory (or None) and a data source hook name, construct a data source dump path. ''' if not borgmatic_source_directory: borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY return os.path.join(borgmatic_source_directory, data_source_hook_name) def make_data_source_dump_filename(dump_path, name, hostname=None): ''' Based on the given dump directory path, data source name, and hostname, return a filename to use for the data source dump. The hostname defaults to localhost. Raise ValueError if the data source name is invalid. ''' if os.path.sep in name: raise ValueError(f'Invalid data source name {name}') return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name) def create_parent_directory_for_dump(dump_path): ''' Create a directory to contain the given dump path. ''' os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True) def create_named_pipe_for_dump(dump_path): ''' Create a named pipe at the given dump path. ''' create_parent_directory_for_dump(dump_path) os.mkfifo(dump_path, mode=0o600) def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run): ''' Remove all data source dumps in the given dump directory path (including the directory itself). If this is a dry run, then don't actually remove anything. ''' dry_run_label = ' (dry run; not actually removing anything)' if dry_run else '' logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}') expanded_path = os.path.expanduser(dump_path) if dry_run: return if os.path.exists(expanded_path): shutil.rmtree(expanded_path) def convert_glob_patterns_to_borg_patterns(patterns): ''' Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive patterns like "sh:etc/*". ''' return [f'sh:{pattern.lstrip(os.path.sep)}' for pattern in patterns] borgmatic-1.8.3/borgmatic/hooks/healthchecks.py000066400000000000000000000114401450610336600215700ustar00rootroot00000000000000import logging import requests from borgmatic.hooks 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', } PAYLOAD_TRUNCATION_INDICATOR = '...\n' DEFAULT_PING_BODY_LIMIT_BYTES = 100000 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. ''' def __init__(self, byte_capacity, log_level): super().__init__() 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 format_buffered_logs_for_payload(): ''' Get the handler previously added to the root logger, and slurp buffered logs out of it to send to Healthchecks. ''' try: buffering_handler = next( handler for handler in logging.getLogger().handlers if isinstance(handler, Forgetful_buffering_handler) ) except StopIteration: # 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 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(PAYLOAD_TRUNCATION_INDICATOR), 0, ) logging.getLogger().addHandler( Forgetful_buffering_handler(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'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states' ) return healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state) if healthchecks_state: ping_url = f'{ping_url}/{healthchecks_state}' logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}') logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}') if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG): payload = format_buffered_logs_for_payload() 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'{config_filename}: Healthchecks error: {error}') def destroy_monitor(hook_config, config, config_filename, 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. ''' logger = logging.getLogger() for handler in tuple(logger.handlers): if isinstance(handler, Forgetful_buffering_handler): logger.removeHandler(handler) borgmatic-1.8.3/borgmatic/hooks/loki.py000066400000000000000000000104651450610336600201060ustar00rootroot00000000000000import json import logging import os import platform import time import requests from borgmatic.hooks 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'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup') def destroy_monitor(hook_config, config, config_filename, 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-1.8.3/borgmatic/hooks/mariadb.py000066400000000000000000000217201450610336600205430ustar00rootroot00000000000000import copy import logging import os from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks import dump logger = logging.getLogger(__name__) def make_dump_path(config): # pragma: no cover ''' Make the dump path from the given configuration dict and the name of this hook. ''' return dump.make_data_source_dump_path( config.get('borgmatic_source_directory'), 'mariadb_databases' ) SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys') def database_names_to_dump(database, extra_environment, log_prefix, dry_run): ''' Given a requested database config, 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 () show_command = ( ('mariadb',) + (tuple(database['list_options'].split(' ')) if 'list_options' in database 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 ()) + (('--user', database['username']) if 'username' in database else ()) + ('--skip-column-names', '--batch') + ('--execute', 'show schemas') ) logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump') show_output = execute_command_and_capture_output( show_command, extra_environment=extra_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, log_prefix, dump_path, database_names, extra_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. Use the given log prefix in any log entries. 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') ) if os.path.exists(dump_filename): logger.warning( f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}' ) return None dump_command = ( ('mariadb-dump',) + (tuple(database['options'].split(' ')) if 'options' in database else ()) + (('--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 ()) + (('--user', database['username']) if 'username' in database else ()) + ('--databases',) + database_names + ('--result-file', dump_filename) ) logger.debug( f'{log_prefix}: 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, extra_environment=extra_environment, run_to_completion=False, ) def dump_data_sources(databases, config, log_prefix, dry_run): ''' Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of dicts, one dict describing each database as per the configuration schema. Use the given configuration dict to construct the destination path and the given log prefix in any log entries. 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. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}') for database in databases: dump_path = make_dump_path(config) extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None dump_database_names = database_names_to_dump( database, extra_environment, log_prefix, dry_run ) 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, log_prefix, dump_path, (dump_name,), extra_environment, dry_run, dry_run_label, ) ) else: processes.append( execute_dump_command( database, log_prefix, dump_path, dump_database_names, extra_environment, dry_run, dry_run_label, ) ) return [process for process in processes if process] def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the given configuration dict to construct the destination path and the log prefix in any log entries. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(config), 'MariaDB', log_prefix, dry_run) def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*') def restore_data_source_dump( hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params ): ''' Restore a database from the given extract stream. The database is supplied as a data source configuration dict, but the given hook configuration is ignored. The given configuration dict is used to construct the destination path, and the given log prefix is used for any log entries. If this is a dry run, then don't actually restore anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce output to consume. ''' 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 = connection_params['username'] or data_source.get( 'restore_username', data_source.get('username') ) password = connection_params['password'] or data_source.get( 'restore_password', data_source.get('password') ) restore_command = ( ('mariadb', '--batch') + ( tuple(data_source['restore_options'].split(' ')) if 'restore_options' in data_source else () ) + (('--host', hostname) if hostname else ()) + (('--port', str(port)) if port else ()) + (('--protocol', 'tcp') if hostname or port else ()) + (('--user', username) if username else ()) ) extra_environment = {'MYSQL_PWD': password} if password else None logger.debug(f"{log_prefix}: 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, extra_environment=extra_environment, ) borgmatic-1.8.3/borgmatic/hooks/mongodb.py000066400000000000000000000156161450610336600206000ustar00rootroot00000000000000import logging from borgmatic.execute import execute_command, execute_command_with_processes from borgmatic.hooks import dump logger = logging.getLogger(__name__) def make_dump_path(config): # pragma: no cover ''' Make the dump path from the given configuration dict and the name of this hook. ''' return dump.make_data_source_dump_path( config.get('borgmatic_source_directory'), 'mongodb_databases' ) def dump_data_sources(databases, config, log_prefix, dry_run): ''' Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of dicts, one dict describing each database as per the configuration schema. Use the configuration dict to construct the destination path and the given log prefix in any log entries. 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. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' logger.info(f'{log_prefix}: 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(config), name, database.get('hostname') ) dump_format = database.get('format', 'archive') logger.debug( f'{log_prefix}: Dumping MongoDB database {name} to {dump_filename}{dry_run_label}', ) if dry_run: continue command = build_dump_command(database, 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)) return processes def build_dump_command(database, dump_filename, dump_format): ''' Return the mongodump command from a single database configuration. ''' all_databases = database['name'] == 'all' return ( ('mongodump',) + (('--out', dump_filename) if dump_format == 'directory' else ()) + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--username', database['username']) if 'username' in database else ()) + (('--password', database['password']) if 'password' in database else ()) + ( ('--authenticationDatabase', database['authentication_database']) if 'authentication_database' in database else () ) + (('--db', database['name']) if not all_databases else ()) + (tuple(database['options'].split(' ')) if 'options' in database else ()) + (('--archive', '>', dump_filename) if dump_format != 'directory' else ()) ) def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the log prefix in any log entries. Use the given configuration dict to construct the destination path. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run) def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover ''' Given a sequence of database configurations dicts, a configuration dict, a prefix to log with, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*') def restore_data_source_dump( hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params ): ''' Restore a database from the given extract stream. The database is supplied as a data source configuration dict, but the given hook configuration is ignored. The given configuration dict is used to construct the destination path, and the given log prefix is used for any log entries. If this is a dry run, then don't actually restore anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce output to consume. 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(config), data_source['name'], data_source.get('hostname') ) restore_command = build_restore_command( extract_process, data_source, dump_filename, connection_params ) logger.debug(f"{log_prefix}: 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, 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 = connection_params['username'] or database.get( 'restore_username', database.get('username') ) password = connection_params['password'] or database.get( 'restore_password', database.get('password') ) 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(('--password', 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-1.8.3/borgmatic/hooks/monitor.py000066400000000000000000000002741450610336600206340ustar00rootroot00000000000000from enum import Enum MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki') class State(Enum): START = 1 FINISH = 2 FAIL = 3 LOG = 4 borgmatic-1.8.3/borgmatic/hooks/mysql.py000066400000000000000000000217011450610336600203100ustar00rootroot00000000000000import copy import logging import os from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks import dump logger = logging.getLogger(__name__) def make_dump_path(config): # pragma: no cover ''' Make the dump path from the given configuration dict and the name of this hook. ''' return dump.make_data_source_dump_path( config.get('borgmatic_source_directory'), 'mysql_databases' ) SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys') def database_names_to_dump(database, extra_environment, log_prefix, dry_run): ''' Given a requested database config, 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 () show_command = ( ('mysql',) + (tuple(database['list_options'].split(' ')) if 'list_options' in database 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 ()) + (('--user', database['username']) if 'username' in database else ()) + ('--skip-column-names', '--batch') + ('--execute', 'show schemas') ) logger.debug(f'{log_prefix}: Querying for "all" MySQL databases to dump') show_output = execute_command_and_capture_output( show_command, extra_environment=extra_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, log_prefix, dump_path, database_names, extra_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. Use the given log prefix in any log entries. 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') ) if os.path.exists(dump_filename): logger.warning( f'{log_prefix}: Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}' ) return None dump_command = ( ('mysqldump',) + (tuple(database['options'].split(' ')) if 'options' in database else ()) + (('--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 ()) + (('--user', database['username']) if 'username' in database else ()) + ('--databases',) + database_names + ('--result-file', dump_filename) ) logger.debug( f'{log_prefix}: 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, extra_environment=extra_environment, run_to_completion=False, ) def dump_data_sources(databases, config, log_prefix, dry_run): ''' Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence of dicts, one dict describing each database as per the configuration schema. Use the given configuration dict to construct the destination path and the given log prefix in any log entries. 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. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'{log_prefix}: Dumping MySQL databases{dry_run_label}') for database in databases: dump_path = make_dump_path(config) extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None dump_database_names = database_names_to_dump( database, extra_environment, log_prefix, dry_run ) 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, log_prefix, dump_path, (dump_name,), extra_environment, dry_run, dry_run_label, ) ) else: processes.append( execute_dump_command( database, log_prefix, dump_path, dump_database_names, extra_environment, dry_run, dry_run_label, ) ) return [process for process in processes if process] def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the given configuration dict to construct the destination path and the log prefix in any log entries. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(config), 'MySQL', log_prefix, dry_run) def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*') def restore_data_source_dump( hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params ): ''' Restore a database from the given extract stream. The database is supplied as a data source configuration dict, but the given hook configuration is ignored. The given configuration dict is used to construct the destination path, and the given log prefix is used for any log entries. If this is a dry run, then don't actually restore anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce output to consume. ''' 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 = connection_params['username'] or data_source.get( 'restore_username', data_source.get('username') ) password = connection_params['password'] or data_source.get( 'restore_password', data_source.get('password') ) restore_command = ( ('mysql', '--batch') + ( tuple(data_source['restore_options'].split(' ')) if 'restore_options' in data_source else () ) + (('--host', hostname) if hostname else ()) + (('--port', str(port)) if port else ()) + (('--protocol', 'tcp') if hostname or port else ()) + (('--user', username) if username else ()) ) extra_environment = {'MYSQL_PWD': password} if password else None logger.debug(f"{log_prefix}: 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, extra_environment=extra_environment, ) borgmatic-1.8.3/borgmatic/hooks/ntfy.py000066400000000000000000000054441450610336600201310ustar00rootroot00000000000000import logging import requests logger = logging.getLogger(__name__) def initialize_monitor( ping_url, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover ''' No initialization is necessary for this monitor. ''' pass def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run): ''' 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'{config_filename}: Pinging ntfy topic {topic}{dry_run_label}') logger.debug(f'{config_filename}: 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'), } username = hook_config.get('username') password = hook_config.get('password') auth = None if (username and password) is not None: auth = requests.auth.HTTPBasicAuth(username, password) logger.info(f'{config_filename}: Using basic auth with user {username} for ntfy') elif username is not None: logger.warning( f'{config_filename}: Password missing for ntfy authentication, defaulting to no auth' ) elif password is not None: logger.warning( f'{config_filename}: 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'{config_filename}: ntfy error: {error}') def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic-1.8.3/borgmatic/hooks/pagerduty.py000066400000000000000000000050561450610336600211540ustar00rootroot00000000000000import datetime import json import logging import platform import requests from borgmatic.hooks import monitor logger = logging.getLogger(__name__) EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue' def initialize_monitor( integration_key, 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): ''' 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'{config_filename}: 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'{config_filename}: Sending failure event to PagerDuty {dry_run_label}') if dry_run: return hostname = platform.node() local_timestamp = ( datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().isoformat() ) payload = json.dumps( { 'routing_key': hook_config['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, }, }, } ) logger.debug(f'{config_filename}: Using PagerDuty payload: {payload}') 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'{config_filename}: PagerDuty error: {error}') def destroy_monitor( ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run ): # pragma: no cover ''' No destruction is necessary for this monitor. ''' pass borgmatic-1.8.3/borgmatic/hooks/postgresql.py000066400000000000000000000275121450610336600213540ustar00rootroot00000000000000import csv import itertools import logging import os import shlex from borgmatic.execute import ( execute_command, execute_command_and_capture_output, execute_command_with_processes, ) from borgmatic.hooks import dump logger = logging.getLogger(__name__) def make_dump_path(config): # pragma: no cover ''' Make the dump path from the given configuration dict and the name of this hook. ''' return dump.make_data_source_dump_path( config.get('borgmatic_source_directory'), 'postgresql_databases' ) def make_extra_environment(database, restore_connection_params=None): ''' Make the extra_environment dict from the given database configuration. If restore connection params are given, this is for a restore operation. ''' extra = dict() try: if restore_connection_params: extra['PGPASSWORD'] = restore_connection_params.get('password') or database.get( 'restore_password', database['password'] ) else: extra['PGPASSWORD'] = database['password'] except (AttributeError, KeyError): pass extra['PGSSLMODE'] = database.get('ssl_mode', 'disable') if 'ssl_cert' in database: extra['PGSSLCERT'] = database['ssl_cert'] if 'ssl_key' in database: extra['PGSSLKEY'] = database['ssl_key'] if 'ssl_root_cert' in database: extra['PGSSLROOTCERT'] = database['ssl_root_cert'] if 'ssl_crl' in database: extra['PGSSLCRL'] = database['ssl_crl'] return extra EXCLUDED_DATABASE_NAMES = ('template0', 'template1') def database_names_to_dump(database, extra_environment, log_prefix, dry_run): ''' Given a requested database config, 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 = shlex.split(database.get('psql_command') or 'psql') list_command = ( tuple(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', database['username']) if 'username' in database else ()) + (tuple(database['list_options'].split(' ')) if 'list_options' in database else ()) ) logger.debug(f'{log_prefix}: Querying for "all" PostgreSQL databases to dump') list_output = execute_command_and_capture_output( list_command, extra_environment=extra_environment ) return tuple( row[0] for row in csv.reader(list_output.splitlines(), delimiter=',', quotechar='"') if row[0] not in EXCLUDED_DATABASE_NAMES ) def dump_data_sources(databases, config, log_prefix, dry_run): ''' Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of dicts, one dict describing each database as per the configuration schema. Use the given configuration dict to construct the destination path and the given log prefix in any log entries. 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. 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'{log_prefix}: Dumping PostgreSQL databases{dry_run_label}') for database in databases: extra_environment = make_extra_environment(database) dump_path = make_dump_path(config) dump_database_names = database_names_to_dump( database, extra_environment, log_prefix, 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') default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump' dump_command = database.get('pg_dump_command') or default_dump_command dump_filename = dump.make_data_source_dump_filename( dump_path, database_name, database.get('hostname') ) if os.path.exists(dump_filename): logger.warning( f'{log_prefix}: Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}' ) continue command = ( ( dump_command, '--no-password', '--clean', '--if-exists', ) + (('--host', database['hostname']) if 'hostname' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ()) + (('--username', database['username']) if 'username' in database else ()) + (('--no-owner',) if database.get('no_owner', False) else ()) + (('--format', dump_format) if dump_format else ()) + (('--file', dump_filename) if dump_format == 'directory' else ()) + (tuple(database['options'].split(' ')) if 'options' in database else ()) + (() if database_name == 'all' else (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. + (('>', dump_filename) if dump_format != 'directory' else ()) ) logger.debug( f'{log_prefix}: 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, extra_environment=extra_environment, ) else: dump.create_named_pipe_for_dump(dump_filename) processes.append( execute_command( command, shell=True, extra_environment=extra_environment, run_to_completion=False, ) ) return processes def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover ''' Remove all database dump files for this hook regardless of the given databases. Use the given configuration dict to construct the destination path and the log prefix in any log entries. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run) def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover ''' Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a database name to match, return the corresponding glob patterns to match the database dump in an archive. ''' return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*') def restore_data_source_dump( hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params ): ''' Restore a database from the given extract stream. The database is supplied as a data source configuration dict, but the given hook configuration is ignored. The given configuration dict is used to construct the destination path, and the given log prefix is used for any log entries. If this is a dry run, then don't actually restore anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce output to consume. 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 = connection_params['username'] or data_source.get( 'restore_username', data_source.get('username') ) all_databases = bool(data_source['name'] == 'all') dump_filename = dump.make_data_source_dump_filename( make_dump_path(config), data_source['name'], data_source.get('hostname') ) psql_command = shlex.split(data_source.get('psql_command') or 'psql') analyze_command = ( tuple(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 = shlex.split(data_source.get('pg_restore_command') or 'pg_restore') restore_command = ( tuple(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 (dump_filename,)) + tuple( itertools.chain.from_iterable(('--schema', schema) for schema in data_source['schemas']) if data_source.get('schemas') else () ) ) extra_environment = make_extra_environment( data_source, restore_connection_params=connection_params ) logger.debug( f"{log_prefix}: 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, extra_environment=extra_environment, ) execute_command(analyze_command, extra_environment=extra_environment) borgmatic-1.8.3/borgmatic/hooks/sqlite.py000066400000000000000000000112051450610336600204420ustar00rootroot00000000000000import logging import os from borgmatic.execute import execute_command, execute_command_with_processes from borgmatic.hooks import dump logger = logging.getLogger(__name__) def make_dump_path(config): # pragma: no cover ''' Make the dump path from the given configuration dict and the name of this hook. ''' return dump.make_data_source_dump_path( config.get('borgmatic_source_directory'), 'sqlite_databases' ) def dump_data_sources(databases, config, log_prefix, dry_run): ''' Dump the given SQLite3 databases to a file. The databases are supplied as a sequence of configuration dicts, as per the configuration schema. Use the given configuration dict to construct the destination path and the given log prefix in any log entries. If this is a dry run, then don't actually dump anything. ''' dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else '' processes = [] logger.info(f'{log_prefix}: 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'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped' ) dump_path = make_dump_path(config) dump_filename = dump.make_data_source_dump_filename(dump_path, database['name']) if os.path.exists(dump_filename): logger.warning( f'{log_prefix}: Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}' ) continue command = ( 'sqlite3', database_path, '.dump', '>', dump_filename, ) logger.debug( f'{log_prefix}: Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}' ) if dry_run: continue dump.create_parent_directory_for_dump(dump_filename) processes.append(execute_command(command, shell=True, run_to_completion=False)) return processes def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover ''' Remove the given SQLite3 database dumps from the filesystem. The databases are supplied as a sequence of configuration dicts, as per the configuration schema. Use the given configuration dict to construct the destination path and the given log prefix in any log entries. If this is a dry run, then don't actually remove anything. ''' dump.remove_data_source_dumps(make_dump_path(config), 'SQLite', log_prefix, dry_run) def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover ''' Make a pattern that matches the given SQLite3 databases. The databases are supplied as a sequence of configuration dicts, as per the configuration schema. ''' return dump.make_data_source_dump_filename(make_dump_path(config), name) def restore_data_source_dump( hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params ): ''' Restore a database from the given extract stream. The database is supplied as a data source configuration dict, but the given hook configuration is ignored. The given configuration dict is used to construct the destination path, and the given log prefix is used for any log entries. If this is a dry run, then don't actually restore anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce output to consume. ''' 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'{log_prefix}: Restoring SQLite database at {database_path}{dry_run_label}') if dry_run: return try: os.remove(database_path) logger.warning(f'{log_prefix}: 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-1.8.3/borgmatic/logger.py000066400000000000000000000157461450610336600173130ustar00rootroot00000000000000import logging import logging.handlers import os import sys import colorama 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 colorama marking up. ''' if no_color: return False if any(config.get('output', {}).get('color') is False for config in configs.values()): 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 Console_color_formatter(logging.Formatter): def format(self, record): add_custom_log_levels() color = { logging.CRITICAL: colorama.Fore.RED, logging.ERROR: colorama.Fore.RED, logging.WARN: colorama.Fore.YELLOW, logging.ANSWER: colorama.Fore.MAGENTA, logging.INFO: colorama.Fore.GREEN, logging.DEBUG: colorama.Fore.CYAN, }.get(record.levelno) return color_text(color, record.msg) def color_text(color, message): ''' Give colored text. ''' if not color: return message return f'{color}{message}{colorama.Style.RESET_ALL}' 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 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, ): ''' Configure logging to go to both the console and (syslog or log file). Use the given log levels, respectively. Raise FileNotFoundError or PermissionError if the log file could not be opened for writing. ''' 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 add_custom_log_levels() # 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, } ) console_handler.setFormatter(Console_color_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( logging.Formatter('borgmatic: {levelname} {message}', style='{') # 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( logging.Formatter( log_file_format or '[{asctime}] {levelname}: {message}', style='{' # noqa: FS003 ) ) file_handler.setLevel(log_file_log_level) handlers.append(file_handler) logging.basicConfig( level=min(handler.level for handler in handlers), handlers=handlers, ) borgmatic-1.8.3/borgmatic/signals.py000066400000000000000000000021401450610336600174540ustar00rootroot00000000000000import 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: 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-1.8.3/borgmatic/verbosity.py000066400000000000000000000011121450610336600200400ustar00rootroot00000000000000import 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-1.8.3/docs/000077500000000000000000000000001450610336600144265ustar00rootroot00000000000000borgmatic-1.8.3/docs/Dockerfile000066400000000000000000000030101450610336600164120ustar00rootroot00000000000000FROM docker.io/alpine:3.17.1 as borgmatic COPY . /app RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ && for action in rcreate transfer create prune compact check extract config "config bootstrap" "config generate" "config validate" export-tar mount umount restore rlist list rinfo info break-lock borg; do \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \ && borgmatic $action --help >> /command-line.txt; done FROM docker.io/node:19.5.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 . /source RUN NODE_ENV=${ENVIRONMENT} npx eleventy --input=/source/docs --output=/output/docs \ && mv /output/docs/index.html /output/index.html FROM docker.io/nginx:1.22.1-alpine COPY --from=html /output /usr/share/nginx/html COPY --from=borgmatic /etc/borgmatic/config.yaml /usr/share/nginx/html/docs/reference/config.yaml borgmatic-1.8.3/docs/README.md000077700000000000000000000000001450610336600173722../README.mdustar00rootroot00000000000000borgmatic-1.8.3/docs/SECURITY.md000066400000000000000000000012401450610336600162140ustar00rootroot00000000000000--- 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-1.8.3/docs/_data/000077500000000000000000000000001450610336600154765ustar00rootroot00000000000000borgmatic-1.8.3/docs/_data/borgmatic.js000066400000000000000000000001601450610336600200000ustar00rootroot00000000000000module.exports = function() { return { environment: process.env.NODE_ENV || "development" }; }; borgmatic-1.8.3/docs/_data/layout.json000066400000000000000000000000231450610336600177010ustar00rootroot00000000000000"layouts/main.njk" borgmatic-1.8.3/docs/_includes/000077500000000000000000000000001450610336600163735ustar00rootroot00000000000000borgmatic-1.8.3/docs/_includes/asciinema.css000066400000000000000000000000641450610336600210360ustar00rootroot00000000000000.asciicast > iframe { width: 100% !important; } borgmatic-1.8.3/docs/_includes/components/000077500000000000000000000000001450610336600205605ustar00rootroot00000000000000borgmatic-1.8.3/docs/_includes/components/external-links.css000066400000000000000000000007541450610336600242400ustar00rootroot00000000000000/* 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-1.8.3/docs/_includes/components/info-blocks.css000066400000000000000000000011741450610336600235030ustar00rootroot00000000000000/* 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-1.8.3/docs/_includes/components/lists.css000066400000000000000000000120051450610336600224260ustar00rootroot00000000000000/* 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-1.8.3/docs/_includes/components/minilink.css000066400000000000000000000033741450610336600231130ustar00rootroot00000000000000/* 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-1.8.3/docs/_includes/components/suggestion-link.html000066400000000000000000000003331450610336600245670ustar00rootroot00000000000000

Improve this documentation

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

borgmatic-1.8.3/docs/_includes/components/toc.css000066400000000000000000000035061450610336600220630ustar00rootroot00000000000000.elv-toc { font-size: 1rem; /* Reset */ } .elv-toc details { --details-force-closed: (max-width: 63.9375em); /* 1023px */ } .elv-toc details > summary { font-size: 1.375rem; /* 22px /16 */ margin-bottom: .5em; } @media (min-width: 64em) { /* 1024px */ .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-1.8.3/docs/_includes/header.njk000066400000000000000000000004111450610336600203230ustar00rootroot00000000000000
{% if page.url != '/' %}

borgmatic

{% endif %}

{{ title | safe }}

borgmatic-1.8.3/docs/_includes/index.css000066400000000000000000000633441450610336600202260ustar00rootroot00000000000000@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: 64em) { /* 1024px */ 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 { margin: .5em 0; padding-left: 1em; } li li { padding-top: .1em; padding-bottom: .1em; } /* 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: 64em) { /* 1024px */ .elv-layout-toc { padding-left: 15rem; max-width: 60rem; 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-1.8.3/docs/_includes/layouts/000077500000000000000000000000001450610336600200735ustar00rootroot00000000000000borgmatic-1.8.3/docs/_includes/layouts/base.njk000066400000000000000000000016151450610336600215140ustar00rootroot00000000000000 {{ 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-1.8.3/docs/_includes/layouts/main.njk000066400000000000000000000022401450610336600215210ustar00rootroot00000000000000--- 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-1.8.3/docs/_includes/prism-theme.css000066400000000000000000000057371450610336600213530ustar00rootroot00000000000000/** * 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-1.8.3/docs/docker-compose.yaml000066400000000000000000000006461450610336600202320ustar00rootroot00000000000000version: '3' services: docs: image: borgmatic-docs container_name: docs ports: - 8080:80 build: dockerfile: docs/Dockerfile context: .. args: ENVIRONMENT: development message: image: alpine container_name: message command: - sh - -c - | echo; echo "You can view dev docs at http://localhost:8080"; echo depends_on: - docs borgmatic-1.8.3/docs/how-to/000077500000000000000000000000001450610336600156435ustar00rootroot00000000000000borgmatic-1.8.3/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md000066400000000000000000000122351450610336600266310ustar00rootroot00000000000000--- title: How to add preparation and cleanup steps to backups eleventyNavigation: key: 🧹 Add preparation and cleanup steps parent: How-to guides order: 9 --- ## Preparation and cleanup hooks If you find yourself performing preparation tasks before your backup runs, or cleanup work afterwards, borgmatic hooks may be of interest. Hooks are shell commands that borgmatic executes for you at various points as it runs, and they're configured in the `hooks` section of your configuration file. 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 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 run in a non-interactive console). 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-1.8.3/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md000066400000000000000000000124311450610336600304140ustar00rootroot00000000000000--- title: How to backup to a removable drive or an intermittent server eleventyNavigation: key: 💾 Backup to a removable drive/server parent: How-to guides order: 10 --- ## 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 that configuration file. If you return any other status, then it's a standard success or error. (Zero is success; anything else other than 75 is an error). 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 in that configurable file, and proceeds onward to any other borgmatic configuration files you may have. 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. * The soft failure only applies to the scope of a single borgmatic configuration file. So put anything that you don't want soft-failed, like always-online cloud backups, in separate configuration files from your soft-failing repositories. * The soft failure doesn't have to apply to a repository. You can even perform a test to make sure that individual source directories are mounted and available. Use your imagination! * 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-1.8.3/docs/how-to/backup-your-databases.md000066400000000000000000000420141450610336600223540ustar00rootroot00000000000000--- 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.) To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by default. To customize this path, set the `borgmatic_source_directory` option in borgmatic's configuration. Also note that using a database hook implicitly enables both the `read_special` and `one_file_system` configuration settings (even if they're disabled in your configuration) to support this dump and restore streaming. See Limitations below for more on this. 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.). ### 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. ### 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 via [environment variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/) or command-line [configuration overrides](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides). ### 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 To restore a database dump from an archive, use the `borgmatic restore` action. But the first step is to figure out which archive to restore from. A good way to do that is to use the `rlist` action: ```bash borgmatic rlist ``` (No borgmatic `rlist` action? Try `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!) With newer versions of borgmatic, you can simplify this to: ```bash borgmatic restore --archive latest ``` The `--archive` value is the name of the archive to restore from. This restores all databases dumps that borgmatic originally backed up to that archive. 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 host-2023-... ``` ### 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 host-2023-... --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 all databases To restore all databases: ```bash borgmatic restore --archive host-2023-... --database all ``` Or omit the `--database` flag entirely: ```bash borgmatic restore --archive host-2023-... ``` Prior to borgmatic version 1.7.6, this restores a combined "all" database dump from the archive. 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. ### 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 ``` ### Limitations There are a few important limitations with borgmatic's current database restoration feature that you should know about: 1. You must restore as the same Unix user that created the archive containing the database dump. That's because the user's home directory path is encoded into the path of the database dump within the archive. 2. As mentioned above, borgmatic can only restore a database that's defined in borgmatic's own configuration file. So include your configuration file in backups to avoid getting caught without a way to restore a database. 3. borgmatic does not currently support backing up or restoring multiple databases that share the exact same name on different hosts. 4. Because database hooks implicitly enable the `read_special` configuration, any special files are excluded from backups (named pipes, block devices, character devices, and sockets) to prevent hanging. Try a command like `find /your/source/path -type b -or -type c -or -type p -or -type s` to find such files. Common directories to exclude are `/dev` and `/run`, but that may not be exhaustive. New in version 1.7.3 When database hooks are enabled, borgmatic automatically excludes special files (and symlinks to special files) that may cause Borg to hang, so generally you no longer need to manually exclude them. There are potential edge cases though in which applications on your system create new special files *after* borgmatic constructs its exclude list, resulting in Borg hangs. If that occurs, you can resort to the manual excludes described above. And to opt out of the auto-exclude feature entirely, explicitly set `read_special` to true. ### Manual restoration If you prefer to restore a database without the help of borgmatic, first [extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an archive containing a database dump. borgmatic extracts the dump file into the *`username`*`/.borgmatic/` directory within the extraction destination path, where *`username`* is the user that created the backup. For example, if you created the backup with the `root` user and you're extracting to `/tmp`, then the dump will be in `/tmp/root/.borgmatic`. 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). ## 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. This is likely not the cause of the authentication problem unless you mistyped your password, however; borgmatic passes your password to the database via an environment variable that does not appear in the logs. 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-1.8.3/docs/how-to/deal-with-very-large-backups.md000066400000000000000000000173441450610336600235550ustar00rootroot00000000000000--- 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 `prune`, `compact`, `create`, 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). ### 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. (Although see below about check frequency.) This is equivalent to what `borg check` does if run without options. 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 from fastest to slowest: * `repository`: Checks the consistency of the repository itself. * `archives`: Checks all of the archives in the repository. * `extract`: Performs an extraction dry-run of the most recent archive. * `data`: Verifies the data integrity of all archives contents, decrypting and decompressing all data. 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. ### 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 `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 (as recorded in a file within `~/.borgmatic/checks`). If it hasn't been long enough, the check is skipped. And you still have to run `borgmatic check` (or `borgmatic` without actions) in order for checks to run, even when a `frequency` is configured! 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. If you want to temporarily ignore your configured frequencies, you can invoke `borgmatic check --force` to run checks unconditionally. ### 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. Disabling all consistency checks looks like this: ```yaml checks: - name: disabled ``` Prior to version 1.8.0 Put this option 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-1.8.3/docs/how-to/develop-on-borgmatic.md000066400000000000000000000143051450610336600222050ustar00rootroot00000000000000--- title: How to develop on borgmatic eleventyNavigation: key: 🏗️ Develop on borgmatic parent: How-to guides order: 13 --- ## Source code To get set up to develop on borgmatic, first [`install pipx`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation) to make managing your borgmatic environment easy without impacting other 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 . ``` 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 CI-test parity. If you would like to run the full test suite, first install Docker (or Podman; see below) and [Docker Compose](https://docs.docker.com/compose/install/). Then run: ```bash scripts/run-end-to-end-dev-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. borgmatic code uses the [Black](https://black.readthedocs.io/en/stable/) code formatter, the [Flake8](http://flake8.pycqa.org/en/latest/) code checker, and the [isort](https://github.com/timothycrosley/isort) import orderer, so certain code style requirements will be enforced when running automated tests. See the Black, Flake8, and isort documentation for more information. ## Continuous integration Each pull request triggers a continuous integration build which runs the test suite. You can view these builds on [build.torsion.org](https://build.torsion.org/borgmatic-collective/borgmatic), and they're also linked from the commits list on each pull request. ## 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-1.8.3/docs/how-to/extract-a-backup.md000066400000000000000000000143411450610336600213230ustar00rootroot00000000000000--- 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 `rlist` action: ```bash borgmatic rlist ``` (No borgmatic `rlist` action? Try `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 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 ``` 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 borgmatic automatically stores all the configuration files used to create an archive inside the archive itself. 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 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 ``` New in version 1.8.1 Set the `store_config_files` option to `false` to disable the automatic backup of borgmatic configuration files, for instance if they contain sensitive information you don't want to store even inside your encrypted backups. If you do this though, the `config bootstrap` action will no longer work. borgmatic-1.8.3/docs/how-to/index.md000066400000000000000000000001151450610336600172710ustar00rootroot00000000000000--- eleventyNavigation: key: How-to guides order: 1 permalink: false --- borgmatic-1.8.3/docs/how-to/inspect-your-backups.md000066400000000000000000000144301450610336600222560ustar00rootroot00000000000000--- 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 borgmatic version 1.7.0 There are also `rlist` and `rinfo` actions for displaying repository information with Borg 2.x: ```bash borgmatic rlist borgmatic rinfo ``` 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. ## Logging By default, borgmatic logs to a local syslog-compatible daemon if one is present and borgmatic is running in a non-interactive console. Where those 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. You can customize the log level used for syslog logging with the `--syslog-verbosity` flag, and this is independent from the console logging `--verbosity` flag described above. For instance, to get additional information about the progress of the backup as it proceeds: ```bash borgmatic --syslog-verbosity 1 ``` Or to increase syslog logging to include debug spew: ```bash borgmatic --syslog-verbosity 2 ``` ### 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 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` flg only applies to the specified `--log-file` and not to syslog or other logging. borgmatic-1.8.3/docs/how-to/make-backups-redundant.md000066400000000000000000000041651450610336600225200ustar00rootroot00000000000000--- 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-1.8.3/docs/how-to/make-per-application-backups.md000066400000000000000000000472541450610336600236310ustar00rootroot00000000000000--- 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 is `{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}`, meaning your system hostname plus a timestamp in a particular format. ### 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 `rlist` or `info` results—even if the same repository appears in multiple borgmatic configuration files. To take advantage of this feature, use a different `archive_name_format` in each configuration file. 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 `rlist`, `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-* ``` For Borg 1.x, use a shell pattern for the `match_archives` value and see the [Borg patterns documentation](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns) for more information. For Borg 2.x, see the [match archives documentation](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/help.html#borg-help-match-archives). 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 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 with 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: hostname: myhostname repositories: - path: repo.borg ``` This is what `common.yaml` might look like: ```yaml constants: prefix: myprefix hostname: otherhost ``` Once this include gets merged in, the resulting configuration would have a `prefix` value of `myprefix` and an overridden `hostname` value of `myhostname`. 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 even set list values by using brackets. For instance: ```bash borgmatic create --override repositories=[test1.borg,test2.borg] ``` Or even a single list element: ```bash borgmatic create --override repositories=[/root/test.borg] ``` If your override value contains special YAML characters like colons, then you'll need quotes for it to parse correctly: ```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. Note that if you override an option of the list type (like `location.repositories`), you do need to use the `[ ]` list syntax. 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.) Be sure to quote your overrides if they contain spaces or other characters that your shell may interpret. 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 anywhere in the entire configuration file. (Constants don't work across includes or separate configuration files though.) 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 in this particular example, `{now}` doesn't get replaced with anything, but gets passed directly to Borg.) After substitution, the logical result looks something like this: ```yaml source_directories: - /home/foo/.config - /home/foo/.ssh ... archive_name_format: 'bar-{now}' ``` An alternate to constants is passing in your values via [environment variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/). borgmatic-1.8.3/docs/how-to/monitor-your-backups.md000066400000000000000000000362141450610336600223040ustar00rootroot00000000000000--- 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 monitoring services like [Healthchecks](https://healthchecks.io/), [Cronitor](https://cronitor.io), [Cronhub](https://cronhub.io), [PagerDuty](https://www.pagerduty.com/), [ntfy](https://ntfy.sh/), and [Grafana Loki](https://grafana.com/oss/loki/) and pings these services whenever borgmatic runs. That way, you'll receive an alert when something goes wrong or (for certain hooks) the service doesn't hear from borgmatic for a configured interval. See [Healthchecks hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), [Cronhub hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), [PagerDuty hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook), [ntfy hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook), and [Loki hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook), below for how to configure this. While these services 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. ## 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 hook in place, 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 10-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 hook in place, borgmatic pings your Cronitor monitor when a backup begins, ends, or errors, but only when any of the `prune`, `compact`, `create`, or `check` actions are run. Then, if the actions complete 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 hook in place, borgmatic pings your Cronhub monitor when a backup begins, ends, or errors, but only when any of the `prune`, `compact`, `create`, or `check` actions are run. Then, if the actions complete 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 hook in place, 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). ## ntfy hook New in version 1.6.3 [ntfy](https://ntfy.sh) is a free, simple, service (either 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. As ntfy is unauthenticated, it isn't a suitable channel for any private information so the default messages are intentionally generic. These can be overridden, depending on your risk assessment. 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 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. ## 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 ``` With this hook in place, borgmatic sends its logs to your Loki instance as any of the `prune`, `compact`, `create`, or `check` actions are run. Then, after the actions complete, borgmatic notifies Loki of success or failure. This hook supports sending arbitrary labels to Loki. For instance: ```yaml loki: url: http://localhost:3100/loki/api/v1/push labels: app: borgmatic hostname: example.org ``` 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 ``` ## Scripting borgmatic To consume the output of borgmatic in other software, you can include an optional `--json` flag with `create`, `rlist`, `rinfo`, or `info` to get the output formatted as JSON. 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 rlist` manually, which can be handy in automated scripts. Here's an example: ```bash borgmatic info --archive latest ``` borgmatic-1.8.3/docs/how-to/provide-your-passwords.md000066400000000000000000000063721450610336600226640ustar00rootroot00000000000000--- title: How to provide your passwords eleventyNavigation: key: 🔒 Provide your passwords parent: How-to guides order: 2 --- ## Environment variable interpolation 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. But if you'd rather store them outside of borgmatic, whether for convenience or security reasons, read on. 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. For instance: ```yaml encryption_passphrase: ${MY_PASSPHRASE} ``` Prior to version 1.8.0 Put this option in the `storage:` section of your configuration. This uses the `MY_PASSPHRASE` environment variable as your encryption passphrase. Note that the `{` `}` brackets are required. `$MY_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: ${MY_DATABASE_PASSWORD} ``` Prior to version 1.8.0 Put this option in the `hooks:` section of your configuration. This uses the `MY_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: ${MY_PASSPHRASE:-defaultpass} ``` Here, "`defaultpass`" is the default passphrase if the `MY_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-1.8.3/docs/how-to/restore-a-backup.md000066400000000000000000000001741450610336600213330ustar00rootroot00000000000000 borgmatic-1.8.3/docs/how-to/run-arbitrary-borg-commands.md000066400000000000000000000134451450610336600235230ustar00rootroot00000000000000--- title: How to run arbitrary Borg commands eleventyNavigation: key: 🔧 Run arbitrary Borg commands parent: How-to guides order: 11 --- ## 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 binary 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 rlist --short ``` This runs Borg's `rlist` 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 just 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. * 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-1.8.3/docs/how-to/run-preparation-steps-before-backups.md000066400000000000000000000002301450610336600253300ustar00rootroot00000000000000 borgmatic-1.8.3/docs/how-to/set-up-backups.md000066400000000000000000000354421450610336600210400ustar00rootroot00000000000000--- 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 easy 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`. ### 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.7.0 Or, with Borg 2.x: ```bash sudo borgmatic rcreate --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 ``` ## 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 variable `PY_COLORS=False`, 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-1.8.3/docs/how-to/upgrade.md000066400000000000000000000162241450610336600176210ustar00rootroot00000000000000--- title: How to upgrade borgmatic and Borg eleventyNavigation: key: 📦 Upgrade borgmatic/Borg parent: How-to guides order: 12 --- ## 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. ## 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.7.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 `rcreate` action (formerly `init`) to create that new Borg 2 repository: ```bash borgmatic rcreate --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 rcreate --verbosity 1 --encryption repokey-aes-ocb \ --source-repository original.borg --repository upgraded.borg ``` Read about [Borg encryption modes](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/rcreate.html#encryption-mode-tldr) 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.0b5/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-1.8.3/docs/reference/000077500000000000000000000000001450610336600163645ustar00rootroot00000000000000borgmatic-1.8.3/docs/reference/command-line.md000066400000000000000000000015251450610336600212540ustar00rootroot00000000000000--- 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-1.8.3/docs/reference/configuration.md000066400000000000000000000014521450610336600215570ustar00rootroot00000000000000--- 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 %} ``` Note that you can also [download this configuration borgmatic-1.8.3/docs/reference/index.md000066400000000000000000000001201450610336600200060ustar00rootroot00000000000000--- eleventyNavigation: key: Reference guides order: 2 permalink: false --- borgmatic-1.8.3/docs/reference/source-code.md000066400000000000000000000044661450610336600211300ustar00rootroot00000000000000--- 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. 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. * [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. Within the `borgmatic` directory you'll find: * [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): Mid-level code for running each borgmatic action (create, list, check, etc.). * [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that actually shells out to Borg for 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. * [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here. So, broadly speaking, the control flow goes: `commands` → `config` followed by `commands` → `actions` → `borg` and `hooks`. borgmatic-1.8.3/docs/static/000077500000000000000000000000001450610336600157155ustar00rootroot00000000000000borgmatic-1.8.3/docs/static/borgbase.png000066400000000000000000000132761450610336600202200ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/borgmatic.png000066400000000000000000000070111450610336600203710ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/borgmatic.svg000066400000000000000000000032121450610336600204030ustar00rootroot00000000000000borgmatic-1.8.3/docs/static/cronhub.png000066400000000000000000000557601450610336600201000ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/cronitor.png000066400000000000000000000241741450610336600202720ustar00rootroot00000000000000PNG  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ߔ[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-1.8.3/docs/static/mariadb.png000066400000000000000000000224021450610336600200220ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/mysql.png000066400000000000000000000072611450610336600175760ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/ntfy.png000066400000000000000000000240321450610336600174040ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/pagerduty.png000066400000000000000000000472131450610336600204360ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/postgresql.png000066400000000000000000000754501450610336600206410ustar00rootroot00000000000000PNG  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-1.8.3/docs/static/sqlite.png000066400000000000000000000110741450610336600177270ustar00rootroot00000000000000PNG  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#'{kRK 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-1.8.3/scripts/push000077500000000000000000000002111450610336600160640ustar00rootroot00000000000000#!/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-1.8.3/scripts/release000077500000000000000000000032251450610336600165350ustar00rootroot00000000000000#!/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 setup.py bdist_wheel python3 setup.py sdist 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-1.8.3/scripts/run-end-to-end-dev-tests000077500000000000000000000012221450610336600215600ustar00rootroot00000000000000#!/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 up --force-recreate \ --renew-anon-volumes --abort-on-container-exit borgmatic-1.8.3/scripts/run-full-tests000077500000000000000000000025051450610336600200210ustar00rootroot00000000000000#!/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-dev-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 postgresql-client mariadb-client mongodb-tools \ py3-ruamel.yaml py3-ruamel.yaml.clib bash sqlite fish # If certain dependencies of black are available in this version of Alpine, install them. apk add --no-cache py3-typed-ast py3-regex || true python3 -m pip install --no-cache --upgrade pip==22.2.2 setuptools==64.0.1 pymongo==4.4.1 pip3 install --ignore-installed tox==3.25.1 export COVERAGE_FILE=/tmp/.coverage if [ "$1" != "--end-to-end-only" ]; then tox --workdir /tmp/.tox --sitepackages fi tox --workdir /tmp/.tox --sitepackages -e end-to-end borgmatic-1.8.3/setup.cfg000066400000000000000000000007161450610336600153230ustar00rootroot00000000000000[metadata] description_file=README.md [tool:pytest] testpaths = tests addopts = --cov-report term-missing:skip-covered --cov=borgmatic --ignore=tests/end-to-end [flake8] max-line-length = 100 extend-ignore = E203,E501,W503 exclude = *.*/* multiline-quotes = ''' docstring-quotes = ''' [tool:isort] profile=black known_first_party = borgmatic line_length = 100 skip = .tox [codespell] skip = .git,.tox,build [pycodestyle] ignore = E203 max_line_length = 100 borgmatic-1.8.3/setup.py000066400000000000000000000024401450610336600152100ustar00rootroot00000000000000from setuptools import find_packages, setup VERSION = '1.8.3' setup( name='borgmatic', version=VERSION, description='Simple, configuration-driven backup software for servers and workstations', author='Dan Helfman', author_email='witten@torsion.org', url='https://torsion.org/borgmatic', 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', ], packages=find_packages(exclude=['tests*']), entry_points={ 'console_scripts': [ 'borgmatic = borgmatic.commands.borgmatic:main', 'generate-borgmatic-config = borgmatic.commands.generate_config:main', 'validate-borgmatic-config = borgmatic.commands.validate_config:main', ] }, obsoletes=['atticmatic'], install_requires=( 'colorama>=0.4.1,<0.5', 'jsonschema', 'packaging', 'requests', 'ruamel.yaml>0.15.0,<0.18.0', 'setuptools', ), include_package_data=True, python_requires='>=3.7', ) borgmatic-1.8.3/test_requirements.txt000066400000000000000000000014621450610336600200240ustar00rootroot00000000000000appdirs==1.4.4; python_version >= '3.8' attrs==22.2.0; python_version >= '3.8' black==23.3.0; python_version >= '3.8' chardet==5.1.0 click==8.1.3; python_version >= '3.8' codespell==2.2.4 colorama==0.4.6 coverage==7.2.3 flake8==6.0.0 flake8-quotes==3.3.2 flake8-use-fstring==1.4 flake8-variables-names==0.0.5 flexmock==0.11.3 idna==3.4 importlib_metadata==6.3.0; python_version < '3.8' isort==5.12.0 mccabe==0.7.0 packaging==23.1 pluggy==1.0.0 pathspec==0.11.1; python_version >= '3.8' py==1.11.0 pycodestyle==2.10.0 pyflakes==3.0.1 jsonschema==4.17.3 pytest==7.3.0 pytest-cov==4.0.0 regex; python_version >= '3.8' requests==2.31.0 ruamel.yaml>0.15.0,<0.18.0 toml==0.10.2; python_version >= '3.8' typed-ast; python_version >= '3.8' typing-extensions==4.5.0; python_version < '3.8' zipp==3.15.0; python_version < '3.8' borgmatic-1.8.3/tests/000077500000000000000000000000001450610336600146405ustar00rootroot00000000000000borgmatic-1.8.3/tests/__init__.py000066400000000000000000000000001450610336600167370ustar00rootroot00000000000000borgmatic-1.8.3/tests/end-to-end/000077500000000000000000000000001450610336600165725ustar00rootroot00000000000000borgmatic-1.8.3/tests/end-to-end/__init__.py000066400000000000000000000000001450610336600206710ustar00rootroot00000000000000borgmatic-1.8.3/tests/end-to-end/docker-compose.yaml000066400000000000000000000032441450610336600223730ustar00rootroot00000000000000version: '3' services: postgresql: image: docker.io/postgres:13.1-alpine environment: POSTGRES_PASSWORD: test POSTGRES_DB: test postgresql2: image: docker.io/postgres:13.1-alpine environment: POSTGRES_PASSWORD: test2 POSTGRES_DB: test command: docker-entrypoint.sh -p 5433 mariadb: image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test mariadb2: image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test command: docker-entrypoint.sh --port=3307 not-actually-mysql: image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test MARIADB_DATABASE: test not-actually-mysql2: image: docker.io/mariadb:10.11.4 environment: MARIADB_ROOT_PASSWORD: test2 MARIADB_DATABASE: test command: docker-entrypoint.sh --port=3307 mongodb: image: docker.io/mongo:5.0.5 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: test mongodb2: image: docker.io/mongo:5.0.5 environment: MONGO_INITDB_ROOT_USERNAME: root2 MONGO_INITDB_ROOT_PASSWORD: test2 command: docker-entrypoint.sh --port=27018 tests: image: docker.io/alpine:3.13 environment: TEST_CONTAINER: true volumes: - "../..:/app:ro" tmpfs: - "/app/borgmatic.egg-info" - "/app/build" tty: true working_dir: /app entrypoint: /app/scripts/run-full-tests command: --end-to-end-only depends_on: - postgresql - postgresql2 - mariadb - mariadb2 - mongodb - mongodb2 borgmatic-1.8.3/tests/end-to-end/test_borgmatic.py000066400000000000000000000056451450610336600221640ustar00rootroot00000000000000import 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', '') .replace('- /var/log/syslog*', '') + 'encryption_passphrase: "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} rcreate --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-1.8.3/tests/end-to-end/test_completion.py000066400000000000000000000004141450610336600223530ustar00rootroot00000000000000import 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-1.8.3/tests/end-to-end/test_database.py000066400000000000000000000460221450610336600217530ustar00rootroot00000000000000import 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, borgmatic_source_directory, postgresql_dump_format='custom', 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. ''' config_yaml = f''' source_directories: - {source_directory} repositories: - path: {repository_path} borgmatic_source_directory: {borgmatic_source_directory} encryption_passphrase: "test" postgresql_databases: - name: test hostname: postgresql username: postgres password: test format: {postgresql_dump_format} - name: all hostname: postgresql username: postgres password: test - name: all format: custom hostname: postgresql username: postgres password: test mariadb_databases: - name: test hostname: mariadb username: root password: test - name: all hostname: mariadb username: root password: test - name: all format: sql hostname: mariadb username: root password: test mysql_databases: - name: test hostname: not-actually-mysql username: root password: test - name: all hostname: not-actually-mysql username: root password: test - name: all format: sql 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) def write_custom_restore_configuration( source_directory, config_path, repository_path, borgmatic_source_directory, postgresql_dump_format='custom', 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} borgmatic_source_directory: {borgmatic_source_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, borgmatic_source_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} borgmatic_source_directory: {borgmatic_source_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') borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic') # 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, borgmatic_source_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--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') borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic') 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, borgmatic_source_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--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') borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic') 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, borgmatic_source_directory ) create_test_tables(config) select_test_tables(config) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--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') borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') config = write_configuration( temporary_directory, config_path, repository_path, borgmatic_source_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, 'rcreate', '--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') borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic') original_working_directory = os.getcwd() try: config_path = os.path.join(temporary_directory, 'test.yaml') write_configuration( temporary_directory, config_path, repository_path, borgmatic_source_directory ) subprocess.check_call( ['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--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-1.8.3/tests/end-to-end/test_dev_parity_with_build_server.py000066400000000000000000000032571450610336600261600ustar00rootroot00000000000000import ruamel.yaml def test_dev_docker_compose_has_same_services_as_build_server_configuration(): ''' The end-to-end test configuration for local development and the build server's test configuration use two different mechanisms for configuring and spinning up "services"—the database containers upon which the end-to-end tests are reliant. The dev configuration uses Docker Compose, while the Drone build server configuration uses its own similar-but-different configuration file format. Therefore, to ensure dev-build parity, these tests assert that the services are the same across the dev and build configurations. This includes service name, container image, environment variables, and commands. This test only compares services and does not assert anything else about the respective testing environments. ''' yaml = ruamel.yaml.YAML(typ='safe') dev_services = { name: service for name, service in yaml.load(open('tests/end-to-end/docker-compose.yaml').read())[ 'services' ].items() if name != 'tests' } build_server_services = tuple(yaml.load_all(open('.drone.yml').read()))[0]['services'] assert len(dev_services) == len(build_server_services) for build_service in build_server_services: dev_service = dev_services[build_service['name']] assert dev_service['image'] == build_service['image'] assert dev_service['environment'] == build_service['environment'] if 'command' in dev_service or 'commands' in build_service: assert len(build_service['commands']) <= 1 assert dev_service['command'] == build_service['commands'][0] borgmatic-1.8.3/tests/end-to-end/test_generate_config.py000066400000000000000000000011141450610336600233170ustar00rootroot00000000000000import 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-1.8.3/tests/end-to-end/test_override.py000066400000000000000000000036741450610336600220340ustar00rootroot00000000000000import 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_get_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} rcreate --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-1.8.3/tests/end-to-end/test_validate_config.py000066400000000000000000000031361450610336600233240ustar00rootroot00000000000000import 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'validate-borgmatic-config --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'validate-borgmatic-config --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'validate-borgmatic-config --config {config_path} --show'.split(' ') ).decode(sys.stdout.encoding) assert 'repositories:' in output borgmatic-1.8.3/tests/integration/000077500000000000000000000000001450610336600171635ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/__init__.py000066400000000000000000000000001450610336600212620ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/actions/000077500000000000000000000000001450610336600206235ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/actions/__init__.py000066400000000000000000000000001450610336600227220ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/actions/config/000077500000000000000000000000001450610336600220705ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/actions/config/__init__.py000066400000000000000000000000001450610336600241670ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/actions/config/test_validate.py000066400000000000000000000021001450610336600252630ustar00rootroot00000000000000import 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-1.8.3/tests/integration/borg/000077500000000000000000000000001450610336600201145ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/borg/test_commands.py000066400000000000000000000123501450610336600233270ustar00rootroot00000000000000import 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.rlist 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)}" 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_rlist_command_does_not_duplicate_flags_or_raise(): arguments = borgmatic.commands.arguments.parse_arguments('rlist')['rlist'] for argument_name in dir(arguments): if argument_name.startswith('_'): continue command = borgmatic.borg.rlist.make_rlist_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-1.8.3/tests/integration/borg/test_feature.py000066400000000000000000000010421450610336600231550ustar00rootroot00000000000000from 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-1.8.3/tests/integration/commands/000077500000000000000000000000001450610336600207645ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/commands/__init__.py000066400000000000000000000000001450610336600230630ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/commands/completion/000077500000000000000000000000001450610336600231355ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/commands/completion/__init__.py000066400000000000000000000000001450610336600252340ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/commands/completion/test_actions.py000066400000000000000000000014531450610336600262110ustar00rootroot00000000000000import 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-1.8.3/tests/integration/commands/completion/test_bash.py000066400000000000000000000002121450610336600254560ustar00rootroot00000000000000from borgmatic.commands.completion import bash as module def test_bash_completion_does_not_raise(): assert module.bash_completion() borgmatic-1.8.3/tests/integration/commands/completion/test_fish.py000066400000000000000000000002121450610336600254720ustar00rootroot00000000000000from borgmatic.commands.completion import fish as module def test_fish_completion_does_not_raise(): assert module.fish_completion() borgmatic-1.8.3/tests/integration/commands/test_arguments.py000066400000000000000000000557611450610336600244200ustar00rootroot00000000000000import 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 'rcreate' in arguments assert arguments['rcreate'].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', 'rcreate') 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_rinfo(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('list', 'rinfo', '--json') def test_parse_arguments_disallows_json_with_both_rinfo_and_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(ValueError): module.parse_arguments('rinfo', '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_rlist_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('rlist', '--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' ) borgmatic-1.8.3/tests/integration/commands/test_borgmatic.py000066400000000000000000000006601450610336600243460ustar00rootroot00000000000000import 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-1.8.3/tests/integration/commands/test_generate_config.py000066400000000000000000000003261450610336600255150ustar00rootroot00000000000000from 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-1.8.3/tests/integration/commands/test_validate_config.py000066400000000000000000000003261450610336600255140ustar00rootroot00000000000000from 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-1.8.3/tests/integration/config/000077500000000000000000000000001450610336600204305ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/config/__init__.py000066400000000000000000000000001450610336600225270ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/config/test_generate.py000066400000000000000000000205631450610336600236410ustar00rootroot00000000000000import 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.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.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.yaml.comments.CommentedSeq([module.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.yaml.comments.CommentedSeq([module.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.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.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.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.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.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.yaml).should_receive('round_trip_load') 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.yaml).should_receive('round_trip_load') 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.yaml).should_receive('round_trip_load') 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-1.8.3/tests/integration/config/test_load.py000066400000000000000000001246711450610336600227730ustar00rootroot00000000000000import 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) assert module.load_configuration('config.yaml') == {'key': 'value'} def test_load_configuration_replaces_constants(): builtins = flexmock(sys.modules['builtins']) config_file = io.StringIO( ''' constants: key: value key: {key} ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) assert module.load_configuration('config.yaml') == {'key': 'value'} def test_load_configuration_replaces_complex_constants(): builtins = flexmock(sys.modules['builtins']) config_file = io.StringIO( ''' constants: key: subkey: value key: {key} ''' ) config_file.name = 'config.yaml' builtins.should_receive('open').with_args('config.yaml').and_return(config_file) assert module.load_configuration('config.yaml') == {'key': {'subkey': 'value'}} 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) assert module.load_configuration('config.yaml') == 33 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) assert module.load_configuration('config.yaml') == {'key': 'value'} 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) assert module.load_configuration('/etc/config.yaml') == {'key': 'value'} 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) with pytest.raises(FileNotFoundError): module.load_configuration('/etc/config.yaml') 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) assert module.load_configuration('config.yaml') == {'key': 'value'} 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) with pytest.raises(FileNotFoundError): assert module.load_configuration('config.yaml') 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) assert module.load_configuration('config.yaml') == {'key': ['value2', 'value1']} 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) assert module.load_configuration('config.yaml') == {'foo': 'override', 'baz': 'quux'} 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) assert module.load_configuration('config.yaml') == { 'foo': 'override', 'baz': 'second', 'original': 'yes', } 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) assert module.load_configuration('config.yaml') == { 'stuff': {'foo': 'override'}, 'other': {'a': 'override', 'c': 'd'}, } 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) assert module.load_configuration('config.yaml') == {'stuff': ['a', 'c', 'x', 'y']} 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) assert module.load_configuration('config.yaml') == {'stuff': ['a', 'b', 'c', 'x', 'y']} 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) with pytest.raises(ValueError): module.load_configuration('config.yaml') 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) with pytest.raises(module.ruamel.yaml.error.YAMLError): assert module.load_configuration('config.yaml') @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-1.8.3/tests/integration/config/test_override.py000066400000000000000000000020171450610336600236600ustar00rootroot00000000000000import pytest from borgmatic.config import override as module @pytest.mark.parametrize( 'value,expected_result', ( ('thing', 'thing'), ('33', 33), ('33b', '33b'), ('true', True), ('false', False), ('[foo]', ['foo']), ('[foo, bar]', ['foo', 'bar']), ), ) def test_convert_value_type_coerces_values(value, expected_result): assert module.convert_value_type(value) == expected_result def test_apply_overrides_updates_config(): raw_overrides = [ 'section.key=value1', 'other_section.thing=value2', 'section.nested.key=value3', 'new.foo=bar', ] config = { 'section': {'key': 'value', 'other': 'other_value'}, 'other_section': {'thing': 'thing_value'}, } module.apply_overrides(config, raw_overrides) assert config == { 'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}}, 'other_section': {'thing': 'value2'}, 'new': {'foo': 'bar'}, } borgmatic-1.8.3/tests/integration/config/test_schema.py000066400000000000000000000003521450610336600233010ustar00rootroot00000000000000MAXIMUM_LINE_LENGTH = 80 def test_schema_line_length_stays_under_limit(): schema_file = open('borgmatic/config/schema.yaml') for line in schema_file.readlines(): assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH borgmatic-1.8.3/tests/integration/config/test_validate.py000066400000000000000000000160201450610336600236310ustar00rootroot00000000000000import io 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, 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'}], } 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, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': [f'/home/{string.punctuation}'], 'repositories': [{'path': 'test.borg'}], } 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, 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, } 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, 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, } 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/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, logs = module.parse_configuration( '/tmp/config.yaml', '/tmp/schema.yaml', overrides=['location.local_path=borg2'] ) assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'hostname.borg'}], 'local_path': 'borg2', } assert logs == [] def test_parse_configuration_applies_normalization(): mock_config_and_schema( ''' location: source_directories: - /home repositories: - path: hostname.borg exclude_if_present: .nobackup ''' ) config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') assert config == { 'source_directories': ['/home'], 'repositories': [{'path': 'hostname.borg'}], 'exclude_if_present': ['.nobackup'], } assert logs borgmatic-1.8.3/tests/integration/hooks/000077500000000000000000000000001450610336600203065ustar00rootroot00000000000000borgmatic-1.8.3/tests/integration/hooks/test_healthchecks.py000066400000000000000000000013611450610336600243460ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks import healthchecks as module def test_destroy_monitor_removes_healthchecks_handler(): logger = logging.getLogger() original_handlers = list(logger.handlers) logger.addHandler(module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)) module.destroy_monitor(flexmock(), 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(), flexmock()) assert logger.handlers == original_handlers borgmatic-1.8.3/tests/integration/hooks/test_loki.py000066400000000000000000000061101450610336600226530ustar00rootroot00000000000000import logging import platform from flexmock import flexmock from borgmatic.hooks 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'{config_filename}: {module.MONITOR_STATE_TO_LOKI[module.monitor.State.FINISH]} backup', map(lambda x: x[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(), config_filename, flexmock(), dry_run) for handler in tuple(logging.getLogger().handlers): if isinstance(handler, module.Loki_log_handler): assert False borgmatic-1.8.3/tests/integration/test_execute.py000066400000000000000000000257701450610336600222510ustar00rootroot00000000000000import 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('exit_code_indicates_error').and_return(False) 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', ) 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('exit_code_indicates_error').and_return(False) 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', ) def test_log_outputs_returns_output_without_logging_for_output_log_level_none(): flexmock(module.logger).should_receive('log').never() flexmock(module).should_receive('exit_code_indicates_error').and_return(False) 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', ) 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('exit_code_indicates_error').and_return(True) 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' ) 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('exit_code_indicates_error').and_return(True) 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' ) def test_log_outputs_skips_error_output_in_exception_for_process_with_none_stdout(): flexmock(module.logger).should_receive('log') flexmock(module).should_receive('exit_code_indicates_error').and_return(True) 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' ) assert error.value.returncode == 2 assert not error.value.output def test_log_outputs_kills_other_processes_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('exit_code_indicates_error').with_args( ['grep'], None, 'borg' ).and_return(False) flexmock(module).should_receive('exit_code_indicates_error').with_args( ['grep'], 2, 'borg' ).and_return(True) other_process = subprocess.Popen( ['sleep', '2'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) flexmock(module).should_receive('exit_code_indicates_error').with_args( ['sleep', '2'], None, 'borg' ).and_return(False) 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', ) assert error.value.returncode == 2 assert error.value.output 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', ) 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', ) 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('exit_code_indicates_error').with_args( ['grep'], None, 'borg' ).and_return(False) flexmock(module).should_receive('exit_code_indicates_error').with_args( ['grep'], 2, 'borg' ).and_return(True) 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' ) 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('exit_code_indicates_error').and_return(False) 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' ) def test_log_outputs_with_unfinished_process_re_polls(): flexmock(module.logger).should_receive('log').never() flexmock(module).should_receive('exit_code_indicates_error').and_return(False) 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' ) borgmatic-1.8.3/tests/unit/000077500000000000000000000000001450610336600156175ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/__init__.py000066400000000000000000000000001450610336600177160ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/actions/000077500000000000000000000000001450610336600172575ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/actions/__init__.py000066400000000000000000000000001450610336600213560ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/actions/config/000077500000000000000000000000001450610336600205245ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/actions/config/test_bootstrap.py000066400000000000000000000076701450610336600241640ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.actions.config import bootstrap as module def test_get_config_paths_returns_list_of_config_paths(): bootstrap_arguments = flexmock( borgmatic_source_directory=None, repository='repo', archive='archive', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() 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 ) flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( 'archive' ) assert module.get_config_paths(bootstrap_arguments, global_arguments, local_borg_version) == [ '/borgmatic/config.yaml' ] def test_get_config_paths_with_missing_manifest_raises_value_error(): bootstrap_arguments = flexmock( borgmatic_source_directory=None, repository='repo', archive='archive', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() extract_process = flexmock(stdout=flexmock(read=lambda: '')) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( 'archive' ) with pytest.raises(ValueError): module.get_config_paths(bootstrap_arguments, global_arguments, local_borg_version) def test_get_config_paths_with_broken_json_raises_value_error(): bootstrap_arguments = flexmock( borgmatic_source_directory=None, repository='repo', archive='archive', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() extract_process = flexmock( stdout=flexmock(read=lambda: '{"config_paths": ["/oops'), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( 'archive' ) with pytest.raises(ValueError): module.get_config_paths(bootstrap_arguments, global_arguments, local_borg_version) def test_get_config_paths_with_json_missing_key_raises_value_error(): bootstrap_arguments = flexmock( borgmatic_source_directory=None, repository='repo', archive='archive', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() extract_process = flexmock( stdout=flexmock(read=lambda: '{}'), ) flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( extract_process ) flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( 'archive' ) with pytest.raises(ValueError): module.get_config_paths(bootstrap_arguments, global_arguments, local_borg_version) def test_run_bootstrap_does_not_raise(): bootstrap_arguments = flexmock( repository='repo', archive='archive', destination='dest', strip_components=1, progress=False, borgmatic_source_directory='/borgmatic', ) global_arguments = flexmock( dry_run=False, ) local_borg_version = flexmock() 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 ).twice() flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( 'archive' ) module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version) borgmatic-1.8.3/tests/unit/actions/config/test_generate.py000066400000000000000000000024311450610336600237270ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/config/test_validate.py000066400000000000000000000011271450610336600237270ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_arguments.py000066400000000000000000000006061450610336600226770ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_borg.py000066400000000000000000000014651450610336600216270ustar00rootroot00000000000000from 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.rlist).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-1.8.3/tests/unit/actions/test_break_lock.py000066400000000000000000000012521450610336600227640ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_check.py000066400000000000000000000054371450610336600217560ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import check as module def test_run_check_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.check).should_receive('check_archives').once() flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2) check_arguments = flexmock( repository=None, progress=flexmock(), repair=flexmock(), only=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_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.check).should_receive('check_archives').once() check_arguments = flexmock( repository=flexmock(), progress=flexmock(), repair=flexmock(), only=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=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-1.8.3/tests/unit/actions/test_compact.py000066400000000000000000000057511450610336600223260ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_create.py000066400000000000000000000164631450610336600221450ustar00rootroot00000000000000import sys from flexmock import flexmock from borgmatic.actions import create as module 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.borg.create).should_receive('create_archive').once() flexmock(module).should_receive('create_borgmatic_manifest').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({}) create_arguments = flexmock( repository=None, progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={}, 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_with_store_config_files_false_does_not_create_borgmatic_manifest(): flexmock(module.logger).answer = lambda message: None flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() flexmock(module.borgmatic.borg.create).should_receive('create_archive').once() flexmock(module).should_receive('create_borgmatic_manifest').never() 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({}) create_arguments = flexmock( repository=None, progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={'store_config_files': False}, 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.borg.create).should_receive('create_archive').once() flexmock(module).should_receive('create_borgmatic_manifest').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({}) create_arguments = flexmock( repository=flexmock(), progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) list( module.run_create( config_filename='test.yaml', repository={'path': 'repo'}, config={}, 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.borg.create).should_receive('create_archive').never() flexmock(module).should_receive('create_borgmatic_manifest').never() create_arguments = flexmock( repository=flexmock(), progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock(), ) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) list( module.run_create( config_filename='test.yaml', repository='repo', config={}, 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_create_borgmatic_manifest_creates_manifest_file(): flexmock(module.os.path).should_receive('join').with_args( module.borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY, 'bootstrap', 'manifest.json' ).and_return('/home/user/.borgmatic/bootstrap/manifest.json') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os).should_receive('makedirs').and_return(True) flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0') flexmock(sys.modules['builtins']).should_receive('open').with_args( '/home/user/.borgmatic/bootstrap/manifest.json', 'w' ).and_return( flexmock( __enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None), __exit__=lambda *args: None, ) ) flexmock(module.json).should_receive('dump').and_return(True).once() module.create_borgmatic_manifest({}, 'test.yaml', False) def test_create_borgmatic_manifest_creates_manifest_file_with_custom_borgmatic_source_directory(): flexmock(module.os.path).should_receive('join').with_args( '/borgmatic', 'bootstrap', 'manifest.json' ).and_return('/borgmatic/bootstrap/manifest.json') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os).should_receive('makedirs').and_return(True) flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0') flexmock(sys.modules['builtins']).should_receive('open').with_args( '/borgmatic/bootstrap/manifest.json', 'w' ).and_return( flexmock( __enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None), __exit__=lambda *args: None, ) ) flexmock(module.json).should_receive('dump').and_return(True).once() module.create_borgmatic_manifest( {'borgmatic_source_directory': '/borgmatic'}, 'test.yaml', False ) def test_create_borgmatic_manifest_does_not_create_manifest_file_on_dry_run(): flexmock(module.os.path).should_receive('expanduser').never() module.create_borgmatic_manifest({}, 'test.yaml', True) borgmatic-1.8.3/tests/unit/actions/test_export_key.py000066400000000000000000000012361450610336600230630ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_export_tar.py000066400000000000000000000017071450610336600230640ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_extract.py000066400000000000000000000020621450610336600223420ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_info.py000066400000000000000000000017411450610336600216260ustar00rootroot00000000000000from 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.rlist).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=flexmock()) 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, ) ) borgmatic-1.8.3/tests/unit/actions/test_list.py000066400000000000000000000017301450610336600216440ustar00rootroot00000000000000from 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.rlist).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=flexmock()) 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, ) ) borgmatic-1.8.3/tests/unit/actions/test_mount.py000066400000000000000000000014701450610336600220340ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_prune.py000066400000000000000000000050611450610336600220230ustar00rootroot00000000000000from 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-1.8.3/tests/unit/actions/test_rcreate.py000066400000000000000000000033241450610336600223170ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import rcreate as module def test_run_rcreate_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.rcreate).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_rcreate( repository={'path': 'repo'}, config={}, local_borg_version=None, rcreate_arguments=arguments, global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, ) def test_run_rcreate_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.rcreate).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_rcreate( repository={'path': 'repo'}, config={}, local_borg_version=None, rcreate_arguments=arguments, global_arguments=flexmock(dry_run=False), local_path=None, remote_path=None, ) borgmatic-1.8.3/tests/unit/actions/test_restore.py000066400000000000000000000470451450610336600223650ustar00rootroot00000000000000import pytest from flexmock import flexmock import borgmatic.actions.restore as module def test_get_configured_data_source_matches_data_source_by_name(): assert module.get_configured_data_source( config={ 'other_databases': [{'name': 'other'}], 'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}], }, archive_data_source_names={'postgresql_databases': ['other', 'foo', 'bar']}, hook_name='postgresql_databases', data_source_name='bar', ) == ('postgresql_databases', {'name': 'bar'}) def test_get_configured_data_source_matches_nothing_when_nothing_configured(): assert module.get_configured_data_source( config={}, archive_data_source_names={'postgresql_databases': ['foo']}, hook_name='postgresql_databases', data_source_name='quux', ) == (None, None) def test_get_configured_data_source_matches_nothing_when_data_source_name_not_configured(): assert module.get_configured_data_source( config={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]}, archive_data_source_names={'postgresql_databases': ['foo']}, hook_name='postgresql_databases', data_source_name='quux', ) == (None, None) def test_get_configured_data_source_matches_nothing_when_data_source_name_not_in_archive(): assert module.get_configured_data_source( config={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]}, archive_data_source_names={'postgresql_databases': ['bar']}, hook_name='postgresql_databases', data_source_name='foo', ) == (None, None) def test_get_configured_data_source_matches_data_source_by_configuration_data_source_name(): assert module.get_configured_data_source( config={'postgresql_databases': [{'name': 'all'}, {'name': 'bar'}]}, archive_data_source_names={'postgresql_databases': ['foo']}, hook_name='postgresql_databases', data_source_name='foo', configuration_data_source_name='all', ) == ('postgresql_databases', {'name': 'all'}) def test_get_configured_data_source_with_unspecified_hook_matches_data_source_by_name(): assert module.get_configured_data_source( config={ 'other_databases': [{'name': 'other'}], 'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}], }, archive_data_source_names={'postgresql_databases': ['other', 'foo', 'bar']}, hook_name=module.UNSPECIFIED_HOOK, data_source_name='bar', ) == ('postgresql_databases', {'name': 'bar'}) def test_collect_archive_data_source_names_parses_archive_paths(): flexmock(module.borgmatic.hooks.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', '.borgmatic/mysql_databases/localhost/quux', ] ) archive_data_source_names = module.collect_archive_data_source_names( repository={'path': 'repo'}, archive='archive', config={'borgmatic_source_directory': '.borgmatic'}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), ) assert archive_data_source_names == { 'postgresql_databases': ['foo', 'bar'], 'mysql_databases': ['quux'], } def test_collect_archive_data_source_names_parses_directory_format_archive_paths(): flexmock(module.borgmatic.hooks.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_data_source_names = module.collect_archive_data_source_names( repository={'path': 'repo'}, archive='archive', config={'borgmatic_source_directory': '.borgmatic'}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), ) assert archive_data_source_names == { 'postgresql_databases': ['foo'], } def test_collect_archive_data_source_names_skips_bad_archive_paths(): flexmock(module.borgmatic.hooks.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/invalid', 'invalid/as/well'] ) archive_data_source_names = module.collect_archive_data_source_names( repository={'path': 'repo'}, archive='archive', config={'borgmatic_source_directory': '.borgmatic'}, local_borg_version=flexmock(), global_arguments=flexmock(log_json=False), local_path=flexmock(), remote_path=flexmock(), ) assert archive_data_source_names == { 'postgresql_databases': ['foo'], } def test_find_data_sources_to_restore_passes_through_requested_names_found_in_archive(): restore_names = module.find_data_sources_to_restore( requested_data_source_names=['foo', 'bar'], archive_data_source_names={'postresql_databases': ['foo', 'bar', 'baz']}, ) assert restore_names == {module.UNSPECIFIED_HOOK: ['foo', 'bar']} def test_find_data_sources_to_restore_raises_for_requested_names_missing_from_archive(): with pytest.raises(ValueError): module.find_data_sources_to_restore( requested_data_source_names=['foo', 'bar'], archive_data_source_names={'postresql_databases': ['foo']}, ) def test_find_data_sources_to_restore_without_requested_names_finds_all_archive_data_sources(): archive_data_source_names = {'postresql_databases': ['foo', 'bar']} restore_names = module.find_data_sources_to_restore( requested_data_source_names=[], archive_data_source_names=archive_data_source_names, ) assert restore_names == archive_data_source_names def test_find_data_sources_to_restore_with_all_in_requested_names_finds_all_archive_data_sources(): archive_data_source_names = {'postresql_databases': ['foo', 'bar']} restore_names = module.find_data_sources_to_restore( requested_data_source_names=['all'], archive_data_source_names=archive_data_source_names, ) assert restore_names == archive_data_source_names def test_find_data_sources_to_restore_with_all_in_requested_names_plus_additional_requested_names_omits_duplicates(): archive_data_source_names = {'postresql_databases': ['foo', 'bar']} restore_names = module.find_data_sources_to_restore( requested_data_source_names=['all', 'foo', 'bar'], archive_data_source_names=archive_data_source_names, ) assert restore_names == archive_data_source_names def test_find_data_sources_to_restore_raises_for_all_in_requested_names_and_requested_named_missing_from_archives(): with pytest.raises(ValueError): module.find_data_sources_to_restore( requested_data_source_names=['all', 'foo', 'bar'], archive_data_source_names={'postresql_databases': ['foo']}, ) def test_ensure_data_sources_found_with_all_data_sources_found_does_not_raise(): module.ensure_data_sources_found( restore_names={'postgresql_databases': ['foo']}, remaining_restore_names={'postgresql_databases': ['bar']}, found_names=['foo', 'bar'], ) def test_ensure_data_sources_found_with_no_data_sources_raises(): with pytest.raises(ValueError): module.ensure_data_sources_found( restore_names={'postgresql_databases': []}, remaining_restore_names={}, found_names=[], ) def test_ensure_data_sources_found_with_missing_data_sources_raises(): with pytest.raises(ValueError): module.ensure_data_sources_found( restore_names={'postgresql_databases': ['foo']}, remaining_restore_names={'postgresql_databases': ['bar']}, found_names=['foo'], ) def test_run_restore_restores_each_data_source(): restore_names = { 'postgresql_databases': ['foo', 'bar'], } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock()) flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names) flexmock(module).should_receive('get_configured_data_source').and_return( ('postgresql_databases', {'name': 'foo'}) ).and_return(('postgresql_databases', {'name': 'bar'})) flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('ensure_data_sources_found') 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.hooks.dispatch).should_receive( 'call_hooks_even_if_unconfigured' ).never() flexmock(module).should_receive('restore_single_data_source').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_configured_with_all_name(): restore_names = { 'postgresql_databases': ['foo', 'bar'], } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock()) flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='foo', ).and_return(('postgresql_databases', {'name': 'foo'})) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='bar', ).and_return((None, None)) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='bar', configuration_data_source_name='all', ).and_return(('postgresql_databases', {'name': 'bar'})) flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('ensure_data_sources_found') 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(): restore_names = { 'postgresql_databases': ['foo', 'bar'], } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock()) flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='foo', ).and_return(('postgresql_databases', {'name': 'foo'})) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='bar', ).and_return((None, None)) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='bar', configuration_data_source_name='all', ).and_return((None, None)) flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('restore_single_data_source').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, ).never() flexmock(module).should_receive('ensure_data_sources_found') 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(): restore_names = { 'postgresql_databases': ['foo'], 'mysql_databases': ['bar'], } flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured') flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( flexmock() ) flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock()) flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='postgresql_databases', data_source_name='foo', ).and_return(('postgresql_databases', {'name': 'foo'})) flexmock(module).should_receive('get_configured_data_source').with_args( config=object, archive_data_source_names=object, hook_name='mysql_databases', data_source_name='bar', ).and_return(('mysql_databases', {'name': 'bar'})) flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('restore_single_data_source').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, ).once() flexmock(module).should_receive('ensure_data_sources_found') 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-1.8.3/tests/unit/actions/test_rinfo.py000066400000000000000000000013471450610336600220120ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import rinfo as module def test_run_rinfo_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.rinfo).should_receive('display_repository_info') rinfo_arguments = flexmock(repository=flexmock(), json=flexmock()) list( module.run_rinfo( repository={'path': 'repo'}, config={}, local_borg_version=None, rinfo_arguments=rinfo_arguments, global_arguments=flexmock(log_json=False), local_path=None, remote_path=None, ) ) borgmatic-1.8.3/tests/unit/actions/test_rlist.py000066400000000000000000000013211450610336600220220ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.actions import rlist as module def test_run_rlist_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.rlist).should_receive('list_repository') rlist_arguments = flexmock(repository=flexmock(), json=flexmock()) list( module.run_rlist( repository={'path': 'repo'}, config={}, local_borg_version=None, rlist_arguments=rlist_arguments, global_arguments=flexmock(), local_path=None, remote_path=None, ) ) borgmatic-1.8.3/tests/unit/actions/test_transfer.py000066400000000000000000000011661450610336600225200ustar00rootroot00000000000000from 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-1.8.3/tests/unit/borg/000077500000000000000000000000001450610336600165505ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/borg/__init__.py000066400000000000000000000000001450610336600206470ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/borg/test_borg.py000066400000000000000000000242461450610336600211220ustar00rootroot00000000000000import 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '--info', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '--debug', '--show-rc', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '--lock-wait', '5', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '::$ARCHIVE'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': 'archive'}, ) 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).should_receive('execute_command').with_args( ('borg1', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg1', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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_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).should_receive('execute_command').with_args( ('borg', 'break-lock', '--remote-path', 'borg1', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'list', '--progress', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'break-lock', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg',), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'key', 'export', '--info', '::'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) 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).should_receive('execute_command').with_args( ('borg', 'debug', 'dump-manifest', '--info', '::', 'path'), output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_local_path='borg', shell=True, extra_environment={'BORG_REPO': 'repo', 'ARCHIVE': ''}, ) insert_logging_mock(logging.INFO) module.run_arbitrary_borg( repository_path='repo', config={}, local_borg_version='1.2.3', options=['debug', 'dump-manifest', '::', 'path'], ) borgmatic-1.8.3/tests/unit/borg/test_break_lock.py000066400000000000000000000064041450610336600222610ustar00rootroot00000000000000import 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): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, borg_local_path='borg', extra_environment=None, ).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_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), ) borgmatic-1.8.3/tests/unit/borg/test_check.py000066400000000000000000001154521450610336600212460ustar00rootroot00000000000000import 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): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, extra_environment=None ).once() def insert_execute_command_never(): flexmock(module).should_receive('execute_command').never() 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_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_restains_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_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', {}, ('repository', 'archives'), prefix='foo', ) 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', {}, ('repository', 'archives', 'extract'), prefix='foo', ) 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', {}, ('repository', 'archives', 'extract'), prefix='foo', ) 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', {}, ('archives',), check_last=3) 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', {}, ('data',), check_last=3) 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', {}, ('repository',), check_last=3) 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', {}, ('repository', 'archives'), check_last=3) 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', {}, ('archives',), prefix='foo-') 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', {}, ('data',), prefix='foo-') assert flags == ('--match-archives', 'sh:foo-*') 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}'}, ('archives',), prefix='' # noqa: FS003 ) 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',), prefix=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', {}, ('repository',), prefix='foo-') 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', {}, ('repository', 'archives'), prefix='foo-') assert flags == ('--match-archives', 'sh:foo-*') 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_flags_with_repository_check_returns_flag(): flags = module.make_check_flags(('repository',), ()) assert flags == ('--repository-only',) def test_make_check_flags_with_archives_check_returns_flag(): flags = module.make_check_flags(('archives',), ()) assert flags == ('--archives-only',) def test_make_check_flags_with_archives_check_and_archive_filter_flags_includes_those_flags(): flags = module.make_check_flags(('archives',), ('--match-archives', 'sh:foo-*')) assert flags == ('--archives-only', '--match-archives', 'sh:foo-*') def test_make_check_flags_without_archives_check_and_with_archive_filter_flags_includes_those_flags(): flags = module.make_check_flags(('repository',), ('--match-archives', 'sh:foo-*')) assert flags == ('--repository-only',) def test_make_check_flags_with_data_check_returns_flag_and_implies_archives(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_check_flags(('data',), ()) assert flags == ( '--archives-only', '--verify-data', ) def test_make_check_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_flags(('extract',), ()) assert flags == () def test_make_check_flags_with_repository_and_data_checks_does_not_return_repository_only(): flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flags = module.make_check_flags( ( 'repository', 'data', ), (), ) assert flags == ('--verify-data',) def test_make_check_time_path_with_borgmatic_source_directory_includes_it(): flexmock(module.os.path).should_receive('expanduser').with_args('~/.borgmatic').and_return( '/home/user/.borgmatic' ) assert ( module.make_check_time_path( {'borgmatic_source_directory': '~/.borgmatic'}, '1234', 'archives', '5678' ) == '/home/user/.borgmatic/checks/1234/archives/5678' ) def test_make_check_time_path_without_borgmatic_source_directory_uses_default(): flexmock(module.os.path).should_receive('expanduser').with_args( module.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY ).and_return('/home/user/.borgmatic') assert ( module.make_check_time_path({}, '1234', 'archives', '5678') == '/home/user/.borgmatic/checks/1234/archives/5678' ) def test_make_check_time_path_with_archives_check_and_no_archives_check_id_defaults_to_all(): flexmock(module.os.path).should_receive('expanduser').with_args('~/.borgmatic').and_return( '/home/user/.borgmatic' ) assert ( module.make_check_time_path( {'borgmatic_source_directory': '~/.borgmatic'}, '1234', 'archives', ) == '/home/user/.borgmatic/checks/1234/archives/all' ) def test_make_check_time_path_with_repositories_check_ignores_archives_check_id(): flexmock(module.os.path).should_receive('expanduser').with_args('~/.borgmatic').and_return( '/home/user/.borgmatic' ) assert ( module.make_check_time_path( {'borgmatic_source_directory': '~/.borgmatic'}, '1234', 'repository', '5678' ) == '/home/user/.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_renames_old_check_paths_to_all(): base_path = '~/.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.os).should_receive('rename').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.os).should_receive('rename').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(): base_path = '~/.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.os).should_receive('rename').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.os).should_receive('rename').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_missing_check_paths(): flexmock(module).should_receive('make_check_time_path').and_return( '~/.borgmatic/checks/1234/archives/all' ) flexmock(module.os.path).should_receive('isfile').and_return(False) flexmock(module.os).should_receive('rename').never() flexmock(module.os).should_receive('mkdir').never() module.upgrade_check_times(flexmock(), flexmock()) def test_upgrade_check_times_renames_stale_temporary_check_path(): base_path = '~/.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(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.os).should_receive('rename').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.os).should_receive('rename').with_args( f'{base_path}/archives.temp', f'{base_path}/archives/all' ).once() module.upgrade_check_times(flexmock(), flexmock()) def test_check_archives_with_progress_calls_borg_with_progress_parameter(): checks = ('repository',) config = {'check_last': None} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').and_return(()) flexmock(module).should_receive('execute_command').never() flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE, extra_environment=None, ).once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), progress=True, ) def test_check_archives_with_repair_calls_borg_with_repair_parameter(): checks = ('repository',) config = {'check_last': None} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').and_return(()) flexmock(module).should_receive('execute_command').never() flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE, extra_environment=None, ).once() flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), repair=True, ) @pytest.mark.parametrize( 'checks', ( ('repository',), ('archives',), ('repository', 'archives'), ('repository', 'archives', 'other'), ), ) def test_check_archives_calls_borg_with_parameters(checks): check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_json_error_raises(): checks = ('archives',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"unexpected": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) with pytest.raises(ValueError): module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_missing_json_keys_raises(): checks = ('archives',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return('{invalid JSON') flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) with pytest.raises(ValueError): module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_extract_check_calls_extract_only(): checks = ('extract',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').never() flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.extract).should_receive('extract_last_archive_dry_run').once() flexmock(module).should_receive('write_check_time') insert_execute_command_never() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_log_info_calls_borg_with_info_parameter(): checks = ('repository',) config = {'check_last': None} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').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')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_log_debug_calls_borg_with_debug_parameter(): checks = ('repository',) config = {'check_last': None} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').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')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_without_any_checks_bails(): config = {'check_last': None} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(()) insert_execute_command_never() module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_local_path_calls_borg_via_local_path(): checks = ('repository',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg1', 'check', 'repo')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), local_path='borg1', ) def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(): checks = ('repository',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_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')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), remote_path='borg1', ) def test_check_archives_with_log_json_calls_borg_with_log_json_parameters(): checks = ('repository',) check_last = flexmock() config = {'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_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')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True), ) def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters(): checks = ('repository',) check_last = flexmock() config = {'lock_wait': 5, 'check_last': check_last} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_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')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_retention_prefix(): checks = ('repository',) check_last = flexmock() prefix = 'foo-' config = {'check_last': check_last, 'prefix': prefix} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').with_args(checks, ()).and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', 'repo')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options(): checks = ('repository',) config = {'check_last': None, 'extra_borg_options': {'check': '--extra --options'}} flexmock(module.rinfo).should_receive('display_repository_info').and_return( '{"repository": {"id": "repo"}}' ) flexmock(module).should_receive('upgrade_check_times') flexmock(module).should_receive('parse_checks') flexmock(module).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(checks) flexmock(module).should_receive('make_check_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo')) flexmock(module).should_receive('make_check_time_path') flexmock(module).should_receive('write_check_time') module.check_archives( repository_path='repo', config=config, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False), ) borgmatic-1.8.3/tests/unit/borg/test_compact.py000066400000000000000000000150231450610336600216100ustar00rootroot00000000000000import 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): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( compact_command, output_log_level=output_log_level, borg_local_path=compact_command[0], extra_environment=None, ).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_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), ) borgmatic-1.8.3/tests/unit/borg/test_create.py000066400000000000000000003732341450610336600214400ustar00rootroot00000000000000import logging import sys import pytest from flexmock import flexmock from borgmatic.borg import create as module from ..test_verbosity import insert_logging_mock 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') 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*') assert paths == ['foo', 'food'] def test_expand_directories_flattens_expanded_directories(): flexmock(module).should_receive('expand_directory').with_args('~/foo').and_return(['/root/foo']) flexmock(module).should_receive('expand_directory').with_args('bar*').and_return( ['bar', 'barf'] ) paths = module.expand_directories(('~/foo', 'bar*')) assert paths == ('/root/foo', 'bar', 'barf') def test_expand_directories_considers_none_as_no_directories(): paths = module.expand_directories(None) assert paths == () def test_expand_home_directories_expands_tildes(): flexmock(module.os.path).should_receive('expanduser').with_args('~/bar').and_return('/foo/bar') flexmock(module.os.path).should_receive('expanduser').with_args('baz').and_return('baz') paths = module.expand_home_directories(('~/bar', 'baz')) assert paths == ('/foo/bar', 'baz') def test_expand_home_directories_considers_none_as_no_directories(): paths = module.expand_home_directories(None) assert paths == () def test_map_directories_to_devices_gives_device_id_per_path(): 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.map_directories_to_devices(('/foo', '/bar')) assert device_map == { '/foo': 55, '/bar': 66, } def test_map_directories_to_devices_with_missing_path_does_not_error(): 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_raise(FileNotFoundError) device_map = module.map_directories_to_devices(('/foo', '/bar')) assert device_map == { '/foo': 55, '/bar': None, } @pytest.mark.parametrize( 'directories,additional_directories,expected_directories', ( ({'/': 1, '/root': 1}, {}, ('/',)), ({'/': 1, '/root/': 1}, {}, ('/',)), ({'/': 1, '/root': 2}, {}, ('/', '/root')), ({'/root': 1, '/': 1}, {}, ('/',)), ({'/root': 1, '/root/foo': 1}, {}, ('/root',)), ({'/root/': 1, '/root/foo': 1}, {}, ('/root/',)), ({'/root': 1, '/root/foo/': 1}, {}, ('/root',)), ({'/root': 1, '/root/foo': 2}, {}, ('/root', '/root/foo')), ({'/root/foo': 1, '/root': 1}, {}, ('/root',)), ({'/root': None, '/root/foo': None}, {}, ('/root', '/root/foo')), ({'/root': 1, '/etc': 1, '/root/foo/bar': 1}, {}, ('/etc', '/root')), ({'/root': 1, '/root/foo': 1, '/root/foo/bar': 1}, {}, ('/root',)), ({'/dup': 1, '/dup': 1}, {}, ('/dup',)), ({'/foo': 1, '/bar': 1}, {}, ('/bar', '/foo')), ({'/foo': 1, '/bar': 2}, {}, ('/bar', '/foo')), ({'/root/foo': 1}, {'/root': 1}, ()), ({'/root/foo': 1}, {'/root': 2}, ('/root/foo',)), ({'/root/foo': 1}, {}, ('/root/foo',)), ), ) def test_deduplicate_directories_removes_child_paths_on_the_same_filesystem( directories, additional_directories, expected_directories ): assert ( module.deduplicate_directories(directories, additional_directories) == expected_directories ) def test_write_pattern_file_writes_pattern_lines(): temporary_file = flexmock(name='filename', flush=lambda: None) temporary_file.should_receive('write').with_args('R /foo\n+ /foo/bar') flexmock(module.tempfile).should_receive('NamedTemporaryFile').and_return(temporary_file) module.write_pattern_file(['R /foo', '+ /foo/bar']) def test_write_pattern_file_with_sources_writes_sources_as_roots(): temporary_file = flexmock(name='filename', flush=lambda: None) temporary_file.should_receive('write').with_args('R /foo\n+ /foo/bar\nR /baz\nR /quux') flexmock(module.tempfile).should_receive('NamedTemporaryFile').and_return(temporary_file) module.write_pattern_file(['R /foo', '+ /foo/bar'], sources=['/baz', '/quux']) def test_write_pattern_file_without_patterns_but_with_sources_writes_sources_as_roots(): temporary_file = flexmock(name='filename', flush=lambda: None) temporary_file.should_receive('write').with_args('R /baz\nR /quux') flexmock(module.tempfile).should_receive('NamedTemporaryFile').and_return(temporary_file) module.write_pattern_file([], sources=['/baz', '/quux']) def test_write_pattern_file_with_empty_exclude_patterns_does_not_raise(): module.write_pattern_file([]) def test_write_pattern_file_overwrites_existing_file(): pattern_file = flexmock(name='filename', flush=lambda: None) pattern_file.should_receive('seek').with_args(0).once() pattern_file.should_receive('write').with_args('R /foo\n+ /foo/bar') flexmock(module.tempfile).should_receive('NamedTemporaryFile').never() module.write_pattern_file(['R /foo', '+ /foo/bar'], pattern_file=pattern_file) @pytest.mark.parametrize( 'filename_lists,opened_filenames', ( ([('foo', 'bar'), ('baz', 'quux')], ('foo', 'bar', 'baz', 'quux')), ([None, ('foo', 'bar')], ('foo', 'bar')), ([None, None], ()), ), ) def test_ensure_files_readable_opens_filenames(filename_lists, opened_filenames): for expected_filename in opened_filenames: flexmock(sys.modules['builtins']).should_receive('open').with_args( expected_filename ).and_return(flexmock(close=lambda: None)) module.ensure_files_readable(*filename_lists) def test_make_pattern_flags_includes_pattern_filename_when_given(): pattern_flags = module.make_pattern_flags( config={'patterns': ['R /', '- /var']}, pattern_filename='/tmp/patterns' ) assert pattern_flags == ('--patterns-from', '/tmp/patterns') def test_make_pattern_flags_includes_patterns_from_filenames_when_in_config(): pattern_flags = module.make_pattern_flags(config={'patterns_from': ['patterns', 'other']}) assert pattern_flags == ('--patterns-from', 'patterns', '--patterns-from', 'other') def test_make_pattern_flags_includes_both_filenames_when_patterns_given_and_patterns_from_in_config(): pattern_flags = module.make_pattern_flags( config={'patterns_from': ['patterns']}, pattern_filename='/tmp/patterns' ) assert pattern_flags == ('--patterns-from', 'patterns', '--patterns-from', '/tmp/patterns') def test_make_pattern_flags_considers_none_patterns_from_filenames_as_empty(): pattern_flags = module.make_pattern_flags(config={'patterns_from': None}) assert pattern_flags == () def test_make_exclude_flags_includes_exclude_patterns_filename_when_given(): exclude_flags = module.make_exclude_flags( config={'exclude_patterns': ['*.pyc', '/var']}, exclude_filename='/tmp/excludes' ) assert exclude_flags == ('--exclude-from', '/tmp/excludes') def test_make_exclude_flags_includes_exclude_from_filenames_when_in_config(): exclude_flags = module.make_exclude_flags(config={'exclude_from': ['excludes', 'other']}) assert exclude_flags == ('--exclude-from', 'excludes', '--exclude-from', 'other') def test_make_exclude_flags_includes_both_filenames_when_patterns_given_and_exclude_from_in_config(): exclude_flags = module.make_exclude_flags( config={'exclude_from': ['excludes']}, exclude_filename='/tmp/excludes' ) assert exclude_flags == ('--exclude-from', 'excludes', '--exclude-from', '/tmp/excludes') def test_make_exclude_flags_considers_none_exclude_from_filenames_as_empty(): exclude_flags = module.make_exclude_flags(config={'exclude_from': None}) assert exclude_flags == () 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-' def test_collect_borgmatic_source_directories_set_when_directory_exists(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os.path).should_receive('expanduser') assert module.collect_borgmatic_source_directories('/tmp') == ['/tmp'] def test_collect_borgmatic_source_directories_empty_when_directory_does_not_exist(): flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os.path).should_receive('expanduser') assert module.collect_borgmatic_source_directories('/tmp') == [] def test_collect_borgmatic_source_directories_defaults_when_directory_not_given(): flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.os.path).should_receive('expanduser') assert module.collect_borgmatic_source_directories(None) == [ module.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY ] def test_pattern_root_directories_deals_with_none_patterns(): assert module.pattern_root_directories(patterns=None) == [] def test_pattern_root_directories_parses_roots_and_ignores_others(): assert module.pattern_root_directories( ['R /root', '+ /root/foo', '- /root/foo/bar', 'R /baz'] ) == ['/root', '/baz'] @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_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).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).should_receive('any_parent_directories').and_return(False) assert module.collect_special_file_paths( ('borg', 'create'), local_path=None, working_directory=None, borg_environment=None, skip_directories=flexmock(), ) == ('/foo', '/bar', '/baz') def test_collect_special_file_paths_excludes_requested_directories(): 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).should_receive('any_parent_directories').and_return(False).and_return( True ).and_return(False) assert module.collect_special_file_paths( ('borg', 'create'), local_path=None, working_directory=None, borg_environment=None, skip_directories=flexmock(), ) == ('/foo', '/baz') def test_collect_special_file_paths_excludes_non_special_files(): 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).should_receive('any_parent_directories').and_return(False) assert module.collect_special_file_paths( ('borg', 'create'), local_path=None, working_directory=None, borg_environment=None, skip_directories=flexmock(), ) == ('/foo', '/baz') def test_collect_special_file_paths_omits_exclude_no_dump_flag_from_command(): flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--dry-run', '--list'), capture_stderr=True, working_directory=None, extra_environment=None, borg_local_path='borg', ).and_return('Processing files ...\n- /foo\n+ /bar\n- /baz').once() flexmock(module).should_receive('special_file').and_return(True) flexmock(module).should_receive('any_parent_directories').and_return(False) module.collect_special_file_paths( ('borg', 'create', '--exclude-nodump'), local_path='borg', working_directory=None, borg_environment=None, skip_directories=flexmock(), ) DEFAULT_ARCHIVE_NAME = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003 REPO_ARCHIVE_WITH_PATHS = (f'repo::{DEFAULT_ARCHIVE_NAME}', 'foo', 'bar') 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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}',) ) environment = {'BORG_THINGY': 'YUP'} flexmock(module.environment).should_receive('make_environment').and_return(environment) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=environment, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_patterns_calls_borg_with_patterns_including_converted_source_directories(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER pattern_flags = ('--patterns-from', 'patterns') flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return( flexmock(name='/tmp/patterns') ).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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(pattern_flags) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + pattern_flags + (f'repo::{DEFAULT_ARCHIVE_NAME}',), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'patterns': ['pattern'], }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_sources_and_used_config_paths_calls_borg_with_sources_and_config_paths(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return( ('foo', 'bar', '/etc/borgmatic/config.yaml') ) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').with_args([]).and_return(()) flexmock(module).should_receive('expand_directories').with_args( ('foo', 'bar', '/etc/borgmatic/config.yaml') ).and_return(('foo', 'bar', '/etc/borgmatic/config.yaml')) flexmock(module).should_receive('expand_directories').with_args([]).and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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}',) ) environment = {'BORG_THINGY': 'YUP'} flexmock(module.environment).should_receive('make_environment').and_return(environment) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('/etc/borgmatic/config.yaml',), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=environment, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=['/etc/borgmatic/config.yaml']), ) def test_create_archive_with_sources_and_used_config_paths_with_store_config_files_false_calls_borg_with_sources_and_no_config_paths(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').with_args([]).and_return(()) flexmock(module).should_receive('expand_directories').with_args(('foo', 'bar')).and_return( ('foo', 'bar') ) flexmock(module).should_receive('expand_directories').with_args([]).and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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}',) ) environment = {'BORG_THINGY': 'YUP'} flexmock(module.environment).should_receive('make_environment').and_return(environment) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=environment, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'store_config_files': False, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=['/etc/borgmatic/config.yaml']), ) def test_create_archive_with_exclude_patterns_calls_borg_with_excludes(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER exclude_flags = ('--exclude-from', 'excludes') flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(('exclude',)) flexmock(module).should_receive('write_pattern_file').and_return(None).and_return( flexmock(name='/tmp/excludes') ) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) flexmock(module).should_receive('make_exclude_flags').and_return(exclude_flags) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + exclude_flags + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': ['exclude'], }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--info') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_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, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS, working_directory=None, extra_environment=None, borg_local_path='borg', ) insert_logging_mock(logging.INFO) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--debug', '--show-rc') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_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, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS, working_directory=None, extra_environment=None, borg_local_path='borg', ) insert_logging_mock(logging.DEBUG) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), json=True, ) def test_create_archive_with_dry_run_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).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--dry-run') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=True, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats_parameter(): # --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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--dry-run', '--info') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_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, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stats=True, ) def test_create_archive_with_checkpoint_interval_calls_borg_with_checkpoint_interval_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--checkpoint-interval', '600') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'checkpoint_interval': 600, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_checkpoint_volume_calls_borg_with_checkpoint_volume_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--checkpoint-volume', '1024') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'checkpoint_volume': 1024, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_chunker_params_calls_borg_with_chunker_params_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--chunker-params', '1,2,3,4') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'chunker_params': '1,2,3,4', }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_compression_calls_borg_with_compression_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--compression', 'rle') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'compression': 'rle', }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) @pytest.mark.parametrize( 'feature_available,option_flag', ((True, '--upload-ratelimit'), (False, '--remote-ratelimit')), ) def test_create_archive_with_upload_rate_limit_calls_borg_with_upload_ratelimit_parameters( feature_available, option_flag ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', option_flag, '100') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'upload_rate_limit': 100, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').with_args('/working/dir').and_return( '/working/dir' ) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory='/working/dir', extra_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, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_one_file_system_calls_borg_with_one_file_system_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--one-file-system') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'one_file_system': True, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) @pytest.mark.parametrize( 'feature_available,option_flag', ((True, '--numeric-ids'), (False, '--numeric-owner')), ) def test_create_archive_with_numeric_ids_calls_borg_with_numeric_ids_parameter( feature_available, option_flag ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', option_flag) + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'numeric_ids': True, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_read_special_calls_borg_with_read_special_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(()) create_command = ('borg', 'create', '--read-special') + REPO_ARCHIVE_WITH_PATHS flexmock(module).should_receive('execute_command').with_args( create_command + ('--dry-run', '--list'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module).should_receive('execute_command').with_args( create_command, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'read_special': True, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) @pytest.mark.parametrize( 'option_name,option_value', ( ('ctime', True), ('ctime', False), ('birthtime', True), ('birthtime', False), ), ) def test_create_archive_with_basic_option_calls_borg_with_corresponding_parameter( option_name, option_value ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER option_flag = '--no' + option_name.replace('', '') if option_value is False else None flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + ((option_flag,) if option_flag else ()) + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], option_name: option_value, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) @pytest.mark.parametrize( 'option_value,feature_available,option_flag', ( (True, True, '--atime'), (True, False, None), (False, True, None), (False, False, '--noatime'), ), ) def test_create_archive_with_atime_option_calls_borg_with_corresponding_parameter( option_value, feature_available, option_flag ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + ((option_flag,) if option_flag else ()) + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'atime': option_value, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) @pytest.mark.parametrize( 'option_value,feature_available,option_flag', ( (True, True, None), (True, False, None), (False, True, '--noflags'), (False, False, '--nobsdflags'), ), ) def test_create_archive_with_flags_option_calls_borg_with_corresponding_parameter( option_value, feature_available, option_flag ): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(None) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(feature_available) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create') + ((option_flag,) if option_flag else ()) + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'flags': option_value, 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_files_cache_calls_borg_with_files_cache_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--files-cache', 'ctime,size') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'files_cache': 'ctime,size', 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg1', 'create') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg1', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), local_path='borg1', ) def test_create_archive_with_remote_path_calls_borg_with_remote_path_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--remote-path', 'borg1') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), remote_path='borg1', ) def test_create_archive_with_umask_calls_borg_with_umask_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--umask', '740') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'umask': 740, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_log_json_calls_borg_with_log_json_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--log-json') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=True, used_config_paths=[]), ) def test_create_archive_with_lock_wait_calls_borg_with_lock_wait_parameters(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--lock-wait', '5') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'lock_wait': 5, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--stats') + REPO_ARCHIVE_WITH_PATHS, output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stats=True, ) def test_create_archive_with_files_calls_borg_with_list_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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--list', '--filter', 'FOO') + REPO_ARCHIVE_WITH_PATHS, output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--info', '--progress') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', working_directory=None, extra_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, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--progress') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), 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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(()) create_command = ( 'borg', 'create', '--one-file-system', '--read-special', '--progress', ) + REPO_ARCHIVE_WITH_PATHS 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', working_directory=None, extra_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', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), progress=True, stream_processes=processes, ) def test_create_archive_with_stream_processes_ignores_read_special_false_and_logs_warnings(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER processes = flexmock() flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_file').and_return(flexmock(name='/tmp/excludes')) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module.logger).should_receive('warning').twice() flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',)) create_command = ( 'borg', 'create', '--one-file-system', '--read-special', ) + REPO_ARCHIVE_WITH_PATHS flexmock(module).should_receive('execute_command_with_processes').with_args( create_command + ('--dry-run', '--list'), processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_command, processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'read_special': False, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stream_processes=processes, ) def test_create_archive_with_stream_processes_adds_special_files_to_excludes(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER processes = flexmock() flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()).and_return( ('special',) ) flexmock(module).should_receive('write_pattern_file').and_return(None).and_return( flexmock(name='/excludes') ) flexmock(module).should_receive('make_list_filter_flags').and_return('FOO') flexmock(module.feature).should_receive('available').and_return(True) flexmock(module).should_receive('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) flexmock(module).should_receive('make_exclude_flags').and_return(()).and_return( '--exclude-from', '/excludes' ) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (f'repo::{DEFAULT_ARCHIVE_NAME}',) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(('special',)) create_flags = ( 'borg', 'create', '--one-file-system', '--read-special', ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_flags + ('--dry-run', '--list') + REPO_ARCHIVE_WITH_PATHS, processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_flags + ('--exclude-from', '/excludes') + REPO_ARCHIVE_WITH_PATHS, processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stream_processes=processes, ) def test_create_archive_with_stream_processes_and_read_special_does_not_add_special_files_to_excludes(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER processes = flexmock() flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()).and_return( ('special',) ) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(('special',)) create_command = ( 'borg', 'create', '--one-file-system', '--read-special', ) + REPO_ARCHIVE_WITH_PATHS flexmock(module).should_receive('execute_command_with_processes').with_args( create_command + ('--dry-run', '--list'), processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_command, processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'read_special': True, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stream_processes=processes, ) def test_create_archive_with_json_calls_borg_with_json_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS, working_directory=None, extra_environment=None, borg_local_path='borg', ).and_return('[]') json_output = module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), json=True, ) assert json_output == '[]' def test_create_archive_with_stats_and_json_calls_borg_without_stats_parameter(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS, working_directory=None, extra_environment=None, borg_local_path='borg', ).and_return('[]') json_output = module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), json=True, stats=True, ) assert json_output == '[]' def test_create_archive_with_source_directories_glob_expands(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'food')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', f'repo::{DEFAULT_ARCHIVE_NAME}', 'foo', 'food'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module.glob).should_receive('glob').with_args('foo*').and_return(['foo', 'food']) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo*'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_non_matching_source_directories_glob_passes_through(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo*',)) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', f'repo::{DEFAULT_ARCHIVE_NAME}', 'foo*'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module.glob).should_receive('glob').with_args('foo*').and_return([]) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo*'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_glob_calls_borg_with_expanded_directories(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'food')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', f'repo::{DEFAULT_ARCHIVE_NAME}', 'foo', 'food'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo*'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_archive_name_format_calls_borg_with_archive_name(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( ('repo::ARCHIVE_NAME',) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', 'repo::ARCHIVE_NAME', 'foo', 'bar'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'archive_name_format': 'ARCHIVE_NAME', }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_archive_name_format_accepts_borg_placeholders(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER repository_archive_pattern = 'repo::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (repository_archive_pattern,) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', repository_archive_pattern, 'foo', 'bar'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'archive_name_format': 'Documents_{hostname}-{now}', # noqa: FS003 }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_repository_accepts_borg_placeholders(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER repository_archive_pattern = '{fqdn}::Documents_{hostname}-{now}' # noqa: FS003 flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) flexmock(module).should_receive('make_exclude_flags').and_return(()) flexmock(module.flags).should_receive('make_repository_archive_flags').and_return( (repository_archive_pattern,) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', repository_archive_pattern, 'foo', 'bar'), output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='{fqdn}', # noqa: FS003 config={ 'source_directories': ['foo', 'bar'], 'repositories': ['{fqdn}'], # noqa: FS003 'exclude_patterns': None, 'archive_name_format': 'Documents_{hostname}-{now}', # noqa: FS003 }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_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('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--extra', '--options') + REPO_ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'extra_borg_options': {'create': '--extra --options'}, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_create_archive_with_stream_processes_calls_borg_with_processes_and_read_special(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER processes = flexmock() flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('map_directories_to_devices').and_return({}) flexmock(module).should_receive('expand_directories').and_return(()) flexmock(module).should_receive('pattern_root_directories').and_return([]) flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError) flexmock(module).should_receive('expand_home_directories').and_return(()) flexmock(module).should_receive('write_pattern_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('ensure_files_readable') flexmock(module).should_receive('make_pattern_flags').and_return(()) 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.environment).should_receive('make_environment') flexmock(module).should_receive('collect_special_file_paths').and_return(()) create_command = ( 'borg', 'create', '--one-file-system', '--read-special', ) + REPO_ARCHIVE_WITH_PATHS flexmock(module).should_receive('execute_command_with_processes').with_args( create_command + ('--dry-run', 'list'), processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) flexmock(module).should_receive('execute_command_with_processes').with_args( create_command, processes=processes, output_log_level=logging.INFO, output_file=None, borg_local_path='borg', working_directory=None, extra_environment=None, ) module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), stream_processes=processes, ) def test_create_archive_with_non_existent_directory_and_source_directories_must_exist_raises_error(): ''' If a source directory doesn't exist and source_directories_must_exist is True, raise an error. ''' flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([]) flexmock(module).should_receive('check_all_source_directories_exist').and_raise(ValueError) with pytest.raises(ValueError): module.create_archive( dry_run=False, repository_path='repo', config={ 'source_directories': ['foo', 'bar'], 'repositories': ['repo'], 'exclude_patterns': None, 'source_directories_must_exist': True, }, local_borg_version='1.2.3', global_arguments=flexmock(log_json=False, used_config_paths=[]), ) def test_check_all_source_directories_exist_with_glob_and_tilde_directories(): flexmock(module).should_receive('expand_directory').with_args('foo*').and_return( ('foo', 'food') ) flexmock(module).should_receive('expand_directory').with_args('~/bar').and_return( ('/root/bar',) ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os.path).should_receive('exists').with_args('foo').and_return(True) flexmock(module.os.path).should_receive('exists').with_args('food').and_return(True) flexmock(module.os.path).should_receive('exists').with_args('/root/bar').and_return(True) module.check_all_source_directories_exist(['foo*', '~/bar']) def test_check_all_source_directories_exist_with_non_existent_directory_raises(): flexmock(module).should_receive('expand_directory').with_args('foo').and_return(('foo',)) flexmock(module.os.path).should_receive('exists').and_return(False) with pytest.raises(ValueError): module.check_all_source_directories_exist(['foo']) borgmatic-1.8.3/tests/unit/borg/test_environment.py000066400000000000000000000037041450610336600225310ustar00rootroot00000000000000from borgmatic.borg import environment as module def test_make_environment_with_passcommand_should_set_environment(): environment = module.make_environment({'encryption_passcommand': 'command'}) assert environment.get('BORG_PASSCOMMAND') == 'command' def test_make_environment_with_passphrase_should_set_environment(): environment = module.make_environment({'encryption_passphrase': 'pass'}) assert environment.get('BORG_PASSPHRASE') == 'pass' def test_make_environment_with_ssh_command_should_set_environment(): environment = module.make_environment({'ssh_command': 'ssh -C'}) assert environment.get('BORG_RSH') == 'ssh -C' def test_make_environment_without_configuration_should_not_set_environment(): environment = module.make_environment({}) assert environment == {} def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes(): 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(): 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(): 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(): 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(): environment = module.make_environment({'borg_files_cache_ttl': 40}) assert environment.get('BORG_FILES_CACHE_TTL') == '40' borgmatic-1.8.3/tests/unit/borg/test_export_key.py000066400000000000000000000204341450610336600223550ustar00rootroot00000000000000import 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): borgmatic.logger.add_custom_log_levels() flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, output_file=output_file, output_log_level=module.logging.ANSWER, borg_local_path='borg', extra_environment=None, ).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_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').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_skip_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), ) borgmatic-1.8.3/tests/unit/borg/test_export_tar.py000066400000000000000000000256101450610336600223540ustar00rootroot00000000000000import 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, borg_local_path='borg', capture=True ): flexmock(module.environment).should_receive('make_environment') 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, borg_local_path=borg_local_path, extra_environment=None, ).once() def test_export_tar_archive_calls_borg_with_path_parameters(): 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_parameters(): 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_with_remote_path_parameters(): 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_parameters(): 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_parameters(): 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_parameters(): 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_parameters(): 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), ) borgmatic-1.8.3/tests/unit/borg/test_extract.py000066400000000000000000000465521450610336600216470ustar00rootroot00000000000000import 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, working_directory=None): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, working_directory=working_directory, extra_environment=None, ).once() def test_extract_last_archive_dry_run_calls_borg_with_last_archive(): flexmock(module.rlist).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.rlist).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.rlist).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.rlist).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.rlist).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_with_remote_path_flags(): flexmock(module.rlist).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.rlist).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.rlist).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.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_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.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.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.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.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.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.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.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.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'), working_directory='/dest') flexmock(module.feature).should_receive('available').and_return(True) 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.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.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.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).should_receive('execute_command').with_args( ('borg', 'extract', '--progress', 'repo::archive'), output_file=module.DO_NOT_CAPTURE, working_directory=None, extra_environment=None, ).once() flexmock(module.feature).should_receive('available').and_return(True) 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).should_receive('execute_command').with_args( ('borg', 'extract', '--stdout', 'repo::archive'), output_file=module.subprocess.PIPE, working_directory=None, run_to_completion=False, extra_environment=None, ).and_return(process).once() flexmock(module.feature).should_receive('available').and_return(True) 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).should_receive('execute_command').with_args( ('borg', 'extract', 'server:repo::archive'), working_directory=None, extra_environment=None, ).once() flexmock(module.feature).should_receive('available').and_return(True) 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), ) borgmatic-1.8.3/tests/unit/borg/test_flags.py000066400000000000000000000122161450610336600212570ustar00rootroot00000000000000import 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',) @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 ), ), ) 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) assert ( module.make_match_archives_flags( match_archives, archive_name_format, local_borg_version=flexmock() ) == expected_result ) borgmatic-1.8.3/tests/unit/borg/test_info.py000066400000000000000000000571441450610336600211270ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.borg import info as module from ..test_verbosity import insert_logging_mock def test_display_archives_info_calls_borg_with_parameters(): 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, 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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) 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_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_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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) insert_logging_mock(logging.INFO) 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_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.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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'info', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') insert_logging_mock(logging.INFO) json_output = 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), ) assert json_output == '[]' def test_display_archives_info_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.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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--debug', '--show-rc', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) insert_logging_mock(logging.DEBUG) 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_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.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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'info', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') insert_logging_mock(logging.DEBUG) json_output = 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), ) assert json_output == '[]' def test_display_archives_info_with_json_calls_borg_with_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_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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'info', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') json_output = 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), ) assert json_output == '[]' def test_display_archives_info_with_archive_calls_borg_with_match_archives_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_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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'archive', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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), ) def test_display_archives_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.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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg1', 'info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg1', extra_environment=None, ) 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), local_path='borg1', ) def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_parameters(): 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', '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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--remote-path', 'borg1', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) 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), remote_path='borg1', ) def test_display_archives_info_with_log_json_calls_borg_with_log_json_parameters(): 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').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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--log-json', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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), ) def test_display_archives_info_with_lock_wait_calls_borg_with_lock_wait_parameters(): 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').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} flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--lock-wait', '5', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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), ) def test_display_archives_info_transforms_prefix_into_match_archives_parameters(): 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( '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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) 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='foo'), ) def test_display_archives_info_prefers_prefix_over_archive_name_format(): 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( '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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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'), ) def test_display_archives_info_transforms_archive_name_format_into_match_archives_parameters(): 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).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'sh:bar-*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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), ) def test_display_archives_with_match_archives_option_calls_borg_with_match_archives_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_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).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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), ) def test_display_archives_with_match_archives_flag_calls_borg_with_match_archives_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_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).should_receive('execute_command').with_args( ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_archives_info( 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-*'), ) @pytest.mark.parametrize('argument_name', ('sort_by', 'first', 'last')) def test_display_archives_info_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').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') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', flag_name, 'value', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) 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, **{argument_name: 'value'} ), ) def test_display_archives_info_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').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')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ( 'borg', 'info', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--repo', 'repo', ), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) info_arguments = flexmock( archive=None, json=False, prefix=None, match_archives=None, newer='1d', newest='1y', older='1m', oldest='1w', ) module.display_archives_info( repository_path='repo', config={}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), info_arguments=info_arguments, ) borgmatic-1.8.3/tests/unit/borg/test_list.py000066400000000000000000000624261450610336600211460ustar00rootroot00000000000000import 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(('--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( ('--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_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).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=flexmock(), 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).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg2', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg2', extra_environment=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_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.rlist).should_receive('make_rlist_command').and_return(('borg', 'list', 'repo')) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', 'repo'), extra_environment=None, borg_local_path='borg', ).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, borg_local_path='borg', extra_environment=None, ).once() flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive2') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=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.rlist).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.rlist).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.RLIST, '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).should_receive('execute_command').with_args( ('borg', 'list', 'repo::archive'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=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_rlist( 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.rlist).should_receive('make_rlist_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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', 'rlist', '--repo', 'repo')) flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'rlist', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).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, borg_local_path='borg', extra_environment=None, ).once() flexmock(module).should_receive('execute_command').with_args( ('borg', 'list', '--repo', 'repo', 'archive2') + glob_paths, output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=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, ) borgmatic-1.8.3/tests/unit/borg/test_mount.py000066400000000000000000000250041450610336600213240ustar00rootroot00000000000000import 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): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( command, borg_local_path='borg', extra_environment=None, ).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_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).should_receive('execute_command').with_args( ('borg', 'mount', '--foreground', 'repo::archive', '/mnt'), output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ( 'borg', 'mount', '--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w', '--match-archives', None, '--repo', 'repo', '/mnt', ), borg_local_path='borg', extra_environment=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), ) borgmatic-1.8.3/tests/unit/borg/test_prune.py000066400000000000000000000364671450610336600213320ustar00rootroot00000000000000import 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): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( prune_command, output_log_level=output_log_level, borg_local_path=prune_command[0], extra_environment=None, ).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, 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, 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, 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, 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-*')) result = module.make_prune_flags(config, 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, 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_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).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, borg_local_path='borg', extra_environment=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, ) borgmatic-1.8.3/tests/unit/borg/test_rcreate.py000066400000000000000000000303541450610336600216130ustar00rootroot00000000000000import logging import subprocess import pytest from flexmock import flexmock from borgmatic.borg import rcreate as module from ..test_verbosity import insert_logging_mock RINFO_SOME_UNKNOWN_EXIT_CODE = -999 RCREATE_COMMAND = ('borg', 'rcreate', '--encryption', 'repokey') def insert_rinfo_command_found_mock(): flexmock(module.rinfo).should_receive('display_repository_info') def insert_rinfo_command_not_found_mock(): flexmock(module.rinfo).should_receive('display_repository_info').and_raise( subprocess.CalledProcessError(module.RINFO_REPOSITORY_NOT_FOUND_EXIT_CODE, []) ) def insert_rcreate_command_mock(rcreate_command, **kwargs): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( rcreate_command, output_file=module.DO_NOT_CAPTURE, borg_local_path=rcreate_command[0], extra_environment=None, ).once() def test_create_repository_calls_borg_with_flags(): insert_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_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_rcreate_error(): insert_rinfo_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).should_receive('execute_command').and_raise( module.subprocess.CalledProcessError(2, 'borg rcreate') ) 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_rinfo_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_raises_for_unknown_rinfo_command_error(): flexmock(module.rinfo).should_receive('display_repository_info').and_raise( subprocess.CalledProcessError(RINFO_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_rinfo_command_not_found_mock() insert_rcreate_command_mock(('borg1',) + RCREATE_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_remote_path_calls_borg_with_remote_path_flag(): insert_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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_extra_borg_options_calls_borg_with_extra_options(): insert_rinfo_command_not_found_mock() insert_rcreate_command_mock(RCREATE_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': {'rcreate': '--extra --options'}}, local_borg_version='2.3.4', global_arguments=flexmock(log_json=False), encryption_mode='repokey', ) borgmatic-1.8.3/tests/unit/borg/test_rinfo.py000066400000000000000000000260751450610336600213100ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.borg import rinfo 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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return(('repo',)) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'info', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) insert_logging_mock(logging.INFO) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'rinfo', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') insert_logging_mock(logging.INFO) json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--debug', '--show-rc', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) insert_logging_mock(logging.DEBUG) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'rinfo', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') insert_logging_mock(logging.DEBUG) json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'rinfo', '--json', '--repo', 'repo'), extra_environment=None, borg_local_path='borg', ).and_return('[]') json_output = module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg1', 'rinfo', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg1', extra_environment=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), local_path='borg1', ) 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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--remote-path', 'borg1', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), remote_path='borg1', ) 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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--log-json', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_repository_info( repository_path='repo', config={}, local_borg_version='2.3.4', rinfo_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_repository_flags').and_return( ( '--repo', 'repo', ) ) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rinfo', '--lock-wait', '5', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ) module.display_repository_info( repository_path='repo', config=config, local_borg_version='2.3.4', rinfo_arguments=flexmock(json=False), global_arguments=flexmock(log_json=False), ) borgmatic-1.8.3/tests/unit/borg/test_rlist.py000066400000000000000000000556461450610336600213360ustar00rootroot00000000000000import argparse import logging import pytest from flexmock import flexmock from borgmatic.borg import rlist 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).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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).should_receive('execute_command_and_capture_output').with_args( ('borg1', 'list') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg1', ).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_remote_path_calls_borg_with_remote_path_flags(): expected_archive = 'archive-name' flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--remote-path', 'borg1') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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_without_archives_raises(): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--log-json') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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).should_receive('execute_command_and_capture_output').with_args( ('borg', 'list', '--lock-wait', 'okay') + BORG_LIST_LATEST_ARGUMENTS, extra_environment=None, borg_local_path='borg', ).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_make_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={'lock_wait': 5}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_command_includes_remote_path(): flexmock(module.flags).should_receive('make_flags').and_return( ('--remote-path', 'borg2') ).and_return(()).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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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_borg_with_flags(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER rlist_arguments = argparse.Namespace(json=False) global_arguments = flexmock() flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_rlist_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_arguments=rlist_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'rlist', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command').with_args( ('borg', 'rlist', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, borg_local_path='borg', extra_environment=None, ).once() module.list_repository( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_arguments=rlist_arguments, global_arguments=global_arguments, ) def test_list_repository_with_json_returns_borg_output(): flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels') flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER rlist_arguments = argparse.Namespace(json=True) global_arguments = flexmock() json_output = flexmock() flexmock(module.feature).should_receive('available').and_return(False) flexmock(module).should_receive('make_rlist_command').with_args( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_arguments=rlist_arguments, global_arguments=global_arguments, local_path='borg', remote_path=None, ).and_return(('borg', 'rlist', 'repo')) flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').and_return(json_output) assert ( module.list_repository( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_arguments=rlist_arguments, global_arguments=global_arguments, ) == json_output ) def test_make_rlist_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_rlist_command( repository_path='repo', config={}, local_borg_version='1.2.3', rlist_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', ) borgmatic-1.8.3/tests/unit/borg/test_transfer.py000066400000000000000000000504151450610336600220120ustar00rootroot00000000000000import 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).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo', '--dry-run'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--info', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--debug', '--show-rc', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'archive', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'sh:foo*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--match-archives', 'sh:bar-*', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg2', 'transfer', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg2', extra_environment=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_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).should_receive('execute_command').with_args( ('borg', 'transfer', '--remote-path', 'borg2', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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_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).should_receive('execute_command').with_args( ('borg', 'transfer', '--log-json', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--lock-wait', '5', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--progress', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=module.DO_NOT_CAPTURE, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', flag_name, 'value', '--repo', 'repo'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).should_receive('execute_command').with_args( ('borg', 'transfer', '--repo', 'repo', '--other-repo', 'other'), output_log_level=module.borgmatic.logger.ANSWER, output_file=None, borg_local_path='borg', extra_environment=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).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, borg_local_path='borg', extra_environment=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', ), ) borgmatic-1.8.3/tests/unit/borg/test_umount.py000066400000000000000000000016301450610336600215100ustar00rootroot00000000000000import 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): flexmock(module).should_receive('execute_command').with_args(command).once() def test_unmount_archive_calls_borg_with_required_parameters(): insert_execute_command_mock(('borg', 'umount', '/mnt')) module.unmount_archive(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(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(mount_point='/mnt') borgmatic-1.8.3/tests/unit/borg/test_version.py000066400000000000000000000041001450610336600216410ustar00rootroot00000000000000import 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, borg_local_path='borg', version_output=f'borg {VERSION}' ): flexmock(module.environment).should_receive('make_environment') flexmock(module).should_receive('execute_command_and_capture_output').with_args( command, extra_environment=None, borg_local_path=borg_local_path, ).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_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({}) borgmatic-1.8.3/tests/unit/commands/000077500000000000000000000000001450610336600174205ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/commands/__init__.py000066400000000000000000000000001450610336600215170ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/commands/completion/000077500000000000000000000000001450610336600215715ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/commands/completion/__init__.py000066400000000000000000000000001450610336600236700ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/commands/completion/test_actions.py000066400000000000000000000003421450610336600246410ustar00rootroot00000000000000from 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-1.8.3/tests/unit/commands/completion/test_bash.py000066400000000000000000000006571450610336600241270ustar00rootroot00000000000000from 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-1.8.3/tests/unit/commands/completion/test_fish.py000066400000000000000000000110101450610336600241240ustar00rootroot00000000000000from 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-1.8.3/tests/unit/commands/test_arguments.py000066400000000000000000000414641450610336600230470ustar00rootroot00000000000000import 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_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( 'arguments, expected', [ # A global flag remaining from each parsed action. ( ( ('--latest', 'archive', 'prune', 'extract', 'list', '--test-flag'), ('--latest', 'archive', 'check', 'extract', 'list', '--test-flag'), ('prune', 'check', 'list', '--test-flag'), ('prune', 'check', 'extract', '--test-flag'), ), ('--test-flag',), ), # No global flags remaining. ( ( ('--latest', 'archive', 'prune', 'extract', 'list'), ('--latest', 'archive', 'check', 'extract', 'list'), ('prune', 'check', 'list'), ('prune', 'check', 'extract'), ), (), ), # No flags. ((), ()), ], ) def test_get_unparsable_arguments_returns_remaining_arguments_that_no_action_can_parse( arguments, expected ): 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-1.8.3/tests/unit/commands/test_borgmatic.py000066400000000000000000001433771450610336600230170ustar00rootroot00000000000000import logging import subprocess import time from flexmock import flexmock import borgmatic.hooks.command from borgmatic.commands import borgmatic as module def test_run_configuration_runs_actions_for_each_repository(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) expected_results = [flexmock(), 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, arguments)) assert results == expected_results def test_run_configuration_with_invalid_borg_version_errors(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) 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('run_actions').never() config = {'repositories': ['foo']} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()} list(module.run_configuration('test.yaml', config, arguments)) def test_run_configuration_logs_monitor_start_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) 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('run_actions').never() config = {'repositories': ['foo']} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, 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.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) flexmock(module).should_receive('log_error_records').never() flexmock(module).should_receive('run_actions').never() config = {'repositories': ['foo']} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, arguments)) assert results == [] def test_run_configuration_logs_actions_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) 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('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, arguments)) assert results == expected_results def test_run_configuration_bails_for_actions_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') flexmock(module).should_receive('run_actions').and_raise(error) flexmock(module).should_receive('log_error_records').never() 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, arguments)) assert results == [] def test_run_configuration_logs_monitor_log_error(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) 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('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, arguments)) assert results == expected_results def test_run_configuration_bails_for_monitor_log_soft_failure(): flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) 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) flexmock(module).should_receive('log_error_records').never() 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, 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.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('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, 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.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('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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.dispatch).should_receive('call_hooks').never() 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, 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.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('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, 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.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('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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('log_error_records').with_args( 'foo: 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( 'foo: 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) expected_results = [flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args( 'foo: Error running actions for repository', OSError ).and_return(expected_results[:1]).ordered() flexmock(module).should_receive('log_error_records').with_args( 'bar: 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'foo: 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( 'bar: 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( 'foo: 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( 'bar: 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') 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( 'foo: 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( 'bar: 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( 'bar: 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('log_error_records').with_args( 'foo: 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( 'foo: 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( 'foo: 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( 'foo: 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, 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.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.command).should_receive('execute_hook') 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( 'foo: 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( 'bar: 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( 'bar: 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, arguments)) assert results == error_logs def test_run_actions_runs_rcreate(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.command).should_receive('execute_hook') flexmock(borgmatic.actions.rcreate).should_receive('run_rcreate').once() tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'rcreate': flexmock()}, config_filename=flexmock(), config={'repositories': []}, local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_adds_log_file_to_hook_context(): flexmock(module).should_receive('add_custom_log_levels') 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': []}, hook_context={'repository': 'repo', 'repositories': '', 'log_file': 'foo'}, 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': []}, 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.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': []}, 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.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': []}, local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_prune(): flexmock(module).should_receive('add_custom_log_levels') 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': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_run_actions_runs_rlist(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.rlist).should_receive('run_rlist').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'rlist': flexmock()}, config_filename=flexmock(), config={'repositories': []}, 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.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': []}, local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) assert result == (expected,) def test_run_actions_runs_rinfo(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.command).should_receive('execute_hook') expected = flexmock() flexmock(borgmatic.actions.rinfo).should_receive('run_rinfo').and_yield(expected).once() result = tuple( module.run_actions( arguments={'global': flexmock(dry_run=False, log_file='foo'), 'rinfo': flexmock()}, config_filename=flexmock(), config={'repositories': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, 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.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': []}, local_path=flexmock(), remote_path=flexmock(), local_borg_version=flexmock(), repository={'path': 'repo'}, ) ) def test_load_configurations_collects_parsed_configurations_and_logs(): 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, test_expected_logs ).and_return(other_configuration, other_expected_logs) configs, logs = tuple(module.load_configurations(('test.yaml', 'other.yaml'))) assert configs == {'test.yaml': configuration, 'other.yaml': other_configuration} 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, logs = tuple(module.load_configurations(('test.yaml',))) assert configs == {} 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, logs = tuple(module.load_configurations(('test.yaml',))) assert configs == {} 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_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'), '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'), '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'), '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('run_configuration').and_return([]) arguments = {} logs = tuple( module.collect_configuration_run_summary_logs({'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('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': {}}, 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_single_repository_selected') flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'extract': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs({'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': {}}, arguments=arguments) ) assert logs == expected_logs def test_collect_configuration_run_summary_logs_info_for_success_with_mount(): flexmock(module.validate).should_receive('guard_single_repository_selected') flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module).should_receive('run_configuration').and_return([]) arguments = {'mount': flexmock(repository='repo')} logs = tuple( module.collect_configuration_run_summary_logs({'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': {}}, 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({}, 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': {}}, 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('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': {}}, 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': {}}, 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('run_configuration').and_return([]) arguments = {'list': flexmock(repository='repo', archive=None)} logs = tuple( module.collect_configuration_run_summary_logs({'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('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': {}}, 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('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': {}}, 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('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': {}}, arguments=arguments ) ) borgmatic-1.8.3/tests/unit/config/000077500000000000000000000000001450610336600170645ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/config/__init__.py000066400000000000000000000000001450610336600211630ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/config/test_checks.py000066400000000000000000000013051450610336600217340ustar00rootroot00000000000000from 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', consistency={}) assert enabled def test_repository_enabled_for_checks_is_enabled_for_specified_repositories(): enabled = module.repository_enabled_for_checks( 'repo.borg', consistency={'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', consistency={'check_repositories': ['other.borg']} ) assert not enabled borgmatic-1.8.3/tests/unit/config/test_collect.py000066400000000000000000000176251450610336600221350ustar00rootroot00000000000000import 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-1.8.3/tests/unit/config/test_environment.py000066400000000000000000000060221450610336600230410ustar00rootroot00000000000000import 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-1.8.3/tests/unit/config/test_generate.py000066400000000000000000000115641450610336600222760ustar00rootroot00000000000000from collections import OrderedDict import pytest from flexmock import flexmock from borgmatic.config import generate as module def test_schema_to_sample_configuration_generates_config_map_with_examples(): flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module).should_receive('add_comments_to_configuration_object') schema = { 'type': 'object', 'properties': OrderedDict( [ ('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'}), ('field3', {'example': 'Example 3'}), ] ), } 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.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(): flexmock(module.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') schema = { 'type': 'array', 'items': { 'type': 'object', 'properties': OrderedDict( [('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})] ), }, } 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('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('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('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('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-1.8.3/tests/unit/config/test_load.py000066400000000000000000000025561450610336600214240ustar00rootroot00000000000000import 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() flexmock(module).should_receive('load_configuration').with_args('/etc/include.yaml').and_return( config ).once() assert module.probe_and_include_file('/etc/include.yaml', ['/etc', '/var']) == config def test_probe_and_include_file_with_relative_path_probes_include_directories(): config = flexmock() 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').never() flexmock(module).should_receive('load_configuration').with_args('/var/include.yaml').and_return( config ).once() assert module.probe_and_include_file('include.yaml', ['/etc', '/var']) == config 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']) borgmatic-1.8.3/tests/unit/config/test_normalize.py000066400000000000000000000160031450610336600224750ustar00rootroot00000000000000import 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, ), ( {}, {}, 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, ), ( {'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': ['file:///repo']}, {'repositories': [{'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) assert config == expected_config if produces_logs: assert logs else: assert logs == [] def test_normalize_raises_error_if_repository_data_is_not_consistent(): flexmock(module).should_receive('normalize_sections').and_return([]) with pytest.raises(TypeError): module.normalize( 'test.yaml', { 'repositories': [{'path': 'foo@bar:/repo', 'label': 'foo'}, 'file:///repo'], }, ) borgmatic-1.8.3/tests/unit/config/test_override.py000066400000000000000000000073341450610336600223230ustar00rootroot00000000000000import 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'}} @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('convert_value_type').replace_with(lambda value: value) raw_overrides = ['option.my_option=value1', 'other_option=value2'] expected_result = ( (('option', 'my_option'), 'value1'), (('other_option'), 'value2'), ) module.parse_overrides(raw_overrides) == 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('convert_value_type').replace_with(lambda value: value) raw_overrides = ['option=this===value'] expected_result = ((('option',), 'this===value'),) module.parse_overrides(raw_overrides) == 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('convert_value_type').replace_with(lambda value: value) raw_overrides = ['option'] with pytest.raises(ValueError): module.parse_overrides(raw_overrides) 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('convert_value_type').and_raise(ruamel.yaml.parser.ParserError) raw_overrides = ['option=[in valid]'] with pytest.raises(ValueError): module.parse_overrides(raw_overrides) 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('convert_value_type').replace_with(lambda value: value) raw_overrides = ['option=value'] expected_result = ((('option',), 'value'),) module.parse_overrides(raw_overrides) == expected_result def test_parse_overrides_handles_empty_overrides(): module.parse_overrides(raw_overrides=None) == () borgmatic-1.8.3/tests/unit/config/test_validate.py000066400000000000000000000172351450610336600222760ustar00rootroot00000000000000import 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).format_json_error = lambda error: error.message 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_path_in_check_repositories(): module.apply_logical_validation( 'config.yaml', { 'repositories': [{'path': 'repo.borg'}, {'path': 'other.borg'}], 'keep_secondly': 1000, 'check_repositories': ['repo.borg'], }, ) def test_apply_logical_validation_does_not_raise_if_known_repository_label_in_check_repositories(): module.apply_logical_validation( 'config.yaml', { 'repositories': [ {'path': 'repo.borg', 'label': 'my_repo'}, {'path': 'other.borg', 'label': 'other_repo'}, ], 'keep_secondly': 1000, 'check_repositories': ['my_repo'], }, ) def test_apply_logical_validation_does_not_raise_if_archive_name_format_and_prefix_present(): module.apply_logical_validation( 'config.yaml', { 'archive_name_format': '{hostname}-{now}', # noqa: FS003 'prefix': '{hostname}-', # noqa: FS003 'prefix': '{hostname}-', # noqa: FS003 }, ) def test_apply_logical_validation_does_not_raise_otherwise(): module.apply_logical_validation('config.yaml', {'keep_secondly': 1000}) 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 def test_repositories_match_does_not_raise(): flexmock(module).should_receive('normalize_repository_path') module.repositories_match('foo', 'bar') def test_guard_configuration_contains_repository_does_not_raise_when_repository_in_config(): flexmock(module).should_receive('repositories_match').replace_with( lambda first, second: first == second ) module.guard_configuration_contains_repository( repository='repo', configurations={'config.yaml': {'repositories': ['repo']}} ) def test_guard_configuration_contains_repository_does_not_raise_when_repository_label_in_config(): module.guard_configuration_contains_repository( repository='repo', configurations={'config.yaml': {'repositories': [{'path': 'foo/bar', 'label': 'repo'}]}}, ) def test_guard_configuration_contains_repository_does_not_raise_when_repository_not_given(): module.guard_configuration_contains_repository( repository=None, configurations={'config.yaml': {'repositories': ['repo']}} ) def test_guard_configuration_contains_repository_errors_when_repository_missing_from_config(): flexmock(module).should_receive('repositories_match').replace_with( lambda first, second: first == second ) with pytest.raises(ValueError): module.guard_configuration_contains_repository( repository='nope', configurations={'config.yaml': {'repositories': ['repo', 'repo2']}}, ) def test_guard_configuration_contains_repository_errors_when_repository_matches_config_twice(): flexmock(module).should_receive('repositories_match').replace_with( lambda first, second: first == second ) with pytest.raises(ValueError): module.guard_configuration_contains_repository( repository='repo', configurations={ 'config.yaml': {'repositories': ['repo', 'repo2']}, 'other.yaml': {'repositories': ['repo']}, }, ) def test_guard_single_repository_selected_raises_when_multiple_repositories_configured_and_none_selected(): with pytest.raises(ValueError): module.guard_single_repository_selected( repository=None, configurations={'config.yaml': {'repositories': ['repo', 'repo2']}}, ) def test_guard_single_repository_selected_does_not_raise_when_single_repository_configured_and_none_selected(): module.guard_single_repository_selected( repository=None, configurations={'config.yaml': {'repositories': ['repo']}}, ) def test_guard_single_repository_selected_does_not_raise_when_no_repositories_configured_and_one_selected(): module.guard_single_repository_selected( repository='repo', configurations={'config.yaml': {'repositories': []}}, ) def test_guard_single_repository_selected_does_not_raise_when_repositories_configured_and_one_selected(): module.guard_single_repository_selected( repository='repo', configurations={'config.yaml': {'repositories': ['repo', 'repo2']}}, ) borgmatic-1.8.3/tests/unit/hooks/000077500000000000000000000000001450610336600167425ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/hooks/__init__.py000066400000000000000000000000001450610336600210410ustar00rootroot00000000000000borgmatic-1.8.3/tests/unit/hooks/test_command.py000066400000000000000000000075201450610336600217750ustar00rootroot00000000000000import 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('test.yaml', '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('test.yaml', '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('test.yaml', 'pre-backup', command, context) == 'ls barquux quux' ) def test_execute_hook_invokes_each_command(): flexmock(module).should_receive('interpolate_context').replace_with( lambda config_file, hook_description, command, context: command ) flexmock(module.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True ).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 config_file, hook_description, command, context: command ) flexmock(module.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True ).once() flexmock(module.execute).should_receive('execute_command').with_args( ['true'], output_log_level=logging.WARNING, shell=True ).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 config_file, 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.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.WARNING, shell=True ) 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 config_file, hook_description, command, context: command ) flexmock(module.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 config_file, hook_description, command, context: command ) flexmock(module.execute).should_receive('execute_command').with_args( [':'], output_log_level=logging.ERROR, shell=True ).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('config.yaml', 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('config.yaml', error) def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail(): assert not module.considered_soft_failure('config.yaml', Exception()) borgmatic-1.8.3/tests/unit/hooks/test_cronhub.py000066400000000000000000000072161450610336600220210ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks 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-1.8.3/tests/unit/hooks/test_cronitor.py000066400000000000000000000061321450610336600222140ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks 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-1.8.3/tests/unit/hooks/test_dispatch.py000066400000000000000000000106121450610336600221520ustar00rootroot00000000000000import sys import pytest from flexmock import flexmock from borgmatic.hooks import dispatch as module def hook_function(hook_config, config, log_prefix, 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).HOOK_NAME_TO_MODULE = {'super_hook': test_module} flexmock(test_module).should_receive('hook_function').with_args( config['super_hook'], config, 'prefix', 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'prefix', 'super_hook', 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).HOOK_NAME_TO_MODULE = {'super_hook': test_module} flexmock(test_module).should_receive('hook_function').with_args( {}, config, 'prefix', 55, value=66 ).and_return(expected_return_value).once() return_value = module.call_hook('hook_function', config, 'prefix', '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).HOOK_NAME_TO_MODULE = {'other_hook': test_module} flexmock(test_module).should_receive('hook_function').never() with pytest.raises(ValueError): module.call_hook('hook_function', config, 'prefix', 'super_hook', 55, value=66) 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).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, 'prefix', ('super_hook', 'other_hook'), 55 ) assert return_values == expected_return_values def test_call_hooks_calls_skips_return_values_for_missing_hooks(): config = {'super_hook': flexmock()} expected_return_values = {'super_hook': flexmock()} flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook']) return_values = module.call_hooks( 'do_stuff', config, 'prefix', ('super_hook', 'other_hook'), 55 ) assert return_values == expected_return_values def test_call_hooks_calls_skips_return_values_for_null_hooks(): config = {'super_hook': flexmock(), 'other_hook': None} expected_return_values = {'super_hook': flexmock()} flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook']) return_values = module.call_hooks( 'do_stuff', config, 'prefix', ('super_hook', 'other_hook'), 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).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, 'prefix', ('super_hook', 'other_hook'), 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).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, 'prefix', ('super_hook', 'other_hook'), 55 ) assert return_values == expected_return_values borgmatic-1.8.3/tests/unit/hooks/test_dump.py000066400000000000000000000056721450610336600213320ustar00rootroot00000000000000import pytest from flexmock import flexmock from borgmatic.hooks 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_path_defaults_without_source_directory(): assert ( module.make_data_source_dump_path(None, 'super_databases') == '~/.borgmatic/super_databases' ) def test_make_data_source_dump_filename_uses_name_and_hostname(): flexmock(module.os.path).should_receive('expanduser').and_return('databases') assert ( module.make_data_source_dump_filename('databases', 'test', 'hostname') == 'databases/hostname/test' ) def test_make_data_source_dump_filename_without_hostname_defaults_to_localhost(): flexmock(module.os.path).should_receive('expanduser').and_return('databases') assert module.make_data_source_dump_filename('databases', 'test') == 'databases/localhost/test' def test_make_data_source_dump_filename_with_invalid_name_raises(): flexmock(module.os.path).should_receive('expanduser').and_return('databases') 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('expanduser').and_return('databases/localhost') flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.shutil).should_receive('rmtree').with_args('databases/localhost').once() module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False) def test_remove_data_source_dumps_with_dry_run_skips_removal(): flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost') flexmock(module.os.path).should_receive('exists').never() flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=True) def test_remove_data_source_dumps_without_dump_path_present_skips_removal(): flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost') flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.shutil).should_receive('rmtree').never() module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False) def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash(): assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar'] borgmatic-1.8.3/tests/unit/hooks/test_healthchecks.py000066400000000000000000000267231450610336600230130ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks import healthchecks as module def test_forgetful_buffering_handler_emit_collects_log_records(): handler = module.Forgetful_buffering_handler(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(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(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_format_buffered_logs_for_payload_flattens_log_buffer(): handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] logger = flexmock(handlers=[handler]) logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(logger) payload = module.format_buffered_logs_for_payload() assert payload == 'foo\nbar\n' def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten(): handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] handler.forgot = True logger = flexmock(handlers=[handler]) logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(logger) payload = module.format_buffered_logs_for_payload() assert payload == '...\nfoo\nbar\n' def test_format_buffered_logs_for_payload_without_handler_produces_empty_payload(): logger = flexmock(handlers=[module.logging.Handler()]) logger.should_receive('removeHandler') flexmock(module.logging).should_receive('getLogger').and_return(logger) payload = module.format_buffered_logs_for_payload() assert payload == '' 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).should_receive('Forgetful_buffering_handler').with_args( ping_body_limit - len(module.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).should_receive('Forgetful_buffering_handler').with_args( module.DEFAULT_PING_BODY_LIMIT_BYTES - len(module.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).should_receive('Forgetful_buffering_handler').with_args( 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).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).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).should_receive('Forgetful_buffering_handler') 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).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).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).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).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).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).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).should_receive('Forgetful_buffering_handler') 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).should_receive('Forgetful_buffering_handler') 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).should_receive('Forgetful_buffering_handler') 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_with_connection_error_logs_warning(): flexmock(module).should_receive('Forgetful_buffering_handler') 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).should_receive('Forgetful_buffering_handler') 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-1.8.3/tests/unit/hooks/test_loki.py000066400000000000000000000061701450610336600213150ustar00rootroot00000000000000import json import requests from flexmock import flexmock from borgmatic.hooks 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-1.8.3/tests/unit/hooks/test_mariadb.py000066400000000000000000000461551450610336600217650ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks import mariadb as module def test_database_names_to_dump_passes_through_name(): extra_environment = flexmock() log_prefix = '' names = module.database_names_to_dump( {'name': 'foo'}, extra_environment, log_prefix, dry_run=False ) assert names == ('foo',) def test_database_names_to_dump_bails_for_dry_run(): extra_environment = flexmock() log_prefix = '' flexmock(module).should_receive('execute_command_and_capture_output').never() names = module.database_names_to_dump( {'name': 'all'}, extra_environment, log_prefix, dry_run=True ) assert names == () def test_database_names_to_dump_queries_mariadb_for_database_names(): extra_environment = flexmock() log_prefix = '' flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('mariadb', '--skip-column-names', '--batch', '--execute', 'show schemas'), extra_environment=extra_environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all'}, extra_environment, log_prefix, dry_run=False ) assert names == ('foo', 'bar') 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).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}, log_prefix=object, dump_path=object, database_names=(name,), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module).should_receive('execute_dump_command').with_args( database=database, log_prefix=object, dump_path=object, database_names=('foo',), extra_environment={'MYSQL_PWD': 'trustsome1'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources([database], {}, 'test.yaml', 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).should_receive('database_names_to_dump').and_return(('foo', 'bar')) flexmock(module).should_receive('execute_dump_command').with_args( database={'name': 'all'}, log_prefix=object, dump_path=object, database_names=('foo', 'bar'), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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).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'}, log_prefix=object, dump_path=object, database_names=(name,), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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'} flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mariadb', '--defaults-extra-file=mariadb.cnf', '--skip-column-names', '--batch', '--execute', 'show schemas', ), extra_environment=None, ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'add_drop_database': False}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--add-drop-database', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--add-drop-database', '--user', 'root', '--databases', 'foo', '--result-file', 'dump', ), extra_environment={'MYSQL_PWD': 'trustsome1'}, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_environment={'MYSQL_PWD': 'trustsome1'}, 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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mariadb-dump', '--stuff=such', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'options': '--stuff=such'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.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, {}, 'test.yaml', 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.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, {}, 'test.yaml', 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).should_receive('execute_command_with_processes').with_args( ('mariadb', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_runs_mariadb_with_options(): hook_config = [{'name': 'foo', 'restore_options': '--harder'}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mariadb', '--batch', '--harder'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( ('mariadb', '--batch', '--user', 'root'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'trustsome1'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--batch', '--host', 'clihost', '--port', 'cliport', '--protocol', 'tcp', '--user', 'cliusername', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'clipassword'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, ) 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', 'restore_username': 'restoreuser', 'restore_password': 'restorepass', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mariadb', '--batch', '--host', 'restorehost', '--port', 'restoreport', '--protocol', 'tcp', '--user', 'restoreuser', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'restorepass'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo'}] flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) borgmatic-1.8.3/tests/unit/hooks/test_mongodb.py000066400000000000000000000413221450610336600220020ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks import mongodb as module 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, {}, 'test.yaml', 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, {}, 'test.yaml', 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, {}, 'test.yaml', 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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mongodump', '--username', 'mongo', '--password', 'trustsome1', '--authenticationDatabase', 'admin', '--db', 'foo', '--archive', '>', 'databases/localhost/foo', ), shell=True, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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, {}, 'test.yaml', 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, {}, 'test.yaml', 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, {}, 'test.yaml', dry_run=False) == [process] 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).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, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--username', 'mongo', '--password', 'trustsome1', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--host', 'clihost', '--port', 'cliport', '--username', 'cliusername', '--password', 'clipassword', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, ) 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).should_receive('execute_command_with_processes').with_args( [ 'mongorestore', '--archive', '--drop', '--host', 'restorehost', '--port', 'restoreport', '--username', 'restoreuser', '--password', 'restorepass', '--authenticationDatabase', 'admin', ], processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) borgmatic-1.8.3/tests/unit/hooks/test_mysql.py000066400000000000000000000460421450610336600215260ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks import mysql as module def test_database_names_to_dump_passes_through_name(): extra_environment = flexmock() log_prefix = '' names = module.database_names_to_dump( {'name': 'foo'}, extra_environment, log_prefix, dry_run=False ) assert names == ('foo',) def test_database_names_to_dump_bails_for_dry_run(): extra_environment = flexmock() log_prefix = '' flexmock(module).should_receive('execute_command_and_capture_output').never() names = module.database_names_to_dump( {'name': 'all'}, extra_environment, log_prefix, dry_run=True ) assert names == () def test_database_names_to_dump_queries_mysql_for_database_names(): extra_environment = flexmock() log_prefix = '' flexmock(module).should_receive('execute_command_and_capture_output').with_args( ('mysql', '--skip-column-names', '--batch', '--execute', 'show schemas'), extra_environment=extra_environment, ).and_return('foo\nbar\nmysql\n').once() names = module.database_names_to_dump( {'name': 'all'}, extra_environment, log_prefix, dry_run=False ) assert names == ('foo', 'bar') 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).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}, log_prefix=object, dump_path=object, database_names=(name,), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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).should_receive('database_names_to_dump').and_return(('foo',)).and_return( ('bar',) ) flexmock(module).should_receive('execute_dump_command').with_args( database=database, log_prefix=object, dump_path=object, database_names=('foo',), extra_environment={'MYSQL_PWD': 'trustsome1'}, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources([database], {}, 'test.yaml', 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).should_receive('database_names_to_dump').and_return(('foo', 'bar')) flexmock(module).should_receive('execute_dump_command').with_args( database={'name': 'all'}, log_prefix=object, dump_path=object, database_names=('foo', 'bar'), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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).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'}, log_prefix=object, dump_path=object, database_names=(name,), extra_environment=object, dry_run=object, dry_run_label=object, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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'} flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'mysql', '--defaults-extra-file=my.cnf', '--skip-column-names', '--batch', '--execute', 'show schemas', ), extra_environment=None, ).and_return(('foo\nbar')).once() assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'add_drop_database': False}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--add-drop-database', '--host', 'database.example.org', '--port', '5433', '--protocol', 'tcp', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--add-drop-database', '--user', 'root', '--databases', 'foo', '--result-file', 'dump', ), extra_environment={'MYSQL_PWD': 'trustsome1'}, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_environment={'MYSQL_PWD': 'trustsome1'}, 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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'mysqldump', '--stuff=such', '--add-drop-database', '--databases', 'foo', '--result-file', 'dump', ), extra_environment=None, run_to_completion=False, ).and_return(process).once() assert ( module.execute_dump_command( database={'name': 'foo', 'options': '--stuff=such'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').never() assert ( module.execute_dump_command( database={'name': 'foo'}, log_prefix='log', dump_path=flexmock(), database_names=('foo',), extra_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.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, {}, 'test.yaml', 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.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, {}, 'test.yaml', 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).should_receive('execute_command_with_processes').with_args( ('mysql', '--batch'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_runs_mysql_with_options(): hook_config = [{'name': 'foo', 'restore_options': '--harder'}] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ('mysql', '--batch', '--harder'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).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, extra_environment=None, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( ('mysql', '--batch', '--user', 'root'), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'trustsome1'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--batch', '--host', 'clihost', '--port', 'cliport', '--protocol', 'tcp', '--user', 'cliusername', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'clipassword'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, ) 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', 'restore_username': 'restoreuser', 'restore_password': 'restorepass', 'restore_hostname': 'restorehost', 'restore_port': 'restoreport', } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('execute_command_with_processes').with_args( ( 'mysql', '--batch', '--host', 'restorehost', '--port', 'restoreport', '--protocol', 'tcp', '--user', 'restoreuser', ), processes=[extract_process], output_log_level=logging.DEBUG, input_file=extract_process.stdout, extra_environment={'MYSQL_PWD': 'restorepass'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo'}] flexmock(module).should_receive('execute_command_with_processes').never() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) borgmatic-1.8.3/tests/unit/hooks/test_ntfy.py000066400000000000000000000163631450610336600213440ustar00rootroot00000000000000from enum import Enum from flexmock import flexmock import borgmatic.hooks.monitor from borgmatic.hooks 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.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_auth_hits_hosted_ntfy_on_fail(): hook_config = { 'topic': topic, 'username': 'testuser', 'password': 'fakepassword', } flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.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.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_auth_with_no_username_warning(): hook_config = {'topic': topic, 'password': 'fakepassword'} flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.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.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_auth_with_no_password_warning(): hook_config = {'topic': topic, 'username': 'testuser'} flexmock(module.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.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.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.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.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.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.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.requests).should_receive('post').with_args( f'{custom_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.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.requests).should_receive('post').never() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.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.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.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.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.monitor.State.START), auth=None, ).and_return(flexmock(ok=True)).once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.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.requests).should_receive('post').with_args( f'{default_base_url}/{topic}', headers=return_default_message_headers(borgmatic.hooks.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.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) def test_ping_monitor_with_other_error_logs_warning(): hook_config = {'topic': topic} 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.monitor.State.FAIL), auth=None, ).and_return(response) flexmock(module.logger).should_receive('warning').once() module.ping_monitor( hook_config, {}, 'config.yaml', borgmatic.hooks.monitor.State.FAIL, monitoring_log_level=1, dry_run=False, ) borgmatic-1.8.3/tests/unit/hooks/test_pagerduty.py000066400000000000000000000043771450610336600223720ustar00rootroot00000000000000from flexmock import flexmock from borgmatic.hooks import pagerduty as module def test_ping_monitor_ignores_start_state(): 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.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.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.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.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_other_error_logs_warning(): response = flexmock(ok=False) 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-1.8.3/tests/unit/hooks/test_postgresql.py000066400000000000000000001116231450610336600225620ustar00rootroot00000000000000import logging import pytest from flexmock import flexmock from borgmatic.hooks import postgresql as module def test_make_extra_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 = { 'PGPASSWORD': 'pass', 'PGSSLMODE': 'require', 'PGSSLCERT': 'cert.crt', 'PGSSLKEY': 'key.key', 'PGSSLROOTCERT': 'root.crt', 'PGSSLCRL': 'crl.crl', } extra_env = module.make_extra_environment(database) assert extra_env == expected def test_make_extra_environment_with_cli_password_sets_correct_password(): database = {'name': 'foo', 'restore_password': 'trustsome1', 'password': 'anotherpassword'} extra = module.make_extra_environment( database, restore_connection_params={'password': 'clipassword'} ) assert extra['PGPASSWORD'] == 'clipassword' def test_make_extra_environment_without_cli_password_or_configured_password_does_not_set_password(): database = {'name': 'foo'} extra = module.make_extra_environment( database, restore_connection_params={'username': 'someone'} ) assert 'PGPASSWORD' not in extra def test_database_names_to_dump_passes_through_individual_database_name(): database = {'name': 'foo'} assert module.database_names_to_dump(database, flexmock(), 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(), 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(), 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).should_receive('execute_command_and_capture_output').never() assert module.database_names_to_dump(database, flexmock(), flexmock(), dry_run=True) == () def test_database_names_to_dump_with_all_and_format_lists_databases(): database = {'name': 'all', 'format': 'custom'} 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(), 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).should_receive('execute_command_and_capture_output').with_args( ( 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--host', 'localhost', '--port', '1234', ), extra_environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, flexmock(), 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).should_receive('execute_command_and_capture_output').with_args( ( 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--username', 'postgres', ), extra_environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, flexmock(), 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).should_receive('execute_command_and_capture_output').with_args( ('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'), extra_environment=object, ).and_return('foo,test,\nbar,test,"stuff and such"') assert module.database_names_to_dump(database, flexmock(), 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).should_receive('execute_command_and_capture_output').and_return( 'foo,test,\ntemplate0,test,blah' ) assert module.database_names_to_dump(database, flexmock(), 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 mycontainer psql'} flexmock(module).should_receive('execute_command_and_capture_output').with_args( ( 'docker', 'exec', 'mycontainer', 'psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', ), extra_environment=object, ).and_return('foo,text').once() assert module.database_names_to_dump(database, flexmock(), flexmock(), dry_run=False) == ( 'foo', ) def test_dump_data_sources_runs_pg_dump_for_each_database(): databases = [{'name': 'foo'}, {'name': 'bar'}] processes = [flexmock(), flexmock()] flexmock(module).should_receive('make_extra_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.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, extra_environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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_extra_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, {}, 'test.yaml', 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_extra_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, {}, 'test.yaml', dry_run=True) == [] def test_dump_data_sources_with_duplicate_dump_skips_pg_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_extra_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, {}, 'test.yaml', dry_run=False) == [] def test_dump_data_sources_with_dry_run_skips_pg_dump(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module).should_receive('make_extra_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.dump).should_receive('create_named_pipe_for_dump').never() flexmock(module).should_receive('execute_command').never() assert module.dump_data_sources(databases, {}, 'test.yaml', 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_extra_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.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, extra_environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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_extra_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.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, extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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_extra_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.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, extra_environment={'PGSSLMODE': 'disable'}, ).and_return(flexmock()).once() assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [] def test_dump_data_sources_runs_pg_dump_with_options(): databases = [{'name': 'foo', 'options': '--stuff=such'}] process = flexmock() flexmock(module).should_receive('make_extra_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.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, extra_environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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_extra_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.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, extra_environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process] def test_dump_data_sources_runs_non_default_pg_dump(): databases = [{'name': 'foo', 'pg_dump_command': 'special_pg_dump'}] process = flexmock() flexmock(module).should_receive('make_extra_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.dump).should_receive('create_named_pipe_for_dump') flexmock(module).should_receive('execute_command').with_args( ( 'special_pg_dump', '--no-password', '--clean', '--if-exists', '--format', 'custom', 'foo', '>', 'databases/localhost/foo', ), shell=True, extra_environment={'PGSSLMODE': 'disable'}, run_to_completion=False, ).and_return(process).once() assert module.dump_data_sources(databases, {}, 'test.yaml', 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).should_receive('make_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('make_extra_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, extra_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', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('make_extra_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, extra_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', ), extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('make_extra_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, extra_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', ), extra_environment={'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': 'clihost', 'port': 'cliport', 'username': 'cliusername', 'password': 'clipassword', }, ) 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).should_receive('make_extra_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, extra_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', ), extra_environment={'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('make_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--smarter', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ('psql', '--no-password', '--no-psqlrc', '--quiet', '--command', 'ANALYZE'), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'all'}, dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) 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).should_receive('make_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_runs_non_default_pg_restore_and_psql(): hook_config = [ { 'name': 'foo', 'pg_restore_command': 'docker exec mycontainer pg_restore', 'psql_command': 'docker exec mycontainer psql', 'schemas': None, } ] extract_process = flexmock(stdout=flexmock()) flexmock(module).should_receive('make_extra_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', '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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'docker', 'exec', 'mycontainer', 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_with_dry_run_skips_restore(): hook_config = [{'name': 'foo', 'schemas': None}] flexmock(module).should_receive('make_extra_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, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=True, extract_process=flexmock(), connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_without_extract_process_restores_from_disk(): hook_config = [{'name': 'foo', 'schemas': None}] flexmock(module).should_receive('make_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source={'name': 'foo'}, dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) def test_restore_data_source_dump_with_schemas_restores_schemas(): hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}] flexmock(module).should_receive('make_extra_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, extra_environment={'PGSSLMODE': 'disable'}, ).once() flexmock(module).should_receive('execute_command').with_args( ( 'psql', '--no-password', '--no-psqlrc', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE', ), extra_environment={'PGSSLMODE': 'disable'}, ).once() module.restore_data_source_dump( hook_config, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=None, connection_params={ 'hostname': None, 'port': None, 'username': None, 'password': None, }, ) borgmatic-1.8.3/tests/unit/hooks/test_sqlite.py000066400000000000000000000155131450610336600216610ustar00rootroot00000000000000import logging from flexmock import flexmock from borgmatic.hooks import sqlite as module 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('/path/to/dump') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/path/to/dump/database' ) flexmock(module.os.path).should_receive('exists').and_return(True) flexmock(module.dump).should_receive('create_parent_directory_for_dump').never() flexmock(module).should_receive('execute_command').never() assert module.dump_data_sources(databases, {}, 'test.yaml', 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('/path/to/dump') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/path/to/dump/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_parent_directory_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]).and_return( processes[1] ) assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes def test_dumping_database_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('/path/to/dump') flexmock(module.logger).should_receive('warning').once() flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/path/to/dump/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_parent_directory_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]) assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes def test_dumping_database_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('/path/to/dump') 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( '/path/to/dump/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_parent_directory_for_dump') flexmock(module).should_receive('execute_command').and_return(processes[0]) assert module.dump_data_sources(databases, {}, 'test.yaml', 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('/path/to/dump') flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return( '/path/to/dump/database' ) flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.dump).should_receive('create_parent_directory_for_dump').never() flexmock(module).should_receive('execute_command').never() assert module.dump_data_sources(databases, {}, 'test.yaml', 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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={'restore_path': None}, ) 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, {}, 'test.yaml', data_source={'name': 'database'}, dry_run=False, extract_process=extract_process, connection_params={'restore_path': 'cli/path/to/database'}, ) 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, {}, 'test.yaml', data_source=hook_config[0], dry_run=False, extract_process=extract_process, connection_params={'restore_path': None}, ) 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, {}, 'test.yaml', data_source={'name': 'database'}, dry_run=True, extract_process=extract_process, connection_params={'restore_path': None}, ) borgmatic-1.8.3/tests/unit/test_execute.py000066400000000000000000000435661450610336600207100ustar00rootroot00000000000000import subprocess import pytest from flexmock import flexmock from borgmatic import execute as module @pytest.mark.parametrize( 'command,exit_code,borg_local_path,expected_result', ( (['grep'], 2, None, True), (['grep'], 2, 'borg', True), (['borg'], 2, 'borg', True), (['borg1'], 2, 'borg1', True), (['grep'], 1, None, True), (['grep'], 1, 'borg', True), (['borg'], 1, 'borg', False), (['borg1'], 1, 'borg1', False), (['grep'], 0, None, False), (['grep'], 0, 'borg', False), (['borg'], 0, 'borg', False), (['borg1'], 0, 'borg1', False), # -9 exit code occurs when child process get SIGKILLed. (['grep'], -9, None, True), (['grep'], -9, 'borg', True), (['borg'], -9, 'borg', True), (['borg1'], -9, 'borg1', True), (['borg'], None, None, False), ), ) def test_exit_code_indicates_error_respects_exit_code_and_borg_local_path( command, exit_code, borg_local_path, expected_result ): assert module.exit_code_indicates_error(command, exit_code, borg_local_path) 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_execute_command_calls_full_command(): full_command = ['foo', 'bar'] flexmock(module.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stderr=None)).once() 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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None ).and_return(flexmock(wait=lambda: 0)).once() flexmock(module).should_receive('exit_code_indicates_error').and_return(False) 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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_extra_environment(): full_command = ['foo', 'bar'] flexmock(module.os, environ={'a': 'b'}) 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', 'c': 'd'}, cwd=None, ).and_return(flexmock(stdout=None)).once() flexmock(module).should_receive('log_outputs') output = module.execute_command(full_command, extra_environment={'c': 'd'}) assert output is None def test_execute_command_calls_full_command_with_working_directory(): full_command = ['foo', 'bar'] flexmock(module.os, environ={'a': 'b'}) 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', ).and_return(flexmock(stdout=None)).once() 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.os, environ={'a': 'b'}) 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, ).and_return(process).once() 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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=None, shell=False, env=None, cwd=None ).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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None ).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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=None, shell=False, env=None, cwd=None ).and_raise(subprocess.CalledProcessError(1, full_command, err_output)).once() flexmock(module).should_receive('exit_code_indicates_error').and_return(False).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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=None, shell=False, env=None, cwd=None ).and_raise(subprocess.CalledProcessError(2, full_command, expected_output)).once() flexmock(module).should_receive('exit_code_indicates_error').and_return(True).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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( 'foo bar', stderr=None, shell=True, env=None, cwd=None ).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_extra_environment(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=None, shell=False, env={'a': 'b', 'c': 'd'}, cwd=None, ).and_return(flexmock(decode=lambda: expected_output)).once() output = module.execute_command_and_capture_output( full_command, shell=False, extra_environment={'c': 'd'} ) assert output == expected_output def test_execute_command_and_capture_output_returns_output_with_working_directory(): full_command = ['foo', 'bar'] expected_output = '[]' flexmock(module.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('check_output').with_args( full_command, stderr=None, shell=False, env=None, cwd='/working' ).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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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.os, environ={'a': 'b'}) 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, ).and_return(process).once() 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stderr=None)).once() 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.os, environ={'a': 'b'}) flexmock(module.subprocess).should_receive('Popen').with_args( full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None ).and_return(flexmock(wait=lambda: 0)).once() flexmock(module).should_receive('exit_code_indicates_error').and_return(False) 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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.os, environ={'a': 'b'}) 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, ).and_return(flexmock(stdout=None)).once() 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_extra_environment(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module.os, environ={'a': 'b'}) 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', 'c': 'd'}, cwd=None, ).and_return(flexmock(stdout=None)).once() flexmock(module).should_receive('log_outputs') output = module.execute_command_with_processes( full_command, processes, extra_environment={'c': 'd'} ) assert output is None def test_execute_command_with_processes_calls_full_command_with_working_directory(): full_command = ['foo', 'bar'] processes = (flexmock(),) flexmock(module.os, environ={'a': 'b'}) 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', ).and_return(flexmock(stdout=None)).once() 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'] process = flexmock(stdout=flexmock(read=lambda count: None)) process.should_receive('poll') process.should_receive('kill').once() processes = (process,) flexmock(module.os, environ={'a': 'b'}) 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, ).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once() flexmock(module).should_receive('log_outputs').never() with pytest.raises(subprocess.CalledProcessError): module.execute_command_with_processes(full_command, processes) borgmatic-1.8.3/tests/unit/test_logger.py000066400000000000000000000377031450610336600205210ustar00rootroot00000000000000import 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(): assert module.should_do_markup(no_color=True, configs={}) is False def test_should_do_markup_respects_config_value(): assert ( module.should_do_markup(no_color=False, configs={'foo.yaml': {'output': {'color': False}}}) is False ) def test_should_do_markup_prefers_any_false_config_value(): assert ( module.should_do_markup( no_color=False, configs={ 'foo.yaml': {'output': {'color': True}}, 'bar.yaml': {'output': {'color': False}}, }, ) is False ) def test_should_do_markup_respects_PY_COLORS_environment_variable(): flexmock(module.os.environ).should_receive('get').and_return('True') 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(): assert ( module.should_do_markup(no_color=True, configs={'foo.yaml': {'output': {'color': True}}}) is False ) def test_should_do_markup_prefers_config_value_to_PY_COLORS(): flexmock(module.os.environ).should_receive('get').and_return('True') flexmock(module).should_receive('to_bool').and_return(True) assert ( module.should_do_markup(no_color=False, configs={'foo.yaml': {'output': {'color': False}}}) is False ) def test_should_do_markup_prefers_no_color_value_to_PY_COLORS(): flexmock(module.os.environ).should_receive('get').and_return('True') flexmock(module).should_receive('to_bool').and_return(True) 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').and_return('True') flexmock(module).should_receive('to_bool').and_return(True) flexmock(module).should_receive('interactive_console').and_return(False) assert module.should_do_markup(no_color=False, configs={}) is True 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' record = flexmock(levelno=logging.CRITICAL, msg=plain_message) 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(): module.color_text(module.colorama.Fore.RED, 'hi') def test_color_text_without_color_does_not_raise(): 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_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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('Console_color_formatter') flexmock(module).should_receive('interactive_console').and_return(False) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('Console_color_formatter') flexmock(module).should_receive('interactive_console').and_return(False) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('Console_color_formatter') flexmock(module).should_receive('interactive_console').and_return(False) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('Console_color_formatter') 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('Console_color_formatter') 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) 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.logging).should_receive('Formatter').with_args( '{message}', style='{' # noqa: FS003 ).once() flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) flexmock(module).should_receive('interactive_console').and_return(False) 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 flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock( setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO ) ) 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) borgmatic-1.8.3/tests/unit/test_signals.py000066400000000000000000000034741450610336600207000ustar00rootroot00000000000000import 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'))) flexmock(module.os).should_receive('getpgrp').and_return(flexmock) flexmock(module.os).should_receive('killpg') 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-1.8.3/tests/unit/test_verbosity.py000066400000000000000000000026221450610336600212600ustar00rootroot00000000000000import 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-1.8.3/tox.ini000066400000000000000000000015061450610336600150130ustar00rootroot00000000000000[tox] envlist = py37,py38,py39,py310,py311 skip_missing_interpreters = True skipsdist = True minversion = 3.14.1 [testenv] usedevelop = True deps = -rtest_requirements.txt whitelist_externals = find sh passenv = COVERAGE_FILE commands = pytest {posargs} py38,py39,py310,py311: black --check . isort --check-only --settings-path setup.cfg . flake8 borgmatic tests codespell [testenv:black] commands = black {posargs} . [testenv:test] commands = pytest {posargs} [testenv:end-to-end] usedevelop = False deps = -rtest_requirements.txt . passenv = COVERAGE_FILE commands = pytest {posargs} --no-cov tests/end-to-end [testenv:isort] deps = {[testenv]deps} commands = isort --settings-path setup.cfg . [testenv:codespell] deps = {[testenv]deps} commands = codespell --write-changes