├── media └── 0.png ├── completions └── timepatrol ├── hooks ├── zz-timepatrol-post.hook ├── 05-timepatrol-pre.hook └── timepatrol-pacman ├── config-example ├── install.sh ├── README.md └── timepatrol /media/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abdeoliveira/timepatrol/HEAD/media/0.png -------------------------------------------------------------------------------- /completions/timepatrol: -------------------------------------------------------------------------------- 1 | complete -W 'snapshot snapshot-keep toggle-keep delete check\ 2 | rollback list list-verbose help list-grep change-comment' timepatrol 3 | -------------------------------------------------------------------------------- /hooks/zz-timepatrol-post.hook: -------------------------------------------------------------------------------- 1 | [Trigger] 2 | Operation = Upgrade 3 | Operation = Install 4 | Operation = Remove 5 | Type = Package 6 | Target = * 7 | 8 | [Action] 9 | Description = Performing timepatrol post processing... 10 | When = PostTransaction 11 | Exec = /usr/share/libalpm/scripts/timepatrol-pacman post 12 | NeedsTargets 13 | -------------------------------------------------------------------------------- /hooks/05-timepatrol-pre.hook: -------------------------------------------------------------------------------- 1 | [Trigger] 2 | Operation = Upgrade 3 | Operation = Install 4 | Operation = Remove 5 | Type = Package 6 | Target = * 7 | 8 | [Action] 9 | Description = Performing timepatrol pre snapshot... 10 | When = PreTransaction 11 | Exec = /usr/share/libalpm/scripts/timepatrol-pacman pre 12 | NeedsTargets 13 | AbortOnFail 14 | -------------------------------------------------------------------------------- /config-example: -------------------------------------------------------------------------------- 1 | # DEVICE is where lies your root partition. It will be something like 2 | # /dev/sda1, /dev/nvme0n1p1 and so son. For encrypted roots, 3 | # it will be something like /dev/mapper/something. 4 | DEVICE = '/dev/mapper/root' 5 | 6 | # The folder which will hold your snapshots. 7 | SNAPSHOTS_FOLDER = '/.snapshots' 8 | 9 | # Subvolume associated to the SNAPSHOTS_FOLDER. 10 | SNAPSHOTS_VOLUME = '@snapshots' 11 | 12 | # The root subvolume. 13 | ROOT_VOLUME = '@' 14 | 15 | # Mamixum number of snapshots to keep. 16 | # Protected snapshots will not count against the MAXIMUM_SNAPSHOTS number. 17 | MAXIMUM_SNAPSHOTS = '60' 18 | 19 | # Set 'true' to disable colors. Any other string, including empty string, 20 | # will be interpreted as 'false'. 21 | DISABLE_COLORS = '' 22 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | CONFIG_DIR=/etc/timepatrol 5 | SRC_DIR=/usr/bin 6 | 7 | # CHECK IF RUNNING AS ROOT. ABORT IF FAILS. 8 | if [ "$EUID" -ne 0 ]; then 9 | echo "You shall run as 'root'. ABORTED." 10 | exit 1 11 | fi 12 | 13 | # CHECK FOR BTRFS EXECUTABLE. ABORT IF FAILS. 14 | if ! command -v btrfs &> /dev/null; then 15 | echo "'btrfs' not found. ABORTED" 16 | exit 1 17 | else 18 | echo "* Found '$(command -v btrfs)'. Proceeding." 19 | fi 20 | 21 | # CHECK FOR INSTALLED RUBY. ABORT IF FAILS. 22 | if ! command -v ruby &> /dev/null; then 23 | echo "'ruby' not found. ABORTED." 24 | exit 1 25 | else 26 | echo "* Found '$(command -v ruby)'. Proceeding." 27 | fi 28 | 29 | # INSTALL TIMEPATROL. 30 | install -Dm 755 timepatrol -t $SRC_DIR/ 31 | echo "* Installed 'timepatrol' at '$SRC_DIR'." 32 | 33 | # INSTALL CONFIG FILE 34 | install -Dm 644 config-example -t $CONFIG_DIR/ 35 | echo "* Installed the 'config-example' file at '$CONFIG_DIR'" 36 | echo " " 37 | echo ":: You shall rename the 'config-example' file as '$CONFIG_DIR/config' and" 38 | echo ":: edit it according to your system." 39 | echo " " 40 | echo "Finished installation successfully!" 41 | -------------------------------------------------------------------------------- /hooks/timepatrol-pacman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # frozen_string_literal: true 3 | 4 | unless File.file? '/etc/timepatrol/config' 5 | puts ':: TIMEPATROL: Missing configuration file. Skipping hooks!!' 6 | exit 0 7 | end 8 | 9 | post_snapshot = true 10 | post_snapshot = false if File.file? '/etc/timepatrol/ignore_post_snapshot' 11 | 12 | @magic_string = '@PACMAN-PRE-HOOK' 13 | 14 | def change_string(actions, path) 15 | info = File.read("#{path}/info") 16 | info.sub!(@magic_string, "PRE: #{actions}") 17 | File.write("#{path}/info", info, mode: 'w') 18 | end 19 | 20 | `/usr/bin/timepatrol snapshot #{@magic_string}` if ARGV[0] == 'pre' 21 | 22 | if ARGV[0] == 'post' 23 | 24 | path = File.read('/tmp/timepatrol_last_snapshot').strip 25 | pacman_lock_file = "#{path}/data/var/lib/pacman/db.lck" 26 | `rm #{pacman_lock_file}` if File.exist? "#{path}/data/var/lib/pacman/db.lck" 27 | 28 | first_stage = [] 29 | second_stage = [] 30 | actions = '' 31 | 32 | (File.readlines '/var/log/pacman.log').reverse.each do |line| 33 | break if line.include? 'timepatrol-pre.hook' 34 | 35 | first_stage << line 36 | end 37 | 38 | first_stage.reverse.each do |line| 39 | break if line.include? 'transaction completed' 40 | 41 | second_stage << line unless line.include? 'transaction started' 42 | end 43 | 44 | second_stage.each.with_index do |line, index| 45 | unless line.include?('[ALPM-SCRIPTLET]') 46 | actions += line.split('[ALPM]').last.strip 47 | actions += ', ' if index + 1 < second_stage.length 48 | end 49 | end 50 | 51 | actions.gsub!('upgraded', 'upgrade') 52 | actions.gsub!('installed', 'install') 53 | actions.gsub!('removed', 'remove') 54 | actions.gsub!('downgraded', 'downgrade') 55 | 56 | change_string(actions, path) 57 | 58 | `/usr/bin/timepatrol snapshot 'POST: #{actions}'` if post_snapshot 59 | end 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # timepatrol 2 | 3 | ## BTRFS snapshots manager and rollback tool 4 | 5 | ![Alt text](/media/0.png?raw=true "Timepatrol in action!") 6 | 7 | 8 | Timepatrol is a BTRFS snapshot manager and a rollback tool in a single script. 9 | There are great tools out there which do the same, like Timeshift and Snapper, 10 | for example, but I still prefer Timepatrol because: 11 | 12 | * Easy to rollback to any snapshot. 13 | * Minimal dependency: `ruby`. 14 | * Outputs fit in half screen. Perfect for window manager users. 15 | * It has colors! Although it can be disabled. 16 | 17 | In fact, it was written based on my personal needs but it may 18 | be of interest of a few people also. 19 | 20 | In principle it can be used in any Linux distribution. Arch users 21 | will benefit from the `pacman` pre and post hooks which I found to be 22 | very handy in the day-to-day use. 23 | 24 | ## Dependency 25 | * `ruby` 26 | 27 | ## Installation 28 | 29 | ### Arch 30 | 31 | From [AUR](https://aur.archlinux.org/packages/timepatrol-git), which I maintain myself. 32 | 33 | ### Other Linux 34 | 1. `git clone https://github.com/abdeoliveira/timepatrol` 35 | 2. `cd timepatrol` 36 | 3. `sudo ./install.sh` 37 | 38 | ## Uninstall 39 | 40 | ### Arch 41 | 42 | `sudo pacman -Rs timepatrol-git` 43 | 44 | ### Other Linux 45 | 46 | `sudo rm -r /usr/bin/timepatrol /etc/timepatrol` 47 | 48 | ## Configuration 49 | Copy the example configuration file as 50 | 51 | ``` 52 | sudo cp /etc/timepatrol/config-example /etc/timepatrol/config 53 | ``` 54 | 55 | Then, check the comments in `config` file 56 | for directions and adjust it as per your system. 57 | 58 | **Once you have finished, I recommend you run `sudo timepatrol check` to see if timepatrol spots any misconfiguration.** 59 | 60 | **A note regarding `/etc/fstab`**: The default installation in some distributions 61 | (Arch for instance) include the `subvolid` information in `fstab` for mounting 62 | points. Since rollbacks change such a number I recommend you omit the `subvolid` 63 | in the `/` entry. Mine reads as follows: 64 | 65 | ``` 66 | # Static information about the filesystems. 67 | # See fstab(5) for details. 68 | 69 | # 70 | # /dev/mapper/ainstnvme0n1p2 71 | UUID=054b4420-a2e0-41b1-8d66-8cc7198d8b55 / btrfs rw,relatime,ssd,space_cache=v2,subvol=/@ 0 0 72 | 73 | # /dev/mapper/ainstnvme0n1p2 74 | UUID=054b4420-a2e0-41b1-8d66-8cc7198d8b55 /home btrfs rw,relatime,ssd,space_cache=v2,subvolid=257,subvol=/@home 0 0 75 | 76 | # /dev/mapper/ainstnvme0n1p2 77 | UUID=054b4420-a2e0-41b1-8d66-8cc7198d8b55 /var/log btrfs rw,relatime,ssd,space_cache=v2,subvolid=258,subvol=/@log 0 0 78 | 79 | ... 80 | ``` 81 | 82 | ## Usage 83 | 84 | Type `sudo timepatrol help` for a basic list of commands. They are 85 | 86 | * `check`: checks the configuration file and simulates (dryrun) a rollback. 87 | 88 | * `list`: lists snapshots limiting shown comment characters. 89 | 90 | * `list-verbose`: lists snapshots without limiting comment characters. 91 | 92 | * `list-grep 'STRING'`: lists snapshots containning `STRING` in comments. 93 | 94 | * `snapshot 'OPTIONAL COMMENT'`: takes a snapshot of `/` with (optional) 95 | given comment. 96 | 97 | * `snapshot-keep 'OPTIONAL COMMENT'`: same as above plus it adds a protection against 98 | automatic deletion. Automatic deletion is set via the `MAXIMUM_SNAPSHOTS` 99 | variable in the `/etc/timepatrol/config` file. 100 | Protected snapshots have a `*` mark next to their IDs, and 101 | they do not count against the `MAXIMUM_SNAPSHOTS` variable. 102 | For example, if 2 snapshots are protected and `MAXIMUM_SNAPSHOTS = 20`, 103 | then the maximum number of snapshots will be 22. 104 | 105 | * `change-comment ID 'NEW COMMENT'`: replaces current COMMENT of snapshot ID by 106 | 'NEW COMMENT'. 107 | 108 | * `delete`: deletes a snapshot. It accepts individual `ID` numbers and ranges. 109 | For example: `sudo timepatrol delete 1,10,20-23` will delete snapshots whose 110 | `ID`s are 1, 10, 20, 21, 22, and 23. The `delete` command also accepts 111 | the following substring selectors: `date=`, `time=`, `kernel=`, and `comment=`. 112 | See the example of usage below: 113 | 114 | ``` 115 | oliveira@arch:~$ sudo timepatrol delete time=16: 116 | *[208] 2024.08.12 16:30:05 6.10.4-arch2-1 Niri OK 117 | [258] 2024.08.20 16:07:01 6.10.5-arch1-1 PRE: install libxp (1.0.4-3) 118 | [259] 2024.08.20 16:07:56 6.10.5-arch1-1 PRE: remove libxp (1.0.4-3) 119 | [260] 2024.08.20 16:26:31 6.10.5-arch1-1 PRE: install libxp (1.0.4-3), install 120 | openmotif (2.3.8-3), install t1lib 121 | (5.1.2-8) 122 | [264] 2024.08.21 10:16:52 6.10.6-arch1-1 PRE: upgrade timepatrol-git 123 | (r149.7e4bff6-1 -> r151.3f4f304-1) 124 | :: Confirm deletion of the selected snapshot(s) above? [y/N] 125 | ``` 126 | 127 | Note that all snapshots containing the user-given substring `16:` in the `time` field 128 | were selected for deletion. I recommend you play with the other selectors. In 129 | any case, the user will always be prompted to confirm the deletion 130 | with the `No` answer being the defaut. 131 | 132 | * `toggle-keep`: Toggles between protect and unprotect snapshots. 133 | It accepts an individul `ID`, list of `ID`s, ranges and selectors similar to the 134 | `delete` command above. 135 | 136 | * `rollback`: rolls back the installation to a previous, selected snapshot state. 137 | Some notes: 138 | 139 | (i) rolling back to a snapshot whose kernel is different from the 140 | running kernel is not allowed (the script will ABORT). 141 | You must adjust the current kernel (downgrade/upgrade), then 142 | reboot (so it is loaded), then try to rollback. 143 | 144 | (ii) Plese read the recommendation regarding the `/etc/fstab` file before rollback. 145 | 146 | (iii) Reboot immediately after kernel upgrade. To be on the safe side, 147 | reboot immediately after **any** system upgrade. See the `Troubleshooting` 148 | section also. 149 | 150 | ## Bash completion 151 | 152 | ### Arch 153 | 1. Install `bash-completion` 154 | 155 | 2. Logout and login, or reboot. 156 | 157 | 158 | ### Other Linux 159 | 1. Install `bash-completion` 160 | 161 | 2. Copy and paste the contents of 162 | `completions/timepatrol` to your `~/.bashrc`: 163 | 164 | ``` 165 | cat completions/timepatrol >> ~/.bashrc 166 | ``` 167 | 168 | 3. Logout and login, or reboot. 169 | 170 | ## Periodic, automatic snapshots 171 | 172 | For a 24/7 running machine, probably the simplest way is setting a cronjob as root. 173 | 174 | For notebooks, I recommend [simplecron](https://github.com/abdeoliveira/simplecron), 175 | which I mantain myself. 176 | 177 | ## Troubleshooting 178 | 179 | ### Unbootable system after rollback 180 | 181 | Several factors can lead to an unbootable system after a rollback. For instance, if you upgraded the kernel but didn't reboot before rolling back, your system might fail to start. 182 | 183 | While I can't cover every possible system recovery scenario, the following steps should help in many cases: 184 | 185 | 1. Boot from a live media. 186 | 2. Chroot into your (broken) system. 187 | 3. Mount all partitions (e.g., `mount -a`). 188 | 4. Regenerate the initramfs (`mkinitcpio -P` for Arch, or the equivalent for your distro). 189 | 5. If the issue persists, try downgrading the kernel. On Arch, pacman generally handles this well. 190 | 6. Exit chroot and reboot. 191 | -------------------------------------------------------------------------------- /timepatrol: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | config_file = '/etc/timepatrol/config' 4 | 5 | if File.file? config_file 6 | load config_file 7 | else 8 | puts 'It seems this is your first run.' 9 | puts "You must 'sudo cp /etc/timepatrol/config-example #{config_file}'," 10 | puts "then edit 'config' according to your system." 11 | puts 'ABORTED.' 12 | abort 13 | end 14 | 15 | # CHANGE (OR NOT) STRING COLORS 16 | class String 17 | def colorize(code1, code2) 18 | if DISABLE_COLORS != 'true' 19 | "\e[#{code1}m#{self}\e[#{code2}m" 20 | else 21 | self 22 | end 23 | end 24 | 25 | def red 26 | colorize(31, 0) 27 | end 28 | 29 | def green 30 | colorize(32, 0) 31 | end 32 | 33 | def yellow 34 | colorize(33, 0) 35 | end 36 | 37 | def blue 38 | colorize(34, 0) 39 | end 40 | 41 | def magenta 42 | colorize(35, 0) 43 | end 44 | 45 | def grey 46 | colorize(37, 0) 47 | end 48 | 49 | def bold 50 | colorize(1, 22) 51 | end 52 | end 53 | 54 | # FORMAT TIMEPATROL MESSAGES 55 | class String 56 | def abort 57 | ':: '.red.bold + self.bold 58 | end 59 | 60 | def inform 61 | ':: '.blue.bold + self.bold 62 | end 63 | 64 | def decide 65 | ':: '.yellow.bold + self.bold 66 | end 67 | end 68 | 69 | def check_config(config_file) 70 | puts 'Performing configuration check...'.inform 71 | puts "Verifying '#{config_file}'...".bold 72 | all_ok = true 73 | 74 | # --- Part 1: Variable and Value Checks --- 75 | required_vars = { 76 | 'DISABLE_COLORS' => 'A string, either "true" or "false".', 77 | 'MAXIMUM_SNAPSHOTS' => 'A positive integer, e.g., 20.', 78 | 'SNAPSHOTS_FOLDER' => 'A valid path to an existing directory, e.g., "/.snapshots".', 79 | 'SNAPSHOTS_VOLUME' => 'The name of your snapshots subvolume, e.g., ".snapshots".', 80 | 'DEVICE' => 'Your Btrfs partition, e.g., "/dev/sda1".', 81 | 'ROOT_VOLUME' => 'The name of your root subvolume, e.g., "root".' 82 | } 83 | 84 | required_vars.each do |var, description| 85 | if Object.const_defined?(var) 86 | value = Object.const_get(var) 87 | puts " [OK] ".green + "'#{var}' is defined." 88 | 89 | if var == 'SNAPSHOTS_FOLDER' && !Dir.exist?(value) 90 | puts " └─ " + "ERROR: This directory does not exist.".red 91 | all_ok = false 92 | elsif var == 'MAXIMUM_SNAPSHOTS' 93 | int_value = value.to_i 94 | if int_value <= 0 95 | puts " └─ " + "ERROR: This value must be a positive number.".red 96 | all_ok = false 97 | elsif int_value >= 999 98 | puts " └─ " + "ERROR: This value must be less than 999.".red 99 | all_ok = false 100 | end 101 | end 102 | else 103 | puts " [FAIL] ".red + "'#{var}' is missing. It should be: #{description}" 104 | all_ok = false 105 | end 106 | end 107 | 108 | # --- Part 2: Rollback Simulation (using shell redirection) --- 109 | if all_ok 110 | puts 'Simulating rollback mount procedure...'.inform 111 | check_mount_point = '/tmp/timepatrol_check_mount' 112 | mount_succeeded = false 113 | 114 | begin 115 | `mkdir #{check_mount_point}` 116 | 117 | # 1. Add `2>&1` to the command to redirect stderr to stdout 118 | mount_cmd = "mount -o subvolid=5,ro #{DEVICE} #{check_mount_point} 2>&1" 119 | output = `#{mount_cmd}` # Backticks will now capture errors 120 | 121 | # 2. Check the special variable `$?.success?` for the exit status 122 | if $?.success? 123 | mount_succeeded = true 124 | puts " [OK] ".green + "Device mounted successfully (read-only)." 125 | 126 | if Dir.exist?("#{check_mount_point}/#{ROOT_VOLUME}") 127 | puts " [OK] ".green + "Found root volume: '#{ROOT_VOLUME}'" 128 | else 129 | puts " [FAIL] ".red + "Could NOT find root volume: '#{ROOT_VOLUME}'. Rollback will fail." 130 | all_ok = false 131 | end 132 | 133 | if Dir.exist?("#{check_mount_point}/#{SNAPSHOTS_VOLUME}") 134 | puts " [OK] ".green + "Found snapshots volume: '#{SNAPSHOTS_VOLUME}'" 135 | else 136 | puts " [FAIL] ".red + "Could NOT find snapshots volume: '#{SNAPSHOTS_VOLUME}'. Rollback will fail." 137 | all_ok = false 138 | end 139 | else 140 | # If the command failed, the 'output' variable now holds the error message. 141 | puts " [FAIL] ".red + "Could not mount device '#{DEVICE}'." 142 | puts " └─ " + "Reason: #{output.strip}".red # Print the captured error 143 | all_ok = false 144 | end 145 | ensure 146 | `umount #{check_mount_point} 2>/dev/null` if mount_succeeded 147 | `rm -r #{check_mount_point} 2>/dev/null` 148 | end 149 | end 150 | 151 | # --- Final Summary --- 152 | if all_ok 153 | puts 'Configuration check passed!'.inform 154 | else 155 | puts 'Configuration check failed. Please review your config file.'.abort 156 | abort 157 | end 158 | end 159 | 160 | def max_kernel_size 161 | array_kernel_size = [] 162 | 163 | snapid.each do |id| 164 | kernel = readinfo(id)[3] 165 | array_kernel_size << kernel.size 166 | end 167 | 168 | return array_kernel_size.max 169 | end 170 | 171 | def root? 172 | ENV['USER'] == 'root' 173 | end 174 | 175 | def max_snapshots_limit? 176 | MAXIMUM_SNAPSHOTS.to_i > 999 177 | end 178 | 179 | def now 180 | Time.new.strftime '%Y.%m.%d;%H:%M:%S' 181 | end 182 | 183 | def snapid 184 | id_list = [] 185 | subvolumes = `btrfs subvolume list /` 186 | subvolumes.split("\n").each do |line| 187 | if line.include?(SNAPSHOTS_VOLUME) && line.include?('data') 188 | id_list << (line.split "#{SNAPSHOTS_VOLUME}/").last.delete('/data').to_i 189 | end 190 | end 191 | id_list.sort 192 | end 193 | 194 | def sanitize(string) 195 | string ||= '' 196 | string.gsub(';', '') 197 | end 198 | 199 | def snapshot(comm, keep, fake) 200 | snapshot_path = "#{SNAPSHOTS_FOLDER}/#{(snapid.last || 0) + 1}" 201 | `mkdir #{snapshot_path}` 202 | unless fake 203 | puts `btrfs subvolume snapshot / #{snapshot_path}/data` 204 | File.write('/tmp/timepatrol_last_snapshot', snapshot_path, mode: 'w') 205 | end 206 | info = "#{now};#{sanitize(comm)};#{`uname -r`.chomp};#{keep}" 207 | File.write("#{snapshot_path}/info", info, mode: 'w') 208 | end 209 | 210 | def chunk_words(words) 211 | words.flat_map do |word| 212 | if word.length > @number_cols 213 | # Chunk the word into pieces of size `@number_cols` 214 | word.scan(/.{1,#{@number_cols}}/) 215 | else 216 | # If the word fits within `number_cols`, return it as is 217 | word 218 | end 219 | end 220 | end 221 | 222 | def word_wrap(text, indent) 223 | 224 | words = text.split(' ') # Split by spaces 225 | words = chunk_words(words) # Split big words into chunks 226 | 227 | lines = [] 228 | line = "" # First line has no indentation 229 | 230 | words.each do |word| 231 | if (line + " " + word).strip.length <= @number_cols # Fits in current line 232 | line += " " unless line.empty? 233 | line += word 234 | else # Move to a new line 235 | lines << line.rstrip # Store the current line 236 | break if @max_lines && lines.size >= @max_lines # Stop if limit reached 237 | line = (' ' * indent) + word # Indent only from the second line 238 | end 239 | end 240 | 241 | lines << line.rstrip unless line.empty? || (@max_lines && lines.size >= @max_lines) # Add the last line if space allows 242 | lines.join("\n") # Convert to a single string 243 | end 244 | 245 | def no_snapshot 246 | puts 'No snapshot.'.abort 247 | abort 248 | end 249 | 250 | def readinfo(id) 251 | path = "#{SNAPSHOTS_FOLDER}/#{id}" 252 | no_snapshot unless Dir.exist? path 253 | return File.read("#{path}/info").strip.split(';') 254 | end 255 | 256 | def colorline(string, index) 257 | if index.odd? 258 | string.grey 259 | else 260 | string 261 | end 262 | end 263 | 264 | def line_length 265 | @number_cols + 32 + @max_kernel_size 266 | end 267 | 268 | def lineline(type) 269 | type * line_length 270 | end 271 | 272 | def title 273 | string = ':: TIMEPATROL SNAPSHOTS ::' 274 | ' ' * (line_length / 2 - string.size / 2) + string.yellow.bold 275 | end 276 | 277 | def legend 278 | k = @max_kernel_size - 4 279 | k = 0 if k.negative? 280 | "#{' '*3}ID#{' '*3}DATE#{' '*8}TIME#{' '*6}KERNEL#{' '*k}COMMENT".bold 281 | end 282 | 283 | def format_information(id, info, index) 284 | date, time, comm, kernel, keep = info 285 | 286 | comm = word_wrap(comm, 32 + @max_kernel_size) 287 | 288 | kernel_space = @max_kernel_size - kernel.size + 2 289 | 290 | id_space = 4 - id.to_s.size 291 | id = colorline("[#{id}]", index) 292 | 293 | date = colorline(date, index) 294 | time = colorline(time, index) 295 | comm = colorline(comm, index) 296 | kernel = colorline(kernel, index) 297 | 298 | if keep == '1' 299 | id = '*'.green.bold + id 300 | id_space -= 1 301 | end 302 | 303 | ' '*id_space+id + ' '*2 + date+' '*2 + time + ' '*2 + kernel + ' ' * kernel_space + comm 304 | end 305 | 306 | def array_selector(selid) 307 | special = false 308 | special_selectors = ['date=', 'time=', 'comment=', 'kernel=', 'keep='] 309 | special_selectors.each do |string| 310 | special = true if selid.include? string 311 | end 312 | return substring_selector(selid) if special 313 | return ids_selector(selid) unless special 314 | end 315 | 316 | def substring_selector(selid) 317 | array_selected = [] 318 | keyword = ['date=', 'time=', 'comment=', 'kernel=', 'keep='] 319 | 5.times do |j| 320 | snapid.each do |id| 321 | string = readinfo(id)[j] 322 | array_selected << id if string.include? selid.sub(keyword[j], '') 323 | end 324 | end 325 | array_selected.uniq 326 | end 327 | 328 | def ids_selector(selid) 329 | array_selected = [] 330 | selid.split(',').each do |item| 331 | if item.include? '-' 332 | range = item.split('-').map(&:to_i) 333 | snapid.each do |id| 334 | array_selected << id if id >= range.min and id <= range.max 335 | end 336 | else 337 | array_selected << item 338 | end 339 | end 340 | array_selected.uniq 341 | end 342 | 343 | def grep_string(array_selected, substring) 344 | array_selected.each do |id| 345 | date, time, comm, kernel, keep = readinfo(id) 346 | comm.gsub!(substring, substring.bold) 347 | info = [date, time, comm, kernel, keep] 348 | puts format_information(id, info, 1) 349 | end 350 | end 351 | 352 | def repack 353 | puts 'Repacking snapshots...' 354 | path = "#{SNAPSHOTS_FOLDER}/" 355 | snapid.each.with_index do |v, j| 356 | from = path + v.to_s 357 | to = path + (j + 1).to_s 358 | if from != to 359 | `mv #{from} #{to}` 360 | puts "#{v} --> #{j + 1}" 361 | end 362 | end 363 | end 364 | 365 | def prune 366 | count = 0 367 | keep_count = 0 368 | snapid.map { |id| keep_count += 1 if readinfo(id)[4] == '1' } 369 | maxdel = snapid.length - (MAXIMUM_SNAPSHOTS.to_i + keep_count) 370 | snapid.each do |id| 371 | break if count >= maxdel 372 | 373 | unless readinfo(id)[4] == '1' 374 | code1 = `btrfs subvolume delete #{SNAPSHOTS_FOLDER}/#{id}/data; echo $?`.to_i 375 | code2 = `rm -r #{SNAPSHOTS_FOLDER}/#{id}; echo $?`.to_i 376 | if (code1.abs + code2.abs).positive? 377 | puts "FAILED pruning snapshot [#{id}]." 378 | else 379 | puts "pruning snapshot [#{id}]." 380 | count += 1 381 | end 382 | end 383 | end 384 | end 385 | 386 | def write_to_file(id, string) 387 | file = "#{SNAPSHOTS_FOLDER}/#{id}/info" 388 | File.write(file, string, mode: 'w') 389 | end 390 | 391 | def number_cols 392 | #`stty size 2>/dev/null `.split.last.to_i - 32 - @max_kernel_size 393 | `tput cols`.to_i - 32 - @max_kernel_size 394 | end 395 | #============================================= 396 | 397 | unless root? 398 | puts 'Please run as root.'.abort 399 | abort 400 | end 401 | 402 | if max_snapshots_limit? 403 | puts "'MAXIMUM_SNAPSHOTS' must be < 1000.".abort 404 | abort 405 | end 406 | 407 | option = ARGV[0] 408 | selid = ARGV[1] 409 | new_comment = ARGV[2] 410 | 411 | option ||= 'help' 412 | selid ||= '' 413 | new_comment ||= '' 414 | 415 | # A list of commands that need display variables calculated 416 | display_commands = ['list', 'list-verbose', 'list-grep', 'rollback', 'delete'] 417 | 418 | if display_commands.include?(option) 419 | # If the command is in the list, calculate display variables 420 | @max_kernel_size = max_kernel_size || 0 421 | @number_cols = number_cols 422 | else 423 | # For other commands, just set a default column number 424 | @number_cols = `tput cols`.to_i 425 | end 426 | 427 | @mount_point = '/tmp/timepatrol_rollback' 428 | 429 | case option 430 | 431 | when 'check' 432 | check_config(config_file) 433 | 434 | when 'snapshot', 'snapshot-keep' 435 | keep = 0 436 | keep = 1 if option == 'snapshot-keep' 437 | snapshot(selid, keep, false) 438 | prune if snapid.length >= MAXIMUM_SNAPSHOTS.to_i 439 | repack if snapid.last > 990 440 | 441 | when 'list', 'list-verbose' 442 | @max_lines = nil 443 | @max_lines = 1 if option == 'list' 444 | 445 | puts lineline('=') 446 | puts title 447 | puts lineline('=') 448 | puts legend 449 | 450 | snapid.each.with_index do |id, index| 451 | puts format_information(id, readinfo(id), index) 452 | end 453 | 454 | puts lineline('-') 455 | puts "TOTAL: #{snapid.length}".bold 456 | 457 | when 'list-grep' 458 | selid ||= abort 459 | grep_string(substring_selector("comment=#{selid}"), selid) 460 | 461 | when 'change-comment' 462 | array = array_selector(selid) 463 | if array.length > 1 464 | puts 'You must choose a single ID. ABORTED.'.abort 465 | abort 466 | end 467 | 468 | no_snapshot if array.empty? 469 | 470 | id = array.first 471 | date, time, comm, kernel, keep = readinfo(id) 472 | info = "#{date};#{time};#{new_comment};#{kernel};#{keep}" 473 | write_to_file(id, info) 474 | 475 | when 'rollback' 476 | countdown = 10 # seconds before rebooting 477 | 478 | if Dir.exist? @mount_point 479 | puts "The '#{@mount_point}' folder shouldn't exist. ABORTED.".abort 480 | abort 481 | end 482 | 483 | array = array_selector(selid) 484 | if array.length > 1 485 | puts 'You must choose a single ID. ABORTED.'.abort 486 | abort 487 | end 488 | 489 | no_snapshot if array.empty? 490 | 491 | rollid = array.first 492 | kernel_from = `uname -r`.chomp 493 | kernel_to = readinfo(rollid)[3] 494 | 495 | if kernel_from != kernel_to 496 | puts "Running kernel (#{kernel_from}) is different from [#{rollid}]'s kernel. ABORTED.".abort 497 | abort 498 | end 499 | 500 | puts 'Rolling back to the following snapshot:'.inform 501 | puts format_information(rollid, readinfo(rollid), 1) 502 | 503 | puts 'Confirm? [y/N]'.decide 504 | unless $stdin.gets.chomp == 'y' 505 | puts 'ABORTED.'.abort 506 | abort 507 | end 508 | 509 | `mkdir #{@mount_point} && mount -o subvolid=5 #{DEVICE} #{@mount_point}` 510 | root_dir_exists = Dir.exist? "#{@mount_point}/#{ROOT_VOLUME}" 511 | snapshots_dir_exists = Dir.exist? "#{@mount_point}/#{SNAPSHOTS_VOLUME}" 512 | unless root_dir_exists && snapshots_dir_exists 513 | puts 'Mounting stage went wrong. ABORTED.'.abort 514 | abort 515 | end 516 | 517 | id = snapid.last + 1 518 | date, time = readinfo(rollid) 519 | snapshot("PRE: rollback to [#{date} #{time}]", 0, true) 520 | 521 | `mv #{@mount_point}/#{ROOT_VOLUME} #{@mount_point}/#{SNAPSHOTS_VOLUME}/#{id}/data` 522 | `btrfs subvolume snapshot #{@mount_point}/#{SNAPSHOTS_VOLUME}/#{rollid}/data #{@mount_point}/#{ROOT_VOLUME}` 523 | list_subvol = `btrfs subvolume list /`.split("\n") 524 | list_subvol.each do |subvol| 525 | data = subvol.split(' ') 526 | if data[8] == ROOT_VOLUME 527 | `btrfs subvolume set-default #{data[1]} /` 528 | break 529 | end 530 | end 531 | `umount #{@mount_point} && rm -r #{@mount_point}` 532 | 533 | puts 'Confirmed. Rebooting in...'.inform 534 | (countdown - 1).times do 535 | puts countdown -= 1 536 | sleep 1 537 | end 538 | puts 'BUCKLE UP!' 539 | sleep 2 540 | `reboot` 541 | 542 | when 'delete' 543 | array = array_selector(selid) 544 | no_snapshot if array.empty? 545 | 546 | array.each do |iid| 547 | puts format_information(iid, readinfo(iid), 1) 548 | end 549 | 550 | puts 'Confirm deletion of the selected snapshot(s) above? [y/N]'.decide 551 | unless $stdin.gets.chomp == 'y' 552 | puts 'ABORTED.'.abort 553 | abort 554 | end 555 | 556 | puts 'Confirmed.'.inform 557 | array.each do |iid| 558 | code1 = `btrfs subvolume delete #{SNAPSHOTS_FOLDER}/#{iid}/data; echo $?`.to_i 559 | code2 = `rm -r #{SNAPSHOTS_FOLDER}/#{iid}; echo $?`.to_i 560 | puts "FAILED deleting snapshot [#{iid}].".abort if (code1.abs + code2.abs).positive? 561 | end 562 | 563 | when 'toggle-keep' 564 | array = array_selector(selid) 565 | no_snapshot if array.empty? 566 | 567 | array.each do |iid| 568 | date, time, comm, kernel = readinfo(iid) 569 | keep = (-readinfo(iid)[4].to_i + 2) / 2 570 | info = "#{date};#{time};#{comm};#{kernel};#{keep}" 571 | write_to_file(iid, info) 572 | end 573 | 574 | when 'help' 575 | space = 40 576 | line = ('-'.*space).bold 577 | puts line 578 | puts 'USAGE:'.yellow.bold + ' timepatrol [COMMAND]'.bold 579 | puts line 580 | puts 'COMMANDS:'.yellow.bold + " snapshot 'OPTIONAL COMMENT'" 581 | puts " snapshot-keep 'OPTIONAL COMMENT'" 582 | puts " change-comment ID 'NEW COMMENT'" 583 | puts ' toggle-keep ID' 584 | puts ' delete ID' 585 | puts ' rollback ID' 586 | puts ' check' 587 | puts ' list' 588 | puts ' list-verbose' 589 | puts " list-grep 'STRING'" 590 | puts line 591 | puts 'https://github.com/abdeoliveira/timepatrol/#Usage' 592 | 593 | else 594 | puts "There is no '#{option}' option.".abort 595 | puts "Type 'sudo timepatrol help' for a list of commands.".abort 596 | end 597 | --------------------------------------------------------------------------------