├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── docs └── example-configs │ ├── basic-last.yaml │ ├── basic-monthly.yaml │ ├── basic-yearly.yaml │ ├── cascading.yaml │ └── custom-snapshot-prefix.yaml ├── flake.lock ├── flake.nix ├── release.sh ├── rust-toolchain ├── src ├── commands.rs ├── commands │ ├── backup.rs │ ├── backup_and_prune.rs │ ├── debug_list_instances.rs │ ├── debug_nuke.rs │ ├── prune.rs │ ├── prune │ │ ├── find_snapshots.rs │ │ └── find_snapshots_to_keep.rs │ ├── validate.rs │ └── validate │ │ └── tests │ │ └── missing_lxc_path │ │ └── config.yaml ├── config.rs ├── config │ ├── hooks.rs │ ├── policies.rs │ ├── policy.rs │ └── remotes.rs ├── environment.rs ├── lxd.rs ├── lxd │ ├── clients.rs │ ├── clients │ │ ├── fake.rs │ │ ├── process.rs │ │ └── process │ │ │ └── tests │ │ │ ├── lxc-non-zero-exit-code.sh │ │ │ └── lxc-timeout.sh │ ├── error.rs │ ├── models.rs │ └── models │ │ ├── instance.rs │ │ ├── instance_name.rs │ │ ├── instance_status.rs │ │ ├── project.rs │ │ ├── project_name.rs │ │ ├── remote_name.rs │ │ ├── serde.rs │ │ ├── snapshot.rs │ │ └── snapshot_name.rs ├── main.rs ├── testing.rs ├── utils.rs └── utils │ ├── pretty_lxd_instance_name.rs │ └── summary.rs ├── tests.nix └── tests ├── README.md ├── _fixtures └── lxd-config.yaml ├── backup-and-prune-with-projects ├── config.yaml ├── test.nix └── test.py ├── backup-and-prune-with-remotes ├── config.yaml ├── expected.out.1.txt ├── expected.out.2.txt ├── test.nix └── test.py ├── backup-and-prune ├── config.yaml ├── expected.out.1.txt ├── expected.out.2.txt ├── test.nix └── test.py ├── dry-run ├── config.yaml ├── expected.out.1.txt ├── expected.out.2.txt ├── expected.out.3.txt ├── expected.out.4.txt ├── test.nix └── test.py ├── hooks ├── config.yaml ├── expected.log.txt ├── expected.out.1.txt ├── expected.out.2.txt ├── expected.out.3.txt ├── expected.out.4.txt ├── test.nix └── test.py ├── prelude.py.nix └── timeout ├── config.yaml ├── expected.out.txt ├── test.nix └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.3.1 2 | 3 | - New configuration option: `lxc-timeout`. 4 | - Fixed a typo where lxd-snapper would say `Error::` instead of just `Error:`. 5 | 6 | # 1.3.0 7 | 8 | - lxd-snapper now supports LXD remotes! 9 | - New hooks: `on_instance_backed_up`, `on_instance_pruned`. 10 | - Revamped output to be more user-friendly. 11 | - Required rustc version is now 1.63.0. 12 | - Dropped support for i686-linux. 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lxd-snapper" 3 | version = "1.4.0" 4 | license = "MIT" 5 | authors = ["Patryk Wychowaniec "] 6 | edition = "2021" 7 | 8 | [dependencies] 9 | anyhow = "1.0" 10 | chrono = { version = "0.4", features = ["serde"] } 11 | clap = { version = "4.0", features = ["derive"] } 12 | colored = "2.1" 13 | humantime = "2.1" 14 | indexmap = { version = "2.3", features = ["serde"] } 15 | itertools = "0.13" 16 | pathsearch = "0.2" 17 | prettytable-rs = "0.10" 18 | serde = { version = "1.0", features = ["derive"] } 19 | serde_json = "1.0" 20 | serde_with = "3.9" 21 | serde_yaml = "0.9" 22 | thiserror = "1.0" 23 | 24 | [dev-dependencies] 25 | ansi-parser = "0.8" 26 | glob = "0.3" 27 | indoc = "1.0" 28 | pretty_assertions = "1.3" 29 | test-case = "2.0" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Patryk Wychowaniec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/example-configs/basic-last.yaml: -------------------------------------------------------------------------------- 1 | # This is the most simple configuration - it will keep the five latest 2 | # snapshots for each instance. 3 | # 4 | # For instance: 5 | # 6 | # - if you run `./lxd-snapper backup-and-prune` once every hour, with this 7 | # configuration you'll have snapshots spanning for the past five hours. 8 | # 9 | # - if you run `./lxd-snapper backup-and-prune` once every day, with this 10 | # configuration you'll have snapshots spanning for the past five days. 11 | # 12 | # If you seek more elaborate configurations (e.g. to keep one snapshot for each 13 | # day and one for each month), please take a look into other `basic-*` examples 14 | # in this directory. 15 | 16 | policies: 17 | every-instance: 18 | keep-last: 5 -------------------------------------------------------------------------------- /docs/example-configs/basic-monthly.yaml: -------------------------------------------------------------------------------- 1 | # This is a simple configuration that backs-up all the instances, keeping a 2 | # maximum of one snapshot per end-of-day (for five most recent days) and one 3 | # snapshot per end-of-month (for two consecutive months). 4 | # 5 | # Overall, it will keep a maximum of 5+2=7 snapshots for each instance, with 6 | # the oldest snapshot being the one from two/three months ago. 7 | # 8 | # Assuming that today is 2015-06-01 and you've been doing snapshots daily, 9 | # using this configuration you would end up with snapshots for the following 10 | # days (for each instance): 11 | # 12 | # - 2015-06-01 (thanks to keep-daily) 13 | # - 2015-05-31 (thanks to keep-daily) 14 | # - 2015-05-30 (thanks to keep-daily) 15 | # - 2015-05-29 (thanks to keep-daily) 16 | # - 2015-05-28 (thanks to keep-daily) 17 | # - 2015-04-30 (thanks to keep-monthly) 18 | # - 2015-03-31 (thanks to keep-monthly) 19 | 20 | policies: 21 | every-instance: 22 | keep-daily: 5 23 | keep-monthly: 2 24 | -------------------------------------------------------------------------------- /docs/example-configs/basic-yearly.yaml: -------------------------------------------------------------------------------- 1 | # This is a bit more elaborate variant of `basic-monthly`. 2 | # 3 | # This configuration will keep a maximum of fourteen snapshots per end-of-day, 4 | # six snapshots per end-of-month, and two snapshots per end-of-year. 5 | # 6 | # Overall, it will keep a maximum of 14+6+2=22 snapshots for each instance, 7 | # with the oldest snapshot being the one from two/three years ago. 8 | # 9 | # Assuming that today is 2015-06-01 and you've been doing snapshots daily, 10 | # using this configuration you would end up with snapshots for the following 11 | # days (for each instance): 12 | # 13 | # - 2015-06-01 (thanks to keep-daily) 14 | # - 2015-05-31 (thanks to keep-daily) 15 | # - 2015-05-30 (thanks to keep-daily) 16 | # - 2015-05-29 (thanks to keep-daily) 17 | # - 2015-05-28 (thanks to keep-daily) 18 | # - 2015-05-27 (thanks to keep-daily) 19 | # - 2015-05-26 (thanks to keep-daily) 20 | # - 2015-05-25 (thanks to keep-daily) 21 | # - 2015-05-24 (thanks to keep-daily) 22 | # - 2015-05-23 (thanks to keep-daily) 23 | # - 2015-05-22 (thanks to keep-daily) 24 | # - 2015-05-21 (thanks to keep-daily) 25 | # - 2015-05-20 (thanks to keep-daily) 26 | # - 2015-05-19 (thanks to keep-daily) 27 | # - 2015-04-30 (thanks to keep-monthly) 28 | # - 2015-03-31 (thanks to keep-monthly) 29 | # - 2015-02-28 (thanks to keep-monthly) 30 | # - 2015-01-31 (thanks to keep-monthly) 31 | # - 2014-12-31 (thanks to keep-monthly) 32 | # - 2014-11-30 (thanks to keep-monthly) 33 | # - 2013-12-31 (thanks to keep-yearly) 34 | # - 2012-12-31 (thanks to keep-yearly) 35 | 36 | policies: 37 | every-instance: 38 | keep-daily: 14 39 | keep-monthly: 6 40 | keep-yearly: 2 41 | -------------------------------------------------------------------------------- /docs/example-configs/cascading.yaml: -------------------------------------------------------------------------------- 1 | # This configuration presents the feature of cascading policies. 2 | # 3 | # In order to explain it, first let's imagine we have following instances set 4 | # up: 5 | # 6 | # Project | Instance 7 | # -------- | -------- 8 | # default | nginx 9 | # client-a | php 10 | # client-a | mysql 11 | # client-c | php 12 | # client-c | mysql 13 | # 14 | # Now - we'd like for every instance to have at least two snapshots (just for 15 | # some quick accident recovery), with the exception of: 16 | # 17 | # - important clients, for which we'd like to keep latest 15 snapshots, 18 | # 19 | # - unimportant clients, for which we'd like to keep latest 5 snapshots, 20 | # 21 | # - databases (doesn't matter for which client!), for which we'd like to keep 22 | # the latest 25 snapshots. 23 | # 24 | # That's what cascading policies are for - when an instance matches many 25 | # policies (e.g. like the `mysql` instance inside each of our sample projects 26 | # does), what happens is that lxd-snapper _squashes_ all of the matching 27 | # policies, overwriting duplicated properties. 28 | # 29 | # This allows you to reduce the amount of duplicated rules, because you can 30 | # just extract the "core" policies to the top of the file and overwrite them 31 | # for some selected instances below. 32 | # 33 | # For example, in case of the `client-a/mysql` instance, lxd-snapper first 34 | # finds the `important-clients` policy, but then it notices that the 35 | # `databases` policy matches `mysql` too; the `keep-last` from `databases` 36 | # takes over the priority (because it's _below_ `important-clients`) and voilà. 37 | # 38 | # The README.md provides a bit more interesting example with similar use-case 39 | # in mind; you'll find this feature the most useful when dealing with instances 40 | # scattered among different projects. 41 | 42 | policies: 43 | everyone: 44 | keep-last: 2 45 | 46 | important-clients: 47 | included-projects: ['client-a', 'client-b'] 48 | keep-last: 15 49 | 50 | unimportant-clients: 51 | included-projects: ['client-c'] 52 | keep-last: 5 53 | 54 | databases: 55 | included-instances: ['mysql'] 56 | keep-last: 25 57 | -------------------------------------------------------------------------------- /docs/example-configs/custom-snapshot-prefix.yaml: -------------------------------------------------------------------------------- 1 | # To distinguish between manually-created & automatically-created snapshots, 2 | # lxd-snapper prefixes each snapshot with `snapshot-name-prefix` (which, by 3 | # default, is "auto-"). 4 | # 5 | # If you want, you might change this prefix; currently there's no way to change 6 | # rest of the formatting string though - the snapshots are always named in the 7 | # `prefix-yyyymmdd-hhmmss` fashion. 8 | 9 | snapshot-name-prefix: 'magic-' 10 | 11 | policies: 12 | every-instance: 13 | keep-last: 5 14 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "naersk": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs" 6 | }, 7 | "locked": { 8 | "lastModified": 1721727458, 9 | "narHash": "sha256-r/xppY958gmZ4oTfLiHN0ZGuQ+RSTijDblVgVLFi1mw=", 10 | "owner": "nix-community", 11 | "repo": "naersk", 12 | "rev": "3fb418eaf352498f6b6c30592e3beb63df42ef11", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "nix-community", 17 | "repo": "naersk", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1720768451, 24 | "narHash": "sha256-EYekUHJE2gxeo2pM/zM9Wlqw1Uw2XTJXOSAO79ksc4Y=", 25 | "path": "/nix/store/qmh8bas1qni03drm0lnjas2azh7h87cn-source", 26 | "rev": "7e7c39ea35c5cdd002cd4588b03a3fb9ece6fad9", 27 | "type": "path" 28 | }, 29 | "original": { 30 | "id": "nixpkgs", 31 | "type": "indirect" 32 | } 33 | }, 34 | "nixpkgs--lxd-4": { 35 | "locked": { 36 | "lastModified": 1650308445, 37 | "narHash": "sha256-3muuhz3fjtF1bz32UXOYCho51E8JSeEwo2iDZFQJdXo=", 38 | "owner": "nixos", 39 | "repo": "nixpkgs", 40 | "rev": "d1c3fea7ecbed758168787fe4e4a3157e52bc808", 41 | "type": "github" 42 | }, 43 | "original": { 44 | "owner": "nixos", 45 | "repo": "nixpkgs", 46 | "rev": "d1c3fea7ecbed758168787fe4e4a3157e52bc808", 47 | "type": "github" 48 | } 49 | }, 50 | "nixpkgs--lxd-5": { 51 | "locked": { 52 | "lastModified": 1662073868, 53 | "narHash": "sha256-R18MixER2iwduNqOlLzXUms0Z7G3emnKZOKyQS52SSA=", 54 | "owner": "nixos", 55 | "repo": "nixpkgs", 56 | "rev": "ee01de29d2f58d56b1be4ae24c24bd91c5380cea", 57 | "type": "github" 58 | }, 59 | "original": { 60 | "owner": "nixos", 61 | "repo": "nixpkgs", 62 | "rev": "ee01de29d2f58d56b1be4ae24c24bd91c5380cea", 63 | "type": "github" 64 | } 65 | }, 66 | "nixpkgs--lxd-6": { 67 | "locked": { 68 | "lastModified": 1719892255, 69 | "narHash": "sha256-17pWrBH3DIdOaUjxAFNIbHPzNz7ZVogzFBxi08evDMw=", 70 | "owner": "nixos", 71 | "repo": "nixpkgs", 72 | "rev": "4802ed07225c42ec290c86800ccf668807763567", 73 | "type": "github" 74 | }, 75 | "original": { 76 | "owner": "nixos", 77 | "repo": "nixpkgs", 78 | "rev": "4802ed07225c42ec290c86800ccf668807763567", 79 | "type": "github" 80 | } 81 | }, 82 | "nixpkgs_2": { 83 | "locked": { 84 | "lastModified": 1722813957, 85 | "narHash": "sha256-IAoYyYnED7P8zrBFMnmp7ydaJfwTnwcnqxUElC1I26Y=", 86 | "owner": "nixos", 87 | "repo": "nixpkgs", 88 | "rev": "cb9a96f23c491c081b38eab96d22fa958043c9fa", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "nixos", 93 | "ref": "nixos-unstable", 94 | "repo": "nixpkgs", 95 | "type": "github" 96 | } 97 | }, 98 | "nixpkgs_3": { 99 | "locked": { 100 | "lastModified": 1718428119, 101 | "narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", 102 | "owner": "NixOS", 103 | "repo": "nixpkgs", 104 | "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "NixOS", 109 | "ref": "nixpkgs-unstable", 110 | "repo": "nixpkgs", 111 | "type": "github" 112 | } 113 | }, 114 | "root": { 115 | "inputs": { 116 | "naersk": "naersk", 117 | "nixpkgs": "nixpkgs_2", 118 | "nixpkgs--lxd-4": "nixpkgs--lxd-4", 119 | "nixpkgs--lxd-5": "nixpkgs--lxd-5", 120 | "nixpkgs--lxd-6": "nixpkgs--lxd-6", 121 | "rust-overlay": "rust-overlay" 122 | } 123 | }, 124 | "rust-overlay": { 125 | "inputs": { 126 | "nixpkgs": "nixpkgs_3" 127 | }, 128 | "locked": { 129 | "lastModified": 1722910815, 130 | "narHash": "sha256-v6Vk/xlABhw2QzOa6xh3Jx/IvmlbKbOazFM+bDFQlWU=", 131 | "owner": "oxalica", 132 | "repo": "rust-overlay", 133 | "rev": "7df2ac544c203d21b63aac23bfaec7f9b919a733", 134 | "type": "github" 135 | }, 136 | "original": { 137 | "owner": "oxalica", 138 | "repo": "rust-overlay", 139 | "type": "github" 140 | } 141 | } 142 | }, 143 | "root": "root", 144 | "version": 7 145 | } 146 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "lxd-snapper: LXD snapshots, automated"; 3 | 4 | inputs = { 5 | naersk = { 6 | url = "github:nix-community/naersk"; 7 | }; 8 | 9 | nixpkgs = { 10 | url = "github:nixos/nixpkgs/nixos-unstable"; 11 | }; 12 | 13 | # nixpkgs containing LXD 4, used for testing purposes 14 | nixpkgs--lxd-4 = { 15 | url = "github:nixos/nixpkgs/d1c3fea7ecbed758168787fe4e4a3157e52bc808"; 16 | }; 17 | 18 | # nixpkgs containing LXD 5, used for testing purposes 19 | nixpkgs--lxd-5 = { 20 | url = "github:nixos/nixpkgs/ee01de29d2f58d56b1be4ae24c24bd91c5380cea"; 21 | }; 22 | 23 | # nixpkgs containing LXD 6, used for testing purposes 24 | nixpkgs--lxd-6 = { 25 | url = "github:nixos/nixpkgs/4802ed07225c42ec290c86800ccf668807763567"; 26 | }; 27 | 28 | rust-overlay = { 29 | url = "github:oxalica/rust-overlay"; 30 | }; 31 | }; 32 | 33 | outputs = 34 | { self 35 | , naersk 36 | , nixpkgs 37 | , nixpkgs--lxd-4 38 | , nixpkgs--lxd-5 39 | , nixpkgs--lxd-6 40 | , rust-overlay 41 | }: 42 | let 43 | mkPackage = { system, target, RUSTFLAGS }: 44 | let 45 | pkgs = import nixpkgs { 46 | inherit system; 47 | 48 | overlays = [ 49 | rust-overlay.overlays.default 50 | ]; 51 | }; 52 | 53 | rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override { 54 | targets = [ target ]; 55 | }; 56 | 57 | naersk' = pkgs.callPackage naersk { 58 | cargo = rust; 59 | rustc = rust; 60 | }; 61 | 62 | # Generates a new derivation without the ./tests directory; allows to 63 | # save a lot of time on incremental `nix flake check`-s, as otherwise 64 | # any change to any end-to-end test would force lxd-snapper to be 65 | # rebuilt from scratch. 66 | src = pkgs.runCommand "src" { } '' 67 | mkdir $out 68 | ln -s "${./Cargo.lock}" $out/Cargo.lock 69 | ln -s "${./Cargo.toml}" $out/Cargo.toml 70 | ln -s "${./docs}" $out/docs 71 | ln -s "${./src}" $out/src 72 | ''; 73 | 74 | in 75 | naersk'.buildPackage { 76 | inherit src RUSTFLAGS; 77 | 78 | doCheck = true; 79 | CARGO_BUILD_TARGET = target; 80 | }; 81 | 82 | mkCheck = { system }: 83 | import ./tests.nix { 84 | inherit 85 | nixpkgs 86 | nixpkgs--lxd-4 87 | nixpkgs--lxd-5 88 | nixpkgs--lxd-6; 89 | 90 | lxd-snapper = self.packages."${system}".default; 91 | }; 92 | 93 | in 94 | { 95 | checks = { 96 | x86_64-linux = mkCheck { 97 | system = "x86_64-linux"; 98 | }; 99 | }; 100 | 101 | packages = { 102 | x86_64-linux = { 103 | default = mkPackage { 104 | system = "x86_64-linux"; 105 | target = "x86_64-unknown-linux-musl"; 106 | RUSTFLAGS = "-C relocation-model=dynamic-no-pic"; 107 | }; 108 | }; 109 | }; 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | function build { 6 | local target="${1}" 7 | local name="lxd-snapper-${2}" 8 | 9 | echo "Building ${target}" 10 | nix build ".#packages.${target}.default" 11 | cp ./result/bin/lxd-snapper "${name}" 12 | rm result 13 | 14 | echo "Signing ${target}" 15 | gpg --output "${name}.sig" --detach-sig "${name}" 16 | } 17 | 18 | build "x86_64-linux" "linux64" 19 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.78.0 2 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | mod backup; 2 | mod backup_and_prune; 3 | mod debug_list_instances; 4 | mod debug_nuke; 5 | mod prune; 6 | mod validate; 7 | 8 | pub use self::{ 9 | backup::*, backup_and_prune::*, debug_list_instances::*, debug_nuke::*, prune::*, validate::*, 10 | }; 11 | -------------------------------------------------------------------------------- /src/commands/backup.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub struct Backup<'a, 'b> { 4 | env: &'a mut Environment<'b>, 5 | summary: Summary, 6 | } 7 | 8 | impl<'a, 'b> Backup<'a, 'b> { 9 | pub fn new(env: &'a mut Environment<'b>) -> Self { 10 | Self { 11 | env, 12 | summary: Summary::default().with_created_snapshots(), 13 | } 14 | } 15 | 16 | pub fn with_summary_title(mut self, title: &'static str) -> Self { 17 | self.summary.set_title(title); 18 | self 19 | } 20 | 21 | pub fn run(mut self) -> Result<()> { 22 | self.env.hooks().on_backup_started()?; 23 | 24 | let cmd_result = self.try_run(); 25 | let hook_result = self.env.hooks().on_backup_completed(); 26 | 27 | cmd_result.and(hook_result) 28 | } 29 | 30 | fn try_run(&mut self) -> Result<()> { 31 | if self.env.config.remotes().has_any_non_local_remotes() { 32 | for remote in self.env.config.remotes().iter() { 33 | self.process_remote(true, remote) 34 | .with_context(|| format!("Couldn't process remote: {}", remote))?; 35 | } 36 | } else { 37 | self.process_remote(false, &LxdRemoteName::local())?; 38 | } 39 | 40 | write!(self.env.stdout, "{}", self.summary)?; 41 | 42 | if self.summary.has_errors() { 43 | bail!("Failed to backup some of the instances"); 44 | } 45 | 46 | self.summary.as_result() 47 | } 48 | 49 | fn process_remote(&mut self, print_remote: bool, remote: &LxdRemoteName) -> Result<()> { 50 | let projects = self 51 | .env 52 | .lxd 53 | .projects(remote) 54 | .context("Couldn't list projects")?; 55 | 56 | let print_project = projects.iter().any(|project| !project.name.is_default()); 57 | 58 | for project in projects { 59 | self.process_project(print_remote, remote, print_project, &project) 60 | .with_context(|| format!("Couldn't process project: {}", project.name))?; 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | fn process_project( 67 | &mut self, 68 | print_remote: bool, 69 | remote: &LxdRemoteName, 70 | print_project: bool, 71 | project: &LxdProject, 72 | ) -> Result<()> { 73 | let instances = self 74 | .env 75 | .lxd 76 | .instances(remote, &project.name) 77 | .context("Couldn't list instances")?; 78 | 79 | for instance in instances { 80 | self.process_instance(print_remote, remote, print_project, project, &instance) 81 | .with_context(|| format!("Couldn't process instance: {}", instance.name))?; 82 | } 83 | 84 | Ok(()) 85 | } 86 | 87 | fn process_instance( 88 | &mut self, 89 | print_remote: bool, 90 | remote: &LxdRemoteName, 91 | print_project: bool, 92 | project: &LxdProject, 93 | instance: &LxdInstance, 94 | ) -> Result<()> { 95 | writeln!( 96 | self.env.stdout, 97 | "{}", 98 | PrettyLxdInstanceName::new( 99 | print_remote, 100 | remote, 101 | print_project, 102 | &project.name, 103 | &instance.name 104 | ) 105 | .to_string() 106 | .bold() 107 | )?; 108 | 109 | if self 110 | .env 111 | .config 112 | .policies() 113 | .matches(remote, project, instance) 114 | { 115 | match self.try_process_instance(remote, project, instance) { 116 | Ok(_) => { 117 | self.summary.add_created_snapshot(); 118 | 119 | writeln!(self.env.stdout, " {}", "[ OK ]".green())?; 120 | } 121 | 122 | Err(err) => { 123 | self.summary.add_error(); 124 | 125 | writeln!(self.env.stdout, " {}", "[ FAILED ]".red())?; 126 | writeln!(self.env.stdout)?; 127 | 128 | let err = format!("{:?}", err); 129 | 130 | for line in err.lines() { 131 | writeln!(self.env.stdout, " {}", line)?; 132 | } 133 | } 134 | } 135 | } else { 136 | writeln!(self.env.stdout, " - {}", "[ EXCLUDED ]".yellow())?; 137 | } 138 | 139 | writeln!(self.env.stdout)?; 140 | 141 | Ok(()) 142 | } 143 | 144 | fn try_process_instance( 145 | &mut self, 146 | remote: &LxdRemoteName, 147 | project: &LxdProject, 148 | instance: &LxdInstance, 149 | ) -> Result { 150 | self.summary.add_processed_instance(); 151 | 152 | let snapshot_name = self.env.config.snapshot_name(self.env.time()); 153 | 154 | write!( 155 | self.env.stdout, 156 | " - creating snapshot: {}", 157 | snapshot_name.as_str().italic() 158 | )?; 159 | 160 | self.env 161 | .lxd 162 | .create_snapshot(remote, &project.name, &instance.name, &snapshot_name) 163 | .context("Couldn't create snapshot")?; 164 | 165 | self.env.hooks().on_snapshot_created( 166 | remote, 167 | &project.name, 168 | &instance.name, 169 | &snapshot_name, 170 | )?; 171 | 172 | self.env 173 | .hooks() 174 | .on_instance_backed_up(remote, &project.name, &instance.name)?; 175 | 176 | Ok(snapshot_name) 177 | } 178 | } 179 | 180 | #[cfg(test)] 181 | mod tests { 182 | use super::*; 183 | use crate::lxd::{utils::*, LxdFakeClient, LxdInstanceStatus}; 184 | use crate::{assert_lxd, assert_result, assert_stdout}; 185 | 186 | #[test] 187 | fn smoke() { 188 | let mut stdout = Vec::new(); 189 | 190 | let config = Config::parse(indoc!( 191 | r#" 192 | policies: 193 | all: 194 | excluded-instances: ['mariadb'] 195 | included-statuses: ['Running'] 196 | "# 197 | )); 198 | 199 | let mut lxd = LxdFakeClient::default(); 200 | 201 | lxd.add(LxdFakeInstance { 202 | name: "elastic", 203 | ..Default::default() 204 | }); 205 | 206 | lxd.add(LxdFakeInstance { 207 | name: "mariadb", 208 | snapshots: vec![snapshot("snapshot-1", "2000-01-01 12:00:00")], 209 | ..Default::default() 210 | }); 211 | 212 | lxd.add(LxdFakeInstance { 213 | name: "mongodb", 214 | status: LxdInstanceStatus::Stopped, 215 | ..Default::default() 216 | }); 217 | 218 | lxd.add(LxdFakeInstance { 219 | name: "postgresql", 220 | snapshots: vec![snapshot("snapshot-1", "2000-01-01 12:00:00")], 221 | ..Default::default() 222 | }); 223 | 224 | Backup::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 225 | .run() 226 | .unwrap(); 227 | 228 | assert_stdout!( 229 | r#" 230 | elastic 231 | - creating snapshot: auto-19700101-000000 [ OK ] 232 | 233 | mariadb 234 | - [ EXCLUDED ] 235 | 236 | mongodb 237 | - [ EXCLUDED ] 238 | 239 | postgresql 240 | - creating snapshot: auto-19700101-000000 [ OK ] 241 | 242 | Summary 243 | ------- 244 | processed instances: 2 245 | created snapshots: 2 246 | "#, 247 | stdout 248 | ); 249 | 250 | assert_lxd!( 251 | r#" 252 | local:default/elastic (Running) 253 | -> auto-19700101-000000 254 | 255 | local:default/mariadb (Running) 256 | -> snapshot-1 257 | 258 | local:default/mongodb (Stopped) 259 | 260 | local:default/postgresql (Running) 261 | -> snapshot-1 262 | -> auto-19700101-000000 263 | "#, 264 | lxd 265 | ); 266 | } 267 | 268 | #[test] 269 | fn smoke_with_remotes() { 270 | let mut stdout = Vec::new(); 271 | 272 | let config = Config::parse(indoc!( 273 | r#" 274 | policies: 275 | all: 276 | excluded-remotes: ['db-3'] 277 | included-statuses: ['Running'] 278 | 279 | remotes: 280 | - local 281 | - db-1 282 | - db-2 283 | - db-3 284 | - db-4 285 | "# 286 | )); 287 | 288 | let mut lxd = LxdFakeClient::default(); 289 | 290 | lxd.add(LxdFakeInstance { 291 | remote: "db-1", 292 | name: "postgresql", 293 | ..Default::default() 294 | }); 295 | 296 | lxd.add(LxdFakeInstance { 297 | remote: "db-2", 298 | name: "postgresql", 299 | ..Default::default() 300 | }); 301 | 302 | lxd.add(LxdFakeInstance { 303 | remote: "db-3", 304 | name: "postgresql", 305 | ..Default::default() 306 | }); 307 | 308 | lxd.add(LxdFakeInstance { 309 | remote: "db-4", 310 | name: "postgresql", 311 | status: LxdInstanceStatus::Stopping, 312 | ..Default::default() 313 | }); 314 | 315 | Backup::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 316 | .run() 317 | .unwrap(); 318 | 319 | assert_stdout!( 320 | r#" 321 | db-1:postgresql 322 | - creating snapshot: auto-19700101-000000 [ OK ] 323 | 324 | db-2:postgresql 325 | - creating snapshot: auto-19700101-000000 [ OK ] 326 | 327 | db-3:postgresql 328 | - [ EXCLUDED ] 329 | 330 | db-4:postgresql 331 | - [ EXCLUDED ] 332 | 333 | Summary 334 | ------- 335 | processed instances: 2 336 | created snapshots: 2 337 | "#, 338 | stdout 339 | ); 340 | 341 | assert_lxd!( 342 | r#" 343 | db-1:default/postgresql (Running) 344 | -> auto-19700101-000000 345 | 346 | db-2:default/postgresql (Running) 347 | -> auto-19700101-000000 348 | 349 | db-3:default/postgresql (Running) 350 | 351 | db-4:default/postgresql (Stopping) 352 | "#, 353 | lxd 354 | ); 355 | } 356 | 357 | #[test] 358 | fn failed_snapshot() { 359 | let mut stdout = Vec::new(); 360 | 361 | let config = Config::parse(indoc!( 362 | r#" 363 | policies: 364 | all: 365 | "# 366 | )); 367 | 368 | let mut lxd = LxdFakeClient::default(); 369 | 370 | lxd.add(LxdFakeInstance { 371 | name: "elastic", 372 | ..Default::default() 373 | }); 374 | 375 | lxd.add(LxdFakeInstance { 376 | name: "mariadb", 377 | ..Default::default() 378 | }); 379 | 380 | lxd.add(LxdFakeInstance { 381 | name: "postgresql", 382 | ..Default::default() 383 | }); 384 | 385 | lxd.inject_error(LxdFakeError::OnCreateSnapshot { 386 | remote: "local", 387 | project: "default", 388 | instance: "mariadb", 389 | snapshot: "auto-19700101-000000", 390 | }); 391 | 392 | let result = Backup::new(&mut Environment::test(&mut stdout, &config, &mut lxd)).run(); 393 | 394 | assert_stdout!( 395 | r#" 396 | elastic 397 | - creating snapshot: auto-19700101-000000 [ OK ] 398 | 399 | mariadb 400 | - creating snapshot: auto-19700101-000000 [ FAILED ] 401 | 402 | Couldn't create snapshot 403 | 404 | Caused by: 405 | InjectedError 406 | 407 | postgresql 408 | - creating snapshot: auto-19700101-000000 [ OK ] 409 | 410 | Summary 411 | ------- 412 | processed instances: 3 413 | created snapshots: 2 414 | "#, 415 | stdout 416 | ); 417 | 418 | assert_result!("Failed to backup some of the instances", result); 419 | 420 | assert_lxd!( 421 | r#" 422 | local:default/elastic (Running) 423 | -> auto-19700101-000000 424 | 425 | local:default/mariadb (Running) 426 | 427 | local:default/postgresql (Running) 428 | -> auto-19700101-000000 429 | "#, 430 | lxd 431 | ); 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /src/commands/backup_and_prune.rs: -------------------------------------------------------------------------------- 1 | use super::{Backup, Prune}; 2 | use crate::prelude::*; 3 | use anyhow::Error; 4 | 5 | pub struct BackupAndPrune<'a, 'b> { 6 | env: &'a mut Environment<'b>, 7 | } 8 | 9 | impl<'a, 'b> BackupAndPrune<'a, 'b> { 10 | pub fn new(env: &'a mut Environment<'b>) -> Self { 11 | Self { env } 12 | } 13 | 14 | pub fn run(self) -> Result<()> { 15 | writeln!(self.env.stdout, "{}", "Backing-up".bold())?; 16 | writeln!(self.env.stdout, "----------")?; 17 | writeln!(self.env.stdout)?; 18 | 19 | let backup_result = Backup::new(self.env) 20 | .with_summary_title("Backing-up summary") 21 | .run(); 22 | 23 | writeln!(self.env.stdout)?; 24 | writeln!(self.env.stdout, "{}", "Pruning".bold())?; 25 | writeln!(self.env.stdout, "-------")?; 26 | writeln!(self.env.stdout)?; 27 | 28 | let prune_result = Prune::new(self.env) 29 | .with_summary_title("Pruning summary") 30 | .run(); 31 | 32 | match (backup_result, prune_result) { 33 | (Ok(_), Ok(_)) => Ok(()), 34 | (Ok(_), Err(err)) | (Err(err), Ok(_)) => Err(err), 35 | 36 | (Err(backup_err), Err(prune_err)) => { 37 | bail!( 38 | "Couldn't backup and prune\n\nBackup error:\n{}\n\nPrune error:\n{}", 39 | Self::format_error(backup_err), 40 | Self::format_error(prune_err) 41 | ) 42 | } 43 | } 44 | } 45 | 46 | fn format_error(err: Error) -> String { 47 | format!("{:?}", err) 48 | .lines() 49 | .map(|line| { 50 | if line.is_empty() { 51 | Default::default() 52 | } else { 53 | format!(" {}", line) 54 | } 55 | }) 56 | .join("\n") 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | use crate::lxd::LxdFakeClient; 64 | use crate::{assert_result, assert_stdout}; 65 | 66 | fn lxd() -> LxdFakeClient { 67 | let mut lxd = LxdFakeClient::default(); 68 | 69 | lxd.add(LxdFakeInstance { 70 | name: "instance", 71 | ..Default::default() 72 | }); 73 | 74 | lxd 75 | } 76 | 77 | mod when_backup_succeeds { 78 | use super::*; 79 | 80 | mod and_prune_succeeds { 81 | use super::*; 82 | 83 | const CONFIG: &str = indoc!( 84 | r#" 85 | policies: 86 | all: 87 | keep-last: 0 88 | "# 89 | ); 90 | 91 | #[test] 92 | fn returns_ok() { 93 | let mut stdout = Vec::new(); 94 | let config = Config::parse(CONFIG); 95 | let mut lxd = lxd(); 96 | 97 | BackupAndPrune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 98 | .run() 99 | .unwrap(); 100 | 101 | assert_stdout!( 102 | r#" 103 | Backing-up 104 | ---------- 105 | 106 | instance 107 | - creating snapshot: auto-19700101-000000 [ OK ] 108 | 109 | Backing-up summary 110 | ------------------ 111 | processed instances: 1 112 | created snapshots: 1 113 | 114 | Pruning 115 | ------- 116 | 117 | instance 118 | - deleting snapshot: auto-19700101-000000 [ OK ] 119 | 120 | Pruning summary 121 | --------------- 122 | processed instances: 1 123 | deleted snapshots: 1 124 | kept snapshots: 0 125 | "#, 126 | stdout 127 | ); 128 | } 129 | } 130 | 131 | mod and_prune_fails { 132 | use super::*; 133 | 134 | const CONFIG: &str = indoc!( 135 | r#" 136 | hooks: 137 | on-prune-completed: "exit 1" 138 | 139 | policies: 140 | all: 141 | keep-last: 0 142 | "# 143 | ); 144 | 145 | #[test] 146 | fn returns_prune_error() { 147 | let mut stdout = Vec::new(); 148 | let config = Config::parse(CONFIG); 149 | let mut lxd = lxd(); 150 | 151 | let result = 152 | BackupAndPrune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 153 | .run(); 154 | 155 | assert_stdout!( 156 | r#" 157 | Backing-up 158 | ---------- 159 | 160 | instance 161 | - creating snapshot: auto-19700101-000000 [ OK ] 162 | 163 | Backing-up summary 164 | ------------------ 165 | processed instances: 1 166 | created snapshots: 1 167 | 168 | Pruning 169 | ------- 170 | 171 | instance 172 | - deleting snapshot: auto-19700101-000000 [ OK ] 173 | 174 | Pruning summary 175 | --------------- 176 | processed instances: 1 177 | deleted snapshots: 1 178 | kept snapshots: 0 179 | "#, 180 | stdout 181 | ); 182 | 183 | assert_result!( 184 | r#" 185 | Couldn't execute the `on-prune-completed` hook 186 | 187 | Caused by: 188 | Hook returned a non-zero exit code. 189 | "#, 190 | result 191 | ); 192 | } 193 | } 194 | } 195 | 196 | mod when_backup_fails { 197 | use super::*; 198 | 199 | mod and_prune_succeeds { 200 | use super::*; 201 | 202 | const CONFIG: &str = indoc!( 203 | r#" 204 | hooks: 205 | on-backup-completed: "exit 1" 206 | 207 | policies: 208 | all: 209 | keep-last: 0 210 | "# 211 | ); 212 | 213 | #[test] 214 | fn returns_backup_error() { 215 | let mut stdout = Vec::new(); 216 | let config = Config::parse(CONFIG); 217 | let mut lxd = lxd(); 218 | 219 | let result = 220 | BackupAndPrune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 221 | .run(); 222 | 223 | assert_stdout!( 224 | r#" 225 | Backing-up 226 | ---------- 227 | 228 | instance 229 | - creating snapshot: auto-19700101-000000 [ OK ] 230 | 231 | Backing-up summary 232 | ------------------ 233 | processed instances: 1 234 | created snapshots: 1 235 | 236 | Pruning 237 | ------- 238 | 239 | instance 240 | - deleting snapshot: auto-19700101-000000 [ OK ] 241 | 242 | Pruning summary 243 | --------------- 244 | processed instances: 1 245 | deleted snapshots: 1 246 | kept snapshots: 0 247 | "#, 248 | stdout 249 | ); 250 | 251 | assert_result!( 252 | r#" 253 | Couldn't execute the `on-backup-completed` hook 254 | 255 | Caused by: 256 | Hook returned a non-zero exit code. 257 | "#, 258 | result 259 | ); 260 | } 261 | } 262 | 263 | mod and_prune_fails { 264 | use super::*; 265 | 266 | const CONFIG: &str = indoc!( 267 | r#" 268 | hooks: 269 | on-backup-completed: "exit 1" 270 | on-prune-completed: "exit 1" 271 | 272 | policies: 273 | all: 274 | keep-last: 0 275 | "# 276 | ); 277 | 278 | #[test] 279 | fn returns_both_errors() { 280 | let mut stdout = Vec::new(); 281 | let config = Config::parse(CONFIG); 282 | let mut lxd = lxd(); 283 | 284 | let result = 285 | BackupAndPrune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 286 | .run(); 287 | 288 | assert_stdout!( 289 | r#" 290 | Backing-up 291 | ---------- 292 | 293 | instance 294 | - creating snapshot: auto-19700101-000000 [ OK ] 295 | 296 | Backing-up summary 297 | ------------------ 298 | processed instances: 1 299 | created snapshots: 1 300 | 301 | Pruning 302 | ------- 303 | 304 | instance 305 | - deleting snapshot: auto-19700101-000000 [ OK ] 306 | 307 | Pruning summary 308 | --------------- 309 | processed instances: 1 310 | deleted snapshots: 1 311 | kept snapshots: 0 312 | "#, 313 | stdout 314 | ); 315 | 316 | assert_result!( 317 | r#" 318 | Couldn't backup and prune 319 | 320 | Backup error: 321 | Couldn't execute the `on-backup-completed` hook 322 | 323 | Caused by: 324 | Hook returned a non-zero exit code. 325 | 326 | Prune error: 327 | Couldn't execute the `on-prune-completed` hook 328 | 329 | Caused by: 330 | Hook returned a non-zero exit code. 331 | "#, 332 | result 333 | ); 334 | } 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/commands/debug_list_instances.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use itertools::Itertools; 3 | use prettytable::{row, Table}; 4 | 5 | pub struct DebugListInstances<'a, 'b> { 6 | env: &'a mut Environment<'b>, 7 | } 8 | 9 | impl<'a, 'b> DebugListInstances<'a, 'b> { 10 | pub fn new(env: &'a mut Environment<'b>) -> Self { 11 | Self { env } 12 | } 13 | 14 | pub fn run(self) -> Result<()> { 15 | let mut table = Table::new(); 16 | let has_remotes = self.env.config.remotes().has_any_non_local_remotes(); 17 | 18 | if has_remotes { 19 | table.set_titles(row!["Remote", "Project", "Instance", "Policies"]); 20 | } else { 21 | table.set_titles(row!["Project", "Instance", "Policies"]); 22 | } 23 | 24 | for remote in self.env.config.remotes().iter() { 25 | for project in self.env.lxd.projects(remote)? { 26 | for instance in self.env.lxd.instances(remote, &project.name)? { 27 | let policies = self 28 | .env 29 | .config 30 | .policies() 31 | .find(remote, &project, &instance) 32 | .collect(); 33 | 34 | let policies = format_policies(policies); 35 | 36 | if has_remotes { 37 | table.add_row(row![remote, project.name, instance.name, policies]); 38 | } else { 39 | table.add_row(row![project.name, instance.name, policies]); 40 | } 41 | } 42 | } 43 | } 44 | 45 | write!(self.env.stdout, "{}", table)?; 46 | 47 | Ok(()) 48 | } 49 | } 50 | 51 | fn format_policies(policies: Vec<(&str, &Policy)>) -> String { 52 | if policies.is_empty() { 53 | "NONE".yellow().to_string() 54 | } else { 55 | policies.iter().map(|(name, _)| *name).join(" + ") 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use super::*; 62 | use crate::assert_stdout; 63 | use crate::lxd::{LxdFakeClient, LxdInstanceStatus}; 64 | 65 | #[test] 66 | fn smoke() { 67 | let mut stdout = Vec::new(); 68 | 69 | let config = Config::parse(indoc!( 70 | r#" 71 | policies: 72 | running: 73 | included-statuses: ['Running'] 74 | 75 | databases: 76 | included-instances: ['mysql', 'redis'] 77 | "# 78 | )); 79 | 80 | let mut lxd = LxdFakeClient::default(); 81 | 82 | lxd.add(LxdFakeInstance { 83 | name: "ruby", 84 | ..Default::default() 85 | }); 86 | 87 | lxd.add(LxdFakeInstance { 88 | name: "rust", 89 | ..Default::default() 90 | }); 91 | 92 | lxd.add(LxdFakeInstance { 93 | name: "mysql", 94 | ..Default::default() 95 | }); 96 | 97 | lxd.add(LxdFakeInstance { 98 | name: "redis", 99 | status: LxdInstanceStatus::Stopped, 100 | ..Default::default() 101 | }); 102 | 103 | lxd.add(LxdFakeInstance { 104 | name: "outlander", 105 | status: LxdInstanceStatus::Stopped, 106 | ..Default::default() 107 | }); 108 | 109 | DebugListInstances::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 110 | .run() 111 | .unwrap(); 112 | 113 | assert_stdout!( 114 | r#" 115 | +---------+-----------+---------------------+ 116 | | Project | Instance | Policies | 117 | +=========+===========+=====================+ 118 | | default | mysql | running + databases | 119 | +---------+-----------+---------------------+ 120 | | default | outlander | NONE | 121 | +---------+-----------+---------------------+ 122 | | default | redis | databases | 123 | +---------+-----------+---------------------+ 124 | | default | ruby | running | 125 | +---------+-----------+---------------------+ 126 | | default | rust | running | 127 | +---------+-----------+---------------------+ 128 | "#, 129 | stdout 130 | ); 131 | } 132 | 133 | #[test] 134 | fn smoke_with_remotes() { 135 | let mut stdout = Vec::new(); 136 | 137 | let config = Config::parse(indoc!( 138 | r#" 139 | policies: 140 | important-servers: 141 | included-remotes: ['server-a', 'server-b'] 142 | 143 | remotes: 144 | - server-a 145 | - server-b 146 | - server-c 147 | "# 148 | )); 149 | 150 | let mut lxd = LxdFakeClient::default(); 151 | 152 | for remote in ["local", "server-a", "server-b", "server-c"] { 153 | lxd.add(LxdFakeInstance { 154 | remote, 155 | name: "php", 156 | ..Default::default() 157 | }); 158 | } 159 | 160 | DebugListInstances::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 161 | .run() 162 | .unwrap(); 163 | 164 | assert_stdout!( 165 | r#" 166 | +----------+---------+----------+-------------------+ 167 | | Remote | Project | Instance | Policies | 168 | +==========+=========+==========+===================+ 169 | | server-a | default | php | important-servers | 170 | +----------+---------+----------+-------------------+ 171 | | server-b | default | php | important-servers | 172 | +----------+---------+----------+-------------------+ 173 | | server-c | default | php | NONE | 174 | +----------+---------+----------+-------------------+ 175 | "#, 176 | stdout 177 | ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/commands/debug_nuke.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub struct DebugNuke<'a, 'b> { 4 | env: &'a mut Environment<'b>, 5 | } 6 | 7 | impl<'a, 'b> DebugNuke<'a, 'b> { 8 | pub fn new(env: &'a mut Environment<'b>) -> Self { 9 | Self { env } 10 | } 11 | 12 | pub fn run(self) -> Result<()> { 13 | let mut summary = Summary::default().with_deleted_snapshots(); 14 | 15 | for remote in self.env.config.remotes().iter() { 16 | for project in self.env.lxd.projects(remote)? { 17 | for instance in self.env.lxd.instances(remote, &project.name)? { 18 | writeln!( 19 | self.env.stdout, 20 | "{}", 21 | format!("- {}:{}/{}", remote, project.name, instance.name).bold(), 22 | )?; 23 | 24 | if !self 25 | .env 26 | .config 27 | .policies() 28 | .matches(remote, &project, &instance) 29 | { 30 | writeln!(self.env.stdout, " - {}", "[ EXCLUDED ]".yellow())?; 31 | writeln!(self.env.stdout)?; 32 | 33 | continue; 34 | } 35 | 36 | summary.add_processed_instance(); 37 | 38 | for snapshot in instance.snapshots { 39 | write!( 40 | self.env.stdout, 41 | " - deleting snapshot: {}", 42 | snapshot.name.as_str().italic() 43 | )?; 44 | 45 | let result = self.env.lxd.delete_snapshot( 46 | remote, 47 | &project.name, 48 | &instance.name, 49 | &snapshot.name, 50 | ); 51 | 52 | match result { 53 | Ok(_) => { 54 | summary.add_deleted_snapshot(); 55 | 56 | writeln!(self.env.stdout, " {}", "[ OK ]".green())?; 57 | } 58 | 59 | Err(err) => { 60 | writeln!(self.env.stdout, " {}", "[ FAILED ]".red())?; 61 | 62 | return Err(err.into()); 63 | } 64 | } 65 | } 66 | 67 | writeln!(self.env.stdout)?; 68 | } 69 | } 70 | } 71 | 72 | write!(self.env.stdout, "{}", summary)?; 73 | 74 | Ok(()) 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | use crate::lxd::{utils::*, *}; 82 | use crate::{assert_lxd, assert_stdout}; 83 | 84 | fn lxd() -> LxdFakeClient { 85 | let mut lxd = LxdFakeClient::default(); 86 | 87 | lxd.add(LxdFakeInstance { 88 | name: "instance-a", 89 | snapshots: vec![snapshot("snapshot-1", "2000-01-01 12:00:00")], 90 | ..Default::default() 91 | }); 92 | 93 | lxd.add(LxdFakeInstance { 94 | name: "instance-b", 95 | snapshots: vec![ 96 | snapshot("snapshot-1", "2000-01-01 12:00:00"), 97 | snapshot("snapshot-2", "2000-01-01 13:00:00"), 98 | ], 99 | ..Default::default() 100 | }); 101 | 102 | lxd.add(LxdFakeInstance { 103 | name: "instance-c", 104 | status: LxdInstanceStatus::Stopping, 105 | ..Default::default() 106 | }); 107 | 108 | lxd.add(LxdFakeInstance { 109 | name: "instance-d", 110 | status: LxdInstanceStatus::Stopped, 111 | snapshots: vec![snapshot("snapshot-1", "2000-01-01 12:00:00")], 112 | ..Default::default() 113 | }); 114 | 115 | lxd.add(LxdFakeInstance { 116 | name: "instance-d", 117 | remote: "remote", 118 | snapshots: vec![snapshot("snapshot-1", "2000-01-01 12:00:00")], 119 | ..Default::default() 120 | }); 121 | 122 | lxd 123 | } 124 | 125 | #[test] 126 | fn smoke() { 127 | let mut stdout = Vec::new(); 128 | 129 | let config = Config::parse(indoc!( 130 | r#" 131 | policies: 132 | all: 133 | included-statuses: ['Running'] 134 | "# 135 | )); 136 | 137 | let mut lxd = lxd(); 138 | 139 | DebugNuke::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 140 | .run() 141 | .unwrap(); 142 | 143 | assert_stdout!( 144 | r#" 145 | - local:default/instance-a 146 | - deleting snapshot: snapshot-1 [ OK ] 147 | 148 | - local:default/instance-b 149 | - deleting snapshot: snapshot-1 [ OK ] 150 | - deleting snapshot: snapshot-2 [ OK ] 151 | 152 | - local:default/instance-c 153 | - [ EXCLUDED ] 154 | 155 | - local:default/instance-d 156 | - [ EXCLUDED ] 157 | 158 | Summary 159 | ------- 160 | processed instances: 2 161 | deleted snapshots: 3 162 | "#, 163 | stdout 164 | ); 165 | 166 | assert_lxd!( 167 | r#" 168 | local:default/instance-a (Running) 169 | 170 | local:default/instance-b (Running) 171 | 172 | local:default/instance-c (Stopping) 173 | 174 | local:default/instance-d (Stopped) 175 | -> snapshot-1 176 | 177 | remote:default/instance-d (Running) 178 | -> snapshot-1 179 | "#, 180 | lxd 181 | ); 182 | } 183 | 184 | #[test] 185 | fn smoke_with_remotes() { 186 | let mut stdout = Vec::new(); 187 | 188 | let config = Config::parse(indoc!( 189 | r#" 190 | policies: 191 | all: 192 | included-statuses: ['Running'] 193 | 194 | remotes: 195 | - local 196 | - remote 197 | "# 198 | )); 199 | 200 | let mut lxd = lxd(); 201 | 202 | DebugNuke::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 203 | .run() 204 | .unwrap(); 205 | 206 | assert_stdout!( 207 | r#" 208 | - local:default/instance-a 209 | - deleting snapshot: snapshot-1 [ OK ] 210 | 211 | - local:default/instance-b 212 | - deleting snapshot: snapshot-1 [ OK ] 213 | - deleting snapshot: snapshot-2 [ OK ] 214 | 215 | - local:default/instance-c 216 | - [ EXCLUDED ] 217 | 218 | - local:default/instance-d 219 | - [ EXCLUDED ] 220 | 221 | - remote:default/instance-d 222 | - deleting snapshot: snapshot-1 [ OK ] 223 | 224 | Summary 225 | ------- 226 | processed instances: 3 227 | deleted snapshots: 4 228 | "#, 229 | stdout 230 | ); 231 | 232 | assert_lxd!( 233 | r#" 234 | local:default/instance-a (Running) 235 | 236 | local:default/instance-b (Running) 237 | 238 | local:default/instance-c (Stopping) 239 | 240 | local:default/instance-d (Stopped) 241 | -> snapshot-1 242 | 243 | remote:default/instance-d (Running) 244 | "#, 245 | lxd 246 | ); 247 | } 248 | 249 | #[test] 250 | fn empty_policy() { 251 | let mut stdout = Vec::new(); 252 | let config = Config::default(); 253 | let mut lxd = lxd(); 254 | 255 | DebugNuke::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 256 | .run() 257 | .unwrap(); 258 | 259 | assert_stdout!( 260 | r#" 261 | - local:default/instance-a 262 | - [ EXCLUDED ] 263 | 264 | - local:default/instance-b 265 | - [ EXCLUDED ] 266 | 267 | - local:default/instance-c 268 | - [ EXCLUDED ] 269 | 270 | - local:default/instance-d 271 | - [ EXCLUDED ] 272 | 273 | Summary 274 | ------- 275 | processed instances: 0 276 | deleted snapshots: 0 277 | "#, 278 | stdout 279 | ); 280 | 281 | assert_lxd!( 282 | r#" 283 | local:default/instance-a (Running) 284 | -> snapshot-1 285 | 286 | local:default/instance-b (Running) 287 | -> snapshot-1 288 | -> snapshot-2 289 | 290 | local:default/instance-c (Stopping) 291 | 292 | local:default/instance-d (Stopped) 293 | -> snapshot-1 294 | 295 | remote:default/instance-d (Running) 296 | -> snapshot-1 297 | "#, 298 | lxd 299 | ); 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/commands/prune.rs: -------------------------------------------------------------------------------- 1 | mod find_snapshots; 2 | mod find_snapshots_to_keep; 3 | 4 | use self::{find_snapshots::*, find_snapshots_to_keep::*}; 5 | use crate::prelude::*; 6 | 7 | pub struct Prune<'a, 'b> { 8 | env: &'a mut Environment<'b>, 9 | summary: Summary, 10 | } 11 | 12 | impl<'a, 'b> Prune<'a, 'b> { 13 | pub fn new(env: &'a mut Environment<'b>) -> Self { 14 | Self { 15 | env, 16 | summary: Summary::default() 17 | .with_deleted_snapshots() 18 | .with_kept_snapshots(), 19 | } 20 | } 21 | 22 | pub fn with_summary_title(mut self, title: &'static str) -> Self { 23 | self.summary.set_title(title); 24 | self 25 | } 26 | 27 | pub fn run(mut self) -> Result<()> { 28 | self.env.hooks().on_prune_started()?; 29 | 30 | let cmd_result = self.try_run(); 31 | let hook_result = self.env.hooks().on_prune_completed(); 32 | 33 | cmd_result.and(hook_result) 34 | } 35 | 36 | fn try_run(&mut self) -> Result<()> { 37 | if self.env.config.remotes().has_any_non_local_remotes() { 38 | for remote in self.env.config.remotes().iter() { 39 | self.process_remote(true, remote) 40 | .with_context(|| format!("Couldn't process remote: {}", remote))?; 41 | } 42 | } else { 43 | self.process_remote(false, &LxdRemoteName::local())?; 44 | } 45 | 46 | write!(self.env.stdout, "{}", self.summary)?; 47 | 48 | if self.summary.has_errors() { 49 | bail!("Failed to prune some of the instances"); 50 | } 51 | 52 | self.summary.as_result() 53 | } 54 | 55 | fn process_remote(&mut self, print_remote: bool, remote: &LxdRemoteName) -> Result<()> { 56 | let projects = self 57 | .env 58 | .lxd 59 | .projects(remote) 60 | .context("Couldn't list projects")?; 61 | 62 | let print_project = projects.iter().any(|project| !project.name.is_default()); 63 | 64 | for project in projects { 65 | self.process_project(print_remote, remote, print_project, &project) 66 | .with_context(|| format!("Couldn't process project: {}", project.name))?; 67 | } 68 | 69 | Ok(()) 70 | } 71 | 72 | fn process_project( 73 | &mut self, 74 | print_remote: bool, 75 | remote: &LxdRemoteName, 76 | print_project: bool, 77 | project: &LxdProject, 78 | ) -> Result<()> { 79 | let instances = self 80 | .env 81 | .lxd 82 | .instances(remote, &project.name) 83 | .context("Couldn't list instances")?; 84 | 85 | for instance in instances { 86 | self.process_instance(print_remote, remote, print_project, project, &instance) 87 | .with_context(|| format!("Couldn't process instance: {}", instance.name))?; 88 | } 89 | 90 | Ok(()) 91 | } 92 | 93 | fn process_instance( 94 | &mut self, 95 | print_remote: bool, 96 | remote: &LxdRemoteName, 97 | print_project: bool, 98 | project: &LxdProject, 99 | instance: &LxdInstance, 100 | ) -> Result<()> { 101 | writeln!( 102 | self.env.stdout, 103 | "{}", 104 | PrettyLxdInstanceName::new( 105 | print_remote, 106 | remote, 107 | print_project, 108 | &project.name, 109 | &instance.name 110 | ) 111 | .to_string() 112 | .bold() 113 | )?; 114 | 115 | if let Some(policy) = self.env.config.policies().build(remote, project, instance) { 116 | self.try_process_instance(remote, project, instance, &policy)?; 117 | } else { 118 | writeln!(self.env.stdout, " - {}", "[ EXCLUDED ]".yellow())?; 119 | } 120 | 121 | writeln!(self.env.stdout)?; 122 | 123 | Ok(()) 124 | } 125 | 126 | fn try_process_instance( 127 | &mut self, 128 | remote: &LxdRemoteName, 129 | project: &LxdProject, 130 | instance: &LxdInstance, 131 | policy: &Policy, 132 | ) -> Result<()> { 133 | self.summary.add_processed_instance(); 134 | 135 | let snapshots = find_snapshots(self.env.config, instance); 136 | let snapshots_to_keep = find_snapshots_to_keep(policy, &snapshots); 137 | 138 | for snapshot in &snapshots { 139 | if snapshots_to_keep.contains(&snapshot.name) { 140 | self.summary.add_kept_snapshot(); 141 | 142 | writeln!( 143 | self.env.stdout, 144 | " - keeping snapshot: {}", 145 | snapshot.name.as_str().italic() 146 | )?; 147 | } else { 148 | write!( 149 | self.env.stdout, 150 | " - deleting snapshot: {}", 151 | snapshot.name.as_str().italic() 152 | )?; 153 | 154 | let result = self 155 | .env 156 | .lxd 157 | .delete_snapshot(remote, &project.name, &instance.name, &snapshot.name) 158 | .context("Couldn't delete snapshot"); 159 | 160 | let result = result.and_then(|_| { 161 | self.env.hooks().on_snapshot_deleted( 162 | remote, 163 | &project.name, 164 | &instance.name, 165 | &snapshot.name, 166 | ) 167 | }); 168 | 169 | match result { 170 | Ok(()) => { 171 | self.summary.add_deleted_snapshot(); 172 | 173 | writeln!(self.env.stdout, " {}", "[ OK ]".green())?; 174 | } 175 | 176 | Err(err) => { 177 | self.summary.add_error(); 178 | 179 | writeln!(self.env.stdout, " {}", "[ FAILED ]".red())?; 180 | 181 | let err = format!("{:?}", err); 182 | 183 | for line in err.lines() { 184 | writeln!(self.env.stdout, " {}", line)?; 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | self.env 192 | .hooks() 193 | .on_instance_pruned(remote, &project.name, &instance.name)?; 194 | 195 | Ok(()) 196 | } 197 | } 198 | 199 | #[cfg(test)] 200 | mod tests { 201 | use super::*; 202 | use crate::lxd::{utils::*, LxdFakeClient}; 203 | use crate::{assert_lxd, assert_result, assert_stdout}; 204 | 205 | #[test] 206 | fn smoke() { 207 | let mut stdout = Vec::new(); 208 | 209 | let config = Config::parse(indoc!( 210 | r#" 211 | policies: 212 | all: 213 | excluded-instances: ['mariadb'] 214 | keep-last: 2 215 | "# 216 | )); 217 | 218 | let mut lxd = LxdFakeClient::default(); 219 | 220 | lxd.add(LxdFakeInstance { 221 | name: "elastic", 222 | snapshots: vec![ 223 | snapshot("manual-1", "2000-01-01 12:00:00"), 224 | snapshot("auto-1", "2000-01-01 13:00:00"), 225 | snapshot("auto-2", "2000-01-01 14:00:00"), 226 | snapshot("auto-3", "2000-01-01 15:00:00"), 227 | snapshot("auto-4", "2000-01-01 16:00:00"), 228 | snapshot("manual-2", "2000-01-01 17:00:00"), 229 | ], 230 | ..Default::default() 231 | }); 232 | 233 | lxd.add(LxdFakeInstance { 234 | name: "mariadb", 235 | snapshots: vec![ 236 | snapshot("manual-1", "2000-01-01 12:00:00"), 237 | snapshot("auto-1", "2000-01-01 13:00:00"), 238 | snapshot("auto-2", "2000-01-01 14:00:00"), 239 | snapshot("auto-3", "2000-01-01 15:00:00"), 240 | snapshot("manual-2", "2000-01-01 16:00:00"), 241 | ], 242 | ..Default::default() 243 | }); 244 | 245 | lxd.add(LxdFakeInstance { 246 | name: "postgresql", 247 | snapshots: vec![ 248 | snapshot("manual-1", "2000-01-01 12:00:00"), 249 | snapshot("auto-1", "2000-01-01 13:00:00"), 250 | snapshot("auto-2", "2000-01-01 14:00:00"), 251 | snapshot("manual-2", "2000-01-01 15:00:00"), 252 | ], 253 | ..Default::default() 254 | }); 255 | 256 | Prune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 257 | .run() 258 | .unwrap(); 259 | 260 | assert_stdout!( 261 | r#" 262 | elastic 263 | - keeping snapshot: auto-4 264 | - keeping snapshot: auto-3 265 | - deleting snapshot: auto-2 [ OK ] 266 | - deleting snapshot: auto-1 [ OK ] 267 | 268 | mariadb 269 | - [ EXCLUDED ] 270 | 271 | postgresql 272 | - keeping snapshot: auto-2 273 | - keeping snapshot: auto-1 274 | 275 | Summary 276 | ------- 277 | processed instances: 2 278 | deleted snapshots: 2 279 | kept snapshots: 4 280 | "#, 281 | stdout 282 | ); 283 | 284 | assert_lxd!( 285 | r#" 286 | local:default/elastic (Running) 287 | -> manual-1 288 | -> auto-3 289 | -> auto-4 290 | -> manual-2 291 | 292 | local:default/mariadb (Running) 293 | -> manual-1 294 | -> auto-1 295 | -> auto-2 296 | -> auto-3 297 | -> manual-2 298 | 299 | local:default/postgresql (Running) 300 | -> manual-1 301 | -> auto-1 302 | -> auto-2 303 | -> manual-2 304 | "#, 305 | lxd 306 | ); 307 | } 308 | 309 | #[test] 310 | fn smoke_with_remotes() { 311 | let mut stdout = Vec::new(); 312 | 313 | let config = Config::parse(indoc!( 314 | r#" 315 | policies: 316 | all: 317 | excluded-remotes: ['db-3'] 318 | included-statuses: ['Running'] 319 | 320 | remotes: 321 | - local 322 | - db-1 323 | - db-2 324 | - db-3 325 | - db-4 326 | "# 327 | )); 328 | 329 | let mut lxd = LxdFakeClient::default(); 330 | 331 | lxd.add(LxdFakeInstance { 332 | remote: "db-1", 333 | name: "postgresql", 334 | snapshots: vec![snapshot("auto-1", "2000-01-01 13:00:00")], 335 | ..Default::default() 336 | }); 337 | 338 | lxd.add(LxdFakeInstance { 339 | remote: "db-2", 340 | name: "postgresql", 341 | snapshots: vec![snapshot("auto-1", "2000-01-01 13:00:00")], 342 | ..Default::default() 343 | }); 344 | 345 | lxd.add(LxdFakeInstance { 346 | remote: "db-3", 347 | name: "postgresql", 348 | snapshots: vec![snapshot("auto-1", "2000-01-01 13:00:00")], 349 | ..Default::default() 350 | }); 351 | 352 | lxd.add(LxdFakeInstance { 353 | remote: "db-4", 354 | name: "postgresql", 355 | snapshots: vec![snapshot("auto-1", "2000-01-01 13:00:00")], 356 | status: LxdInstanceStatus::Stopping, 357 | ..Default::default() 358 | }); 359 | 360 | Prune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)) 361 | .run() 362 | .unwrap(); 363 | 364 | assert_stdout!( 365 | r#" 366 | db-1:postgresql 367 | - deleting snapshot: auto-1 [ OK ] 368 | 369 | db-2:postgresql 370 | - deleting snapshot: auto-1 [ OK ] 371 | 372 | db-3:postgresql 373 | - [ EXCLUDED ] 374 | 375 | db-4:postgresql 376 | - [ EXCLUDED ] 377 | 378 | Summary 379 | ------- 380 | processed instances: 2 381 | deleted snapshots: 2 382 | kept snapshots: 0 383 | "#, 384 | stdout 385 | ); 386 | 387 | assert_lxd!( 388 | r#" 389 | db-1:default/postgresql (Running) 390 | 391 | db-2:default/postgresql (Running) 392 | 393 | db-3:default/postgresql (Running) 394 | -> auto-1 395 | 396 | db-4:default/postgresql (Stopping) 397 | -> auto-1 398 | "#, 399 | lxd 400 | ); 401 | } 402 | 403 | #[test] 404 | fn failed_snapshot() { 405 | let mut stdout = Vec::new(); 406 | 407 | let config = Config::parse(indoc!( 408 | r#" 409 | policies: 410 | all: 411 | keep-last: 0 412 | "# 413 | )); 414 | 415 | let mut lxd = LxdFakeClient::default(); 416 | 417 | lxd.add(LxdFakeInstance { 418 | name: "elastic", 419 | snapshots: vec![ 420 | snapshot("auto-1", "2000-01-01 13:00:00"), 421 | snapshot("auto-2", "2000-01-01 13:00:00"), 422 | ], 423 | ..Default::default() 424 | }); 425 | 426 | lxd.add(LxdFakeInstance { 427 | name: "mariadb", 428 | snapshots: vec![ 429 | snapshot("auto-1", "2000-01-01 13:00:00"), 430 | snapshot("auto-2", "2000-01-01 13:00:00"), 431 | ], 432 | ..Default::default() 433 | }); 434 | 435 | lxd.add(LxdFakeInstance { 436 | name: "postgresql", 437 | snapshots: vec![ 438 | snapshot("auto-1", "2000-01-01 13:00:00"), 439 | snapshot("auto-2", "2000-01-01 13:00:00"), 440 | ], 441 | ..Default::default() 442 | }); 443 | 444 | lxd.inject_error(LxdFakeError::OnDeleteSnapshot { 445 | remote: "local", 446 | project: "default", 447 | instance: "mariadb", 448 | snapshot: "auto-1", 449 | }); 450 | 451 | let result = Prune::new(&mut Environment::test(&mut stdout, &config, &mut lxd)).run(); 452 | 453 | assert_stdout!( 454 | r#" 455 | elastic 456 | - deleting snapshot: auto-1 [ OK ] 457 | - deleting snapshot: auto-2 [ OK ] 458 | 459 | mariadb 460 | - deleting snapshot: auto-1 [ FAILED ] 461 | Couldn't delete snapshot 462 | 463 | Caused by: 464 | InjectedError 465 | - deleting snapshot: auto-2 [ OK ] 466 | 467 | postgresql 468 | - deleting snapshot: auto-1 [ OK ] 469 | - deleting snapshot: auto-2 [ OK ] 470 | 471 | Summary 472 | ------- 473 | processed instances: 3 474 | deleted snapshots: 5 475 | kept snapshots: 0 476 | "#, 477 | stdout 478 | ); 479 | 480 | assert_result!("Failed to prune some of the instances", result); 481 | 482 | assert_lxd!( 483 | r#" 484 | local:default/elastic (Running) 485 | 486 | local:default/mariadb (Running) 487 | -> auto-1 488 | 489 | local:default/postgresql (Running) 490 | "#, 491 | lxd 492 | ); 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /src/commands/prune/find_snapshots.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub fn find_snapshots(config: &Config, instances: &LxdInstance) -> Vec { 4 | let mut snapshots: Vec<_> = instances 5 | .snapshots 6 | .iter() 7 | .filter(|snapshot| config.matches_snapshot_name(&snapshot.name)) 8 | .cloned() 9 | .collect(); 10 | 11 | snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 12 | snapshots 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use super::*; 18 | use crate::lxd::utils::*; 19 | 20 | const CONFIG: &str = indoc!( 21 | r#" 22 | snapshot-name-prefix: auto- 23 | 24 | policies: 25 | all: 26 | keep-last: 15 27 | "# 28 | ); 29 | 30 | #[test] 31 | fn returns_only_snapshots_matching_format() { 32 | let config = Config::parse(CONFIG); 33 | 34 | let instance = LxdInstance { 35 | name: instance_name("test"), 36 | status: LxdInstanceStatus::Running, 37 | snapshots: vec![ 38 | snapshot("snap-0", "2000-01-01 12:00:00"), 39 | snapshot("auto", "2000-01-01 13:00:00"), 40 | snapshot("auto-", "2000-01-01 14:00:00"), 41 | snapshot("auto-20000101", "2000-01-01 15:00:00"), 42 | snapshot("auto-20000101-160000", "2000-01-01 16:00:00"), 43 | snapshot("snap-1", "2000-01-01 17:00:00"), 44 | ], 45 | }; 46 | 47 | let actual = find_snapshots(&config, &instance); 48 | 49 | let expected = vec![ 50 | snapshot("auto-20000101-160000", "2000-01-01 16:00:00"), 51 | snapshot("auto-20000101", "2000-01-01 15:00:00"), 52 | snapshot("auto-", "2000-01-01 14:00:00"), 53 | ]; 54 | 55 | pa::assert_eq!(expected, actual); 56 | } 57 | 58 | #[test] 59 | fn returns_snapshots_sorted_by_creation_date_descending() { 60 | let config = Config::parse(CONFIG); 61 | 62 | let instance = LxdInstance { 63 | name: instance_name("test"), 64 | status: LxdInstanceStatus::Running, 65 | snapshots: vec![ 66 | snapshot("auto-1", "2012-08-24 12:34:56"), 67 | snapshot("auto-2", "2012-08-24 12:36:56"), 68 | snapshot("auto-4", "2010-08-24 12:34:56"), 69 | snapshot("auto-0", "2012-08-24 12:35:56"), 70 | ], 71 | }; 72 | 73 | let actual = find_snapshots(&config, &instance); 74 | 75 | let expected = vec![ 76 | snapshot("auto-2", "2012-08-24 12:36:56"), 77 | snapshot("auto-0", "2012-08-24 12:35:56"), 78 | snapshot("auto-1", "2012-08-24 12:34:56"), 79 | snapshot("auto-4", "2010-08-24 12:34:56"), 80 | ]; 81 | 82 | pa::assert_eq!(expected, actual); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/prune/find_snapshots_to_keep.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use indexmap::IndexSet; 3 | 4 | pub fn find_snapshots_to_keep<'a>( 5 | policy: &Policy, 6 | snapshots: &'a [LxdSnapshot], 7 | ) -> IndexSet<&'a LxdSnapshotName> { 8 | const PATTERNS: &[(&str, &str)] = &[ 9 | ("hourly", "%Y-%m-%d %H"), 10 | ("daily", "%Y-%m-%d"), 11 | ("weekly", "%Y-%U"), 12 | ("monthly", "%Y-%m"), 13 | ("yearly", "%Y"), 14 | ("last", "%s"), 15 | ]; 16 | 17 | let mut keep_hourly = policy.keep_hourly(); 18 | let mut keep_daily = policy.keep_daily(); 19 | let mut keep_weekly = policy.keep_weekly(); 20 | let mut keep_monthly = policy.keep_monthly(); 21 | let mut keep_yearly = policy.keep_yearly(); 22 | let mut keep_last = policy.keep_last(); 23 | 24 | let mut alive_names = IndexSet::new(); 25 | let mut alive_dates = IndexSet::new(); 26 | 27 | for snapshot in snapshots { 28 | if let Some(limit) = policy.keep_limit() { 29 | if alive_names.len() >= limit { 30 | break; 31 | } 32 | } 33 | 34 | for (pattern_name, pattern_format) in PATTERNS { 35 | let snapshot_date = format!( 36 | "{}.{}", 37 | pattern_name, 38 | snapshot.created_at.format(pattern_format) 39 | ); 40 | 41 | if !alive_dates.contains(&snapshot_date) { 42 | let keep = match *pattern_name { 43 | "hourly" => &mut keep_hourly, 44 | "daily" => &mut keep_daily, 45 | "weekly" => &mut keep_weekly, 46 | "monthly" => &mut keep_monthly, 47 | "yearly" => &mut keep_yearly, 48 | "last" => &mut keep_last, 49 | _ => unreachable!(), 50 | }; 51 | 52 | if *keep > 0 { 53 | alive_names.insert(&snapshot.name); 54 | alive_dates.insert(snapshot_date); 55 | *keep -= 1; 56 | break; 57 | } 58 | } 59 | } 60 | } 61 | 62 | alive_names 63 | } 64 | 65 | #[cfg(test)] 66 | mod tests { 67 | use super::*; 68 | use crate::lxd::utils::*; 69 | 70 | fn test(policy: Policy, snapshots: Vec, expected: Vec<&str>) { 71 | let actual: Vec<_> = find_snapshots_to_keep(&policy, &snapshots) 72 | .into_iter() 73 | .cloned() 74 | .collect(); 75 | 76 | let expected: Vec<_> = expected.into_iter().map(LxdSnapshotName::new).collect(); 77 | 78 | pa::assert_eq!(expected, actual); 79 | } 80 | 81 | #[test] 82 | fn keep_hourly() { 83 | let policy = Policy { 84 | keep_hourly: Some(4), 85 | ..Default::default() 86 | }; 87 | 88 | let snapshots = vec![ 89 | snapshot("snap-6", "2000-05-10 12:00:00"), 90 | snapshot("snap-5", "2000-05-10 10:30:00"), 91 | snapshot("snap-4", "2000-05-10 10:25:00"), 92 | snapshot("snap-3", "2000-05-10 08:00:00"), 93 | snapshot("snap-2", "2000-05-10 07:30:00"), 94 | snapshot("snap-1", "2000-05-10 06:25:00"), 95 | ]; 96 | 97 | let expected = vec!["snap-6", "snap-5", "snap-3", "snap-2"]; 98 | 99 | test(policy, snapshots, expected); 100 | } 101 | 102 | #[test] 103 | fn keep_daily() { 104 | let policy = Policy { 105 | keep_daily: Some(4), 106 | ..Default::default() 107 | }; 108 | 109 | let snapshots = vec![ 110 | snapshot("snap-6", "2000-05-10 12:00:00"), 111 | snapshot("snap-5", "2000-05-10 12:00:00"), 112 | snapshot("snap-4", "2000-05-09 12:00:00"), 113 | snapshot("snap-3", "2000-05-09 12:00:00"), 114 | snapshot("snap-2", "2000-05-08 12:00:00"), 115 | snapshot("snap-1", "2000-05-07 12:00:00"), 116 | ]; 117 | 118 | let expected = vec!["snap-6", "snap-4", "snap-2", "snap-1"]; 119 | 120 | test(policy, snapshots, expected); 121 | } 122 | 123 | #[test] 124 | fn keep_hourly_and_daily() { 125 | let policy = Policy { 126 | keep_hourly: Some(4), 127 | keep_daily: Some(2), 128 | ..Default::default() 129 | }; 130 | 131 | let snapshots = vec![ 132 | snapshot("snap-9", "2000-05-10 12:00:00"), 133 | snapshot("snap-8", "2000-05-10 10:00:00"), 134 | snapshot("snap-7", "2000-05-10 08:00:00"), 135 | snapshot("snap-6", "2000-05-09 12:00:00"), 136 | snapshot("snap-5", "2000-05-09 10:00:00"), 137 | snapshot("snap-4", "2000-05-09 08:00:00"), 138 | snapshot("snap-3", "2000-05-08 12:00:00"), 139 | snapshot("snap-2", "2000-05-08 10:00:00"), 140 | snapshot("snap-1", "2000-05-08 08:00:00"), 141 | ]; 142 | 143 | let expected = vec![ 144 | "snap-9", // via keep-hourly 145 | "snap-8", // via keep-hourly 146 | "snap-7", // via keep-hourly 147 | "snap-6", // via keep-hourly 148 | "snap-5", // via keep-daily 149 | "snap-3", // via keep-daily 150 | ]; 151 | 152 | test(policy, snapshots, expected); 153 | } 154 | 155 | #[test] 156 | fn keep_weekly() { 157 | let policy = Policy { 158 | keep_weekly: Some(4), 159 | ..Default::default() 160 | }; 161 | 162 | let snapshots = vec![ 163 | snapshot("snap-6", "2000-05-10 00:00:00"), 164 | snapshot("snap-5", "2000-05-09 00:00:00"), 165 | snapshot("snap-4", "2000-05-02 00:00:00"), 166 | snapshot("snap-3", "2000-05-01 00:00:00"), 167 | snapshot("snap-2", "2000-04-25 00:00:00"), 168 | snapshot("snap-1", "2000-04-10 00:00:00"), 169 | ]; 170 | 171 | let expected = vec!["snap-6", "snap-4", "snap-2", "snap-1"]; 172 | 173 | test(policy, snapshots, expected); 174 | } 175 | 176 | #[test] 177 | fn keep_daily_and_weekly() { 178 | let policy = Policy { 179 | keep_daily: Some(4), 180 | keep_weekly: Some(2), 181 | ..Default::default() 182 | }; 183 | 184 | let snapshots = vec![ 185 | snapshot("snap-9", "2000-05-10 00:00:00"), 186 | snapshot("snap-8", "2000-05-09 00:00:00"), 187 | snapshot("snap-7", "2000-05-08 00:00:00"), 188 | snapshot("snap-6", "2000-04-07 00:00:00"), 189 | snapshot("snap-5", "2000-04-06 00:00:00"), 190 | snapshot("snap-4", "2000-04-05 00:00:00"), 191 | snapshot("snap-3", "2000-03-05 00:00:00"), 192 | snapshot("snap-2", "2000-03-05 00:00:00"), 193 | snapshot("snap-1", "2000-03-05 00:00:00"), 194 | ]; 195 | 196 | let expected = vec![ 197 | "snap-9", // via keep-daily 198 | "snap-8", // via keep-daily 199 | "snap-7", // via keep-daily 200 | "snap-6", // via keep-daily 201 | "snap-5", // via keep-weekly 202 | "snap-3", // via keep-weekly 203 | ]; 204 | 205 | test(policy, snapshots, expected); 206 | } 207 | 208 | #[test] 209 | fn keep_monthly() { 210 | let policy = Policy { 211 | keep_monthly: Some(4), 212 | ..Default::default() 213 | }; 214 | 215 | let snapshots = vec![ 216 | snapshot("snap-6", "2000-05-10 00:00:00"), 217 | snapshot("snap-5", "2000-05-02 00:00:00"), 218 | snapshot("snap-4", "2000-04-15 00:00:00"), 219 | snapshot("snap-3", "2000-04-15 00:00:00"), 220 | snapshot("snap-2", "2000-02-25 00:00:00"), 221 | snapshot("snap-1", "2000-01-15 00:00:00"), 222 | ]; 223 | 224 | let expected = vec!["snap-6", "snap-4", "snap-2", "snap-1"]; 225 | 226 | test(policy, snapshots, expected); 227 | } 228 | 229 | #[test] 230 | fn keep_daily_and_weekly_and_monthly() { 231 | let policy = Policy { 232 | keep_daily: Some(6), 233 | keep_weekly: Some(2), 234 | keep_monthly: Some(1), 235 | ..Default::default() 236 | }; 237 | 238 | let snapshots = vec![ 239 | snapshot("snap-10", "2024-08-03 22:00:03"), 240 | snapshot("snap-9", "2024-08-02 22:00:03"), 241 | snapshot("snap-8", "2024-08-01 22:00:04"), 242 | snapshot("snap-7", "2024-07-31 22:00:03"), 243 | snapshot("snap-6", "2024-07-30 22:00:04"), 244 | snapshot("snap-5", "2024-07-29 22:00:04"), 245 | snapshot("snap-4", "2024-07-28 22:00:04"), 246 | snapshot("snap-3", "2024-07-27 22:00:04"), 247 | snapshot("snap-2", "2024-06-30 22:00:03"), 248 | snapshot("snap-1", "2024-05-31 22:00:02"), 249 | ]; 250 | 251 | let expected = vec![ 252 | "snap-10", // via keep-daily 253 | "snap-9", // via keep-daily 254 | "snap-8", // via keep-daily 255 | "snap-7", // via keep-daily 256 | "snap-6", // via keep-daily 257 | "snap-5", // via keep-daily 258 | "snap-4", // via keep-weekly (30th week) 259 | "snap-3", // via keep-monthly 260 | "snap-2", // via keep-weekly (26th week) 261 | ]; 262 | 263 | test(policy, snapshots, expected); 264 | } 265 | 266 | #[test] 267 | fn keep_yearly() { 268 | let policy = Policy { 269 | keep_yearly: Some(4), 270 | ..Default::default() 271 | }; 272 | 273 | let snapshots = vec![ 274 | snapshot("snap-6", "2000-06-10 00:00:00"), 275 | snapshot("snap-5", "2000-05-10 00:00:00"), 276 | snapshot("snap-4", "1999-06-10 00:00:00"), 277 | snapshot("snap-3", "1999-06-10 00:00:00"), 278 | snapshot("snap-2", "1998-06-10 00:00:00"), 279 | snapshot("snap-1", "1995-06-10 00:00:00"), 280 | ]; 281 | 282 | let expected = vec!["snap-6", "snap-4", "snap-2", "snap-1"]; 283 | 284 | test(policy, snapshots, expected); 285 | } 286 | 287 | #[test] 288 | fn keep_last() { 289 | let policy = Policy { 290 | keep_last: Some(4), 291 | ..Default::default() 292 | }; 293 | 294 | let snapshots = vec![ 295 | snapshot("snap-6", "2000-05-10 12:00:00"), 296 | snapshot("snap-5", "2000-05-09 12:00:00"), 297 | snapshot("snap-4", "2000-05-08 12:00:00"), 298 | snapshot("snap-3", "2000-05-07 12:00:00"), 299 | snapshot("snap-2", "2000-05-06 12:00:00"), 300 | snapshot("snap-1", "2000-05-05 12:00:00"), 301 | ]; 302 | 303 | let expected = vec!["snap-6", "snap-5", "snap-4", "snap-3"]; 304 | 305 | test(policy, snapshots, expected); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/commands/validate.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use crate::Args; 3 | use std::ops::DerefMut; 4 | 5 | pub fn validate(stdout: &mut dyn Write, args: Args) -> Result<()> { 6 | let config = load_config(stdout, &args)?; 7 | 8 | writeln!(stdout)?; 9 | let mut lxd = init_lxd(stdout, &args, &config)?; 10 | 11 | writeln!(stdout)?; 12 | validate_config(stdout, &config, lxd.deref_mut())?; 13 | 14 | writeln!(stdout)?; 15 | writeln!(stdout, "✓ Everything seems to be fine")?; 16 | 17 | Ok(()) 18 | } 19 | 20 | fn load_config(stdout: &mut dyn Write, args: &Args) -> Result { 21 | writeln!( 22 | stdout, 23 | "Loading configuration file: {}", 24 | args.config.display() 25 | )?; 26 | 27 | let config = Config::load(&args.config)?; 28 | 29 | writeln!(stdout, ".. [ OK ]")?; 30 | 31 | Ok(config) 32 | } 33 | 34 | fn init_lxd(stdout: &mut dyn Write, args: &Args, config: &Config) -> Result> { 35 | writeln!(stdout, "Connecting to LXD")?; 36 | 37 | let lxd = crate::init_lxd(args, config)?; 38 | 39 | writeln!(stdout, ".. [ OK ]")?; 40 | 41 | Ok(lxd) 42 | } 43 | 44 | fn validate_config(stdout: &mut dyn Write, config: &Config, lxd: &mut dyn LxdClient) -> Result<()> { 45 | writeln!(stdout, "Validating configuration file")?; 46 | 47 | let mut matching_instances = 0; 48 | 49 | for remote in config.remotes().iter() { 50 | for project in lxd.projects(remote)? { 51 | for instance in lxd.instances(remote, &project.name)? { 52 | if config.policies().matches(remote, &project, &instance) { 53 | matching_instances += 1; 54 | } 55 | } 56 | } 57 | } 58 | 59 | if matching_instances == 0 { 60 | writeln!( 61 | stdout, 62 | "{} No instance matches any of the policies", 63 | "warn:".yellow() 64 | )?; 65 | } 66 | 67 | writeln!(stdout, ".. [ OK ]")?; 68 | 69 | Ok(()) 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | use crate::{assert_result, assert_stdout, Command}; 76 | use std::path::PathBuf; 77 | 78 | fn config(test: &str) -> PathBuf { 79 | PathBuf::from(file!()) 80 | .parent() 81 | .unwrap() 82 | .join("validate") 83 | .join("tests") 84 | .join(test) 85 | .join("config.yaml") 86 | } 87 | 88 | #[test] 89 | fn missing_config() { 90 | let mut stdout = Vec::new(); 91 | 92 | let args = Args { 93 | dry_run: false, 94 | config: "/tmp/ayy-ayy".into(), 95 | lxc_path: None, 96 | cmd: Command::Validate, 97 | }; 98 | 99 | let result = validate(&mut stdout, args); 100 | 101 | assert_stdout!( 102 | r#" 103 | Loading configuration file: /tmp/ayy-ayy 104 | "#, 105 | stdout 106 | ); 107 | 108 | assert_result!( 109 | r#" 110 | Couldn't load configuration from: /tmp/ayy-ayy 111 | 112 | Caused by: 113 | 0: Couldn't read file 114 | 1: No such file or directory (os error 2) 115 | "#, 116 | result 117 | ); 118 | } 119 | 120 | #[test] 121 | fn missing_lxc_path() { 122 | let mut stdout = Vec::new(); 123 | 124 | let args = Args { 125 | dry_run: false, 126 | config: config("missing_lxc_path"), 127 | lxc_path: Some("/tmp/ayy-ayy".into()), 128 | cmd: Command::Validate, 129 | }; 130 | 131 | let result = validate(&mut stdout, args); 132 | 133 | assert_stdout!( 134 | r#" 135 | Loading configuration file: src/commands/validate/tests/missing_lxc_path/config.yaml 136 | .. [ OK ] 137 | 138 | Connecting to LXD 139 | "#, 140 | stdout 141 | ); 142 | 143 | assert_result!( 144 | r#" 145 | Couldn't initialize LXC client 146 | 147 | Caused by: 148 | Couldn't find the `lxc` executable: /tmp/ayy-ayy 149 | "#, 150 | result 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/commands/validate/tests/missing_lxc_path/config.yaml: -------------------------------------------------------------------------------- 1 | policies: 2 | all: 3 | keep-last: 1 4 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | mod hooks; 2 | mod policies; 3 | mod policy; 4 | mod remotes; 5 | 6 | use crate::prelude::*; 7 | use chrono::TimeZone; 8 | use serde::Deserialize; 9 | use serde_with::{serde_as, DisplayFromStr}; 10 | use std::{fmt::Display, fs, path::Path, time::Duration}; 11 | 12 | pub use self::{hooks::*, policies::*, policy::*, remotes::*}; 13 | 14 | #[serde_as] 15 | #[derive(Debug, Deserialize)] 16 | #[serde(deny_unknown_fields)] 17 | #[serde(rename_all = "kebab-case")] 18 | pub struct Config { 19 | #[serde(default = "default_snapshot_name_prefix")] 20 | snapshot_name_prefix: String, 21 | 22 | #[serde(default = "default_snapshot_name_format")] 23 | snapshot_name_format: String, 24 | 25 | #[serde(default = "default_lxc_timeout")] 26 | #[serde_as(as = "DisplayFromStr")] 27 | lxc_timeout: humantime::Duration, 28 | 29 | #[serde(default)] 30 | hooks: Hooks, 31 | 32 | #[serde(default)] 33 | remotes: Remotes, 34 | 35 | #[serde(default)] 36 | policies: Policies, 37 | } 38 | 39 | impl Config { 40 | #[cfg(test)] 41 | pub fn parse(code: &str) -> Self { 42 | serde_yaml::from_str(code).unwrap() 43 | } 44 | 45 | pub fn load(file: impl AsRef) -> Result { 46 | let file = file.as_ref(); 47 | 48 | let result: Result<_> = (|| { 49 | let code = fs::read_to_string(file).context("Couldn't read file")?; 50 | serde_yaml::from_str(&code).context("Couldn't parse file") 51 | })(); 52 | 53 | result.with_context(|| format!("Couldn't load configuration from: {}", file.display())) 54 | } 55 | 56 | pub fn lxc_timeout(&self) -> Duration { 57 | self.lxc_timeout.into() 58 | } 59 | 60 | pub fn hooks(&self) -> &Hooks { 61 | &self.hooks 62 | } 63 | 64 | pub fn policies(&self) -> &Policies { 65 | &self.policies 66 | } 67 | 68 | pub fn remotes(&self) -> &Remotes { 69 | &self.remotes 70 | } 71 | 72 | pub fn snapshot_name(&self, now: DateTime) -> LxdSnapshotName 73 | where 74 | Tz: TimeZone, 75 | Tz::Offset: Display, 76 | { 77 | let format = format!("{}{}", self.snapshot_name_prefix, self.snapshot_name_format); 78 | 79 | LxdSnapshotName::new(now.format(&format).to_string()) 80 | } 81 | 82 | pub fn matches_snapshot_name(&self, name: &LxdSnapshotName) -> bool { 83 | name.as_str().starts_with(&self.snapshot_name_prefix) 84 | } 85 | } 86 | 87 | #[cfg(test)] 88 | impl Default for Config { 89 | fn default() -> Self { 90 | Self::parse("") 91 | } 92 | } 93 | 94 | fn default_snapshot_name_prefix() -> String { 95 | "auto-".into() 96 | } 97 | 98 | fn default_snapshot_name_format() -> String { 99 | "%Y%m%d-%H%M%S".into() 100 | } 101 | 102 | fn default_lxc_timeout() -> humantime::Duration { 103 | Duration::from_secs(10 * 60).into() 104 | } 105 | 106 | #[cfg(test)] 107 | mod tests { 108 | use super::*; 109 | use crate::lxd::utils::*; 110 | 111 | mod load { 112 | use super::*; 113 | 114 | #[test] 115 | fn examples() { 116 | let examples: Vec<_> = glob::glob("docs/example-configs/*.yaml") 117 | .unwrap() 118 | .map(|path| path.unwrap()) 119 | .collect(); 120 | 121 | if examples.is_empty() { 122 | panic!("Found no example configs"); 123 | } 124 | 125 | for example in examples { 126 | Config::load(&example).unwrap(); 127 | } 128 | } 129 | } 130 | 131 | mod snapshot_name { 132 | use super::*; 133 | use test_case::test_case; 134 | 135 | #[test_case("", "%Y%m%d-%H%M%S", "20120824-123456")] 136 | #[test_case("auto-", "%Y%m%d-%H%M%S", "auto-20120824-123456")] 137 | #[test_case("auto-", "%Y%m%d", "auto-20120824")] 138 | fn test(snapshot_name_prefix: &str, snapshot_name_format: &str, expected: &str) { 139 | let target = Config { 140 | snapshot_name_prefix: snapshot_name_prefix.into(), 141 | snapshot_name_format: snapshot_name_format.into(), 142 | ..Default::default() 143 | }; 144 | 145 | let actual = target 146 | .snapshot_name(DateTime::parse_from_rfc3339("2012-08-24T12:34:56-00:00").unwrap()); 147 | 148 | assert_eq!(expected, actual.as_str()); 149 | } 150 | } 151 | 152 | mod matches_snapshot_name { 153 | use super::*; 154 | 155 | fn target(snapshot_name_prefix: &str) -> Config { 156 | Config { 157 | snapshot_name_prefix: snapshot_name_prefix.into(), 158 | ..Default::default() 159 | } 160 | } 161 | 162 | #[test] 163 | fn given_empty_prefix() { 164 | let target = target(""); 165 | 166 | assert!(target.matches_snapshot_name(&snapshot_name("auto"))); 167 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824"))); 168 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824-123456"))); 169 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824-123456-bus"))); 170 | 171 | assert!(target.matches_snapshot_name(&snapshot_name("manual"))); 172 | assert!(target.matches_snapshot_name(&snapshot_name("manual-20120824"))); 173 | assert!(target.matches_snapshot_name(&snapshot_name("manual-20120824-123456"))); 174 | assert!(target.matches_snapshot_name(&snapshot_name("manual-20120824-123456-bus"))); 175 | } 176 | 177 | #[test] 178 | fn given_some_prefix() { 179 | let target = target("auto-"); 180 | 181 | assert!(!target.matches_snapshot_name(&snapshot_name("auto"))); 182 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824"))); 183 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824-123456"))); 184 | assert!(target.matches_snapshot_name(&snapshot_name("auto-20120824-123456-bus"))); 185 | 186 | assert!(!target.matches_snapshot_name(&snapshot_name("manual"))); 187 | assert!(!target.matches_snapshot_name(&snapshot_name("manual-20120824"))); 188 | assert!(!target.matches_snapshot_name(&snapshot_name("manual-20120824-123456"))); 189 | assert!(!target.matches_snapshot_name(&snapshot_name("manual-20120824-123456-bus"))); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/config/hooks.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use serde::Deserialize; 3 | 4 | #[derive(Clone, Debug, Default, Deserialize)] 5 | #[serde(deny_unknown_fields)] 6 | #[serde(rename_all = "kebab-case")] 7 | pub struct Hooks { 8 | on_backup_started: Option, 9 | on_snapshot_created: Option, 10 | on_instance_backed_up: Option, 11 | on_backup_completed: Option, 12 | 13 | on_prune_started: Option, 14 | on_snapshot_deleted: Option, 15 | on_instance_pruned: Option, 16 | on_prune_completed: Option, 17 | } 18 | 19 | impl Hooks { 20 | pub fn on_backup_started(&self) -> Option { 21 | Self::render(self.on_backup_started.as_deref(), &[]) 22 | } 23 | 24 | pub fn on_snapshot_created( 25 | &self, 26 | remote_name: &LxdRemoteName, 27 | project_name: &LxdProjectName, 28 | instance_name: &LxdInstanceName, 29 | snapshot_name: &LxdSnapshotName, 30 | ) -> Option { 31 | Self::render( 32 | self.on_snapshot_created.as_deref(), 33 | &[ 34 | ("remoteName", remote_name.as_str()), 35 | ("projectName", project_name.as_str()), 36 | ("instanceName", instance_name.as_str()), 37 | ("snapshotName", snapshot_name.as_str()), 38 | ], 39 | ) 40 | } 41 | 42 | pub fn on_instance_backed_up( 43 | &self, 44 | remote_name: &LxdRemoteName, 45 | project_name: &LxdProjectName, 46 | instance_name: &LxdInstanceName, 47 | ) -> Option { 48 | Self::render( 49 | self.on_instance_backed_up.as_deref(), 50 | &[ 51 | ("remoteName", remote_name.as_str()), 52 | ("projectName", project_name.as_str()), 53 | ("instanceName", instance_name.as_str()), 54 | ], 55 | ) 56 | } 57 | 58 | pub fn on_backup_completed(&self) -> Option { 59 | Self::render(self.on_backup_completed.as_deref(), &[]) 60 | } 61 | 62 | pub fn on_prune_started(&self) -> Option { 63 | Self::render(self.on_prune_started.as_deref(), &[]) 64 | } 65 | 66 | pub fn on_snapshot_deleted( 67 | &self, 68 | remote_name: &LxdRemoteName, 69 | project_name: &LxdProjectName, 70 | instance_name: &LxdInstanceName, 71 | snapshot_name: &LxdSnapshotName, 72 | ) -> Option { 73 | Self::render( 74 | self.on_snapshot_deleted.as_deref(), 75 | &[ 76 | ("remoteName", remote_name.as_str()), 77 | ("projectName", project_name.as_str()), 78 | ("instanceName", instance_name.as_str()), 79 | ("snapshotName", snapshot_name.as_str()), 80 | ], 81 | ) 82 | } 83 | 84 | pub fn on_instance_pruned( 85 | &self, 86 | remote_name: &LxdRemoteName, 87 | project_name: &LxdProjectName, 88 | instance_name: &LxdInstanceName, 89 | ) -> Option { 90 | Self::render( 91 | self.on_instance_pruned.as_deref(), 92 | &[ 93 | ("remoteName", remote_name.as_str()), 94 | ("projectName", project_name.as_str()), 95 | ("instanceName", instance_name.as_str()), 96 | ], 97 | ) 98 | } 99 | 100 | pub fn on_prune_completed(&self) -> Option { 101 | Self::render(self.on_prune_completed.as_deref(), &[]) 102 | } 103 | 104 | fn render(cmd: Option<&str>, variables: &[(&str, &str)]) -> Option { 105 | cmd.map(|cmd| { 106 | variables 107 | .iter() 108 | .fold(cmd.to_string(), |template, (var_name, var_value)| { 109 | template 110 | .replace(&format!("{{{{{}}}}}", var_name), var_value) 111 | .replace(&format!("{{{{ {} }}}}", var_name), var_value) 112 | }) 113 | }) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::*; 120 | 121 | #[test] 122 | fn smoke() { 123 | let hooks = Hooks { 124 | on_backup_started: Some("on-backup-started".into()), 125 | on_snapshot_created: Some( 126 | "on-snapshot-created(\ 127 | {{ remoteName }}, \ 128 | {{ projectName }}, \ 129 | {{ instanceName }}, \ 130 | {{ snapshotName }}\ 131 | )" 132 | .into(), 133 | ), 134 | on_instance_backed_up: Some( 135 | "on-instance-backed-up(\ 136 | {{ remoteName }}, \ 137 | {{ projectName }}, \ 138 | {{ instanceName }}\ 139 | )" 140 | .into(), 141 | ), 142 | on_backup_completed: Some("on-backup-completed".into()), 143 | on_prune_started: Some("on-prune-started".into()), 144 | on_snapshot_deleted: Some( 145 | "on-snapshot-deleted(\ 146 | {{ remoteName }}, \ 147 | {{ projectName }}, \ 148 | {{ instanceName }}, \ 149 | {{ snapshotName }}\ 150 | )" 151 | .into(), 152 | ), 153 | on_instance_pruned: Some( 154 | "on-instance-pruned(\ 155 | {{ remoteName }}, \ 156 | {{ projectName }}, \ 157 | {{ instanceName }}\ 158 | )" 159 | .into(), 160 | ), 161 | on_prune_completed: Some("on-prune-completed".into()), 162 | }; 163 | 164 | let remote_name = LxdRemoteName::new("remote"); 165 | let project_name = LxdProjectName::new("project"); 166 | let instance_name = LxdInstanceName::new("instance"); 167 | let snapshot_name = LxdSnapshotName::new("snapshot"); 168 | 169 | assert_eq!( 170 | Some("on-backup-started"), 171 | hooks.on_backup_started().as_deref() 172 | ); 173 | 174 | assert_eq!( 175 | Some("on-snapshot-created(remote, project, instance, snapshot)"), 176 | hooks 177 | .on_snapshot_created(&remote_name, &project_name, &instance_name, &snapshot_name) 178 | .as_deref() 179 | ); 180 | 181 | assert_eq!( 182 | Some("on-instance-backed-up(remote, project, instance)"), 183 | hooks 184 | .on_instance_backed_up(&remote_name, &project_name, &instance_name) 185 | .as_deref() 186 | ); 187 | 188 | assert_eq!( 189 | Some("on-backup-completed"), 190 | hooks.on_backup_completed().as_deref() 191 | ); 192 | 193 | assert_eq!( 194 | Some("on-prune-started"), 195 | hooks.on_prune_started().as_deref() 196 | ); 197 | 198 | assert_eq!( 199 | Some("on-snapshot-deleted(remote, project, instance, snapshot)"), 200 | hooks 201 | .on_snapshot_deleted(&remote_name, &project_name, &instance_name, &snapshot_name) 202 | .as_deref() 203 | ); 204 | 205 | assert_eq!( 206 | Some("on-instance-pruned(remote, project, instance)"), 207 | hooks 208 | .on_instance_pruned(&remote_name, &project_name, &instance_name) 209 | .as_deref() 210 | ); 211 | 212 | assert_eq!( 213 | Some("on-prune-completed"), 214 | hooks.on_prune_completed().as_deref() 215 | ); 216 | } 217 | 218 | #[test] 219 | fn render() { 220 | assert_eq!(None, Hooks::render(None, &[])); 221 | 222 | // --- 223 | 224 | let actual = Hooks::render( 225 | Some("one {{one}} {{ one }} two {{two}}"), 226 | &[("one", "1"), ("two", "2")], 227 | ); 228 | 229 | assert_eq!(Some("one 1 1 two 2"), actual.as_deref()); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/config/policies.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use indexmap::IndexMap; 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Default, Deserialize)] 6 | #[serde(deny_unknown_fields)] 7 | #[serde(transparent)] 8 | pub struct Policies { 9 | policies: IndexMap, 10 | } 11 | 12 | impl Policies { 13 | pub fn find<'a>( 14 | &'a self, 15 | remote: &'a LxdRemoteName, 16 | project: &'a LxdProject, 17 | instance: &'a LxdInstance, 18 | ) -> impl Iterator + 'a { 19 | self.policies 20 | .iter() 21 | .filter(|(_, policy)| policy.applies_to(remote, project, instance)) 22 | .map(|(name, policy)| (name.as_str(), policy)) 23 | } 24 | 25 | pub fn matches( 26 | &self, 27 | remote: &LxdRemoteName, 28 | project: &LxdProject, 29 | instance: &LxdInstance, 30 | ) -> bool { 31 | self.find(remote, project, instance).next().is_some() 32 | } 33 | 34 | pub fn build( 35 | &self, 36 | remote: &LxdRemoteName, 37 | project: &LxdProject, 38 | instance: &LxdInstance, 39 | ) -> Option { 40 | self.find(remote, project, instance) 41 | .map(|(_, policy)| policy) 42 | .fold(None, |result, current| { 43 | Some(result.unwrap_or_default().merge_with(current.clone())) 44 | }) 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | 52 | mod build { 53 | use super::*; 54 | 55 | fn config(file: &str) -> Config { 56 | Config::load(format!("docs/example-configs/{}.yaml", file)).unwrap() 57 | } 58 | 59 | fn assert_policy( 60 | config: &Config, 61 | remote_name: &str, 62 | project_name: &str, 63 | instance_name: &str, 64 | instance_status: LxdInstanceStatus, 65 | expected_policy: Option, 66 | ) { 67 | let remote_name = LxdRemoteName::new(remote_name); 68 | 69 | let project = LxdProject { 70 | name: LxdProjectName::new(project_name), 71 | }; 72 | 73 | let instance = LxdInstance { 74 | name: LxdInstanceName::new(instance_name), 75 | status: instance_status, 76 | snapshots: Default::default(), 77 | }; 78 | 79 | pa::assert_eq!( 80 | expected_policy, 81 | config.policies.build(&remote_name, &project, &instance), 82 | "remote_name={remote_name}, project_name={project_name}, instance_name={instance_name}, instance_status={instance_status}", 83 | remote_name = remote_name.as_str(), 84 | project_name = project_name, 85 | instance_name = instance_name, 86 | instance_status = format!("{:?}", instance_status), 87 | ); 88 | } 89 | 90 | #[test] 91 | fn cascading() { 92 | let config = config("cascading"); 93 | 94 | // -------- // 95 | // Client A // 96 | 97 | // `everyone` + `important-clients` 98 | assert_policy( 99 | &config, 100 | "local", 101 | "client-a", 102 | "php", 103 | LxdInstanceStatus::Running, 104 | Some(Policy { 105 | keep_last: Some(15), 106 | ..Default::default() 107 | }), 108 | ); 109 | 110 | // `everyone` + `important-clients` + `databases` 111 | assert_policy( 112 | &config, 113 | "local", 114 | "client-a", 115 | "mysql", 116 | LxdInstanceStatus::Running, 117 | Some(Policy { 118 | keep_last: Some(25), 119 | ..Default::default() 120 | }), 121 | ); 122 | 123 | // -------- // 124 | // Client B // 125 | 126 | // `everyone` + `important-clients` 127 | assert_policy( 128 | &config, 129 | "local", 130 | "client-b", 131 | "php", 132 | LxdInstanceStatus::Running, 133 | Some(Policy { 134 | keep_last: Some(15), 135 | ..Default::default() 136 | }), 137 | ); 138 | 139 | // `everyone` + `important-clients` + `databases` 140 | assert_policy( 141 | &config, 142 | "local", 143 | "client-b", 144 | "mysql", 145 | LxdInstanceStatus::Running, 146 | Some(Policy { 147 | keep_last: Some(25), 148 | ..Default::default() 149 | }), 150 | ); 151 | 152 | // -------- // 153 | // Client C // 154 | 155 | // `everyone` + `unimportant-clients` 156 | assert_policy( 157 | &config, 158 | "local", 159 | "client-c", 160 | "php", 161 | LxdInstanceStatus::Running, 162 | Some(Policy { 163 | keep_last: Some(5), 164 | ..Default::default() 165 | }), 166 | ); 167 | 168 | // `everyone` + `unimportant-clients` + `databases` 169 | assert_policy( 170 | &config, 171 | "local", 172 | "client-c", 173 | "mysql", 174 | LxdInstanceStatus::Running, 175 | Some(Policy { 176 | keep_last: Some(25), 177 | ..Default::default() 178 | }), 179 | ); 180 | 181 | // -------- // 182 | // Client D // 183 | 184 | // `everyone` 185 | assert_policy( 186 | &config, 187 | "local", 188 | "client-d", 189 | "php", 190 | LxdInstanceStatus::Running, 191 | Some(Policy { 192 | keep_last: Some(2), 193 | ..Default::default() 194 | }), 195 | ); 196 | 197 | // `everyone` + `databases` 198 | assert_policy( 199 | &config, 200 | "local", 201 | "client-d", 202 | "mysql", 203 | LxdInstanceStatus::Running, 204 | Some(Policy { 205 | keep_last: Some(25), 206 | ..Default::default() 207 | }), 208 | ); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/config/policy.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use serde::Deserialize; 3 | use std::collections::HashSet; 4 | use std::hash::Hash; 5 | 6 | #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)] 7 | #[serde(deny_unknown_fields)] 8 | #[serde(rename_all = "kebab-case")] 9 | pub struct Policy { 10 | pub included_remotes: Option>, 11 | pub excluded_remotes: Option>, 12 | pub included_projects: Option>, 13 | pub excluded_projects: Option>, 14 | pub included_instances: Option>, 15 | pub excluded_instances: Option>, 16 | pub included_statuses: Option>, 17 | pub excluded_statuses: Option>, 18 | pub keep_hourly: Option, 19 | pub keep_daily: Option, 20 | pub keep_weekly: Option, 21 | pub keep_monthly: Option, 22 | pub keep_yearly: Option, 23 | pub keep_last: Option, 24 | pub keep_limit: Option, 25 | } 26 | 27 | impl Policy { 28 | pub fn keep_hourly(&self) -> usize { 29 | self.keep_hourly.unwrap_or(0) 30 | } 31 | 32 | pub fn keep_daily(&self) -> usize { 33 | self.keep_daily.unwrap_or(0) 34 | } 35 | 36 | pub fn keep_weekly(&self) -> usize { 37 | self.keep_weekly.unwrap_or(0) 38 | } 39 | 40 | pub fn keep_monthly(&self) -> usize { 41 | self.keep_monthly.unwrap_or(0) 42 | } 43 | 44 | pub fn keep_yearly(&self) -> usize { 45 | self.keep_yearly.unwrap_or(0) 46 | } 47 | 48 | pub fn keep_last(&self) -> usize { 49 | self.keep_last.unwrap_or(0) 50 | } 51 | 52 | pub fn keep_limit(&self) -> Option { 53 | self.keep_limit 54 | } 55 | 56 | pub fn applies_to( 57 | &self, 58 | remote: &LxdRemoteName, 59 | project: &LxdProject, 60 | instance: &LxdInstance, 61 | ) -> bool { 62 | fn set_contains(items: &Option>, item: &T, default: bool) -> bool 63 | where 64 | T: Hash + Eq, 65 | { 66 | items 67 | .as_ref() 68 | .map(|items| items.contains(item)) 69 | .unwrap_or(default) 70 | } 71 | 72 | let remote_included = set_contains(&self.included_remotes, remote, true); 73 | let remote_excluded = set_contains(&self.excluded_remotes, remote, false); 74 | 75 | let project_included = set_contains(&self.included_projects, &project.name, true); 76 | let project_excluded = set_contains(&self.excluded_projects, &project.name, false); 77 | 78 | let instance_included = set_contains(&self.included_instances, &instance.name, true); 79 | let instance_excluded = set_contains(&self.excluded_instances, &instance.name, false); 80 | 81 | let status_included = set_contains(&self.included_statuses, &instance.status, true); 82 | let status_excluded = set_contains(&self.excluded_statuses, &instance.status, false); 83 | 84 | remote_included 85 | && !remote_excluded 86 | && project_included 87 | && !project_excluded 88 | && instance_included 89 | && !instance_excluded 90 | && status_included 91 | && !status_excluded 92 | } 93 | 94 | pub fn merge_with(self, other: Self) -> Self { 95 | Self { 96 | included_remotes: None, 97 | excluded_remotes: None, 98 | included_projects: None, 99 | excluded_projects: None, 100 | included_instances: None, 101 | excluded_instances: None, 102 | included_statuses: None, 103 | excluded_statuses: None, 104 | keep_hourly: other.keep_hourly.or(self.keep_hourly), 105 | keep_daily: other.keep_daily.or(self.keep_daily), 106 | keep_weekly: other.keep_weekly.or(self.keep_weekly), 107 | keep_monthly: other.keep_monthly.or(self.keep_monthly), 108 | keep_yearly: other.keep_yearly.or(self.keep_yearly), 109 | keep_last: other.keep_last.or(self.keep_last), 110 | keep_limit: other.keep_limit.or(self.keep_limit), 111 | } 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::*; 118 | 119 | mod applies_to { 120 | use super::*; 121 | 122 | fn remotes(names: &[&str]) -> HashSet { 123 | names.iter().map(LxdRemoteName::new).collect() 124 | } 125 | 126 | fn projects(names: &[&str]) -> HashSet { 127 | names.iter().map(LxdProjectName::new).collect() 128 | } 129 | 130 | fn instances(names: &[&str]) -> HashSet { 131 | names.iter().map(LxdInstanceName::new).collect() 132 | } 133 | 134 | fn statuses(statuses: &[LxdInstanceStatus]) -> HashSet { 135 | statuses.iter().cloned().collect() 136 | } 137 | 138 | fn check( 139 | policy: &Policy, 140 | expected: impl Fn(&LxdRemoteName, &LxdProject, &LxdInstance) -> bool, 141 | ) { 142 | let mut matching_instances = 0; 143 | 144 | let instances = [ 145 | ("instance-a", LxdInstanceStatus::Running), 146 | ("instance-b", LxdInstanceStatus::Aborting), 147 | ("instance-c", LxdInstanceStatus::Stopped), 148 | ]; 149 | 150 | for remote in ["remote-a", "remote-b", "remote-c"] { 151 | let remote = LxdRemoteName::new(remote); 152 | 153 | for project in ["project-a", "project-b", "project-c"] { 154 | let project = LxdProject { 155 | name: LxdProjectName::new(project), 156 | }; 157 | 158 | for (instance_name, instance_status) in instances { 159 | let instance = LxdInstance { 160 | name: LxdInstanceName::new(instance_name), 161 | status: instance_status, 162 | snapshots: Default::default(), 163 | }; 164 | 165 | let actual = policy.applies_to(&remote, &project, &instance); 166 | let expected = expected(&remote, &project, &instance); 167 | 168 | assert_eq!( 169 | expected, actual, 170 | "\nAssertion failed for:\n- remote = {:?}\n- project = {:?}\n- instance = {:?}", 171 | remote, project, instance 172 | ); 173 | 174 | if actual { 175 | matching_instances += 1; 176 | } 177 | } 178 | } 179 | } 180 | 181 | assert!( 182 | matching_instances > 0, 183 | "Tested policy doesn't match any instance" 184 | ); 185 | } 186 | 187 | #[test] 188 | fn given_policy_with_no_restrictions() { 189 | check(&Policy::default(), |_, _, _| true); 190 | } 191 | 192 | #[test] 193 | fn given_policy_with_included_remotes() { 194 | let policy = Policy { 195 | included_remotes: Some(remotes(&["remote-a", "remote-c"])), 196 | ..Default::default() 197 | }; 198 | 199 | check(&policy, |remote, _, _| { 200 | ["remote-a", "remote-c"].contains(&remote.as_str()) 201 | }); 202 | } 203 | 204 | #[test] 205 | fn given_policy_with_excluded_remotes() { 206 | let policy = Policy { 207 | excluded_remotes: Some(remotes(&["remote-a", "remote-c"])), 208 | ..Default::default() 209 | }; 210 | 211 | check(&policy, |remote, _, _| { 212 | !["remote-a", "remote-c"].contains(&remote.as_str()) 213 | }); 214 | } 215 | 216 | #[test] 217 | fn given_policy_with_included_projects() { 218 | let policy = Policy { 219 | included_projects: Some(projects(&["project-a", "project-c"])), 220 | ..Default::default() 221 | }; 222 | 223 | check(&policy, |_, project, _| { 224 | ["project-a", "project-c"].contains(&project.name.as_str()) 225 | }); 226 | } 227 | 228 | #[test] 229 | fn given_policy_with_excluded_projects() { 230 | let policy = Policy { 231 | excluded_projects: Some(projects(&["project-a", "project-c"])), 232 | ..Default::default() 233 | }; 234 | 235 | check(&policy, |_, project, _| { 236 | !["project-a", "project-c"].contains(&project.name.as_str()) 237 | }); 238 | } 239 | 240 | #[test] 241 | fn given_policy_with_included_instances() { 242 | let policy = Policy { 243 | included_instances: Some(instances(&["instance-a", "instance-c"])), 244 | ..Default::default() 245 | }; 246 | 247 | check(&policy, |_, _, instance| { 248 | ["instance-a", "instance-c"].contains(&instance.name.as_str()) 249 | }); 250 | } 251 | 252 | #[test] 253 | fn given_policy_with_excluded_instances() { 254 | let policy = Policy { 255 | excluded_instances: Some(instances(&["instance-a", "instance-c"])), 256 | ..Default::default() 257 | }; 258 | 259 | check(&policy, |_, _, instance| { 260 | !["instance-a", "instance-c"].contains(&instance.name.as_str()) 261 | }); 262 | } 263 | 264 | #[test] 265 | fn given_policy_with_included_statuses() { 266 | let policy = Policy { 267 | included_statuses: Some(statuses(&[ 268 | LxdInstanceStatus::Aborting, 269 | LxdInstanceStatus::Stopped, 270 | ])), 271 | ..Default::default() 272 | }; 273 | 274 | check(&policy, |_, _, instance| { 275 | [LxdInstanceStatus::Aborting, LxdInstanceStatus::Stopped].contains(&instance.status) 276 | }); 277 | } 278 | 279 | #[test] 280 | fn given_policy_with_excluded_statuses() { 281 | let policy = Policy { 282 | excluded_statuses: Some(statuses(&[ 283 | LxdInstanceStatus::Aborting, 284 | LxdInstanceStatus::Stopped, 285 | ])), 286 | ..Default::default() 287 | }; 288 | 289 | check(&policy, |_, _, instance| { 290 | ![LxdInstanceStatus::Aborting, LxdInstanceStatus::Stopped] 291 | .contains(&instance.status) 292 | }); 293 | } 294 | 295 | #[test] 296 | fn given_policy_with_mixed_rules() { 297 | let policy = Policy { 298 | included_remotes: Some(remotes(&["remote-a", "remote-b"])), 299 | included_projects: Some(projects(&["project-b", "project-c"])), 300 | included_instances: Some(instances(&["instance-a", "instance-c"])), 301 | ..Default::default() 302 | }; 303 | 304 | check(&policy, |remote, project, instance| { 305 | let remote_matches = ["remote-a", "remote-b"].contains(&remote.as_str()); 306 | let project_matches = ["project-b", "project-c"].contains(&project.name.as_str()); 307 | let instance_matches = 308 | ["instance-a", "instance-c"].contains(&instance.name.as_str()); 309 | 310 | remote_matches && project_matches && instance_matches 311 | }); 312 | } 313 | } 314 | 315 | #[test] 316 | fn merge_with() { 317 | // Policy A: has all values set, serves as a base 318 | let policy_a = Policy { 319 | keep_daily: Some(10), 320 | keep_weekly: Some(5), 321 | keep_monthly: Some(2), 322 | keep_yearly: Some(1), 323 | keep_last: Some(8), 324 | ..Default::default() 325 | }; 326 | 327 | // Policy B: overwrites only the `keep weekly` and `keep monthly` options 328 | let policy_b = Policy { 329 | keep_weekly: Some(100), 330 | keep_monthly: Some(200), 331 | ..Default::default() 332 | }; 333 | 334 | // Policy C: overwrites only the `keep yearly` option 335 | let policy_c = Policy { 336 | keep_yearly: Some(100), 337 | ..Default::default() 338 | }; 339 | 340 | // Policy A + B 341 | let policy_ab = Policy { 342 | keep_weekly: Some(100), // Overwritten from policy B 343 | keep_monthly: Some(200), // Overwritten from policy B 344 | ..policy_a.clone() 345 | }; 346 | 347 | // Policy A + C 348 | let policy_ac = Policy { 349 | keep_yearly: Some(100), // Overwritten from policy C 350 | ..policy_a.clone() 351 | }; 352 | 353 | pa::assert_eq!(policy_a.clone().merge_with(policy_b), policy_ab); 354 | pa::assert_eq!(policy_a.merge_with(policy_c), policy_ac); 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/config/remotes.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use serde::Deserialize; 3 | 4 | #[derive(Debug, Deserialize)] 5 | #[serde(deny_unknown_fields)] 6 | #[serde(transparent)] 7 | pub struct Remotes { 8 | remotes: Vec, 9 | } 10 | 11 | impl Remotes { 12 | pub fn has_any_non_local_remotes(&self) -> bool { 13 | self.remotes.iter().any(|remote| remote.as_str() != "local") 14 | } 15 | 16 | pub fn iter(&self) -> impl Iterator { 17 | self.remotes.iter() 18 | } 19 | } 20 | 21 | impl Default for Remotes { 22 | fn default() -> Self { 23 | Self { 24 | remotes: vec![LxdRemoteName::local()], 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/environment.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::fmt::Write as _; 3 | use std::io::Write; 4 | use std::process::Command; 5 | 6 | pub struct Environment<'a> { 7 | pub time: fn() -> DateTime, 8 | pub stdout: &'a mut dyn Write, 9 | pub config: &'a Config, 10 | pub lxd: &'a mut dyn LxdClient, 11 | pub dry_run: bool, 12 | } 13 | 14 | impl<'a> Environment<'a> { 15 | #[cfg(test)] 16 | pub fn test(stdout: &'a mut dyn Write, config: &'a Config, lxd: &'a mut dyn LxdClient) -> Self { 17 | use chrono::TimeZone; 18 | 19 | Self { 20 | time: || Utc.timestamp_opt(0, 0).unwrap(), 21 | stdout, 22 | config, 23 | lxd, 24 | dry_run: false, 25 | } 26 | } 27 | 28 | pub fn time(&self) -> DateTime { 29 | (self.time)() 30 | } 31 | 32 | pub fn hooks<'b>(&'b mut self) -> EnvironmentHooks<'b, 'a> { 33 | EnvironmentHooks { env: self } 34 | } 35 | } 36 | 37 | pub struct EnvironmentHooks<'b, 'a> { 38 | env: &'b mut Environment<'a>, 39 | } 40 | 41 | impl EnvironmentHooks<'_, '_> { 42 | pub fn on_backup_started(&mut self) -> Result<()> { 43 | let cmd = self.env.config.hooks().on_backup_started(); 44 | 45 | self.run("on-backup-started", cmd) 46 | } 47 | 48 | pub fn on_snapshot_created( 49 | &mut self, 50 | remote_name: &LxdRemoteName, 51 | project_name: &LxdProjectName, 52 | instance_name: &LxdInstanceName, 53 | snapshot_name: &LxdSnapshotName, 54 | ) -> Result<()> { 55 | let cmd = self.env.config.hooks().on_snapshot_created( 56 | remote_name, 57 | project_name, 58 | instance_name, 59 | snapshot_name, 60 | ); 61 | 62 | self.run("on-snapshot-created", cmd) 63 | } 64 | 65 | pub fn on_instance_backed_up( 66 | &mut self, 67 | remote_name: &LxdRemoteName, 68 | project_name: &LxdProjectName, 69 | instance_name: &LxdInstanceName, 70 | ) -> Result<()> { 71 | let cmd = 72 | self.env 73 | .config 74 | .hooks() 75 | .on_instance_backed_up(remote_name, project_name, instance_name); 76 | 77 | self.run("on-instance-backed-up", cmd) 78 | } 79 | 80 | pub fn on_backup_completed(&mut self) -> Result<()> { 81 | let cmd = self.env.config.hooks().on_backup_completed(); 82 | 83 | self.run("on-backup-completed", cmd) 84 | } 85 | 86 | pub fn on_prune_started(&mut self) -> Result<()> { 87 | let cmd = self.env.config.hooks().on_prune_started(); 88 | 89 | self.run("on-prune-started", cmd) 90 | } 91 | 92 | pub fn on_instance_pruned( 93 | &mut self, 94 | remote_name: &LxdRemoteName, 95 | project_name: &LxdProjectName, 96 | instance_name: &LxdInstanceName, 97 | ) -> Result<()> { 98 | let cmd = 99 | self.env 100 | .config 101 | .hooks() 102 | .on_instance_pruned(remote_name, project_name, instance_name); 103 | 104 | self.run("on-instance-pruned", cmd) 105 | } 106 | 107 | pub fn on_snapshot_deleted( 108 | &mut self, 109 | remote_name: &LxdRemoteName, 110 | project_name: &LxdProjectName, 111 | instance_name: &LxdInstanceName, 112 | snapshot_name: &LxdSnapshotName, 113 | ) -> Result<()> { 114 | let cmd = self.env.config.hooks().on_snapshot_deleted( 115 | remote_name, 116 | project_name, 117 | instance_name, 118 | snapshot_name, 119 | ); 120 | 121 | self.run("on-snapshot-deleted", cmd) 122 | } 123 | 124 | pub fn on_prune_completed(&mut self) -> Result<()> { 125 | let cmd = self.env.config.hooks().on_prune_completed(); 126 | 127 | self.run("on-prune-completed", cmd) 128 | } 129 | 130 | fn run(&mut self, hook: &str, cmd: Option) -> Result<()> { 131 | self.try_run(cmd) 132 | .with_context(|| format!("Couldn't execute the `{}` hook", hook)) 133 | } 134 | 135 | fn try_run(&mut self, cmd: Option) -> Result<()> { 136 | let cmd = if let Some(cmd) = cmd { 137 | cmd 138 | } else { 139 | return Ok(()); 140 | }; 141 | 142 | if self.env.dry_run { 143 | return Ok(()); 144 | } 145 | 146 | let result = Command::new("sh") 147 | .arg("-c") 148 | .arg(cmd) 149 | .output() 150 | .context("Couldn't launch hook")?; 151 | 152 | if !result.status.success() { 153 | let stdout = String::from_utf8_lossy(&result.stdout); 154 | let stdout = stdout.trim(); 155 | 156 | let stderr = String::from_utf8_lossy(&result.stderr); 157 | let stderr = stderr.trim(); 158 | 159 | let mut msg = String::from("Hook returned a non-zero exit code."); 160 | 161 | if !stdout.is_empty() { 162 | msg.push_str("\n\nHook's stdout:"); 163 | 164 | for line in stdout.lines() { 165 | _ = write!(&mut msg, "\n {}", line); 166 | } 167 | } 168 | 169 | if !stderr.is_empty() { 170 | msg.push_str("\n\nHook's stderr:"); 171 | 172 | for line in stderr.lines() { 173 | _ = write!(&mut msg, "\n {}", line); 174 | } 175 | } 176 | 177 | bail!(msg); 178 | } 179 | 180 | Ok(()) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/lxd.rs: -------------------------------------------------------------------------------- 1 | mod clients; 2 | mod error; 3 | mod models; 4 | 5 | pub use self::{clients::*, error::*, models::*}; 6 | 7 | pub trait LxdClient { 8 | fn projects(&mut self, remote: &LxdRemoteName) -> LxdResult>; 9 | 10 | fn instances( 11 | &mut self, 12 | remote: &LxdRemoteName, 13 | project: &LxdProjectName, 14 | ) -> LxdResult>; 15 | 16 | fn create_snapshot( 17 | &mut self, 18 | remote: &LxdRemoteName, 19 | project: &LxdProjectName, 20 | instance: &LxdInstanceName, 21 | snapshot: &LxdSnapshotName, 22 | ) -> LxdResult<()>; 23 | 24 | fn delete_snapshot( 25 | &mut self, 26 | remote: &LxdRemoteName, 27 | project: &LxdProjectName, 28 | instance: &LxdInstanceName, 29 | snapshot: &LxdSnapshotName, 30 | ) -> LxdResult<()>; 31 | } 32 | 33 | #[cfg(test)] 34 | pub mod utils { 35 | use super::*; 36 | use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; 37 | 38 | pub fn remote_name(name: impl AsRef) -> LxdRemoteName { 39 | LxdRemoteName::new(name) 40 | } 41 | 42 | pub fn instance(name: impl AsRef) -> LxdInstance { 43 | LxdInstance { 44 | name: instance_name(name), 45 | status: LxdInstanceStatus::Running, 46 | snapshots: Default::default(), 47 | } 48 | } 49 | 50 | pub fn instance_name(name: impl AsRef) -> LxdInstanceName { 51 | LxdInstanceName::new(name) 52 | } 53 | 54 | pub fn project(name: impl AsRef) -> LxdProject { 55 | LxdProject { 56 | name: project_name(name), 57 | } 58 | } 59 | 60 | pub fn project_name(name: impl AsRef) -> LxdProjectName { 61 | LxdProjectName::new(name) 62 | } 63 | 64 | pub fn snapshot(name: impl AsRef, created_at: impl AsRef) -> LxdSnapshot { 65 | LxdSnapshot { 66 | name: snapshot_name(name), 67 | created_at: datetime(created_at), 68 | } 69 | } 70 | 71 | pub fn snapshot_name(name: impl AsRef) -> LxdSnapshotName { 72 | LxdSnapshotName::new(name) 73 | } 74 | 75 | pub fn datetime(datetime: impl AsRef) -> DateTime { 76 | let datetime = 77 | NaiveDateTime::parse_from_str(datetime.as_ref(), "%Y-%m-%d %H:%M:%S").unwrap(); 78 | 79 | Utc.from_utc_datetime(&datetime) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lxd/clients.rs: -------------------------------------------------------------------------------- 1 | mod fake; 2 | mod process; 3 | 4 | pub use self::{fake::*, process::*}; 5 | -------------------------------------------------------------------------------- /src/lxd/clients/fake.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::*; 2 | use chrono::Utc; 3 | use itertools::Itertools; 4 | use std::collections::BTreeMap; 5 | 6 | #[cfg(test)] 7 | use std::fmt; 8 | 9 | #[cfg(test)] 10 | use std::collections::HashSet; 11 | 12 | #[derive(Debug, Default)] 13 | #[cfg_attr(test, derive(PartialEq, Eq))] 14 | pub struct LxdFakeClient { 15 | instances: BTreeMap, 16 | 17 | #[cfg(test)] 18 | errors: HashSet>, 19 | } 20 | 21 | impl LxdFakeClient { 22 | pub fn clone_from<'a>( 23 | other: &mut dyn LxdClient, 24 | remotes: impl IntoIterator, 25 | ) -> LxdResult { 26 | let mut this = Self::default(); 27 | 28 | for remote in remotes { 29 | for project in other.projects(remote)? { 30 | for instance in other.instances(remote, &project.name)? { 31 | this.instances.insert( 32 | LxdInstanceId { 33 | remote: remote.to_owned(), 34 | project: project.name.clone(), 35 | instance: instance.name.clone(), 36 | }, 37 | instance, 38 | ); 39 | } 40 | } 41 | } 42 | 43 | Ok(this) 44 | } 45 | 46 | #[cfg(test)] 47 | pub fn add(&mut self, instance: LxdFakeInstance<'_>) { 48 | self.instances.insert( 49 | LxdInstanceId { 50 | remote: LxdRemoteName::new(instance.remote), 51 | project: LxdProjectName::new(instance.project), 52 | instance: LxdInstanceName::new(instance.name), 53 | }, 54 | LxdInstance { 55 | name: LxdInstanceName::new(instance.name), 56 | status: instance.status, 57 | snapshots: instance.snapshots, 58 | }, 59 | ); 60 | } 61 | 62 | #[cfg(test)] 63 | pub fn inject_error(&mut self, error: LxdFakeError<'static>) { 64 | self.errors.insert(error); 65 | } 66 | 67 | fn get_mut( 68 | &mut self, 69 | remote: &LxdRemoteName, 70 | project: &LxdProjectName, 71 | instance: &LxdInstanceName, 72 | ) -> LxdResult<&mut LxdInstance> { 73 | let id = LxdInstanceId { 74 | remote: remote.to_owned(), 75 | project: project.to_owned(), 76 | instance: instance.to_owned(), 77 | }; 78 | 79 | self.instances 80 | .get_mut(&id) 81 | .ok_or_else(|| LxdError::NoSuchInstance { 82 | remote: remote.to_owned(), 83 | project: project.to_owned(), 84 | instance: instance.to_owned(), 85 | }) 86 | } 87 | } 88 | 89 | impl LxdClient for LxdFakeClient { 90 | fn projects(&mut self, remote: &LxdRemoteName) -> LxdResult> { 91 | let projects = self 92 | .instances 93 | .keys() 94 | .filter(|id| &id.remote == remote) 95 | .map(|id| &id.project) 96 | .unique() 97 | .cloned() 98 | .map(|name| LxdProject { name }) 99 | .collect(); 100 | 101 | Ok(projects) 102 | } 103 | 104 | fn instances( 105 | &mut self, 106 | remote: &LxdRemoteName, 107 | project: &LxdProjectName, 108 | ) -> LxdResult> { 109 | let instances = self 110 | .instances 111 | .iter() 112 | .filter(|(id, _)| &id.remote == remote && &id.project == project) 113 | .map(|(_, instance)| instance.clone()) 114 | .collect(); 115 | 116 | Ok(instances) 117 | } 118 | 119 | fn create_snapshot( 120 | &mut self, 121 | remote: &LxdRemoteName, 122 | project: &LxdProjectName, 123 | instance: &LxdInstanceName, 124 | snapshot: &LxdSnapshotName, 125 | ) -> LxdResult<()> { 126 | #[cfg(test)] 127 | if self.errors.contains(&LxdFakeError::OnCreateSnapshot { 128 | remote: remote.as_str(), 129 | project: project.as_str(), 130 | instance: instance.as_str(), 131 | snapshot: snapshot.as_str(), 132 | }) { 133 | return Err(LxdError::InjectedError); 134 | } 135 | 136 | let instance_obj = self.get_mut(remote, project, instance)?; 137 | 138 | if instance_obj 139 | .snapshots 140 | .iter() 141 | .any(|snapshot_obj| &snapshot_obj.name == snapshot) 142 | { 143 | return Err(LxdError::SnapshotAlreadyExists { 144 | remote: remote.to_owned(), 145 | project: project.to_owned(), 146 | instance: instance.to_owned(), 147 | snapshot: snapshot.to_owned(), 148 | }); 149 | } 150 | 151 | instance_obj.snapshots.push(LxdSnapshot { 152 | name: snapshot.to_owned(), 153 | created_at: Utc::now(), 154 | }); 155 | 156 | Ok(()) 157 | } 158 | 159 | fn delete_snapshot( 160 | &mut self, 161 | remote: &LxdRemoteName, 162 | project: &LxdProjectName, 163 | instance: &LxdInstanceName, 164 | snapshot: &LxdSnapshotName, 165 | ) -> LxdResult<()> { 166 | #[cfg(test)] 167 | if self.errors.contains(&LxdFakeError::OnDeleteSnapshot { 168 | remote: remote.as_str(), 169 | project: project.as_str(), 170 | instance: instance.as_str(), 171 | snapshot: snapshot.as_str(), 172 | }) { 173 | return Err(LxdError::InjectedError); 174 | } 175 | 176 | let instance_obj = self.get_mut(remote, project, instance)?; 177 | 178 | let snapshot_idx = instance_obj 179 | .snapshots 180 | .iter() 181 | .position(|snapshot_obj| &snapshot_obj.name == snapshot) 182 | .ok_or_else(|| LxdError::NoSuchSnapshot { 183 | remote: remote.to_owned(), 184 | project: project.to_owned(), 185 | instance: instance.to_owned(), 186 | snapshot: snapshot.to_owned(), 187 | })?; 188 | 189 | instance_obj.snapshots.remove(snapshot_idx); 190 | 191 | Ok(()) 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | impl fmt::Display for LxdFakeClient { 197 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 198 | for (idx, (id, instance)) in self.instances.iter().enumerate() { 199 | if idx > 0 { 200 | writeln!(f)?; 201 | } 202 | 203 | writeln!( 204 | f, 205 | "{}:{}/{} ({:?})", 206 | id.remote, id.project, id.instance, instance.status 207 | )?; 208 | 209 | for snapshot in &instance.snapshots { 210 | writeln!(f, "-> {}", snapshot.name)?; 211 | } 212 | } 213 | 214 | Ok(()) 215 | } 216 | } 217 | 218 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 219 | struct LxdInstanceId { 220 | remote: LxdRemoteName, 221 | project: LxdProjectName, 222 | instance: LxdInstanceName, 223 | } 224 | 225 | #[cfg(test)] 226 | #[derive(Clone, Debug)] 227 | pub struct LxdFakeInstance<'a> { 228 | pub remote: &'a str, 229 | pub project: &'a str, 230 | pub name: &'a str, 231 | pub status: LxdInstanceStatus, 232 | pub snapshots: Vec, 233 | } 234 | 235 | #[cfg(test)] 236 | impl Default for LxdFakeInstance<'static> { 237 | fn default() -> Self { 238 | Self { 239 | remote: "local", 240 | project: "default", 241 | name: "", 242 | status: LxdInstanceStatus::Running, 243 | snapshots: Default::default(), 244 | } 245 | } 246 | } 247 | 248 | #[cfg(test)] 249 | #[derive(Clone, Debug, PartialEq, Eq, Hash)] 250 | pub enum LxdFakeError<'a> { 251 | OnCreateSnapshot { 252 | remote: &'a str, 253 | project: &'a str, 254 | instance: &'a str, 255 | snapshot: &'a str, 256 | }, 257 | 258 | OnDeleteSnapshot { 259 | remote: &'a str, 260 | project: &'a str, 261 | instance: &'a str, 262 | snapshot: &'a str, 263 | }, 264 | } 265 | 266 | #[cfg(test)] 267 | mod tests { 268 | use super::*; 269 | use crate::lxd::utils::*; 270 | use pretty_assertions as pa; 271 | 272 | fn lxd() -> LxdFakeClient { 273 | let mut lxd = LxdFakeClient::default(); 274 | 275 | lxd.add(LxdFakeInstance { 276 | project: "app", 277 | name: "checker", 278 | ..Default::default() 279 | }); 280 | 281 | lxd.add(LxdFakeInstance { 282 | project: "db", 283 | name: "elastic", 284 | ..Default::default() 285 | }); 286 | 287 | lxd.add(LxdFakeInstance { 288 | project: "db", 289 | name: "mysql", 290 | ..Default::default() 291 | }); 292 | 293 | lxd.add(LxdFakeInstance { 294 | remote: "remote-a", 295 | project: "db", 296 | name: "mysql", 297 | ..Default::default() 298 | }); 299 | 300 | lxd.add(LxdFakeInstance { 301 | remote: "remote-b", 302 | project: "db", 303 | name: "elastic", 304 | ..Default::default() 305 | }); 306 | 307 | lxd.add(LxdFakeInstance { 308 | remote: "remote-b", 309 | project: "log", 310 | name: "grafana", 311 | ..Default::default() 312 | }); 313 | 314 | lxd 315 | } 316 | 317 | #[test] 318 | fn clone_from() { 319 | let mut lxd1 = lxd(); 320 | 321 | let lxd2 = LxdFakeClient::clone_from( 322 | &mut lxd1, 323 | &[ 324 | remote_name("local"), 325 | remote_name("remote-a"), 326 | remote_name("remote-b"), 327 | remote_name("remote-c"), 328 | ], 329 | ) 330 | .unwrap(); 331 | 332 | pa::assert_eq!(lxd2, lxd1); 333 | } 334 | 335 | mod projects { 336 | use super::*; 337 | 338 | #[test] 339 | fn ok() { 340 | let mut lxd = lxd(); 341 | 342 | pa::assert_eq!( 343 | Ok(vec![project("app"), project("db")]), 344 | lxd.projects(&remote_name("local")), 345 | ); 346 | 347 | pa::assert_eq!( 348 | Ok(vec![project("db")]), 349 | lxd.projects(&remote_name("remote-a")) 350 | ); 351 | 352 | pa::assert_eq!( 353 | Ok(vec![project("db"), project("log")]), 354 | lxd.projects(&remote_name("remote-b")), 355 | ); 356 | 357 | pa::assert_eq!( 358 | Ok(vec![project("db"), project("log")]), 359 | lxd.projects(&remote_name("remote-b")), 360 | ); 361 | } 362 | 363 | #[test] 364 | fn given_unknown_remote() { 365 | let mut lxd = lxd(); 366 | 367 | pa::assert_eq!(Ok(vec![]), lxd.projects(&remote_name("unknown"))); 368 | } 369 | } 370 | 371 | mod instances { 372 | use super::*; 373 | 374 | #[test] 375 | fn ok() { 376 | let mut lxd = lxd(); 377 | 378 | pa::assert_eq!( 379 | Ok(vec![instance("checker")]), 380 | lxd.instances(&remote_name("local"), &project_name("app")) 381 | ); 382 | 383 | pa::assert_eq!( 384 | Ok(vec![instance("elastic"), instance("mysql")]), 385 | lxd.instances(&remote_name("local"), &project_name("db")) 386 | ); 387 | 388 | pa::assert_eq!( 389 | Ok(vec![instance("mysql")]), 390 | lxd.instances(&remote_name("remote-a"), &project_name("db")) 391 | ); 392 | 393 | pa::assert_eq!( 394 | Ok(vec![instance("elastic")]), 395 | lxd.instances(&remote_name("remote-b"), &project_name("db")) 396 | ); 397 | 398 | pa::assert_eq!( 399 | Ok(vec![instance("grafana")]), 400 | lxd.instances(&remote_name("remote-b"), &project_name("log")) 401 | ); 402 | } 403 | 404 | #[test] 405 | fn given_unknown_remote() { 406 | let mut lxd = lxd(); 407 | 408 | pa::assert_eq!( 409 | Ok(vec![]), 410 | lxd.instances(&remote_name("unknown"), &project_name("app")) 411 | ); 412 | } 413 | 414 | #[test] 415 | fn given_unknown_project() { 416 | let mut lxd = lxd(); 417 | 418 | pa::assert_eq!( 419 | Ok(vec![]), 420 | lxd.instances(&remote_name("local"), &project_name("unknown")) 421 | ); 422 | } 423 | } 424 | 425 | mod create_snapshot { 426 | use super::*; 427 | 428 | #[test] 429 | fn ok() { 430 | let mut lxd = lxd(); 431 | 432 | lxd.create_snapshot( 433 | &remote_name("local"), 434 | &project_name("db"), 435 | &instance_name("elastic"), 436 | &snapshot_name("auto-1"), 437 | ) 438 | .unwrap(); 439 | 440 | for (instance_id, instance) in &lxd.instances { 441 | if instance_id.remote == remote_name("local") 442 | && instance_id.project.as_str() == "db" 443 | && instance_id.instance.as_str() == "elastic" 444 | { 445 | assert_eq!(1, instance.snapshots.len()); 446 | assert_eq!("auto-1", instance.snapshots[0].name.as_str()); 447 | } else { 448 | assert_eq!(0, instance.snapshots.len()); 449 | } 450 | } 451 | } 452 | 453 | #[test] 454 | fn given_existing_snapshot() { 455 | let mut lxd = lxd(); 456 | 457 | lxd.create_snapshot( 458 | &remote_name("local"), 459 | &project_name("db"), 460 | &instance_name("elastic"), 461 | &snapshot_name("auto-1"), 462 | ) 463 | .unwrap(); 464 | 465 | let actual = lxd 466 | .create_snapshot( 467 | &remote_name("local"), 468 | &project_name("db"), 469 | &instance_name("elastic"), 470 | &snapshot_name("auto-1"), 471 | ) 472 | .unwrap_err(); 473 | 474 | let expected = LxdError::SnapshotAlreadyExists { 475 | remote: remote_name("local"), 476 | project: project_name("db"), 477 | instance: instance_name("elastic"), 478 | snapshot: snapshot_name("auto-1"), 479 | }; 480 | 481 | pa::assert_eq!(expected, actual); 482 | } 483 | 484 | #[test] 485 | fn given_unknown_remote() { 486 | let actual = lxd() 487 | .create_snapshot( 488 | &remote_name("unknown"), 489 | &project_name("db"), 490 | &instance_name("elastic"), 491 | &snapshot_name("auto-1"), 492 | ) 493 | .unwrap_err(); 494 | 495 | let expected = LxdError::NoSuchInstance { 496 | remote: remote_name("unknown"), 497 | project: project_name("db"), 498 | instance: instance_name("elastic"), 499 | }; 500 | 501 | pa::assert_eq!(expected, actual); 502 | } 503 | 504 | #[test] 505 | fn given_unknown_project() { 506 | let actual = lxd() 507 | .create_snapshot( 508 | &remote_name("local"), 509 | &project_name("unknown"), 510 | &instance_name("elastic"), 511 | &snapshot_name("auto-1"), 512 | ) 513 | .unwrap_err(); 514 | 515 | let expected = LxdError::NoSuchInstance { 516 | remote: remote_name("local"), 517 | project: project_name("unknown"), 518 | instance: instance_name("elastic"), 519 | }; 520 | 521 | pa::assert_eq!(expected, actual); 522 | } 523 | 524 | #[test] 525 | fn given_unknown_instance() { 526 | let actual = lxd() 527 | .create_snapshot( 528 | &remote_name("local"), 529 | &project_name("app"), 530 | &instance_name("unknown"), 531 | &snapshot_name("auto-1"), 532 | ) 533 | .unwrap_err(); 534 | 535 | let expected = LxdError::NoSuchInstance { 536 | remote: remote_name("local"), 537 | project: project_name("app"), 538 | instance: instance_name("unknown"), 539 | }; 540 | 541 | pa::assert_eq!(expected, actual); 542 | } 543 | } 544 | 545 | mod delete_snapshot { 546 | use super::*; 547 | 548 | #[test] 549 | fn ok() { 550 | let mut lxd = lxd(); 551 | 552 | for i in 1..=3 { 553 | lxd.create_snapshot( 554 | &remote_name("local"), 555 | &project_name("db"), 556 | &instance_name("elastic"), 557 | &snapshot_name(format!("auto-{}", i)), 558 | ) 559 | .unwrap(); 560 | } 561 | 562 | lxd.delete_snapshot( 563 | &remote_name("local"), 564 | &project_name("db"), 565 | &instance_name("elastic"), 566 | &snapshot_name("auto-2"), 567 | ) 568 | .unwrap(); 569 | 570 | for (instance_id, instance) in &lxd.instances { 571 | if instance_id.remote == remote_name("local") 572 | && instance_id.project.as_str() == "db" 573 | && instance_id.instance.as_str() == "elastic" 574 | { 575 | assert_eq!(2, instance.snapshots.len()); 576 | assert_eq!("auto-1", instance.snapshots[0].name.as_str()); 577 | assert_eq!("auto-3", instance.snapshots[1].name.as_str()); 578 | } else { 579 | assert_eq!(0, instance.snapshots.len()); 580 | } 581 | } 582 | } 583 | 584 | #[test] 585 | fn given_unknown_remote() { 586 | let actual = lxd() 587 | .delete_snapshot( 588 | &remote_name("unknown"), 589 | &project_name("db"), 590 | &instance_name("elastic"), 591 | &snapshot_name("auto-1"), 592 | ) 593 | .unwrap_err(); 594 | 595 | let expected = LxdError::NoSuchInstance { 596 | remote: remote_name("unknown"), 597 | project: project_name("db"), 598 | instance: instance_name("elastic"), 599 | }; 600 | 601 | pa::assert_eq!(expected, actual); 602 | } 603 | 604 | #[test] 605 | fn given_unknown_project() { 606 | let actual = lxd() 607 | .delete_snapshot( 608 | &remote_name("local"), 609 | &project_name("unknown"), 610 | &instance_name("elastic"), 611 | &snapshot_name("auto-1"), 612 | ) 613 | .unwrap_err(); 614 | 615 | let expected = LxdError::NoSuchInstance { 616 | remote: remote_name("local"), 617 | project: project_name("unknown"), 618 | instance: instance_name("elastic"), 619 | }; 620 | 621 | pa::assert_eq!(expected, actual); 622 | } 623 | 624 | #[test] 625 | fn given_unknown_instance() { 626 | let actual = lxd() 627 | .delete_snapshot( 628 | &remote_name("local"), 629 | &project_name("app"), 630 | &instance_name("unknown"), 631 | &snapshot_name("auto-1"), 632 | ) 633 | .unwrap_err(); 634 | 635 | let expected = LxdError::NoSuchInstance { 636 | remote: remote_name("local"), 637 | project: project_name("app"), 638 | instance: instance_name("unknown"), 639 | }; 640 | 641 | pa::assert_eq!(expected, actual); 642 | } 643 | 644 | #[test] 645 | fn given_unknown_snapshot() { 646 | let actual = lxd() 647 | .delete_snapshot( 648 | &remote_name("local"), 649 | &project_name("app"), 650 | &instance_name("checker"), 651 | &snapshot_name("auto-1"), 652 | ) 653 | .unwrap_err(); 654 | 655 | let expected = LxdError::NoSuchSnapshot { 656 | remote: remote_name("local"), 657 | project: project_name("app"), 658 | instance: instance_name("checker"), 659 | snapshot: snapshot_name("auto-1"), 660 | }; 661 | 662 | pa::assert_eq!(expected, actual); 663 | } 664 | } 665 | } 666 | -------------------------------------------------------------------------------- /src/lxd/clients/process.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::*; 2 | use anyhow::{anyhow, Context}; 3 | use pathsearch::find_executable_in_path; 4 | use serde::de::DeserializeOwned; 5 | use std::path::{Path, PathBuf}; 6 | use std::process::Command; 7 | use std::sync::mpsc; 8 | use std::thread; 9 | use std::time::Duration; 10 | 11 | pub struct LxdProcessClient { 12 | lxc: PathBuf, 13 | timeout: Duration, 14 | } 15 | 16 | impl LxdProcessClient { 17 | pub fn new(lxc: impl AsRef, timeout: Duration) -> LxdResult { 18 | let lxc = lxc.as_ref(); 19 | 20 | if !lxc.exists() { 21 | return Err(LxdError::Other(anyhow!( 22 | "Couldn't find the `lxc` executable: {}", 23 | lxc.display() 24 | ))); 25 | } 26 | 27 | Ok(Self { 28 | lxc: lxc.into(), 29 | timeout, 30 | }) 31 | } 32 | 33 | pub fn find(timeout: Duration) -> LxdResult { 34 | let lxc = find_executable_in_path("lxc") 35 | .ok_or_else(|| anyhow!("Couldn't find the `lxc` executable in your `PATH` - please try specifying exact location with `--lxc-path`"))?; 36 | 37 | Self::new(lxc, timeout) 38 | } 39 | 40 | fn execute(&mut self, callback: impl FnOnce(&mut Command)) -> LxdResult { 41 | let mut command = Command::new(&self.lxc); 42 | 43 | callback(&mut command); 44 | 45 | let (tx, rx) = mpsc::channel(); 46 | 47 | thread::spawn(move || { 48 | let result = (|| { 49 | let output = command 50 | .output() 51 | .context("Couldn't launch the `lxc` executable")?; 52 | 53 | if output.status.success() { 54 | let stdout = 55 | String::from_utf8(output.stdout).context("Couldn't read lxc's stdout")?; 56 | 57 | Ok(stdout) 58 | } else { 59 | let stderr = String::from_utf8(output.stderr) 60 | .context("Couldn't read lxc's stderr")? 61 | .trim() 62 | .to_string(); 63 | 64 | Err(LxdError::Other(anyhow!( 65 | "lxc returned a non-zero status code and said: {}", 66 | stderr, 67 | ))) 68 | } 69 | })(); 70 | 71 | _ = tx.send(result); 72 | }); 73 | 74 | rx.recv_timeout(self.timeout) 75 | .map_err(|_| anyhow!("Operation timed out - lxc took too long to answer"))? 76 | } 77 | 78 | fn parse(out: String) -> LxdResult 79 | where 80 | T: DeserializeOwned, 81 | { 82 | serde_json::from_str(&out) 83 | .context("Couldn't parse lxc's stdout") 84 | .map_err(LxdError::Other) 85 | } 86 | } 87 | 88 | impl LxdClient for LxdProcessClient { 89 | fn projects(&mut self, remote: &LxdRemoteName) -> LxdResult> { 90 | let out = self.execute(|command| { 91 | command 92 | .arg("project") 93 | .arg("list") 94 | .arg(format!("{}:", remote)) 95 | .arg("--format=json"); 96 | })?; 97 | 98 | Self::parse(out) 99 | } 100 | 101 | fn instances( 102 | &mut self, 103 | remote: &LxdRemoteName, 104 | project: &LxdProjectName, 105 | ) -> LxdResult> { 106 | let out = self.execute(|command| { 107 | command 108 | .arg("list") 109 | .arg(format!("{}:", remote)) 110 | .arg(format!("--project={}", project)) 111 | .arg("--format=json"); 112 | })?; 113 | 114 | Self::parse(out) 115 | } 116 | 117 | fn create_snapshot( 118 | &mut self, 119 | remote: &LxdRemoteName, 120 | project: &LxdProjectName, 121 | instance: &LxdInstanceName, 122 | snapshot: &LxdSnapshotName, 123 | ) -> LxdResult<()> { 124 | self.execute(|command| { 125 | command 126 | .arg("snapshot") 127 | .arg(instance.on(remote)) 128 | .arg(snapshot.as_str()) 129 | .arg(format!("--project={}", project)); 130 | })?; 131 | 132 | Ok(()) 133 | } 134 | 135 | fn delete_snapshot( 136 | &mut self, 137 | remote: &LxdRemoteName, 138 | project: &LxdProjectName, 139 | instance: &LxdInstanceName, 140 | snapshot: &LxdSnapshotName, 141 | ) -> LxdResult<()> { 142 | self.execute(|command| { 143 | command 144 | .arg("delete") 145 | .arg(format!("{}/{}", instance.on(remote), snapshot)) 146 | .arg(format!("--project={}", project)); 147 | })?; 148 | 149 | Ok(()) 150 | } 151 | } 152 | 153 | #[cfg(test)] 154 | mod tests { 155 | use super::*; 156 | 157 | fn fixture(name: &str) -> PathBuf { 158 | Path::new(file!()) 159 | .parent() 160 | .unwrap() 161 | .join("process") 162 | .join("tests") 163 | .join(name) 164 | } 165 | 166 | #[test] 167 | fn execute_timeout_ok() { 168 | let actual = LxdProcessClient::new(fixture("lxc-timeout.sh"), Duration::from_secs(10)) 169 | .unwrap() 170 | .execute(|_| ()) 171 | .unwrap(); 172 | 173 | assert_eq!("done!", actual.trim()); 174 | } 175 | 176 | #[test] 177 | fn execute_timeout_err() { 178 | let actual = LxdProcessClient::new(fixture("lxc-timeout.sh"), Duration::from_millis(500)) 179 | .unwrap() 180 | .execute(|_| ()) 181 | .unwrap_err() 182 | .to_string(); 183 | 184 | assert_eq!( 185 | "Operation timed out - lxc took too long to answer", 186 | actual.trim() 187 | ); 188 | } 189 | 190 | #[test] 191 | fn execute_non_zero_exit_code() { 192 | let actual = 193 | LxdProcessClient::new(fixture("lxc-non-zero-exit-code.sh"), Duration::from_secs(1)) 194 | .unwrap() 195 | .execute(|_| ()) 196 | .unwrap_err() 197 | .to_string(); 198 | 199 | assert_eq!( 200 | "lxc returned a non-zero status code and said: oii stderr", 201 | actual.trim() 202 | ); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/lxd/clients/process/tests/lxc-non-zero-exit-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "oii stdout" 4 | echo "oii stderr" >&2 5 | exit 1 6 | -------------------------------------------------------------------------------- /src/lxd/clients/process/tests/lxc-timeout.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sleep 2 4 | echo "done!" 5 | -------------------------------------------------------------------------------- /src/lxd/error.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::{LxdInstanceName, LxdProjectName, LxdRemoteName, LxdSnapshotName}; 2 | use std::result; 3 | use thiserror::Error; 4 | 5 | pub type LxdResult = result::Result; 6 | 7 | #[derive(Debug, Error)] 8 | pub enum LxdError { 9 | #[error("No such instance: {} (in project `{project}`)", .instance.on(.remote))] 10 | NoSuchInstance { 11 | remote: LxdRemoteName, 12 | project: LxdProjectName, 13 | instance: LxdInstanceName, 14 | }, 15 | 16 | #[error("No such snapshot: {snapshot} (on instance `{}` in project `{project}`)", .instance.on(.remote))] 17 | NoSuchSnapshot { 18 | remote: LxdRemoteName, 19 | project: LxdProjectName, 20 | instance: LxdInstanceName, 21 | snapshot: LxdSnapshotName, 22 | }, 23 | 24 | #[error( 25 | "Snapshot already exists: {snapshot} (on instance `{}` in project `{project}`)", .instance.on(.remote) 26 | )] 27 | SnapshotAlreadyExists { 28 | remote: LxdRemoteName, 29 | project: LxdProjectName, 30 | instance: LxdInstanceName, 31 | snapshot: LxdSnapshotName, 32 | }, 33 | 34 | #[cfg(test)] 35 | #[error("InjectedError")] 36 | InjectedError, 37 | 38 | #[error(transparent)] 39 | Other(#[from] anyhow::Error), 40 | } 41 | 42 | #[cfg(test)] 43 | impl PartialEq for LxdError { 44 | fn eq(&self, other: &LxdError) -> bool { 45 | self.to_string() == other.to_string() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lxd/models.rs: -------------------------------------------------------------------------------- 1 | mod instance; 2 | mod instance_name; 3 | mod instance_status; 4 | mod project; 5 | mod project_name; 6 | mod remote_name; 7 | mod serde; 8 | mod snapshot; 9 | mod snapshot_name; 10 | 11 | pub use self::{ 12 | instance::*, instance_name::*, instance_status::*, project::*, project_name::*, remote_name::*, 13 | snapshot::*, snapshot_name::*, 14 | }; 15 | -------------------------------------------------------------------------------- /src/lxd/models/instance.rs: -------------------------------------------------------------------------------- 1 | use super::serde::null_to_default; 2 | use crate::lxd::{LxdInstanceName, LxdInstanceStatus, LxdSnapshot}; 3 | use serde::Deserialize; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] 6 | pub struct LxdInstance { 7 | pub name: LxdInstanceName, 8 | pub status: LxdInstanceStatus, 9 | 10 | // We need `null_to_default`, because LXC returns `null` for instances that 11 | // don't have any snapshots (instead of `[]`, as one could guess) 12 | #[serde(deserialize_with = "null_to_default")] 13 | pub snapshots: Vec, 14 | } 15 | -------------------------------------------------------------------------------- /src/lxd/models/instance_name.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::LxdRemoteName; 2 | use serde::Deserialize; 3 | use std::fmt; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] 6 | pub struct LxdInstanceName(String); 7 | 8 | impl LxdInstanceName { 9 | #[cfg(test)] 10 | pub fn new(name: impl AsRef) -> Self { 11 | Self(name.as_ref().into()) 12 | } 13 | 14 | pub fn as_str(&self) -> &str { 15 | &self.0 16 | } 17 | 18 | pub fn on(&self, remote: &LxdRemoteName) -> String { 19 | format!("{}:{}", remote, self) 20 | } 21 | } 22 | 23 | impl fmt::Display for LxdInstanceName { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 25 | write!(f, "{}", self.0) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | 33 | #[test] 34 | fn on() { 35 | let instance = LxdInstanceName::new("some-instance"); 36 | let remote = LxdRemoteName::new("some-remote"); 37 | 38 | assert_eq!("some-remote:some-instance", instance.on(&remote)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lxd/models/instance_status.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize)] 4 | pub enum LxdInstanceStatus { 5 | Aborting, 6 | Running, 7 | Starting, 8 | Stopped, 9 | Stopping, 10 | Ready, 11 | } 12 | -------------------------------------------------------------------------------- /src/lxd/models/project.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::LxdProjectName; 2 | use serde::Deserialize; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] 5 | pub struct LxdProject { 6 | pub name: LxdProjectName, 7 | } 8 | -------------------------------------------------------------------------------- /src/lxd/models/project_name.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::fmt; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] 5 | pub struct LxdProjectName(String); 6 | 7 | impl LxdProjectName { 8 | #[cfg(test)] 9 | pub fn new(name: impl AsRef) -> Self { 10 | Self(name.as_ref().into()) 11 | } 12 | 13 | pub fn as_str(&self) -> &str { 14 | &self.0 15 | } 16 | 17 | pub fn is_default(&self) -> bool { 18 | self.as_str() == "default" 19 | } 20 | } 21 | 22 | #[cfg(test)] 23 | impl Default for LxdProjectName { 24 | fn default() -> Self { 25 | Self::new("default") 26 | } 27 | } 28 | 29 | impl fmt::Display for LxdProjectName { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | write!(f, "{}", self.0) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lxd/models/remote_name.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::fmt; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] 5 | pub struct LxdRemoteName(String); 6 | 7 | impl LxdRemoteName { 8 | pub fn new(name: impl AsRef) -> Self { 9 | Self(name.as_ref().into()) 10 | } 11 | 12 | pub fn local() -> Self { 13 | Self::new("local") 14 | } 15 | 16 | pub fn as_str(&self) -> &str { 17 | &self.0 18 | } 19 | } 20 | 21 | #[cfg(test)] 22 | impl Default for LxdRemoteName { 23 | fn default() -> Self { 24 | Self::local() 25 | } 26 | } 27 | 28 | impl fmt::Display for LxdRemoteName { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | write!(f, "{}", self.0) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lxd/models/serde.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer}; 2 | 3 | /// Deserializes `null` into type's default value. 4 | pub fn null_to_default<'de, D, T>(d: D) -> Result 5 | where 6 | D: Deserializer<'de>, 7 | T: Default + Deserialize<'de>, 8 | { 9 | Ok(Option::deserialize(d)?.unwrap_or_default()) 10 | } 11 | -------------------------------------------------------------------------------- /src/lxd/models/snapshot.rs: -------------------------------------------------------------------------------- 1 | use crate::lxd::LxdSnapshotName; 2 | use chrono::{DateTime, Utc}; 3 | use serde::Deserialize; 4 | 5 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] 6 | pub struct LxdSnapshot { 7 | pub name: LxdSnapshotName, 8 | pub created_at: DateTime, 9 | } 10 | -------------------------------------------------------------------------------- /src/lxd/models/snapshot_name.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use std::fmt; 3 | 4 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] 5 | pub struct LxdSnapshotName(String); 6 | 7 | impl LxdSnapshotName { 8 | pub fn new(name: impl AsRef) -> Self { 9 | Self(name.as_ref().into()) 10 | } 11 | 12 | pub fn as_str(&self) -> &str { 13 | &self.0 14 | } 15 | } 16 | 17 | impl fmt::Display for LxdSnapshotName { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | write!(f, "{}", self.0) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod config; 3 | mod environment; 4 | mod lxd; 5 | mod utils; 6 | 7 | #[cfg(test)] 8 | mod testing; 9 | 10 | mod prelude { 11 | pub(crate) use crate::utils::*; 12 | pub(crate) use crate::{config::*, environment::*, lxd::*}; 13 | pub use anyhow::{bail, Context, Result}; 14 | pub use chrono::{DateTime, Utc}; 15 | pub use colored::Colorize; 16 | pub use itertools::Itertools; 17 | pub use std::io::Write; 18 | 19 | #[cfg(test)] 20 | pub use pretty_assertions as pa; 21 | 22 | #[cfg(test)] 23 | pub use indoc::indoc; 24 | } 25 | 26 | use self::{commands::*, config::*, environment::*, lxd::*}; 27 | use anyhow::*; 28 | use chrono::Utc; 29 | use clap::{Parser, Subcommand}; 30 | use colored::*; 31 | use std::io; 32 | use std::path::PathBuf; 33 | use std::process::ExitCode; 34 | 35 | /// LXD snapshots, automated 36 | #[derive(Parser)] 37 | pub struct Args { 38 | /// Runs application in a simulated safe-mode without applying any changes 39 | /// to the instances 40 | #[clap(short, long)] 41 | dry_run: bool, 42 | 43 | /// Path to the configuration file 44 | #[clap(short, long, default_value = "config.yaml")] 45 | config: PathBuf, 46 | 47 | /// Path to the `lxc` executable; usually inferred automatically from the 48 | /// `PATH` environmental variable 49 | #[clap(short, long)] 50 | lxc_path: Option, 51 | 52 | #[clap(subcommand)] 53 | cmd: Command, 54 | } 55 | 56 | #[derive(Parser)] 57 | pub enum Command { 58 | /// Creates a snapshot for each instance matching the configuration 59 | Backup, 60 | 61 | /// Shorthand for `backup` followed by `prune` 62 | BackupAndPrune, 63 | 64 | /// Removes stale snapshots from each instance matching the configuration 65 | Prune, 66 | 67 | /// Validates configuration syntax 68 | Validate, 69 | 70 | /// Various debug-commands 71 | #[clap(subcommand)] 72 | Debug(DebugCommand), 73 | } 74 | 75 | #[derive(Subcommand)] 76 | pub enum DebugCommand { 77 | /// Lists all the LXD instances together with policies associated with them 78 | ListInstances, 79 | 80 | /// Removes *ALL* snapshots (including the ones created manually) from each 81 | /// instance matching the configuration; if you suddenly created tons of 82 | /// unnecessary snapshots, this is the way to go 83 | Nuke, 84 | } 85 | 86 | fn main() -> ExitCode { 87 | use std::result::Result::*; 88 | 89 | match try_main() { 90 | Ok(_) => ExitCode::SUCCESS, 91 | 92 | Err(err) => { 93 | println!(); 94 | println!("{}: {:?}", "Error".red(), err); 95 | 96 | ExitCode::FAILURE 97 | } 98 | } 99 | } 100 | 101 | fn try_main() -> Result<()> { 102 | let args = Args::parse(); 103 | let stdout = &mut io::stdout(); 104 | 105 | if let Command::Validate = &args.cmd { 106 | return commands::validate(stdout, args); 107 | } 108 | 109 | if args.dry_run { 110 | println!( 111 | "({} is active, no changes will be applied)", 112 | "--dry-run".yellow(), 113 | ); 114 | println!(); 115 | } 116 | 117 | let config = Config::load(&args.config)?; 118 | let mut lxd = init_lxd(&args, &config)?; 119 | 120 | let mut env = Environment { 121 | time: Utc::now, 122 | stdout, 123 | config: &config, 124 | lxd: &mut *lxd, 125 | dry_run: args.dry_run, 126 | }; 127 | 128 | match args.cmd { 129 | Command::Backup => Backup::new(&mut env).run(), 130 | Command::BackupAndPrune => BackupAndPrune::new(&mut env).run(), 131 | Command::Prune => Prune::new(&mut env).run(), 132 | 133 | Command::Validate => { 134 | // Already handled a few lines above 135 | unreachable!() 136 | } 137 | 138 | Command::Debug(DebugCommand::ListInstances) => DebugListInstances::new(&mut env).run(), 139 | Command::Debug(DebugCommand::Nuke) => DebugNuke::new(&mut env).run(), 140 | } 141 | } 142 | 143 | fn init_lxd(args: &Args, config: &Config) -> Result> { 144 | let mut lxd = if let Some(lxc_path) = &args.lxc_path { 145 | LxdProcessClient::new(lxc_path, config.lxc_timeout()) 146 | } else { 147 | LxdProcessClient::find(config.lxc_timeout()) 148 | } 149 | .context("Couldn't initialize LXC client")?; 150 | 151 | if args.dry_run { 152 | Ok(Box::new(LxdFakeClient::clone_from( 153 | &mut lxd, 154 | config.remotes().iter(), 155 | )?)) 156 | } else { 157 | Ok(Box::new(lxd)) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/testing.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use ansi_parser::{AnsiParser, AnsiSequence, Output}; 3 | use std::fmt::Write; 4 | 5 | #[macro_export] 6 | macro_rules! assert_stdout { 7 | ($expected:literal, $actual:expr) => { 8 | $crate::testing::assert_out( 9 | indoc::indoc!($expected).trim(), 10 | String::from_utf8_lossy(&$actual).trim(), 11 | ); 12 | }; 13 | } 14 | 15 | #[macro_export] 16 | macro_rules! assert_result { 17 | ($expected:literal, $actual:expr) => { 18 | let actual = format!("{:?}", $actual.unwrap_err()); 19 | 20 | pa::assert_str_eq!(indoc::indoc!($expected).trim(), actual); 21 | }; 22 | } 23 | 24 | #[macro_export] 25 | macro_rules! assert_lxd { 26 | ($expected:literal, $actual:expr) => { 27 | pa::assert_str_eq!(indoc::indoc!($expected), $actual.to_string()); 28 | }; 29 | } 30 | 31 | #[track_caller] 32 | pub fn assert_out(expected: impl AsRef, actual: impl AsRef) { 33 | let actual = sanitize_ansi_codes(actual); 34 | let actual = sanitize_empty_lines(actual); 35 | let expected = sanitize_empty_lines(expected); 36 | 37 | pa::assert_str_eq!(expected, actual); 38 | } 39 | 40 | fn sanitize_ansi_codes(s: impl AsRef) -> String { 41 | let mut out = String::new(); 42 | let mut active_modes = Vec::new(); 43 | 44 | for item in s.as_ref().ansi_parse() { 45 | match item { 46 | Output::TextBlock(text) => { 47 | _ = write!(out, "{}", text); 48 | } 49 | 50 | Output::Escape(escape) => match escape { 51 | AnsiSequence::SetGraphicsMode(modes) => { 52 | for mode in modes { 53 | match mode { 54 | 0 => { 55 | while let Some(mode) = active_modes.pop() { 56 | _ = write!(out, "", mode); 57 | } 58 | } 59 | 60 | 1 => { 61 | _ = write!(out, ""); 62 | active_modes.push("b"); 63 | } 64 | 65 | 3 => { 66 | _ = write!(out, ""); 67 | active_modes.push("i"); 68 | } 69 | 70 | color @ 30..=37 => { 71 | _ = write!(out, "", color); 72 | active_modes.push("fg"); 73 | } 74 | 75 | mode => { 76 | panic!("Unrecognized SetGraphicsMode: {}", mode); 77 | } 78 | } 79 | } 80 | } 81 | 82 | escape => { 83 | panic!("Unrecognized escape: {:?}", escape); 84 | } 85 | }, 86 | } 87 | } 88 | 89 | out 90 | } 91 | 92 | fn sanitize_empty_lines(s: impl AsRef) -> String { 93 | s.as_ref() 94 | .lines() 95 | .map(|line| if line.trim().is_empty() { "" } else { line }) 96 | .join("\n") 97 | } 98 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | mod pretty_lxd_instance_name; 2 | mod summary; 3 | 4 | pub use self::{pretty_lxd_instance_name::*, summary::*}; 5 | -------------------------------------------------------------------------------- /src/utils/pretty_lxd_instance_name.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::fmt; 3 | 4 | pub struct PrettyLxdInstanceName<'a> { 5 | print_remote: bool, 6 | remote: &'a LxdRemoteName, 7 | print_project: bool, 8 | project: &'a LxdProjectName, 9 | instance: &'a LxdInstanceName, 10 | } 11 | 12 | impl<'a> PrettyLxdInstanceName<'a> { 13 | pub fn new( 14 | print_remote: bool, 15 | remote: &'a LxdRemoteName, 16 | print_project: bool, 17 | project: &'a LxdProjectName, 18 | instance: &'a LxdInstanceName, 19 | ) -> Self { 20 | Self { 21 | print_remote, 22 | remote, 23 | print_project, 24 | project, 25 | instance, 26 | } 27 | } 28 | } 29 | 30 | impl fmt::Display for PrettyLxdInstanceName<'_> { 31 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 32 | if self.print_remote { 33 | write!(f, "{}:", self.remote)?; 34 | } 35 | 36 | if self.print_project { 37 | write!(f, "{}/", self.project)?; 38 | } 39 | 40 | write!(f, "{}", self.instance) 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | use test_case::test_case; 48 | 49 | #[test_case(true, true, "remote:project/instance")] 50 | #[test_case(true, false, "remote:instance")] 51 | #[test_case(false, true, "project/instance")] 52 | #[test_case(false, false, "instance")] 53 | fn display(print_remote: bool, print_project: bool, expected: &str) { 54 | let actual = PrettyLxdInstanceName::new( 55 | print_remote, 56 | &LxdRemoteName::new("remote"), 57 | print_project, 58 | &LxdProjectName::new("project"), 59 | &LxdInstanceName::new("instance"), 60 | ) 61 | .to_string(); 62 | 63 | assert_eq!(expected, actual); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/summary.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use colored::Colorize; 3 | use std::fmt; 4 | 5 | pub struct Summary { 6 | title: &'static str, 7 | processed_instances: usize, 8 | created_snapshots: Option, 9 | deleted_snapshots: Option, 10 | kept_snapshots: Option, 11 | errors: usize, 12 | } 13 | 14 | impl Summary { 15 | pub fn with_created_snapshots(mut self) -> Self { 16 | self.created_snapshots = Some(0); 17 | self 18 | } 19 | 20 | pub fn with_deleted_snapshots(mut self) -> Self { 21 | self.deleted_snapshots = Some(0); 22 | self 23 | } 24 | 25 | pub fn with_kept_snapshots(mut self) -> Self { 26 | self.kept_snapshots = Some(0); 27 | self 28 | } 29 | 30 | pub fn set_title(&mut self, title: &'static str) { 31 | self.title = title; 32 | } 33 | 34 | pub fn add_processed_instance(&mut self) { 35 | self.processed_instances += 1; 36 | } 37 | 38 | pub fn add_created_snapshot(&mut self) { 39 | *self.created_snapshots.as_mut().unwrap() += 1; 40 | } 41 | 42 | pub fn add_deleted_snapshot(&mut self) { 43 | *self.deleted_snapshots.as_mut().unwrap() += 1; 44 | } 45 | 46 | pub fn add_kept_snapshot(&mut self) { 47 | *self.kept_snapshots.as_mut().unwrap() += 1; 48 | } 49 | 50 | pub fn add_error(&mut self) { 51 | self.errors += 1; 52 | } 53 | 54 | pub fn has_errors(&self) -> bool { 55 | self.errors > 0 56 | } 57 | 58 | pub fn as_result(&self) -> Result<()> { 59 | if self.processed_instances == 0 { 60 | bail!("Found no instance(s) that would match the configured policies"); 61 | } 62 | 63 | Ok(()) 64 | } 65 | } 66 | 67 | impl Default for Summary { 68 | fn default() -> Self { 69 | Self { 70 | title: "Summary", 71 | processed_instances: Default::default(), 72 | created_snapshots: Default::default(), 73 | deleted_snapshots: Default::default(), 74 | kept_snapshots: Default::default(), 75 | errors: Default::default(), 76 | } 77 | } 78 | } 79 | 80 | impl fmt::Display for Summary { 81 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 82 | writeln!(f, "{}", self.title.bold())?; 83 | writeln!(f, "{}", "-".repeat(self.title.chars().count()))?; 84 | writeln!(f, " processed instances: {}", self.processed_instances)?; 85 | 86 | if let Some(n) = self.created_snapshots { 87 | writeln!(f, " created snapshots: {}", n)?; 88 | } 89 | 90 | if let Some(n) = self.deleted_snapshots { 91 | writeln!(f, " deleted snapshots: {}", n)?; 92 | } 93 | 94 | if let Some(n) = self.kept_snapshots { 95 | writeln!(f, " kept snapshots: {}", n)?; 96 | } 97 | 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests.nix: -------------------------------------------------------------------------------- 1 | # These are lxd-snapper's acceptance tests; you can run them using: 2 | # 3 | # ``` 4 | # nix flake check -j4 5 | # ``` 6 | 7 | { nixpkgs 8 | , nixpkgs--lxd-4 9 | , nixpkgs--lxd-5 10 | , nixpkgs--lxd-6 11 | , lxd-snapper 12 | }: 13 | 14 | let 15 | inherit (pkgs) lib; 16 | 17 | pkgs = import nixpkgs { 18 | system = "x86_64-linux"; 19 | }; 20 | 21 | lxdContainer = import "${nixpkgs}/nixos/release.nix" { 22 | configuration = { 23 | documentation = { 24 | enable = lib.mkForce false; 25 | }; 26 | 27 | environment = { 28 | noXlibs = lib.mkForce true; 29 | }; 30 | }; 31 | }; 32 | 33 | mkTest = testPath: testName: testNixpkgs: 34 | let 35 | testPkgs = import testNixpkgs { 36 | system = "x86_64-linux"; 37 | }; 38 | 39 | testScript = 40 | let 41 | prelude = import ./tests/prelude.py.nix { 42 | inherit testPath; 43 | 44 | lxdConfig = ./tests/_fixtures/lxd-config.yaml; 45 | lxdContainerMeta = lxdContainer.lxdContainerMeta.${pkgs.system}; 46 | lxdContainerImage = lxdContainer.lxdContainerImage.${pkgs.system}; 47 | }; 48 | 49 | in 50 | prelude 51 | + "\n\n" 52 | + (builtins.readFile "${testPath}/test.py"); 53 | 54 | in 55 | import "${testPath}/test.nix" { 56 | fw = rec { 57 | mkNode = config @ { ... }: 58 | lib.mkMerge [ 59 | { 60 | boot = { 61 | supportedFilesystems = [ "zfs" ]; 62 | }; 63 | 64 | environment = { 65 | systemPackages = with pkgs; [ 66 | jq 67 | lxd-snapper 68 | ]; 69 | }; 70 | 71 | networking = { 72 | hostId = "01234567"; 73 | }; 74 | 75 | virtualisation = { 76 | cores = 2; 77 | memorySize = 2048; 78 | diskSize = 2048; 79 | 80 | lxd = { 81 | enable = true; 82 | package = testPkgs.lxd; 83 | }; 84 | 85 | qemu = { 86 | options = [ 87 | "-rtc base=2018-01-01T12:00:00" 88 | ]; 89 | }; 90 | }; 91 | } 92 | 93 | config 94 | ]; 95 | 96 | mkTest = { nodes }: 97 | pkgs.nixosTest { 98 | inherit testScript nodes; 99 | 100 | name = testName; 101 | }; 102 | 103 | mkDefaultTest = mkTest { 104 | nodes = { 105 | machine = mkNode { }; 106 | }; 107 | }; 108 | }; 109 | }; 110 | 111 | mkTests = { tests, lxds }: 112 | let 113 | testCombinations = 114 | lib.cartesianProduct { 115 | testPath = tests; 116 | testLxd = lxds; 117 | }; 118 | 119 | mkTestFromCombination = { testPath, testLxd }: 120 | let 121 | testName = "${builtins.baseNameOf testPath}.lxd-${testLxd.version}"; 122 | 123 | in 124 | { 125 | name = testName; 126 | value = mkTest testPath testName testLxd.nixpkgs; 127 | }; 128 | 129 | in 130 | builtins.listToAttrs 131 | (builtins.map 132 | mkTestFromCombination 133 | testCombinations); 134 | 135 | in 136 | mkTests { 137 | tests = [ 138 | ./tests/backup-and-prune 139 | ./tests/backup-and-prune-with-projects 140 | ./tests/backup-and-prune-with-remotes 141 | ./tests/dry-run 142 | ./tests/hooks 143 | ./tests/timeout 144 | ]; 145 | 146 | lxds = [ 147 | { version = "4"; nixpkgs = nixpkgs--lxd-4; } 148 | { version = "5"; nixpkgs = nixpkgs--lxd-5; } 149 | { version = "6"; nixpkgs = nixpkgs--lxd-6; } 150 | ]; 151 | } 152 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This directory contains lxd-snapper's integration tests; you can run them by 4 | executing `nix flake check -j4` in the project's directory. 5 | -------------------------------------------------------------------------------- /tests/_fixtures/lxd-config.yaml: -------------------------------------------------------------------------------- 1 | storage_pools: 2 | - name: default 3 | driver: zfs 4 | config: 5 | source: tank 6 | 7 | networks: 8 | - name: lxdbr0 9 | type: bridge 10 | config: 11 | ipv4.address: auto 12 | ipv6.address: none 13 | 14 | profiles: 15 | - name: default 16 | devices: 17 | root: 18 | path: / 19 | pool: default 20 | type: disk 21 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-projects/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | 3 | policies: 4 | apps: 5 | included-projects: ['client-a', 'client-b'] 6 | included-instances: ['php'] 7 | keep-daily: 1 8 | keep-monthly: 1 9 | 10 | databases: 11 | included-projects: ['client-a', 'client-b'] 12 | included-instances: ['mysql'] 13 | keep-daily: 4 14 | keep-monthly: 2 15 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-projects/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: fw.mkDefaultTest 2 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-projects/test.py: -------------------------------------------------------------------------------- 1 | machine = MyMachine(machine) 2 | 3 | with subtest("Create some instances and snapshots for client-a"): 4 | machine.succeed( 5 | "lxc project create client-a -c features.images=false -c features.profiles=false" 6 | ) 7 | 8 | machine.succeed("lxc project switch client-a") 9 | 10 | machine.succeed("lxc launch image apache") 11 | machine.succeed("lxc snapshot apache") 12 | machine.assert_snapshot_exists("client-a", "apache", "snap0") 13 | machine.assert_snapshot_count("client-a", "apache", ".*", 1) 14 | 15 | machine.succeed("lxc launch image mysql") 16 | machine.succeed("lxc snapshot mysql") 17 | machine.assert_snapshot_exists("client-a", "mysql", "snap0") 18 | machine.assert_snapshot_count("client-a", "mysql", ".*", 1) 19 | 20 | machine.succeed("lxc launch image php") 21 | machine.succeed("lxc snapshot php") 22 | machine.assert_snapshot_exists("client-a", "php", "snap0") 23 | machine.assert_snapshot_count("client-a", "php", ".*", 1) 24 | 25 | 26 | with subtest("Create some instances for client-b"): 27 | machine.succeed( 28 | "lxc project create client-b -c features.images=false -c features.profiles=false" 29 | ) 30 | 31 | machine.succeed("lxc project switch client-b") 32 | 33 | machine.succeed("lxc launch image apache") 34 | machine.assert_snapshot_count("client-b", "apache", ".*", 0) 35 | 36 | machine.succeed("lxc launch image mysql") 37 | machine.assert_snapshot_count("client-b", "mysql", ".*", 0) 38 | 39 | machine.succeed("lxc launch image php") 40 | machine.assert_snapshot_count("client-b", "php", ".*", 0) 41 | 42 | 43 | with subtest("Create some instances for client-c"): 44 | machine.succeed( 45 | "lxc project create client-c -c features.images=false -c features.profiles=false" 46 | ) 47 | 48 | machine.succeed("lxc project switch client-c") 49 | 50 | machine.succeed("lxc launch image apache") 51 | machine.assert_snapshot_count("client-c", "apache", ".*", 0) 52 | 53 | machine.succeed("lxc launch image mysql") 54 | machine.assert_snapshot_count("client-c", "mysql", ".*", 0) 55 | 56 | machine.succeed("lxc launch image php") 57 | machine.assert_snapshot_count("client-c", "php", ".*", 0) 58 | 59 | 60 | machine.succeed("lxc project switch default") 61 | 62 | 63 | with subtest("Backup"): 64 | for (date, snapshot_regex) in [ 65 | ("2012-07-30 12:00:00", "auto\-20120730"), 66 | ("2012-07-31 12:00:00", "auto\-20120731"), 67 | ("2012-08-01 12:00:00", "auto\-20120801"), 68 | ("2012-08-02 12:00:00", "auto\-20120802"), 69 | ("2012-08-03 12:00:00", "auto\-20120803"), 70 | ("2012-08-04 12:00:00", "auto\-20120804"), 71 | ("2012-08-05 12:00:00", "auto\-20120805"), 72 | ("2012-08-06 12:00:00", "auto\-20120806"), 73 | ]: 74 | machine.succeed(f"date -s '{date}'") 75 | 76 | out = machine.lxd_snapper("backup") 77 | 78 | assert ( 79 | "created snapshots: 4" in out 80 | ), f"created snapshots != 4; actual output: {out}" 81 | 82 | for project in ["client-a", "client-b"]: 83 | machine.assert_snapshot_does_not_exist(project, "apache", snapshot_regex) 84 | machine.assert_snapshot_exists(project, "mysql", snapshot_regex) 85 | machine.assert_snapshot_exists(project, "php", snapshot_regex) 86 | 87 | for project in ["client-c"]: 88 | machine.assert_snapshot_does_not_exist(project, "apache", snapshot_regex) 89 | machine.assert_snapshot_does_not_exist(project, "mysql", snapshot_regex) 90 | machine.assert_snapshot_does_not_exist(project, "php", snapshot_regex) 91 | 92 | 93 | with subtest("Prune"): 94 | out = machine.lxd_snapper("prune") 95 | 96 | assert ( 97 | "processed instances: 4" in out 98 | ), f"processed instances != 4; actual output: {out}" 99 | 100 | assert ( 101 | "deleted snapshots: 16" in out 102 | ), f"deleted snapshots != 16; actual output: {out}" 103 | 104 | assert "kept snapshots: 16" in out, f"kept snapshots != 16; actual output: {out}" 105 | 106 | for (project, manual_snapshot_count) in [("client-a", 1), ("client-b", 0)]: 107 | # While starting the test, we've created a manual snapshot (via `lxc 108 | # snapshot`) for each instance inside the `client-a` project. 109 | # 110 | # Since those snapshots are manual, they shouldn't be touched by the 111 | # `prune` command. 112 | if manual_snapshot_count > 0: 113 | machine.assert_snapshot_exists(project, "mysql", "snap0") 114 | machine.assert_snapshot_exists(project, "php", "snap0") 115 | 116 | machine.assert_snapshot_does_not_exist(project, "mysql", "auto\-20120730") 117 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120731") 118 | machine.assert_snapshot_does_not_exist(project, "mysql", "auto\-20120801") 119 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120802") 120 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120803") 121 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120804") 122 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120805") 123 | machine.assert_snapshot_exists(project, "mysql", "auto\-20120806") 124 | 125 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120730") 126 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120731") 127 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120801") 128 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120802") 129 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120803") 130 | machine.assert_snapshot_does_not_exist(project, "php", "auto\-20120804") 131 | machine.assert_snapshot_exists(project, "php", "auto\-20120806") 132 | machine.assert_snapshot_exists(project, "php", "auto\-20120805") 133 | 134 | machine.assert_snapshot_count(project, "apache", ".*", manual_snapshot_count) 135 | machine.assert_snapshot_count(project, "mysql", ".*", manual_snapshot_count + 6) 136 | machine.assert_snapshot_count(project, "php", ".*", manual_snapshot_count + 2) 137 | 138 | for project in ["client-c"]: 139 | for instance in ["apache", "mysql", "php"]: 140 | machine.assert_snapshot_count(project, instance, ".*", 0) 141 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-remotes/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | 3 | remotes: 4 | - serverA 5 | - serverB 6 | - serverC 7 | 8 | policies: 9 | all: 10 | excluded-remotes: ['serverB'] 11 | keep-last: 0 12 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-remotes/expected.out.1.txt: -------------------------------------------------------------------------------- 1 | serverA:container 2 | - creating snapshot: auto-20180101 [ OK ] 3 | 4 | serverB:container 5 | - [ EXCLUDED ] 6 | 7 | serverC:container 8 | - creating snapshot: auto-20180101 [ OK ] 9 | 10 | Summary 11 | ------- 12 | processed instances: 2 13 | created snapshots: 2 14 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-remotes/expected.out.2.txt: -------------------------------------------------------------------------------- 1 | serverA:container 2 | - deleting snapshot: auto-20180101 [ OK ] 3 | 4 | serverB:container 5 | - [ EXCLUDED ] 6 | 7 | serverC:container 8 | - deleting snapshot: auto-20180101 [ OK ] 9 | 10 | Summary 11 | ------- 12 | processed instances: 2 13 | deleted snapshots: 2 14 | kept snapshots: 0 15 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-remotes/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: 2 | 3 | let 4 | mkServerNode = ip: fw.mkNode { 5 | networking = { 6 | firewall = { 7 | allowedTCPPorts = [ 8443 ]; 8 | }; 9 | 10 | interfaces = { 11 | eth1 = { 12 | ipv4 = { 13 | addresses = [ 14 | { address = ip; prefixLength = 24; } 15 | ]; 16 | }; 17 | }; 18 | }; 19 | }; 20 | }; 21 | 22 | in 23 | fw.mkTest { 24 | nodes = { 25 | main = fw.mkNode { }; 26 | serverA = mkServerNode "192.168.1.2"; 27 | serverB = mkServerNode "192.168.1.3"; 28 | serverC = mkServerNode "192.168.1.4"; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /tests/backup-and-prune-with-remotes/test.py: -------------------------------------------------------------------------------- 1 | main = MyMachine(main) 2 | serverA = MyMachine(serverA) 3 | serverB = MyMachine(serverB) 4 | serverC = MyMachine(serverC) 5 | 6 | # Setup serverA 7 | serverA.succeed("lxc config set core.https_address 0.0.0.0") 8 | serverA.succeed("lxc config set core.trust_password test") 9 | serverA.succeed("date -s '2018-01-01 13:00:00'") 10 | main.succeed("lxc remote add serverA 192.168.1.2 --accept-certificate --password test") 11 | 12 | # Setup serverB 13 | serverB.succeed("lxc config set core.https_address 0.0.0.0") 14 | serverB.succeed("lxc config set core.trust_password test") 15 | serverB.succeed("date -s '2018-01-01 13:00:00'") 16 | main.succeed("lxc remote add serverB 192.168.1.3 --accept-certificate --password test") 17 | 18 | # Setup serverC 19 | serverC.succeed("lxc config set core.https_address 0.0.0.0") 20 | serverC.succeed("lxc config set core.trust_password test") 21 | serverC.succeed("date -s '2018-01-01 13:00:00'") 22 | main.succeed("lxc remote add serverC 192.168.1.4 --accept-certificate --password test") 23 | 24 | # Create containers 25 | main.succeed("lxc launch image container") 26 | serverA.succeed("lxc launch image container") 27 | serverB.succeed("lxc launch image container") 28 | serverC.succeed("lxc launch image container") 29 | 30 | # Backup containers 31 | main.lxd_snapper("backup", "expected.out.1.txt") 32 | main.assert_snapshot_count("default", "container", ".*", 0) 33 | serverA.assert_snapshot_count("default", "container", ".*", 1) 34 | serverB.assert_snapshot_count("default", "container", ".*", 0) 35 | serverC.assert_snapshot_count("default", "container", ".*", 1) 36 | 37 | # Prune containers 38 | main.lxd_snapper("prune", "expected.out.2.txt") 39 | main.assert_snapshot_count("default", "container", ".*", 0) 40 | serverA.assert_snapshot_count("default", "container", ".*", 0) 41 | serverB.assert_snapshot_count("default", "container", ".*", 0) 42 | serverC.assert_snapshot_count("default", "container", ".*", 0) 43 | -------------------------------------------------------------------------------- /tests/backup-and-prune/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | 3 | policies: 4 | all: 5 | keep-last: 0 6 | -------------------------------------------------------------------------------- /tests/backup-and-prune/expected.out.1.txt: -------------------------------------------------------------------------------- 1 | test 2 | - creating snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | created snapshots: 1 8 | -------------------------------------------------------------------------------- /tests/backup-and-prune/expected.out.2.txt: -------------------------------------------------------------------------------- 1 | test 2 | - deleting snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | deleted snapshots: 1 8 | kept snapshots: 0 9 | -------------------------------------------------------------------------------- /tests/backup-and-prune/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: fw.mkDefaultTest 2 | -------------------------------------------------------------------------------- /tests/backup-and-prune/test.py: -------------------------------------------------------------------------------- 1 | machine = MyMachine(machine) 2 | machine.succeed("lxc launch image test") 3 | 4 | machine.lxd_snapper("backup", "expected.out.1.txt") 5 | machine.assert_snapshot_exists("default", "test", "auto\-.*") 6 | 7 | machine.lxd_snapper("prune", "expected.out.2.txt") 8 | machine.assert_snapshot_does_not_exist("default", "test", "auto\-.*") 9 | -------------------------------------------------------------------------------- /tests/dry-run/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | 3 | policies: 4 | all: 5 | keep-last: 0 6 | -------------------------------------------------------------------------------- /tests/dry-run/expected.out.1.txt: -------------------------------------------------------------------------------- 1 | (--dry-run is active, no changes will be applied) 2 | 3 | test 4 | - creating snapshot: auto-20180101 [ OK ] 5 | 6 | Summary 7 | ------- 8 | processed instances: 1 9 | created snapshots: 1 10 | -------------------------------------------------------------------------------- /tests/dry-run/expected.out.2.txt: -------------------------------------------------------------------------------- 1 | test 2 | - creating snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | created snapshots: 1 8 | -------------------------------------------------------------------------------- /tests/dry-run/expected.out.3.txt: -------------------------------------------------------------------------------- 1 | (--dry-run is active, no changes will be applied) 2 | 3 | test 4 | - deleting snapshot: auto-20180101 [ OK ] 5 | 6 | Summary 7 | ------- 8 | processed instances: 1 9 | deleted snapshots: 1 10 | kept snapshots: 0 11 | -------------------------------------------------------------------------------- /tests/dry-run/expected.out.4.txt: -------------------------------------------------------------------------------- 1 | test 2 | - deleting snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | deleted snapshots: 1 8 | kept snapshots: 0 9 | -------------------------------------------------------------------------------- /tests/dry-run/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: fw.mkDefaultTest 2 | -------------------------------------------------------------------------------- /tests/dry-run/test.py: -------------------------------------------------------------------------------- 1 | machine = MyMachine(machine) 2 | machine.succeed("lxc launch image test") 3 | 4 | machine.lxd_snapper("--dry-run backup", "expected.out.1.txt") 5 | machine.assert_snapshot_does_not_exist("default", "test", "auto\-.*") 6 | 7 | machine.lxd_snapper("backup", "expected.out.2.txt") 8 | machine.assert_snapshot_exists("default", "test", "auto\-.*") 9 | 10 | machine.lxd_snapper("--dry-run prune", "expected.out.3.txt") 11 | machine.assert_snapshot_exists("default", "test", "auto\-.*") 12 | 13 | machine.lxd_snapper("prune", "expected.out.4.txt") 14 | machine.assert_snapshot_does_not_exist("default", "test", "auto\-.*") 15 | -------------------------------------------------------------------------------- /tests/hooks/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | 3 | hooks: 4 | on-backup-started: 'echo "on-backup-started" >> /tmp/log.txt' 5 | on-snapshot-created: 'echo "on-snapshot-created: {{ remoteName }}, {{ projectName }}, {{ instanceName }}, {{snapshotName}}" >> /tmp/log.txt' 6 | on-instance-backed-up: 'echo "on-instance-backed-up: {{ remoteName }}, {{ projectName }}, {{ instanceName }}" >> /tmp/log.txt' 7 | on-backup-completed: 'echo "on-backup-completed" >> /tmp/log.txt' 8 | 9 | on-prune-started: 'echo "on-prune-started" >> /tmp/log.txt' 10 | on-snapshot-deleted: 'echo "on-snapshot-deleted: {{ remoteName }}, {{ projectName }}, {{ instanceName }}, {{ snapshotName }}" >> /tmp/log.txt' 11 | on-instance-pruned: 'echo "on-instance-pruned: {{ remoteName }}, {{ projectName }}, {{ instanceName }}" >> /tmp/log.txt' 12 | on-prune-completed: 'echo "on-prune-completed" >> /tmp/log.txt' 13 | 14 | policies: 15 | all: 16 | keep-last: 0 17 | -------------------------------------------------------------------------------- /tests/hooks/expected.log.txt: -------------------------------------------------------------------------------- 1 | on-backup-started 2 | on-snapshot-created: local, default, test, auto-20180101 3 | on-instance-backed-up: local, default, test 4 | on-backup-completed 5 | on-prune-started 6 | on-snapshot-deleted: local, default, test, auto-20180101 7 | on-instance-pruned: local, default, test 8 | on-prune-completed 9 | -------------------------------------------------------------------------------- /tests/hooks/expected.out.1.txt: -------------------------------------------------------------------------------- 1 | (--dry-run is active, no changes will be applied) 2 | 3 | test 4 | - creating snapshot: auto-20180101 [ OK ] 5 | 6 | Summary 7 | ------- 8 | processed instances: 1 9 | created snapshots: 1 10 | -------------------------------------------------------------------------------- /tests/hooks/expected.out.2.txt: -------------------------------------------------------------------------------- 1 | (--dry-run is active, no changes will be applied) 2 | 3 | test 4 | 5 | Summary 6 | ------- 7 | processed instances: 1 8 | deleted snapshots: 0 9 | kept snapshots: 0 10 | -------------------------------------------------------------------------------- /tests/hooks/expected.out.3.txt: -------------------------------------------------------------------------------- 1 | test 2 | - creating snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | created snapshots: 1 8 | -------------------------------------------------------------------------------- /tests/hooks/expected.out.4.txt: -------------------------------------------------------------------------------- 1 | test 2 | - deleting snapshot: auto-20180101 [ OK ] 3 | 4 | Summary 5 | ------- 6 | processed instances: 1 7 | deleted snapshots: 1 8 | kept snapshots: 0 9 | -------------------------------------------------------------------------------- /tests/hooks/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: fw.mkDefaultTest 2 | -------------------------------------------------------------------------------- /tests/hooks/test.py: -------------------------------------------------------------------------------- 1 | machine = MyMachine(machine) 2 | machine.succeed("lxc launch image test") 3 | machine.succeed("touch /tmp/log.txt") 4 | 5 | # --- 6 | 7 | machine.lxd_snapper("--dry-run backup", "expected.out.1.txt") 8 | machine.lxd_snapper("--dry-run prune", "expected.out.2.txt") 9 | 10 | actual_log = machine.succeed("cat /tmp/log.txt") 11 | 12 | assert "" == actual_log, f"hook-log should be empty, but it isn't:\n{actual_log}" 13 | 14 | # --- 15 | 16 | machine.lxd_snapper("backup", "expected.out.3.txt") 17 | machine.lxd_snapper("prune", "expected.out.4.txt") 18 | 19 | actual_log = machine.succeed("cat /tmp/log.txt") 20 | expected_log = machine.succeed("cat /test/expected.log.txt") 21 | 22 | assert expected_log == actual_log, f"hook-logs don't match; actual:\n{actual_log}" 23 | -------------------------------------------------------------------------------- /tests/prelude.py.nix: -------------------------------------------------------------------------------- 1 | { lxdConfig, lxdContainerMeta, lxdContainerImage, testPath }: '' 2 | class MyMachine(Machine): 3 | def __init__(self, base): 4 | base.succeed("date -s '2018-01-01 12:00:00'") 5 | base.wait_for_unit("multi-user.target") 6 | base.wait_for_file("/var/lib/lxd/unix.socket") 7 | 8 | base.succeed("mkdir /test") 9 | base.succeed("mount --bind ${testPath} /test") 10 | base.succeed("truncate /dev/shm/tank -s 1024MB") 11 | base.succeed("zpool create tank /dev/shm/tank") 12 | 13 | base.succeed( 14 | "cat ${lxdConfig} | lxd init --preseed" 15 | ) 16 | 17 | base.succeed( 18 | "lxc image import ${lxdContainerMeta}/*/*.tar.xz ${lxdContainerImage}/*/*.tar.xz --alias image" 19 | ) 20 | 21 | self.base = base 22 | 23 | 24 | def succeed(self, command): 25 | return self.base.succeed(command) 26 | 27 | 28 | def fail(self, command): 29 | return self.base.fail(command) 30 | 31 | 32 | # Launches `lxd-snapper` with specified command, asserts that it succeeded 33 | # and returns its output. 34 | # 35 | # ```python 36 | # machine.lxd_snapper("backup") 37 | # ``` 38 | def lxd_snapper(self, cmd, expected_out_file = None): 39 | actual_out = self.succeed( 40 | f"lxd-snapper -c /test/config.yaml {cmd}" 41 | ) 42 | 43 | if expected_out_file: 44 | expected_out = self.succeed(f"cat /test/{expected_out_file}") 45 | assert expected_out == actual_out, f"outputs don't match; actual output:\n{actual_out}" 46 | 47 | return actual_out 48 | 49 | 50 | # Launches `lxd-snapper` with specified command, asserts that it failed 51 | # and returns its output. 52 | # 53 | # ```python 54 | # machine.lxd_snapper_err("backup") 55 | # ``` 56 | def lxd_snapper_err(self, cmd, expected_out_file = None): 57 | actual_out = self.fail( 58 | f"lxd-snapper -c /test/config.yaml {cmd}" 59 | ) 60 | 61 | if expected_out_file: 62 | expected_out = self.succeed(f"cat /test/{expected_out_file}") 63 | assert expected_out == actual_out, f"outputs don't match; actual output:\n{actual_out}" 64 | 65 | return actual_out 66 | 67 | 68 | # Asserts that given instance contains exactly `count` snapshots matching 69 | # given regex. 70 | # 71 | # ```python 72 | # machine.assert_snapshot_count("default", "test", "snap\d", 1) 73 | # ``` 74 | def assert_snapshot_count(self, project, instance, snapshot_regex, snapshot_count): 75 | snapshot_regex = snapshot_regex.replace("\\", "\\\\") 76 | 77 | self.succeed( 78 | f"lxc query /1.0/instances/{instance}/snapshots?project={project}" 79 | + f" | jq -e '[ .[] | select(test(\"{snapshot_regex}\")) ] | length == {snapshot_count}'" 80 | ) 81 | 82 | 83 | # Asserts that given instance contains exactly one snapshot matching given 84 | # regex. 85 | # 86 | # ```python 87 | # machine.assert_snapshot_exists("default", "test", "snap\d") 88 | # ``` 89 | def assert_snapshot_exists(self, project, instance, snapshot_regex): 90 | self.assert_snapshot_count(project, instance, snapshot_regex, 1) 91 | 92 | 93 | # Asserts that given instance contains exactly zero snapshots matching given 94 | # regex. 95 | # 96 | # ```python 97 | # machine.assert_snapshot_does_not_exist("default", "test", "snap\d") 98 | # ``` 99 | def assert_snapshot_does_not_exist(self, project, instance, snapshot_regex): 100 | self.assert_snapshot_count(project, instance, snapshot_regex, 0) 101 | '' 102 | -------------------------------------------------------------------------------- /tests/timeout/config.yaml: -------------------------------------------------------------------------------- 1 | snapshot-name-format: '%Y%m%d' 2 | lxc-timeout: '0s' 3 | 4 | policies: 5 | all: 6 | keep-last: 0 7 | -------------------------------------------------------------------------------- /tests/timeout/expected.out.txt: -------------------------------------------------------------------------------- 1 | 2 | Error: Couldn't list projects 3 | 4 | Caused by: 5 | Operation timed out - lxc took too long to answer 6 | -------------------------------------------------------------------------------- /tests/timeout/test.nix: -------------------------------------------------------------------------------- 1 | { fw }: fw.mkDefaultTest 2 | -------------------------------------------------------------------------------- /tests/timeout/test.py: -------------------------------------------------------------------------------- 1 | machine = MyMachine(machine) 2 | machine.succeed("lxc launch image test") 3 | machine.lxd_snapper_err("backup", "expected.out.txt") 4 | --------------------------------------------------------------------------------