├── .editorconfig ├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.toml ├── LICENSE.md ├── cli ├── Cargo.toml ├── build.rs └── src │ ├── args.rs │ ├── create.rs │ ├── delete.rs │ ├── list.rs │ ├── main.rs │ ├── restore.rs │ └── util.rs ├── debian ├── changelog ├── control ├── copyright ├── pop-snapshot.postinst ├── pop-snapshot.prerm ├── rules └── source │ └── format ├── deny.toml ├── interface ├── Cargo.toml └── src │ ├── daemon.rs │ ├── lib.rs │ └── snapshot.rs ├── justfile ├── rust-toolchain.toml └── service ├── Cargo.toml ├── data ├── com.system76.PopSnapshot.xml ├── pop-snapshot.service └── pop-snapshot.toml └── src ├── config.rs ├── main.rs ├── service.rs ├── service └── snapshot.rs ├── snapshot.rs ├── snapshot ├── create.rs ├── delete.rs ├── list.rs ├── metadata.rs ├── mount.rs └── restore.rs └── util.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | indent_size = 4 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | end_of_line = lf 8 | 9 | [*.yml] 10 | indent_style = space 11 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust Checks 2 | env: 3 | CARGO_TERM_COLOR: always 4 | on: 5 | pull_request: 6 | paths: 7 | - "**.rs" 8 | - ".github/workflows/rust.yml" 9 | push: 10 | paths: 11 | - "**.rs" 12 | - ".github/workflows/rust.yml" 13 | jobs: 14 | rustfmt: 15 | name: Check Rust Formatting 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | - name: Install stable Rust toolchain 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | profile: minimal 24 | toolchain: 1.60 25 | components: rustfmt, clippy 26 | - name: Cache build 27 | uses: actions/cache@v3 28 | with: 29 | path: | 30 | ~/.cargo/ 31 | target/ 32 | key: ${{ runner.os }}-v1-cargo-${{ hashFiles('**/Cargo.lock') }} 33 | - name: Check with rustfmt 34 | uses: actions-rs/cargo@v1 35 | with: 36 | command: fmt 37 | args: --check 38 | - name: Check with clippy 39 | uses: actions-rs/cargo@v1 40 | with: 41 | command: clippy 42 | args: --no-deps 43 | cargo-deny: 44 | name: Check with cargo-deny (${{ matrix.checks }}) 45 | runs-on: ubuntu-latest 46 | strategy: 47 | matrix: 48 | checks: 49 | - advisories 50 | - bans licenses sources 51 | continue-on-error: ${{ matrix.checks == 'advisories' }} 52 | steps: 53 | - name: Checkout repository 54 | uses: actions/checkout@v2 55 | - name: Cache cargo-deny 56 | uses: actions/cache@v3 57 | with: 58 | path: | 59 | ~/.cargo/ 60 | key: ${{ runner.os }}-v1-cargo-deny 61 | - name: Install stable Rust toolchain 62 | uses: actions-rs/toolchain@v1 63 | with: 64 | profile: minimal 65 | toolchain: stable 66 | - name: Install cargo-deny 67 | uses: actions-rs/cargo@v1 68 | with: 69 | command: install 70 | args: cargo-deny 71 | - name: Check with cargo-deny 72 | uses: actions-rs/cargo@v1 73 | with: 74 | command: deny 75 | args: check ${{ matrix.checks }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | .cargo/config 4 | vendor.tar 5 | vendor 6 | debian/* 7 | !debian/rules 8 | !debian/control 9 | !debian/copyright 10 | !debian/source/format 11 | !debian/changelog 12 | !debian/pop-snapshot.* 13 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | hard_tabs = true 3 | merge_derives = true 4 | newline_style = "Unix" 5 | remove_nested_parens = true 6 | reorder_imports = true 7 | reorder_modules = true 8 | use_field_init_shorthand = true 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cli", "service", "interface"] 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | ### 1. Definitions 5 | 6 | **1.1. “Contributor”** 7 | means each individual or legal entity that creates, contributes to 8 | the creation of, or owns Covered Software. 9 | 10 | **1.2. “Contributor Version”** 11 | means the combination of the Contributions of others (if any) used 12 | by a Contributor and that particular Contributor's Contribution. 13 | 14 | **1.3. “Contribution”** 15 | means Covered Software of a particular Contributor. 16 | 17 | **1.4. “Covered Software”** 18 | means Source Code Form to which the initial Contributor has attached 19 | the notice in Exhibit A, the Executable Form of such Source Code 20 | Form, and Modifications of such Source Code Form, in each case 21 | including portions thereof. 22 | 23 | **1.5. “Incompatible With Secondary Licenses”** 24 | means 25 | 26 | * **(a)** that the initial Contributor has attached the notice described 27 | in Exhibit B to the Covered Software; or 28 | * **(b)** that the Covered Software was made available under the terms of 29 | version 1.1 or earlier of the License, but not also under the 30 | terms of a Secondary License. 31 | 32 | **1.6. “Executable Form”** 33 | means any form of the work other than Source Code Form. 34 | 35 | **1.7. “Larger Work”** 36 | means a work that combines Covered Software with other material, in 37 | a separate file or files, that is not Covered Software. 38 | 39 | **1.8. “License”** 40 | means this document. 41 | 42 | **1.9. “Licensable”** 43 | means having the right to grant, to the maximum extent possible, 44 | whether at the time of the initial grant or subsequently, any and 45 | all of the rights conveyed by this License. 46 | 47 | **1.10. “Modifications”** 48 | means any of the following: 49 | 50 | * **(a)** any file in Source Code Form that results from an addition to, 51 | deletion from, or modification of the contents of Covered 52 | Software; or 53 | * **(b)** any new file in Source Code Form that contains any Covered 54 | Software. 55 | 56 | **1.11. “Patent Claims” of a Contributor** 57 | means any patent claim(s), including without limitation, method, 58 | process, and apparatus claims, in any patent Licensable by such 59 | Contributor that would be infringed, but for the grant of the 60 | License, by the making, using, selling, offering for sale, having 61 | made, import, or transfer of either its Contributions or its 62 | Contributor Version. 63 | 64 | **1.12. “Secondary License”** 65 | means either the GNU General Public License, Version 2.0, the GNU 66 | Lesser General Public License, Version 2.1, the GNU Affero General 67 | Public License, Version 3.0, or any later versions of those 68 | licenses. 69 | 70 | **1.13. “Source Code Form”** 71 | means the form of the work preferred for making modifications. 72 | 73 | **1.14. “You” (or “Your”)** 74 | means an individual or a legal entity exercising rights under this 75 | License. For legal entities, “You” includes any entity that 76 | controls, is controlled by, or is under common control with You. For 77 | purposes of this definition, “control” means **(a)** the power, direct 78 | or indirect, to cause the direction or management of such entity, 79 | whether by contract or otherwise, or **(b)** ownership of more than 80 | fifty percent (50%) of the outstanding shares or beneficial 81 | ownership of such entity. 82 | 83 | 84 | ### 2. License Grants and Conditions 85 | 86 | #### 2.1. Grants 87 | 88 | Each Contributor hereby grants You a world-wide, royalty-free, 89 | non-exclusive license: 90 | 91 | * **(a)** under intellectual property rights (other than patent or trademark) 92 | Licensable by such Contributor to use, reproduce, make available, 93 | modify, display, perform, distribute, and otherwise exploit its 94 | Contributions, either on an unmodified basis, with Modifications, or 95 | as part of a Larger Work; and 96 | * **(b)** under Patent Claims of such Contributor to make, use, sell, offer 97 | for sale, have made, import, and otherwise transfer either its 98 | Contributions or its Contributor Version. 99 | 100 | #### 2.2. Effective Date 101 | 102 | The licenses granted in Section 2.1 with respect to any Contribution 103 | become effective for each Contribution on the date the Contributor first 104 | distributes such Contribution. 105 | 106 | #### 2.3. Limitations on Grant Scope 107 | 108 | The licenses granted in this Section 2 are the only rights granted under 109 | this License. No additional rights or licenses will be implied from the 110 | distribution or licensing of Covered Software under this License. 111 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 112 | Contributor: 113 | 114 | * **(a)** for any code that a Contributor has removed from Covered Software; 115 | or 116 | * **(b)** for infringements caused by: **(i)** Your and any other third party's 117 | modifications of Covered Software, or **(ii)** the combination of its 118 | Contributions with other software (except as part of its Contributor 119 | Version); or 120 | * **(c)** under Patent Claims infringed by Covered Software in the absence of 121 | its Contributions. 122 | 123 | This License does not grant any rights in the trademarks, service marks, 124 | or logos of any Contributor (except as may be necessary to comply with 125 | the notice requirements in Section 3.4). 126 | 127 | #### 2.4. Subsequent Licenses 128 | 129 | No Contributor makes additional grants as a result of Your choice to 130 | distribute the Covered Software under a subsequent version of this 131 | License (see Section 10.2) or under the terms of a Secondary License (if 132 | permitted under the terms of Section 3.3). 133 | 134 | #### 2.5. Representation 135 | 136 | Each Contributor represents that the Contributor believes its 137 | Contributions are its original creation(s) or it has sufficient rights 138 | to grant the rights to its Contributions conveyed by this License. 139 | 140 | #### 2.6. Fair Use 141 | 142 | This License is not intended to limit any rights You have under 143 | applicable copyright doctrines of fair use, fair dealing, or other 144 | equivalents. 145 | 146 | #### 2.7. Conditions 147 | 148 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 149 | in Section 2.1. 150 | 151 | 152 | ### 3. Responsibilities 153 | 154 | #### 3.1. Distribution of Source Form 155 | 156 | All distribution of Covered Software in Source Code Form, including any 157 | Modifications that You create or to which You contribute, must be under 158 | the terms of this License. You must inform recipients that the Source 159 | Code Form of the Covered Software is governed by the terms of this 160 | License, and how they can obtain a copy of this License. You may not 161 | attempt to alter or restrict the recipients' rights in the Source Code 162 | Form. 163 | 164 | #### 3.2. Distribution of Executable Form 165 | 166 | If You distribute Covered Software in Executable Form then: 167 | 168 | * **(a)** such Covered Software must also be made available in Source Code 169 | Form, as described in Section 3.1, and You must inform recipients of 170 | the Executable Form how they can obtain a copy of such Source Code 171 | Form by reasonable means in a timely manner, at a charge no more 172 | than the cost of distribution to the recipient; and 173 | 174 | * **(b)** You may distribute such Executable Form under the terms of this 175 | License, or sublicense it under different terms, provided that the 176 | license for the Executable Form does not attempt to limit or alter 177 | the recipients' rights in the Source Code Form under this License. 178 | 179 | #### 3.3. Distribution of a Larger Work 180 | 181 | You may create and distribute a Larger Work under terms of Your choice, 182 | provided that You also comply with the requirements of this License for 183 | the Covered Software. If the Larger Work is a combination of Covered 184 | Software with a work governed by one or more Secondary Licenses, and the 185 | Covered Software is not Incompatible With Secondary Licenses, this 186 | License permits You to additionally distribute such Covered Software 187 | under the terms of such Secondary License(s), so that the recipient of 188 | the Larger Work may, at their option, further distribute the Covered 189 | Software under the terms of either this License or such Secondary 190 | License(s). 191 | 192 | #### 3.4. Notices 193 | 194 | You may not remove or alter the substance of any license notices 195 | (including copyright notices, patent notices, disclaimers of warranty, 196 | or limitations of liability) contained within the Source Code Form of 197 | the Covered Software, except that You may alter any license notices to 198 | the extent required to remedy known factual inaccuracies. 199 | 200 | #### 3.5. Application of Additional Terms 201 | 202 | You may choose to offer, and to charge a fee for, warranty, support, 203 | indemnity or liability obligations to one or more recipients of Covered 204 | Software. However, You may do so only on Your own behalf, and not on 205 | behalf of any Contributor. You must make it absolutely clear that any 206 | such warranty, support, indemnity, or liability obligation is offered by 207 | You alone, and You hereby agree to indemnify every Contributor for any 208 | liability incurred by such Contributor as a result of warranty, support, 209 | indemnity or liability terms You offer. You may include additional 210 | disclaimers of warranty and limitations of liability specific to any 211 | jurisdiction. 212 | 213 | 214 | ### 4. Inability to Comply Due to Statute or Regulation 215 | 216 | If it is impossible for You to comply with any of the terms of this 217 | License with respect to some or all of the Covered Software due to 218 | statute, judicial order, or regulation then You must: **(a)** comply with 219 | the terms of this License to the maximum extent possible; and **(b)** 220 | describe the limitations and the code they affect. Such description must 221 | be placed in a text file included with all distributions of the Covered 222 | Software under this License. Except to the extent prohibited by statute 223 | or regulation, such description must be sufficiently detailed for a 224 | recipient of ordinary skill to be able to understand it. 225 | 226 | 227 | ### 5. Termination 228 | 229 | **5.1.** The rights granted under this License will terminate automatically 230 | if You fail to comply with any of its terms. However, if You become 231 | compliant, then the rights granted under this License from a particular 232 | Contributor are reinstated **(a)** provisionally, unless and until such 233 | Contributor explicitly and finally terminates Your grants, and **(b)** on an 234 | ongoing basis, if such Contributor fails to notify You of the 235 | non-compliance by some reasonable means prior to 60 days after You have 236 | come back into compliance. Moreover, Your grants from a particular 237 | Contributor are reinstated on an ongoing basis if such Contributor 238 | notifies You of the non-compliance by some reasonable means, this is the 239 | first time You have received notice of non-compliance with this License 240 | from such Contributor, and You become compliant prior to 30 days after 241 | Your receipt of the notice. 242 | 243 | **5.2.** If You initiate litigation against any entity by asserting a patent 244 | infringement claim (excluding declaratory judgment actions, 245 | counter-claims, and cross-claims) alleging that a Contributor Version 246 | directly or indirectly infringes any patent, then the rights granted to 247 | You by any and all Contributors for the Covered Software under Section 248 | 2.1 of this License shall terminate. 249 | 250 | **5.3.** In the event of termination under Sections 5.1 or 5.2 above, all 251 | end user license agreements (excluding distributors and resellers) which 252 | have been validly granted by You or Your distributors under this License 253 | prior to termination shall survive termination. 254 | 255 | 256 | ### 6. Disclaimer of Warranty 257 | 258 | > Covered Software is provided under this License on an “as is” 259 | > basis, without warranty of any kind, either expressed, implied, or 260 | > statutory, including, without limitation, warranties that the 261 | > Covered Software is free of defects, merchantable, fit for a 262 | > particular purpose or non-infringing. The entire risk as to the 263 | > quality and performance of the Covered Software is with You. 264 | > Should any Covered Software prove defective in any respect, You 265 | > (not any Contributor) assume the cost of any necessary servicing, 266 | > repair, or correction. This disclaimer of warranty constitutes an 267 | > essential part of this License. No use of any Covered Software is 268 | > authorized under this License except under this disclaimer. 269 | 270 | ### 7. Limitation of Liability 271 | 272 | > Under no circumstances and under no legal theory, whether tort 273 | > (including negligence), contract, or otherwise, shall any 274 | > Contributor, or anyone who distributes Covered Software as 275 | > permitted above, be liable to You for any direct, indirect, 276 | > special, incidental, or consequential damages of any character 277 | > including, without limitation, damages for lost profits, loss of 278 | > goodwill, work stoppage, computer failure or malfunction, or any 279 | > and all other commercial damages or losses, even if such party 280 | > shall have been informed of the possibility of such damages. This 281 | > limitation of liability shall not apply to liability for death or 282 | > personal injury resulting from such party's negligence to the 283 | > extent applicable law prohibits such limitation. Some 284 | > jurisdictions do not allow the exclusion or limitation of 285 | > incidental or consequential damages, so this exclusion and 286 | > limitation may not apply to You. 287 | 288 | 289 | ### 8. Litigation 290 | 291 | Any litigation relating to this License may be brought only in the 292 | courts of a jurisdiction where the defendant maintains its principal 293 | place of business and such litigation shall be governed by laws of that 294 | jurisdiction, without reference to its conflict-of-law provisions. 295 | Nothing in this Section shall prevent a party's ability to bring 296 | cross-claims or counter-claims. 297 | 298 | 299 | ### 9. Miscellaneous 300 | 301 | This License represents the complete agreement concerning the subject 302 | matter hereof. If any provision of this License is held to be 303 | unenforceable, such provision shall be reformed only to the extent 304 | necessary to make it enforceable. Any law or regulation which provides 305 | that the language of a contract shall be construed against the drafter 306 | shall not be used to construe this License against a Contributor. 307 | 308 | 309 | ### 10. Versions of the License 310 | 311 | #### 10.1. New Versions 312 | 313 | Mozilla Foundation is the license steward. Except as provided in Section 314 | 10.3, no one other than the license steward has the right to modify or 315 | publish new versions of this License. Each version will be given a 316 | distinguishing version number. 317 | 318 | #### 10.2. Effect of New Versions 319 | 320 | You may distribute the Covered Software under the terms of the version 321 | of the License under which You originally received the Covered Software, 322 | or under the terms of any subsequent version published by the license 323 | steward. 324 | 325 | #### 10.3. Modified Versions 326 | 327 | If you create software not governed by this License, and you want to 328 | create a new license for such software, you may create and use a 329 | modified version of this License if you rename the license and remove 330 | any references to the name of the license steward (except to note that 331 | such modified license differs from this License). 332 | 333 | #### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 334 | 335 | If You choose to distribute Source Code Form that is Incompatible With 336 | Secondary Licenses under the terms of this version of the License, the 337 | notice described in Exhibit B of this License must be attached. 338 | 339 | ## Exhibit A - Source Code Form License Notice 340 | 341 | This Source Code Form is subject to the terms of the Mozilla Public 342 | License, v. 2.0. If a copy of the MPL was not distributed with this 343 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 344 | 345 | If it is not possible or desirable to put the notice in a particular 346 | file, then You may include the notice in a location (such as a LICENSE 347 | file in a relevant directory) where a recipient would be likely to look 348 | for such a notice. 349 | 350 | You may add additional accurate notices of copyright ownership. 351 | 352 | ## Exhibit B - “Incompatible With Secondary Licenses” Notice 353 | 354 | This Source Code Form is "Incompatible With Secondary Licenses", as 355 | defined by the Mozilla Public License, v. 2.0. 356 | 357 | 358 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pop-snapshot-cli" 3 | description = "Manage btrfs snapshots" 4 | authors = ["Lucy "] 5 | version = "0.1.0" 6 | edition = "2021" 7 | license = "MPL-2.0" 8 | publish = false 9 | 10 | [[bin]] 11 | name = "pop-snapshot" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | clap = { version = "3", features = ["derive"] } 16 | color-eyre = "0.6" 17 | owo-colors = "3" 18 | tokio = { version = "1", features = ["full"] } 19 | zbus = { version = "2", default-features = false, features = ["tokio"] } 20 | zbus-pop-snapshot = { path = "../interface" } 21 | 22 | [build-dependencies] 23 | clap = { version = "3", features = ["derive"] } 24 | clap_complete = "3.1.4" 25 | -------------------------------------------------------------------------------- /cli/build.rs: -------------------------------------------------------------------------------- 1 | use clap::IntoApp; 2 | use clap_complete::{ 3 | generate_to, 4 | shells::{Bash, Fish, Zsh}, 5 | }; 6 | use std::path::PathBuf; 7 | 8 | include!("src/args.rs"); 9 | 10 | fn main() { 11 | let outdir = PathBuf::from("../target"); 12 | let mut cmd = CliArgs::command(); 13 | let path = generate_to(Bash, &mut cmd, "pop-snapshot", &outdir) 14 | .expect("failed to generate bash completions"); 15 | println!("cargo:warning=bash completion file generated: {:?}", path); 16 | let path = generate_to(Zsh, &mut cmd, "pop-snapshot", &outdir) 17 | .expect("failed to generate zsh completions"); 18 | println!("cargo:warning=zsh completion file generated: {:?}", path); 19 | let path = generate_to(Fish, &mut cmd, "pop-snapshot", outdir) 20 | .expect("failed to generate fish completions"); 21 | println!("cargo:warning=fish completion file generated: {:?}", path); 22 | } 23 | -------------------------------------------------------------------------------- /cli/src/args.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use clap::{Args, Parser, Subcommand}; 3 | 4 | #[derive(Debug, Parser)] 5 | #[clap( 6 | author = "Lucy ", 7 | about = "CLI tool for managing btrfs snapshots on Pop!_OS" 8 | )] 9 | pub struct CliArgs { 10 | /// Whether to automatically confirm "yes" to prompts or not. 11 | #[clap(short, long)] 12 | pub yes: bool, 13 | #[clap(subcommand)] 14 | pub subcommand: CliSubcommand, 15 | } 16 | 17 | #[derive(Debug, Subcommand)] 18 | pub enum CliSubcommand { 19 | /// List all snapshots. 20 | List, 21 | /// Take a snapshot of the current system state. 22 | Create(CliCreate), 23 | /// Delete an existing snapshot 24 | Delete(CliDelete), 25 | /// Restore your system to a snapshot. 26 | Restore(CliRestore), 27 | } 28 | 29 | #[derive(Debug, Args)] 30 | pub struct CliCreate { 31 | /// The name of the snapshot. 32 | #[clap(short, long)] 33 | pub name: Option, 34 | /// The description of the snapshot 35 | #[clap(short, long)] 36 | pub description: Option, 37 | /// Which subvolumes to snapshot. 38 | /// Defaults to everything except for @home. 39 | #[clap(short, long)] 40 | pub subvolumes: Option>, 41 | } 42 | 43 | #[derive(Debug, Args)] 44 | pub struct CliDelete { 45 | /// The UUID of the snapshot to delete. 46 | #[clap(short, long)] 47 | pub snapshot: String, 48 | } 49 | 50 | #[derive(Debug, Args)] 51 | pub struct CliRestore { 52 | /// Which subvolumes to snapshot. 53 | /// Defaults to all subvolumes in the snapshot. 54 | #[clap(short, long)] 55 | pub subvolumes: Option>, 56 | /// The UUID of the snapshot to restore 57 | pub snapshot: String, 58 | } 59 | -------------------------------------------------------------------------------- /cli/src/create.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use crate::{ 3 | args::{CliArgs, CliCreate}, 4 | util::yes_no_prompt, 5 | }; 6 | use color_eyre::{eyre::WrapErr, Result}; 7 | use owo_colors::OwoColorize; 8 | use zbus_pop_snapshot::{PopSnapshotProxy, SnapshotProxy}; 9 | 10 | pub async fn create(args: &CliArgs, create: &CliCreate) -> Result<()> { 11 | let connection = zbus::Connection::system() 12 | .await 13 | .wrap_err("failed to connect to D-Bus system bus")?; 14 | let proxy = PopSnapshotProxy::new(&connection) 15 | .await 16 | .wrap_err("failed to connect to Pop!_OS snapshot service")?; 17 | let is_sure = args.yes || { 18 | println!( 19 | "Are you {} you want to {} a snapshot?", 20 | "SURE".bold(), 21 | "create".green() 22 | ); 23 | println!( 24 | "Press '{}' for {}, or any other key to {}", 25 | "y".green().bold(), 26 | "yes".green(), 27 | "cancel".red() 28 | ); 29 | yes_no_prompt() 30 | }; 31 | if !is_sure { 32 | println!("Alright, {} creating a snapshot.", "not".red().bold()); 33 | return Ok(()); 34 | } 35 | 36 | print!("Creating a new snapshot, "); 37 | match &create.subvolumes { 38 | Some(subvolumes) => { 39 | let mut iter = subvolumes.iter().peekable(); 40 | print!("{}: ", "using the following subvolumes".dimmed()); 41 | while let Some(subvolume) = iter.next() { 42 | print!("{}", subvolume.green()); 43 | if iter.peek().is_some() { 44 | print!(", "); 45 | } else { 46 | println!() 47 | } 48 | } 49 | } 50 | None => { 51 | println!("using {}", "all valid subvolumes".green().bold()); 52 | } 53 | } 54 | 55 | let new_snapshot_path = proxy 56 | .create_snapshot( 57 | create.name.clone().into(), 58 | create.description.clone().into(), 59 | create.subvolumes.clone().into(), 60 | ) 61 | .await 62 | .wrap_err("failed to create snapshot")?; 63 | let new_snapshot = SnapshotProxy::builder(&connection) 64 | .path(&new_snapshot_path) 65 | .wrap_err("failed to connect to new snapshot path")? 66 | .build() 67 | .await 68 | .wrap_err("failed to connect to new snapshot")?; 69 | let uuid = new_snapshot 70 | .uuid() 71 | .await 72 | .wrap_err("failed to get snapshot UUID")?; 73 | let name = new_snapshot 74 | .name() 75 | .await 76 | .wrap_err("failed to get snapshot name")?; 77 | let description = new_snapshot 78 | .description() 79 | .await 80 | .wrap_err("failed to get snapshot description")?; 81 | let subvolumes = new_snapshot 82 | .subvolumes() 83 | .await 84 | .wrap_err("failed to get snapshot subvolumes")?; 85 | 86 | println!("Created snapshot {}", uuid.blue()); 87 | println!( 88 | "\t{}: {}", 89 | "Name".bold(), 90 | if name.is_empty() { 91 | "none set" 92 | } else { 93 | name.as_str() 94 | } 95 | .dimmed(), 96 | ); 97 | println!( 98 | "\t{}: {}", 99 | "Description".bold(), 100 | if description.is_empty() { 101 | "none set" 102 | } else { 103 | description.as_str() 104 | } 105 | .dimmed(), 106 | ); 107 | print!("\t{}: ", "Subvolumes".bold()); 108 | let mut iter = subvolumes.iter().peekable(); 109 | while let Some(subvolume) = iter.next() { 110 | print!("{}", subvolume.green()); 111 | if iter.peek().is_some() { 112 | print!(", "); 113 | } else { 114 | println!() 115 | } 116 | } 117 | 118 | Ok(()) 119 | } 120 | -------------------------------------------------------------------------------- /cli/src/delete.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use crate::{ 3 | args::{CliArgs, CliDelete}, 4 | util::yes_no_prompt, 5 | }; 6 | use color_eyre::{eyre::WrapErr, Result}; 7 | use owo_colors::OwoColorize; 8 | use zbus::zvariant::OwnedObjectPath; 9 | use zbus_pop_snapshot::{PopSnapshotProxy, SnapshotProxy}; 10 | 11 | pub async fn delete(args: &CliArgs, delete: &CliDelete) -> Result<()> { 12 | let connection = zbus::Connection::system() 13 | .await 14 | .wrap_err("failed to connect to D-Bus system bus")?; 15 | let proxy = PopSnapshotProxy::new(&connection) 16 | .await 17 | .wrap_err("failed to connect to Pop!_OS snapshot service")?; 18 | let snapshot_path = match Option::::from( 19 | proxy 20 | .find_snapshot(&delete.snapshot) 21 | .await 22 | .wrap_err("failed to list snapshots")?, 23 | ) { 24 | Some(path) => path, 25 | None => { 26 | println!("Snapshot {} not found", delete.snapshot.blue()); 27 | return Ok(()); 28 | } 29 | }; 30 | 31 | let snapshot = SnapshotProxy::builder(&connection) 32 | .path(&snapshot_path) 33 | .wrap_err_with(|| format!("failed to connect to snapshot {}", snapshot_path.as_str()))? 34 | .build() 35 | .await 36 | .wrap_err_with(|| format!("failed to connect to snapshot {}", snapshot_path.as_str()))?; 37 | 38 | let is_sure = args.yes || { 39 | println!( 40 | "Are you {} you want to {} snapshot {}?", 41 | "SURE".bold(), 42 | "delete".red(), 43 | delete.snapshot.blue() 44 | ); 45 | println!( 46 | "Press '{}' for {}, or any other key to {}", 47 | "y".green().bold(), 48 | "yes".green(), 49 | "cancel".red() 50 | ); 51 | yes_no_prompt() 52 | }; 53 | if !is_sure { 54 | println!( 55 | "Alright, {} deleting snapshot {}", 56 | "not".bold(), 57 | delete.snapshot.blue() 58 | ); 59 | return Ok(()); 60 | } 61 | 62 | snapshot 63 | .delete() 64 | .await 65 | .wrap_err_with(|| format!("failed to delete snapshot {}", delete.snapshot))?; 66 | 67 | println!("{} snapshot {}", "Deleted".red(), delete.snapshot.blue()); 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /cli/src/list.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use color_eyre::{eyre::WrapErr, Result}; 3 | use owo_colors::OwoColorize; 4 | use zbus_pop_snapshot::{PopSnapshotProxy, SnapshotProxy}; 5 | 6 | pub async fn list() -> Result<()> { 7 | let connection = zbus::Connection::system() 8 | .await 9 | .wrap_err("failed to connect to D-Bus system bus")?; 10 | let proxy = PopSnapshotProxy::new(&connection) 11 | .await 12 | .wrap_err("failed to connect to Pop!_OS snapshot service")?; 13 | let snapshot_objects = proxy 14 | .snapshots() 15 | .await 16 | .wrap_err("failed to list snapshots")?; 17 | for snapshot_path in snapshot_objects { 18 | // We don't use ? here, as we want to gracefully handle a snapshot not existing for some reason. 19 | let snapshot = match SnapshotProxy::builder(&connection).path(&snapshot_path) { 20 | Ok(snapshot) => snapshot, 21 | Err(err) => { 22 | println!( 23 | "{} to get info for the snapshot object {}:\n\t{}", 24 | "Failed".red(), 25 | snapshot_path.as_str().blue(), 26 | err.red() 27 | ); 28 | continue; 29 | } 30 | }; 31 | let snapshot = match snapshot.build().await { 32 | Ok(snapshot) => snapshot, 33 | Err(err) => { 34 | println!( 35 | "{} to get info for the snapshot object {}:\n\t{}", 36 | "Failed".red(), 37 | snapshot_path.as_str().blue(), 38 | err.red() 39 | ); 40 | continue; 41 | } 42 | }; 43 | let uuid = snapshot 44 | .uuid() 45 | .await 46 | .wrap_err("failed to get snapshot UUID")?; 47 | println!("Snapshot {}", uuid.green()); 48 | let name = snapshot 49 | .name() 50 | .await 51 | .wrap_err("failed to get snapshot name")?; 52 | let description = snapshot 53 | .description() 54 | .await 55 | .wrap_err("failed to get snapshot description")?; 56 | let subvolumes = snapshot 57 | .subvolumes() 58 | .await 59 | .wrap_err("failed to get snapshot subvolumes")?; 60 | if !name.is_empty() { 61 | println!("\t{}: {}", "Name".bold(), name.dimmed()); 62 | } 63 | if !description.is_empty() { 64 | println!("\t{}: {}", "Description".bold(), description.dimmed()); 65 | } 66 | print!("\t{}: ", "Subvolumes".bold()); 67 | let mut subvolumes = subvolumes.iter().peekable(); 68 | while let Some(subvolume) = subvolumes.next() { 69 | print!("{} ", subvolume.green()); 70 | match subvolumes.peek() { 71 | Some(_) => print!(", "), 72 | None => println!(), 73 | } 74 | } 75 | } 76 | 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | mod args; 3 | mod create; 4 | mod delete; 5 | mod list; 6 | mod restore; 7 | pub(crate) mod util; 8 | 9 | use self::args::{CliArgs, CliSubcommand}; 10 | use clap::Parser; 11 | use color_eyre::{eyre::WrapErr, Result}; 12 | 13 | #[tokio::main] 14 | async fn main() -> Result<()> { 15 | color_eyre::install()?; 16 | 17 | let args = CliArgs::parse(); 18 | match &args.subcommand { 19 | CliSubcommand::List => list::list().await.wrap_err("failed to list snapshots"), 20 | CliSubcommand::Create(create) => create::create(&args, create) 21 | .await 22 | .wrap_err("failed to create snapshot"), 23 | CliSubcommand::Delete(delete) => delete::delete(&args, delete) 24 | .await 25 | .wrap_err("failed to delete snapshot"), 26 | CliSubcommand::Restore(restore) => restore::restore(&args, restore) 27 | .await 28 | .wrap_err("failed to restore snapshot"), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cli/src/restore.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use crate::{ 3 | args::{CliArgs, CliRestore}, 4 | util::yes_no_prompt, 5 | }; 6 | use color_eyre::{eyre::WrapErr, Result}; 7 | use owo_colors::OwoColorize; 8 | use zbus::zvariant::OwnedObjectPath; 9 | use zbus_pop_snapshot::{PopSnapshotProxy, SnapshotProxy}; 10 | 11 | pub async fn restore(args: &CliArgs, restore: &CliRestore) -> Result<()> { 12 | let connection = zbus::Connection::system() 13 | .await 14 | .wrap_err("failed to connect to D-Bus system bus")?; 15 | let proxy = PopSnapshotProxy::new(&connection) 16 | .await 17 | .wrap_err("failed to connect to Pop!_OS snapshot service")?; 18 | let snapshot_path = match Option::::from( 19 | proxy 20 | .find_snapshot(&restore.snapshot) 21 | .await 22 | .wrap_err("failed to list snapshots")?, 23 | ) { 24 | Some(path) => path, 25 | None => { 26 | println!("Snapshot {} not found", restore.snapshot.blue()); 27 | return Ok(()); 28 | } 29 | }; 30 | 31 | let snapshot = SnapshotProxy::builder(&connection) 32 | .path(&snapshot_path) 33 | .wrap_err_with(|| format!("failed to connect to snapshot {}", snapshot_path.as_str()))? 34 | .build() 35 | .await 36 | .wrap_err_with(|| format!("failed to connect to snapshot {}", snapshot_path.as_str()))?; 37 | 38 | let is_sure = args.yes || { 39 | println!( 40 | "Are you {} you want to {} snapshot {}?", 41 | "SURE".bold(), 42 | "restore".green(), 43 | restore.snapshot.blue() 44 | ); 45 | println!( 46 | "Press '{}' for {}, or any other key to {}", 47 | "y".green().bold(), 48 | "yes".green(), 49 | "cancel".red() 50 | ); 51 | yes_no_prompt() 52 | }; 53 | if !is_sure { 54 | println!( 55 | "Alright, {} restoring to snapshot {}", 56 | "not".bold(), 57 | restore.snapshot.blue() 58 | ); 59 | return Ok(()); 60 | } 61 | 62 | snapshot 63 | .restore() 64 | .await 65 | .wrap_err_with(|| format!("failed to restore snapshot {}", restore.snapshot))?; 66 | 67 | println!( 68 | "Snapshot {} has been restored. You should {} your system, as any changes from now to restored subvolumes will be lost.", 69 | restore.snapshot.blue(), 70 | "reboot".bold() 71 | ); 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /cli/src/util.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use std::io::Read; 3 | 4 | pub fn yes_no_prompt() -> bool { 5 | let stdin = std::io::stdin(); 6 | let mut stdin = stdin.lock(); 7 | let mut buf = [0_u8; 1]; 8 | if stdin.read_exact(&mut buf).is_err() { 9 | return false; 10 | } 11 | let c = buf[0] as char; 12 | c == 'y' || c == 'Y' 13 | } 14 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | pop-snapshot (0.1.0) UNRELEASED; urgency=medium 2 | 3 | * Initial release. 4 | 5 | -- Lucy Mon, 06 Jun 2022 11:00:00 -0500 6 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pop-snapshot 2 | Section: admin 3 | Priority: optional 4 | Maintainer: Lucy 5 | Build-Depends: 6 | debhelper (>= 11), 7 | debhelper-compat (= 11), 8 | rustc (>=1.60), 9 | cargo, 10 | clang, 11 | libdbus-1-dev, 12 | libbtrfsutil-dev, 13 | just, 14 | pkg-config 15 | Standards-Version: 4.1.1 16 | Homepage: https://github.com/pop-os/snapshot 17 | 18 | Package: pop-snapshot 19 | Architecture: amd64 arm64 20 | Depends: 21 | dbus, 22 | systemd, 23 | libbtrfsutil1, 24 | ${misc:Depends}, 25 | ${shlibs:Depends} 26 | Description: Btrfs snapshot manager for Pop!_OS 27 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: pop-snapshot 3 | Source: https://github.com/pop-os/snapshot 4 | 5 | Files: * 6 | Copyright: Copyright 2022 System76 7 | License: MPL-2 8 | -------------------------------------------------------------------------------- /debian/pop-snapshot.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | systemctl enable --now pop-snapshot 5 | 6 | exit 0 7 | -------------------------------------------------------------------------------- /debian/pop-snapshot.prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | systemctl disable pop-snapshot 5 | systemctl stop pop-snapshot 6 | 7 | exit 0 8 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | DESTDIR = debian/pop-snapshot 4 | CLEAN ?= 1 5 | VENDOR ?= 1 6 | 7 | %: 8 | dh $@ 9 | 10 | override_dh_shlibdeps: 11 | dh_shlibdeps --dpkg-shlibdeps-params=--ignore-missing-info 12 | 13 | override_dh_auto_clean: 14 | if test "${CLEAN}" = "1"; then \ 15 | cargo clean; \ 16 | fi 17 | 18 | if ! ischroot && test "${VENDOR}" = "1"; then \ 19 | mkdir -p .cargo; \ 20 | cargo vendor --sync Cargo.toml | head -n -1 > .cargo/config; \ 21 | echo 'directory = "vendor"' >> .cargo/config; \ 22 | tar pcf vendor.tar vendor; \ 23 | rm -rf vendor; \ 24 | fi 25 | 26 | override_dh_auto_build: 27 | just rootdir=$(DESTDIR) debug=$(DEBUG) vendor=$(VENDOR) 28 | 29 | override_dh_auto_install: 30 | just rootdir=$(DESTDIR) install 31 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | targets = [{ triple = "x86_64-unknown-linux-gnu" }, { triple = "aarch64-unknown-linux-gnu" }] 2 | 3 | [licenses] 4 | unlicensed = "deny" 5 | allow = ["MIT", "Apache-2.0", "MPL-2.0"] 6 | deny = [] 7 | copyleft = "warn" 8 | allow-osi-fsf-free = "neither" 9 | default = "allow" 10 | confidence-threshold = 0.8 11 | exceptions = [] 12 | 13 | [bans] 14 | multiple-versions = "warn" 15 | wildcards = "allow" 16 | highlight = "all" 17 | allow = [] 18 | deny = [] 19 | skip = [] 20 | 21 | [sources] 22 | unknown-registry = "deny" 23 | unknown-git = "deny" 24 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 25 | allow-git = [] 26 | 27 | [sources.allow-org] 28 | github = [] 29 | -------------------------------------------------------------------------------- /interface/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zbus-pop-snapshot" 3 | description = "zbus interface for the Pop!_OS btrfs snapshotting daemon" 4 | authors = ["Lucy "] 5 | version = "0.1.0" 6 | edition = "2021" 7 | license = "MPL-2.0" 8 | 9 | [dependencies] 10 | zbus = "2" 11 | -------------------------------------------------------------------------------- /interface/src/daemon.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use zbus::{ 4 | dbus_proxy, fdo, 5 | zvariant::{Optional, OwnedObjectPath}, 6 | }; 7 | 8 | #[dbus_proxy( 9 | interface = "com.system76.PopSnapshot", 10 | default_service = "com.system76.PopSnapshot", 11 | default_path = "/com/system76/PopSnapshot" 12 | )] 13 | pub trait PopSnapshot { 14 | /// The list of snapshots that are currently registered with the daemon. 15 | #[dbus_proxy(property)] 16 | fn snapshots(&self) -> fdo::Result>; 17 | 18 | /// Finds the snapshot with the given UUID. 19 | fn find_snapshot(&self, uuid: &str) -> fdo::Result>; 20 | 21 | /// Takes a snapshot of the current system state. 22 | fn create_snapshot( 23 | &self, 24 | name: Optional, 25 | description: Optional, 26 | subvolumes: Optional>, 27 | ) -> fdo::Result; 28 | 29 | /// Reloads the configuration of the pop-snapshot daemon. 30 | fn reload_config(&self) -> fdo::Result<()>; 31 | 32 | /// Emits a signal when a snapshot is created. 33 | #[dbus_proxy(signal)] 34 | fn snapshot_created(&self, uuid: &str) -> fdo::Result<()>; 35 | 36 | /// Emits a signal when a snapshot is deleted. 37 | #[dbus_proxy(signal)] 38 | fn snapshot_deleted(&self, uuid: &str) -> fdo::Result<()>; 39 | 40 | /// Emits a signal when a snapshot is restored. 41 | /// This signal means a reboot is likely imminent. 42 | #[dbus_proxy(signal)] 43 | fn snapshot_restored(&self, uuid: &str, backup_uuid: &str) -> fdo::Result<()>; 44 | } 45 | -------------------------------------------------------------------------------- /interface/src/lib.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | mod daemon; 4 | mod snapshot; 5 | 6 | pub use daemon::*; 7 | pub use snapshot::*; 8 | -------------------------------------------------------------------------------- /interface/src/snapshot.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use zbus::{dbus_proxy, fdo}; 4 | 5 | #[dbus_proxy( 6 | interface = "com.system76.PopSnapshot.Snapshot", 7 | default_service = "com.system76.PopSnapshot" 8 | )] 9 | pub trait Snapshot { 10 | /// The time at which this snapshot was made, in RFC3339 format. 11 | #[dbus_proxy(property)] 12 | fn creation_time(&self) -> fdo::Result; 13 | 14 | /// The name of this snapshot, if there is any. 15 | #[dbus_proxy(property)] 16 | fn name(&self) -> fdo::Result; 17 | 18 | /// Sets the name of this snapshot. 19 | /// A blank value will remove the name. 20 | #[dbus_proxy(property)] 21 | fn set_name(&self, name: &str) -> fdo::Result<()>; 22 | 23 | /// The description of this snapshot, if there is any. 24 | #[dbus_proxy(property)] 25 | fn description(&self) -> fdo::Result; 26 | 27 | /// Sets the description of this snapshot. 28 | /// A blank value will remove the description. 29 | #[dbus_proxy(property)] 30 | fn set_description(&self, description: &str) -> fdo::Result<()>; 31 | 32 | /// A list of subvolumes that have been captured by this snapshot. 33 | #[dbus_proxy(property)] 34 | fn subvolumes(&self) -> fdo::Result>; 35 | 36 | /// The unique identifier of this snapshot. 37 | #[dbus_proxy(property)] 38 | fn uuid(&self) -> fdo::Result; 39 | 40 | /// Restores the system to this snapshot, 41 | /// creating a backup snapshot of the current system state in the process. 42 | fn restore(&self) -> fdo::Result<()>; 43 | 44 | /// Deletes this snapshot permanently. 45 | fn delete(&self) -> fdo::Result<()>; 46 | } 47 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | rootdir := '' 2 | etcdir := rootdir + '/etc' 3 | prefix := rootdir + '/usr' 4 | clean := '0' 5 | debug := '0' 6 | vendor := '0' 7 | target := if debug == '1' { 'debug' } else { 'release' } 8 | vendor_args := if vendor == '1' { '--frozen --offline' } else { '' } 9 | debug_args := if debug == '1' { '' } else { '--release' } 10 | cargo_args := vendor_args + ' ' + debug_args 11 | 12 | dbusdir := etcdir + '/dbus-1/system.d' 13 | bindir := prefix + '/bin' 14 | systemddir := prefix + '/lib/systemd' 15 | 16 | daemon_id := 'com.system76.PopSnapshot' 17 | service_name := "pop-snapshot" 18 | 19 | all: _extract_vendor 20 | cargo build {{cargo_args}} 21 | 22 | # Installs files into the system 23 | install: 24 | # dbus config, so root can host the daemon, and so we can talk to it without root 25 | install -Dm0644 service/data/{{daemon_id}}.xml {{dbusdir}}/{{daemon_id}}.conf 26 | 27 | # systemd service 28 | install -Dm0644 service/data/{{service_name}}.service {{systemddir}}/system/{{service_name}}.service 29 | 30 | # config file 31 | install -Dm0644 service/data/{{service_name}}.toml {{etcdir}}/{{service_name}}.toml 32 | 33 | # daemon 34 | install -Dm0755 target/release/pop-snapshot-daemon {{bindir}}/pop-snapshot-daemon 35 | 36 | # cli 37 | install -Dm0755 target/release/pop-snapshot {{bindir}}/pop-snapshot 38 | 39 | # cli completions 40 | install -Dm0644 target/pop-snapshot.bash {{etcdir}}/bash_completion.d/pop-snapshot 41 | install -Dm0644 target/_pop-snapshot {{prefix}}/share/zsh/vendor-completions/_pop-snapshot 42 | install -Dm0644 target/pop-snapshot.fish {{prefix}}/share/fish/completions/pop-snapshot.fish 43 | 44 | clean_vendor: 45 | rm -rf vendor vendor.tar .cargo/config 46 | 47 | clean: clean_vendor 48 | cargo clean 49 | 50 | # Extracts vendored dependencies if vendor=1 51 | _extract_vendor: 52 | #!/usr/bin/env sh 53 | if test {{vendor}} = 1; then 54 | rm -rf vendor; tar pxf vendor.tar 55 | fi 56 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.60.0" 3 | components = ["rustfmt", "clippy"] 4 | -------------------------------------------------------------------------------- /service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pop-snapshot-daemon" 3 | description = "A daemon/tool for creating btrfs snapshots of a Pop!_OS system" 4 | authors = ["Lucy "] 5 | version = "0.1.0" 6 | edition = "2021" 7 | license = "MPL-2.0" 8 | publish = false 9 | 10 | [dependencies] 11 | anyhow = "1" 12 | async-signals = "0.4" 13 | futures-util = "0.3.21" 14 | libbtrfsutil = "0.3" 15 | libc = "0.2.126" 16 | serde = { version = "1", features = ["derive"] } 17 | serde_json = "1" 18 | sys-mount = { version = "1.5", default-features = false } 19 | tempfile = "3" 20 | time = { version = "0.3", features = ["serde-well-known"] } 21 | tokio = { version = "1", features = ["full"] } 22 | toml = "0.5" 23 | tracing = "0.1" 24 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 25 | uuid = { version = "1", features = ["v4", "serde"] } 26 | zbus = { version = "2", default-features = false, features = ["tokio"] } 27 | -------------------------------------------------------------------------------- /service/data/com.system76.PopSnapshot.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /service/data/pop-snapshot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Pop!_OS Snapshot Daemon 3 | 4 | [Service] 5 | Type=dbus 6 | BusName=com.system76.PopSnapshot 7 | ExecStart=/usr/bin/pop-snapshot-daemon 8 | ExecReload=busctl call com.system76.PopSnapshot \ 9 | /com/system76/PopSnapshot com.system76.PopSnapshot \ 10 | ReloadConfig 11 | Restart=on-failure 12 | 13 | [Install] 14 | Alias=com.system76.PopSnapshot.service 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /service/data/pop-snapshot.toml: -------------------------------------------------------------------------------- 1 | # The path (relative to the base subvolume of the btrfs partition) 2 | # to the directory containing the snapshots. 3 | # 4 | # Defaults to `@snapshots/pop-snapshots`. 5 | snapshot-path = "@snapshots/pop-snapshots" 6 | 7 | # A list of subvolumes to exclude by default. 8 | # 9 | #`@snapshots` will *always* be excluded, regardless of this list. 10 | exclude_subvolumes = ["@home"] 11 | 12 | # A list of subvolumes to include by default. 13 | # This will take precedence over `subvolumes_to_exclude` if both are specified. 14 | # include_subvolumes = [] 15 | 16 | # The logging filter to use. 17 | # Can be any EnvFilter-compatible string. 18 | # (see: https://docs.rs/tracing-subscriber/*/tracing_subscriber/filter/struct.EnvFilter.html#directives) 19 | # 20 | # The `RUST_LOG` environment variable will override this config option. 21 | # 22 | # Defaults to "info". 23 | log_level = "info" 24 | -------------------------------------------------------------------------------- /service/src/config.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use serde::Deserialize; 3 | use std::path::PathBuf; 4 | 5 | #[derive(Debug, Deserialize)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | pub struct Config { 8 | /// The path (relative to the base subvolume of the btrfs partition) 9 | /// to the directory containing the snapshots. 10 | /// 11 | /// Defaults to `@snapshots/pop-snapshots`. 12 | pub snapshot_path: PathBuf, 13 | /// A list of subvolumes to exclude by default. 14 | /// 15 | /// `@snapshots` will *always* be excluded, regardless of this list. 16 | pub exclude_subvolumes: Vec, 17 | /// A list of subvolumes to include by default. 18 | /// 19 | /// This will take precedence over `subvolumes_to_exclude` if both are 20 | /// specified. 21 | pub include_subvolumes: Option>, 22 | /// The logging filter to use. 23 | /// 24 | /// Can be any [`EnvFilter`](https://docs.rs/tracing-subscriber/0.3.11/tracing_subscriber/filter/struct.EnvFilter.html#directives) 25 | /// compatible string. 26 | /// 27 | /// The `RUST_LOG` environment variable will override this config option. 28 | /// 29 | /// Defaults to "info". 30 | pub log_level: String, 31 | } 32 | 33 | impl Default for Config { 34 | fn default() -> Self { 35 | Self { 36 | snapshot_path: "@snapshots/pop-snapshots".into(), 37 | exclude_subvolumes: vec!["@home".into()], 38 | include_subvolumes: None, 39 | log_level: "info".into(), 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /service/src/main.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | pub(crate) mod config; 3 | pub(crate) mod service; 4 | pub(crate) mod snapshot; 5 | pub(crate) mod util; 6 | 7 | #[macro_use] 8 | extern crate tracing; 9 | 10 | use crate::service::snapshot::SnapshotObject; 11 | use anyhow::{Context, Result}; 12 | use async_signals::Signals; 13 | use futures_util::StreamExt; 14 | use libc::{SIGHUP, SIGTERM}; 15 | use std::sync::{ 16 | atomic::{AtomicUsize, Ordering}, 17 | Arc, 18 | }; 19 | use tokio::sync::RwLock; 20 | use tracing::metadata::LevelFilter; 21 | use tracing_subscriber::{filter::Directive, fmt, prelude::*, EnvFilter}; 22 | use zbus::{zvariant::OwnedObjectPath, ConnectionBuilder, ObjectServer}; 23 | 24 | static COUNTER: AtomicUsize = AtomicUsize::new(1); 25 | 26 | async fn create_new_snapshot( 27 | object_server: &ObjectServer, 28 | snapshot_object: SnapshotObject, 29 | ) -> Result { 30 | let new_id = COUNTER.fetch_add(1, Ordering::SeqCst); 31 | let id = OwnedObjectPath::try_from(format!("/com/system76/PopSnapshot/Snapshot/{}", new_id))?; 32 | object_server 33 | .at(&id, snapshot_object) 34 | .await 35 | .with_context(|| format!("failed to register snapshot {:?}", id))?; 36 | Ok(id) 37 | } 38 | 39 | async fn reload_config(config: Arc>) -> Result<()> { 40 | let mut config = config.write().await; 41 | *config = tokio::fs::read_to_string("/etc/pop-snapshots.toml") 42 | .await 43 | .context("failed to read /etc/pop-snapshots.toml") 44 | .and_then(|s| toml::from_str::(&s).context("failed to parse config"))?; 45 | info!("Configuration reloaded"); 46 | Ok(()) 47 | } 48 | 49 | #[tokio::main] 50 | async fn main() -> Result<()> { 51 | let config = tokio::fs::read_to_string("/etc/pop-snapshots.toml") 52 | .await 53 | .ok() 54 | .and_then(|s| toml::from_str::(&s).ok()) 55 | .unwrap_or_default(); 56 | 57 | let log_level: Directive = config 58 | .log_level 59 | .parse() 60 | .with_context(|| format!("failed to parse log level: {}", config.log_level))?; 61 | // Set up the tracing logger. 62 | tracing_subscriber::registry() 63 | .with(fmt::layer()) 64 | .with( 65 | EnvFilter::builder() 66 | .with_default_directive(if cfg!(debug_assertions) { 67 | LevelFilter::DEBUG.into() 68 | } else { 69 | log_level 70 | }) 71 | .from_env_lossy(), 72 | ) 73 | .init(); 74 | 75 | let config = Arc::new(RwLock::new(config)); 76 | let service = service::SnapshotService::new(config.clone()); 77 | let connection = ConnectionBuilder::system() 78 | .context("failed to get system dbus connection")? 79 | .name("com.system76.PopSnapshot")? 80 | .internal_executor(false) 81 | .build() 82 | .await 83 | .context("failed to build connection")?; 84 | 85 | { 86 | let btrfs = snapshot::MountedBtrfs::new() 87 | .await 88 | .context("failed to mount btrfs to list snapshots")?; 89 | let snapshots = btrfs 90 | .list_snapshots() 91 | .await 92 | .context("failed to list snapshots")?; 93 | let mut snapshots_map = service.snapshots.write().await; 94 | snapshots_map.reserve(snapshots.len()); 95 | for snapshot in snapshots { 96 | let snapshot_uuid = snapshot.uuid; 97 | let snapshot_object = SnapshotObject::new( 98 | snapshot, 99 | service.snapshots.clone(), 100 | service.action_lock.clone(), 101 | config.clone(), 102 | ); 103 | let id = create_new_snapshot(&*connection.object_server(), snapshot_object) 104 | .await 105 | .context("failed to create new snapshot object")?; 106 | 107 | debug!( 108 | "Created new snapshot object for {} at {:?}", 109 | snapshot_uuid, id 110 | ); 111 | snapshots_map.insert(snapshot_uuid, id); 112 | } 113 | } 114 | connection 115 | .object_server() 116 | .at("/com/system76/PopSnapshot", service) 117 | .await?; 118 | 119 | info!("Starting pop-snapshot daemon"); 120 | 121 | let mut signals = Signals::new(vec![SIGHUP, SIGTERM]) 122 | .context("failed to create signal handler for SIGHUP+SIGTERM")?; 123 | 124 | tokio::spawn(async move { 125 | let executor = connection.executor(); 126 | loop { 127 | executor.tick().await; 128 | } 129 | }); 130 | 131 | while let Some(signal) = signals.next().await { 132 | match signal { 133 | SIGHUP => { 134 | info!("Received SIGHUP, reloading config"); 135 | match reload_config(config.clone()).await { 136 | Ok(_) => { 137 | continue; 138 | } 139 | Err(e) => { 140 | error!("Failed to reload config: {}", e); 141 | continue; 142 | } 143 | } 144 | } 145 | SIGTERM => { 146 | info!("Received SIGTERM, shutting down"); 147 | break; 148 | } 149 | _ => continue, 150 | } 151 | } 152 | 153 | Ok(()) 154 | } 155 | -------------------------------------------------------------------------------- /service/src/service.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | pub mod snapshot; 4 | 5 | use self::snapshot::SnapshotObject; 6 | use crate::{config::Config, create_new_snapshot, snapshot::MountedBtrfs, util::ToFdoError}; 7 | use anyhow::{anyhow, Context}; 8 | use std::{collections::HashMap, sync::Arc}; 9 | use tokio::sync::{Mutex, RwLock}; 10 | use uuid::Uuid; 11 | use zbus::{ 12 | dbus_interface, fdo, 13 | zvariant::{Optional, OwnedObjectPath}, 14 | ObjectServer, SignalContext, 15 | }; 16 | 17 | pub struct SnapshotService { 18 | pub(crate) snapshots: Arc>>, 19 | pub(crate) action_lock: Arc>, 20 | config: Arc>, 21 | } 22 | 23 | impl SnapshotService { 24 | pub fn new(config: Arc>) -> Self { 25 | Self { 26 | snapshots: Arc::default(), 27 | action_lock: Arc::default(), 28 | config, 29 | } 30 | } 31 | } 32 | 33 | #[dbus_interface(name = "com.system76.PopSnapshot")] 34 | impl SnapshotService { 35 | #[dbus_interface(property)] 36 | async fn snapshots(&self) -> Vec { 37 | self.snapshots.read().await.values().cloned().collect() 38 | } 39 | 40 | async fn create_snapshot( 41 | &mut self, 42 | name: Optional, 43 | description: Optional, 44 | subvolumes: Optional>, 45 | #[zbus(signal_context)] ctxt: SignalContext<'_>, 46 | #[zbus(object_server)] object_server: &ObjectServer, 47 | ) -> fdo::Result { 48 | let _lock = match self.action_lock.try_lock() { 49 | Ok(lock) => lock, 50 | Err(_) => return Err(anyhow!("pop-snapshot is busy")).to_fdo_err(), 51 | }; 52 | let btrfs = MountedBtrfs::new() 53 | .await 54 | .context("failed to mount btrfs") 55 | .to_fdo_err()?; 56 | let snapshot = btrfs 57 | .create_snapshot(name, description, subvolumes, self.config.clone()) 58 | .await 59 | .context("failed to create snapshot") 60 | .to_fdo_err()?; 61 | let snapshot_uuid = snapshot.uuid; 62 | let snapshot_object = SnapshotObject::new( 63 | snapshot, 64 | self.snapshots.clone(), 65 | self.action_lock.clone(), 66 | self.config.clone(), 67 | ); 68 | let path = create_new_snapshot(object_server, snapshot_object) 69 | .await 70 | .with_context(|| format!("failed to register snapshot '{snapshot_uuid}'")) 71 | .to_fdo_err()?; 72 | self.snapshots 73 | .write() 74 | .await 75 | .insert(snapshot_uuid, path.clone()); 76 | Self::snapshot_created(&ctxt, &snapshot_uuid.to_string()) 77 | .await 78 | .context("failed to emit SnapshotCreated signal") 79 | .to_fdo_err()?; 80 | Ok(path) 81 | } 82 | 83 | async fn find_snapshot(&self, uuid: &str) -> fdo::Result> { 84 | let snapshots = self.snapshots.read().await; 85 | let uuid = Uuid::parse_str(uuid) 86 | .with_context(|| format!("failed to parse UUID '{uuid}'", uuid = uuid)) 87 | .to_fdo_err()?; 88 | let snapshot = snapshots 89 | .iter() 90 | .find(|(k, _)| **k == uuid) 91 | .map(|(_, v)| v.clone()); 92 | Ok(snapshot.into()) 93 | } 94 | 95 | async fn reload_config(&self) -> fdo::Result<()> { 96 | info!("ReloadConfig called, reloading config"); 97 | let _lock = self.action_lock.lock().await; 98 | crate::reload_config(self.config.clone()) 99 | .await 100 | .context("failed to reload config") 101 | .to_fdo_err() 102 | } 103 | 104 | #[dbus_interface(signal)] 105 | async fn snapshot_created(signal_ctxt: &SignalContext<'_>, uuid: &str) -> zbus::Result<()>; 106 | 107 | #[dbus_interface(signal)] 108 | async fn snapshot_deleted(signal_ctxt: &SignalContext<'_>, uuid: &str) -> zbus::Result<()>; 109 | 110 | #[dbus_interface(signal)] 111 | async fn snapshot_restored( 112 | signal_ctxt: &SignalContext<'_>, 113 | uuid: &str, 114 | backup_uuid: &str, 115 | ) -> zbus::Result<()>; 116 | } 117 | -------------------------------------------------------------------------------- /service/src/service/snapshot.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use super::SnapshotService; 4 | use crate::{ 5 | config::Config, 6 | create_new_snapshot, 7 | snapshot::{metadata::SnapshotMetadata, MountedBtrfs}, 8 | util::ToFdoError, 9 | }; 10 | use anyhow::{anyhow, Context, Result}; 11 | use std::{collections::HashMap, sync::Arc}; 12 | use time::format_description::well_known::Rfc3339; 13 | use tokio::sync::{Mutex, RwLock}; 14 | use uuid::Uuid; 15 | use zbus::{ 16 | dbus_interface, fdo, zvariant::OwnedObjectPath, Connection, MessageHeader, ObjectServer, 17 | SignalContext, 18 | }; 19 | 20 | pub struct SnapshotObject { 21 | metadata: SnapshotMetadata, 22 | snapshots: Arc>>, 23 | action_lock: Arc>, 24 | config: Arc>, 25 | } 26 | 27 | impl SnapshotObject { 28 | pub(crate) fn new( 29 | metadata: SnapshotMetadata, 30 | snapshots: Arc>>, 31 | action_lock: Arc>, 32 | config: Arc>, 33 | ) -> Self { 34 | Self { 35 | metadata, 36 | snapshots, 37 | action_lock, 38 | config, 39 | } 40 | } 41 | } 42 | 43 | impl SnapshotObject { 44 | async fn update_metadata_file(&self) -> Result<()> { 45 | let btrfs = MountedBtrfs::new().await.context("failed to mount btrfs")?; 46 | let config = self.config.read().await; 47 | let metadata_path = btrfs 48 | .path() 49 | .join(&config.snapshot_path) 50 | .join(self.metadata.uuid.to_string()) 51 | .with_extension("snapshot.json"); 52 | tokio::fs::write( 53 | &metadata_path, 54 | serde_json::to_string_pretty(&self.metadata)?, 55 | ) 56 | .await 57 | .with_context(|| { 58 | format!( 59 | "failed to write updated metadata to file {}", 60 | metadata_path.display() 61 | ) 62 | })?; 63 | Ok(()) 64 | } 65 | 66 | async fn get_base_service(&self, conn: &Connection) -> zbus::Result> { 67 | let path = OwnedObjectPath::try_from("/com/system76/PopSnapshot")?; 68 | SignalContext::new(conn, path) 69 | } 70 | } 71 | 72 | #[dbus_interface(name = "com.system76.PopSnapshot.Snapshot")] 73 | impl SnapshotObject { 74 | #[dbus_interface(property)] 75 | async fn creation_time(&self) -> String { 76 | self.metadata 77 | .creation_time 78 | .format(&Rfc3339) 79 | .expect("failed to format time as RFC 3399") 80 | } 81 | 82 | #[dbus_interface(property)] 83 | async fn name(&self) -> String { 84 | self.metadata.name.clone().unwrap_or_default() 85 | } 86 | 87 | #[dbus_interface(property)] 88 | async fn set_name(&mut self, value: &str) -> fdo::Result<()> { 89 | self.metadata.name = if value.trim().is_empty() { 90 | None 91 | } else { 92 | Some(value.to_owned()) 93 | }; 94 | self.update_metadata_file() 95 | .await 96 | .context("failed to update metadata file") 97 | .to_fdo_err()?; 98 | Ok(()) 99 | } 100 | 101 | #[dbus_interface(property)] 102 | async fn description(&self) -> String { 103 | self.metadata.description.clone().unwrap_or_default() 104 | } 105 | 106 | #[dbus_interface(property)] 107 | async fn set_description(&mut self, value: &str) -> fdo::Result<()> { 108 | self.metadata.description = if value.trim().is_empty() { 109 | None 110 | } else { 111 | Some(value.to_owned()) 112 | }; 113 | self.update_metadata_file() 114 | .await 115 | .context("failed to update metadata file") 116 | .to_fdo_err()?; 117 | Ok(()) 118 | } 119 | 120 | #[dbus_interface(property)] 121 | async fn subvolumes(&self) -> Vec { 122 | self.metadata.subvolumes.clone() 123 | } 124 | 125 | #[dbus_interface(property)] 126 | async fn uuid(&self) -> String { 127 | self.metadata.uuid.to_string() 128 | } 129 | 130 | async fn restore( 131 | &self, 132 | #[zbus(connection)] connection: &Connection, 133 | #[zbus(object_server)] object_server: &ObjectServer, 134 | ) -> fdo::Result<()> { 135 | let _lock = match self.action_lock.try_lock() { 136 | Ok(lock) => lock, 137 | Err(_) => return Err(anyhow!("pop-snapshot is busy")).to_fdo_err(), 138 | }; 139 | let config = self.config.read().await; 140 | let btrfs = MountedBtrfs::new() 141 | .await 142 | .context("failed to mount btrfs") 143 | .to_fdo_err()?; 144 | let new_snapshot = btrfs 145 | .restore_snapshot(&self.metadata, &config.snapshot_path) 146 | .await 147 | .context("failed to restore snapshot") 148 | .to_fdo_err()?; 149 | let new_snapshot_uuid = new_snapshot.uuid; 150 | let new_snapshot_object = SnapshotObject::new( 151 | new_snapshot, 152 | self.snapshots.clone(), 153 | self.action_lock.clone(), 154 | self.config.clone(), 155 | ); 156 | let path = create_new_snapshot(object_server, new_snapshot_object) 157 | .await 158 | .context("failed to register backup snapshot") 159 | .to_fdo_err()?; 160 | self.snapshots.write().await.insert(new_snapshot_uuid, path); 161 | let base_service = self 162 | .get_base_service(connection) 163 | .await 164 | .context("failed to get base service signal context") 165 | .to_fdo_err()?; 166 | SnapshotService::snapshot_restored( 167 | &base_service, 168 | &self.metadata.uuid.to_string(), 169 | &new_snapshot_uuid.to_string(), 170 | ) 171 | .await 172 | .context("failed to emit SnapshotRestored signal") 173 | .to_fdo_err()?; 174 | SnapshotService::snapshot_created(&base_service, &new_snapshot_uuid.to_string()) 175 | .await 176 | .context("failed to emit SnapshotCreated signal") 177 | .to_fdo_err()?; 178 | Ok(()) 179 | } 180 | 181 | async fn delete( 182 | &self, 183 | #[zbus(connection)] connection: &Connection, 184 | #[zbus(header)] hdr: MessageHeader<'_>, 185 | #[zbus(object_server)] object_server: &ObjectServer, 186 | ) -> fdo::Result<()> { 187 | let _lock = match self.action_lock.try_lock() { 188 | Ok(lock) => lock, 189 | Err(_) => return Err(anyhow!("pop-snapshot is busy")).to_fdo_err(), 190 | }; 191 | let config = self.config.read().await; 192 | let btrfs = MountedBtrfs::new() 193 | .await 194 | .context("failed to mount btrfs") 195 | .to_fdo_err()?; 196 | btrfs 197 | .delete_snapshot(&self.metadata, &config.snapshot_path) 198 | .await 199 | .context("failed to delete snapshot") 200 | .to_fdo_err()?; 201 | let metadata_path = btrfs 202 | .path() 203 | .join(&config.snapshot_path) 204 | .join(self.metadata.uuid.to_string()) 205 | .with_extension("snapshot.json"); 206 | tokio::fs::remove_file(&metadata_path) 207 | .await 208 | .context("failed to remove snapshot metadata") 209 | .to_fdo_err()?; 210 | let path = OwnedObjectPath::from( 211 | hdr.path() 212 | .context("failed to get own path") 213 | .to_fdo_err()? 214 | .context("invalid object path") 215 | .to_fdo_err()? 216 | .to_owned(), 217 | ); 218 | object_server 219 | .remove::(&path) 220 | .await 221 | .context("failed to remove object") 222 | .to_fdo_err()?; 223 | self.snapshots.write().await.remove(&self.metadata.uuid); 224 | let base_service = self 225 | .get_base_service(connection) 226 | .await 227 | .context("failed to get base service signal context") 228 | .to_fdo_err()?; 229 | SnapshotService::snapshot_deleted(&base_service, &self.metadata.uuid.to_string()) 230 | .await 231 | .context("failed to emit SnapshotDeleted signal") 232 | .to_fdo_err()?; 233 | Ok(()) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /service/src/snapshot.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | pub mod create; 4 | pub mod delete; 5 | pub mod list; 6 | pub mod metadata; 7 | pub mod mount; 8 | pub mod restore; 9 | 10 | use std::path::Path; 11 | use sys_mount::{Mount, UnmountDrop}; 12 | use tempfile::TempDir; 13 | 14 | pub struct MountedBtrfs { 15 | _mount: UnmountDrop, 16 | tempdir: TempDir, 17 | } 18 | 19 | impl MountedBtrfs { 20 | pub fn path(&self) -> &Path { 21 | self.tempdir.path() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /service/src/snapshot/create.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use super::{metadata::SnapshotMetadata, MountedBtrfs}; 4 | use crate::{config::Config, util::list_subvolumes_eligible_for_snapshotting}; 5 | use anyhow::{Context, Result}; 6 | use libbtrfsutil::CreateSnapshotFlags; 7 | use std::sync::Arc; 8 | use tokio::sync::RwLock; 9 | 10 | impl MountedBtrfs { 11 | pub async fn create_snapshot( 12 | &self, 13 | name: impl Into>, 14 | description: impl Into>, 15 | subvolumes: impl Into>>, 16 | config: Arc>, 17 | ) -> Result { 18 | let config = config.read().await; 19 | let subvolumes_to_snapshot = match (subvolumes.into(), config.include_subvolumes.clone()) { 20 | (Some(subvolumes), _) | (None, Some(subvolumes)) => subvolumes, 21 | (None, None) => { 22 | let path = self.path().to_path_buf(); 23 | let exclude_subvolumes = config.exclude_subvolumes.clone(); 24 | tokio::task::spawn_blocking(move || { 25 | list_subvolumes_eligible_for_snapshotting(&path, &exclude_subvolumes) 26 | }) 27 | .await? 28 | .context("failed to get eligible subvolumes to snapshot")? 29 | } 30 | }; 31 | let num_subvolumes = subvolumes_to_snapshot.len(); 32 | let snapshot = SnapshotMetadata::now(name, description, subvolumes_to_snapshot); 33 | info!( 34 | "Creating snapshot '{}' with {num_subvolumes} subvolumes", 35 | snapshot.uuid 36 | ); 37 | let snapshot_dir = self 38 | .path() 39 | .join(&config.snapshot_path) 40 | .join(snapshot.uuid.to_string()); 41 | if !snapshot_dir.is_dir() { 42 | std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot dir")?; 43 | } 44 | for subvolume in &snapshot.subvolumes { 45 | info!("Snapshotting {subvolume}"); 46 | let source = self.path().join(subvolume); 47 | let destination = snapshot_dir.join(&subvolume.replace('/', "__")); 48 | tokio::task::spawn_blocking(move || { 49 | libbtrfsutil::create_snapshot( 50 | &source, 51 | &destination, 52 | CreateSnapshotFlags::READ_ONLY, 53 | None, 54 | ) 55 | }) 56 | .await? 57 | .with_context(|| format!("failed to snapshot subvolume '{}'", subvolume))?; 58 | } 59 | 60 | let snapshot_metadata_path = self 61 | .path() 62 | .join(&config.snapshot_path) 63 | .join(snapshot.uuid.to_string()) 64 | .with_extension("snapshot.json"); 65 | tokio::fs::write( 66 | &snapshot_metadata_path, 67 | serde_json::to_string_pretty(&snapshot)?, 68 | ) 69 | .await 70 | .with_context(|| { 71 | format!( 72 | "failed to write snapshot metadata to '{}'", 73 | snapshot_metadata_path.display() 74 | ) 75 | })?; 76 | 77 | Ok(snapshot) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /service/src/snapshot/delete.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | use super::{metadata::SnapshotMetadata, MountedBtrfs}; 3 | use anyhow::{anyhow, Context, Result}; 4 | use libbtrfsutil::DeleteSubvolumeFlags; 5 | use std::path::Path; 6 | use tokio::fs; 7 | 8 | impl MountedBtrfs { 9 | pub async fn delete_snapshot( 10 | &self, 11 | snapshot: &SnapshotMetadata, 12 | snapshot_path: &Path, 13 | ) -> Result<()> { 14 | let snapshot_dir = self 15 | .path() 16 | .join(snapshot_path) 17 | .join(snapshot.uuid.to_string()); 18 | if !snapshot_dir.exists() { 19 | return Err(anyhow!("snapshot {} does not exist", snapshot.uuid)); 20 | } 21 | let mut dir = fs::read_dir(&snapshot_dir) 22 | .await 23 | .with_context(|| format!("failed to read directory {}", snapshot_dir.display()))?; 24 | while let Some(entry) = dir 25 | .next_entry() 26 | .await 27 | .context("failed to read directory entry")? 28 | { 29 | let path = entry.path(); 30 | info!("deleting subvolume at {}", path.display()); 31 | tokio::task::spawn_blocking(move || { 32 | libbtrfsutil::delete_subvolume(&path, DeleteSubvolumeFlags::empty()) 33 | }) 34 | .await? 35 | .context("failed to delete subvolume")?; 36 | } 37 | fs::remove_dir_all(&snapshot_dir) 38 | .await 39 | .with_context(|| format!("failed to delete directory {}", snapshot_dir.display())) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /service/src/snapshot/list.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use super::{metadata::SnapshotMetadata, MountedBtrfs}; 4 | use anyhow::{anyhow, Context, Result}; 5 | use tokio::fs; 6 | 7 | impl MountedBtrfs { 8 | pub async fn list_snapshots(&self) -> Result> { 9 | let mut snapshots = Vec::new(); 10 | let snapshot_dir = self.path().join("@snapshots/pop-snapshots"); 11 | if !snapshot_dir.exists() { 12 | return Ok(Vec::new()); 13 | } 14 | let mut dir = fs::read_dir(&snapshot_dir) 15 | .await 16 | .with_context(|| format!("failed to read directory {}", snapshot_dir.display()))?; 17 | while let Some(entry) = dir 18 | .next_entry() 19 | .await 20 | .context("failed to read directory entry")? 21 | { 22 | let path = entry.path(); 23 | if !path.is_file() { 24 | continue; 25 | } 26 | let name = match path 27 | .file_name() 28 | .and_then(|file_name_os| file_name_os.to_str()) 29 | { 30 | Some(name) => name.to_owned(), 31 | None => { 32 | return Err(anyhow!( 33 | "failed to get file name from path {}", 34 | path.display() 35 | )); 36 | } 37 | }; 38 | if !name.ends_with(".snapshot.json") { 39 | continue; 40 | } 41 | let metadata: SnapshotMetadata = serde_json::from_str( 42 | &fs::read_to_string(&path) 43 | .await 44 | .context(format!("failed to read file {}", path.display()))?, 45 | ) 46 | .with_context(|| format!("failed to parse metadata from file {}", path.display()))?; 47 | snapshots.push(metadata); 48 | } 49 | Ok(snapshots) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /service/src/snapshot/metadata.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use time::OffsetDateTime; 5 | use uuid::Uuid; 6 | 7 | #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] 8 | pub struct SnapshotMetadata { 9 | pub uuid: Uuid, 10 | #[serde(skip_serializing_if = "is_opt_string_empty")] 11 | pub name: Option, 12 | #[serde(skip_serializing_if = "is_opt_string_empty")] 13 | pub description: Option, 14 | #[serde(with = "time::serde::rfc3339")] 15 | pub creation_time: OffsetDateTime, 16 | pub subvolumes: Vec, 17 | } 18 | 19 | impl SnapshotMetadata { 20 | pub fn now( 21 | name: impl Into>, 22 | description: impl Into>, 23 | subvolumes: Vec, 24 | ) -> Self { 25 | SnapshotMetadata { 26 | uuid: Uuid::new_v4(), 27 | name: name.into(), 28 | description: description.into(), 29 | creation_time: OffsetDateTime::now_utc(), 30 | subvolumes, 31 | } 32 | } 33 | } 34 | 35 | impl PartialOrd for SnapshotMetadata { 36 | fn partial_cmp(&self, other: &Self) -> Option { 37 | self.creation_time.partial_cmp(&other.creation_time) 38 | } 39 | } 40 | 41 | impl Ord for SnapshotMetadata { 42 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 43 | self.creation_time.cmp(&other.creation_time) 44 | } 45 | } 46 | 47 | fn is_opt_string_empty(val: &Option) -> bool { 48 | match val { 49 | Some(val) => val.trim().is_empty(), 50 | None => true, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /service/src/snapshot/mount.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use super::MountedBtrfs; 4 | use crate::util::find_root_device; 5 | use anyhow::{Context, Result}; 6 | use libbtrfsutil::CreateSubvolumeFlags; 7 | use sys_mount::{FilesystemType, Mount, UnmountFlags}; 8 | 9 | impl MountedBtrfs { 10 | /// Mounts the base subvolume of the root btrfs partition in 11 | /// a temporary directory. 12 | pub async fn new() -> Result { 13 | let tempdir = tempfile::tempdir().context("failed to create tempdir")?; 14 | let root_device_path = find_root_device() 15 | .await 16 | .context("failed to find root device")?; 17 | debug!("Found root device path at {}", root_device_path.display()); 18 | let tempdir_path = tempdir.path().to_path_buf(); 19 | let snapshots_path = tempdir_path.join("@snapshots"); 20 | 21 | debug!( 22 | "Mounting {}[subvol=/] at {}", 23 | root_device_path.display(), 24 | tempdir_path.display() 25 | ); 26 | let mount = tokio::task::spawn_blocking(move || { 27 | Mount::builder() 28 | .fstype(FilesystemType::Manual("btrfs")) 29 | .data("subvol=/") 30 | .mount_autodrop(root_device_path, tempdir_path, UnmountFlags::DETACH) 31 | }) 32 | .await? 33 | .context("failed to mount root subvolume")?; 34 | 35 | if !snapshots_path.exists() { 36 | let pop_snapshots_dir = snapshots_path.join("pop-snapshots"); 37 | tokio::task::spawn_blocking(move || { 38 | libbtrfsutil::create_subvolume(snapshots_path, CreateSubvolumeFlags::empty(), None) 39 | }) 40 | .await? 41 | .context("failed to create snapshot subvolume")?; 42 | tokio::fs::create_dir(&pop_snapshots_dir) 43 | .await 44 | .with_context(|| format!("failed to create {}", pop_snapshots_dir.display()))?; 45 | } 46 | 47 | Ok(MountedBtrfs { 48 | _mount: mount, 49 | tempdir, 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /service/src/snapshot/restore.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use super::{metadata::SnapshotMetadata, MountedBtrfs}; 4 | use anyhow::{anyhow, Context, Result}; 5 | use libbtrfsutil::CreateSnapshotFlags; 6 | use std::path::Path; 7 | use tokio::fs; 8 | 9 | impl MountedBtrfs { 10 | pub async fn restore_snapshot( 11 | &self, 12 | snapshot: &SnapshotMetadata, 13 | snapshot_path: &Path, 14 | ) -> Result { 15 | let restore_snapshot_dir = self 16 | .path() 17 | .join(snapshot_path) 18 | .join(snapshot.uuid.to_string()); 19 | if !restore_snapshot_dir.exists() { 20 | return Err(anyhow!("snapshot {} does not exist", snapshot.uuid)); 21 | } 22 | let new_snapshot = SnapshotMetadata::now( 23 | None, 24 | format!( 25 | "Automatic snapshot made when restoring snapshot {}", 26 | snapshot.uuid 27 | ), 28 | snapshot.subvolumes.clone(), 29 | ); 30 | let new_snapshot_dir = self 31 | .path() 32 | .join(snapshot_path) 33 | .join(new_snapshot.uuid.to_string()); 34 | if !new_snapshot_dir.exists() { 35 | fs::create_dir_all(&new_snapshot_dir) 36 | .await 37 | .with_context(|| { 38 | format!("failed to create directory {}", new_snapshot_dir.display()) 39 | })?; 40 | } 41 | for subvolume in &snapshot.subvolumes { 42 | let restore_target_subvolume_path = 43 | restore_snapshot_dir.join(subvolume.replace('/', "__")); 44 | let new_snapshot_subvolume_path = new_snapshot_dir.join(subvolume.replace('/', "__")); 45 | let subvolume_path = self.path().join(subvolume); 46 | info!( 47 | "{} -> {}", 48 | subvolume_path.display(), 49 | new_snapshot_subvolume_path.display() 50 | ); 51 | fs::rename(&subvolume_path, &new_snapshot_subvolume_path) 52 | .await 53 | .with_context(|| { 54 | format!( 55 | "failed to rename {} to {}", 56 | subvolume_path.display(), 57 | new_snapshot_subvolume_path.display() 58 | ) 59 | })?; 60 | info!( 61 | "snapshotting {} to {}", 62 | restore_target_subvolume_path.display(), 63 | subvolume_path.display() 64 | ); 65 | let source = restore_target_subvolume_path.clone(); 66 | tokio::task::spawn_blocking(move || { 67 | libbtrfsutil::create_snapshot( 68 | &source, 69 | &subvolume_path, 70 | CreateSnapshotFlags::empty(), 71 | None, 72 | ) 73 | }) 74 | .await? 75 | .with_context(|| { 76 | format!( 77 | "failed to snapshot subvolume '{}'", 78 | restore_target_subvolume_path.display() 79 | ) 80 | })?; 81 | } 82 | 83 | let new_snapshot_metadata_path = self 84 | .path() 85 | .join(snapshot_path) 86 | .join(new_snapshot.uuid.to_string()) 87 | .with_extension("snapshot.json"); 88 | info!( 89 | "writing new snapshot metadata to {}", 90 | new_snapshot_metadata_path.display() 91 | ); 92 | fs::write( 93 | &new_snapshot_metadata_path, 94 | serde_json::to_string_pretty(&new_snapshot)?, 95 | ) 96 | .await 97 | .with_context(|| { 98 | format!( 99 | "failed to write snapshot metadata to {}", 100 | new_snapshot_metadata_path.display() 101 | ) 102 | })?; 103 | 104 | Ok(new_snapshot) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /service/src/util.rs: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MPL-2.0 2 | 3 | use anyhow::{Context, Result}; 4 | use libbtrfsutil::{SubvolumeIterator, SubvolumeIteratorFlags}; 5 | use std::path::{Path, PathBuf}; 6 | use tokio::fs; 7 | 8 | /// Finds the btrfs partition that contains the root subvolume. 9 | /// 10 | /// This works by scanning /proc/mounts for a mount that has the 11 | /// `subvol=/@root` option. 12 | pub async fn find_root_device() -> Result { 13 | let mounts = fs::read_to_string("/proc/mounts") 14 | .await 15 | .context("failed to read /proc/mounts")?; 16 | mounts 17 | .lines() 18 | .find(|line| line.contains("subvol=/@root")) 19 | .and_then(|line| line.split_whitespace().next()) 20 | .map(PathBuf::from) 21 | .context("failed to find @root") 22 | } 23 | 24 | pub fn list_subvolumes_eligible_for_snapshotting( 25 | root_path: &Path, 26 | exclude_subvolumes: &[String], 27 | ) -> Result> { 28 | let mut subvolumes = Vec::new(); 29 | let info = 30 | libbtrfsutil::subvolume_info(root_path, None).context("failed to get subvolume info")?; 31 | let iter = SubvolumeIterator::new(root_path, info.parent_id(), SubvolumeIteratorFlags::empty()) 32 | .context("failed to iterate root subvolumes")?; 33 | let snapshots_path = PathBuf::from("@snapshots"); 34 | for subvolume in iter { 35 | let (path, id) = subvolume.context("failed to get subvolume")?; 36 | debug!("Found subvolume '{}' (id {id})", path.display()); 37 | if path.starts_with(&snapshots_path) 38 | || exclude_subvolumes 39 | .iter() 40 | .any(|exclude| path.starts_with(&exclude)) 41 | { 42 | debug!( 43 | "Skipping subvolume '{}', it is not eligible for snapshotting", 44 | path.display() 45 | ); 46 | continue; 47 | } 48 | subvolumes.push(path.display().to_string()); 49 | } 50 | Ok(subvolumes) 51 | } 52 | 53 | pub trait ToFdoError { 54 | fn to_fdo_err(self) -> zbus::fdo::Result; 55 | } 56 | 57 | impl ToFdoError for anyhow::Result { 58 | fn to_fdo_err(self) -> zbus::fdo::Result { 59 | self.map_err(|err| zbus::fdo::Error::Failed(format!("{:?}", err))) 60 | } 61 | } 62 | --------------------------------------------------------------------------------