backup (9377B)
1 #!/usr/bin/env ruby 2 # frozen_string_literal: true 3 4 # vim: ft=ruby 5 6 require "English" 7 require "optparse" 8 require "open3" 9 require "yaml" 10 11 module Backup 12 Result = Struct.new(:status, :out, :err) do 13 def success? = status&.success? 14 def exitcode = status&.exitstatus || 127 15 end 16 17 Backend = Struct.new(:list, :run) 18 19 @failures = [] 20 21 def self.ts = Time.now.strftime("%Y-%m-%dT%H:%M:%S%z") 22 def self.log(msg) = puts("[#{ts}]: #{msg}") 23 def self.err(msg) = warn("[#{ts}]: #{msg}") 24 25 def self.fatal(msg) 26 err(msg) 27 exit(1) 28 end 29 30 def self.record_failure(msg) 31 @failures << msg 32 err("ERROR: #{msg}") 33 end 34 35 def self.check(msg, result) 36 return true if result.success? 37 38 detail = result.err.to_s.strip 39 record_failure(detail.empty? ? msg : "#{msg}: #{detail}") 40 false 41 end 42 43 HOME = ENV.fetch("HOME") { fatal("HOME not set") } 44 PLAKAR_CONFIG_DIR = "#{HOME}/.config/plakar".freeze 45 46 GIT_DIRS = { 47 "bandcamp" => "#{HOME}/misc/music/backlog/_todo", 48 "mpd" => "#{HOME}/.config/mpd", 49 "nix" => "#{HOME}/src/sr.ht/nix", 50 "nota" => "#{HOME}/.nb/notes", 51 "pass" => "#{HOME}/.password-store", 52 "releases" => "#{HOME}/src/mtmn.name/releases" 53 }.freeze 54 55 TARSNAP_DIRS = { 56 "mpd" => "#{HOME}/.config/mpd", 57 "nota" => "#{HOME}/.nb/notes", 58 "password-store" => "#{HOME}/.password-store", 59 "releases" => "#{HOME}/src/mtmn.name/releases" 60 }.freeze 61 62 def self.run_cmd(verbose, *args) 63 if verbose 64 $stdout.flush 65 $stderr.flush 66 system(*args) 67 Result.new($CHILD_STATUS, "", $CHILD_STATUS.nil? ? "command not found: #{args.first}" : "") 68 else 69 out, err, status = Open3.capture3(*args) 70 Result.new(status, out, err) 71 end 72 rescue Errno::ENOENT => e 73 Result.new(nil, "", e.message) 74 end 75 76 def self.git(dir, *args, verbose:) 77 run_cmd(verbose, "git", "--git-dir=#{dir}/.git", "--work-tree=#{dir}", *args) 78 end 79 80 def self.commit_changes(dir, verbose:) 81 r = git(dir, "diff", "--cached", "--name-only", verbose: false) 82 return false unless check("Failed to list staged changes in #{dir}", r) 83 84 files = r.out.lines.map(&:strip).reject(&:empty?).join(" ") 85 r2 = git(dir, "commit", "-m", files, verbose: verbose) 86 return false unless check("Failed to commit changes in #{dir}", r2) 87 88 log("Committed changes in #{dir}") 89 true 90 end 91 92 def self.git_remote(dir, action, verb) 93 log("#{verb.capitalize} changes from #{dir}") 94 r = git(dir, action, verbose: false) 95 case r.exitcode 96 when 0 97 log("Done #{verb}ing #{dir}") 98 when 128 99 log("No #{verb} destination for #{dir} (skipped)") 100 else 101 record_failure("#{verb.capitalize} failed for #{dir} (exit #{r.exitcode}): #{r.err.strip}") 102 end 103 end 104 105 def self.push_changes(dir) = git_remote(dir, "push", "push") 106 def self.fetch_changes(dir) = git_remote(dir, "pull", "fetch") 107 108 def self.sync_repo(verbose:, push:, fetch:, name:, dir:) 109 log("git/#{name}") 110 fetch_changes(dir) if fetch 111 r = git(dir, "add", "-A", verbose: verbose) 112 return unless check("Failed to stage changes in #{dir}", r) 113 114 diff = git(dir, "diff", "--cached", "--quiet", verbose: false) 115 committed = if diff.success? 116 log("Nothing to commit in #{dir}") 117 true 118 else 119 commit_changes(dir, verbose: verbose) 120 end 121 122 push_changes(dir) if push && committed 123 end 124 125 def self.tarsnap_archive(verbose:, name:, dir:) 126 stamp = Time.now.strftime("%Y-%m-%d_%H:%M:%S") 127 log("Archiving tarsnap/#{name} from #{dir}") 128 r = run_cmd(verbose, "tarsnap", "-c", "-f", "#{name}-#{stamp}", "-C", dir, ".") 129 return unless check("tarsnap failed for #{name}", r) 130 131 log("Done tarsnap/#{name}") 132 end 133 134 def self.parse_plakar_show(text) 135 records = {} 136 name = nil 137 text.each_line do |line| 138 stripped = line.strip 139 next if stripped.empty? 140 141 if line.start_with?(" ") 142 key, val = stripped.split(":", 2) 143 records[name][key] = val&.strip if name 144 else 145 name = stripped.delete_suffix(":") 146 records[name] = {} 147 end 148 end 149 150 records 151 end 152 153 def self.plakar_query(subcommand) 154 r = run_cmd(false, "plakar", subcommand, "show") 155 return {} unless r.success? 156 157 parse_plakar_show(r.out) 158 end 159 160 def self.load_plakar_yml(name) 161 path = File.join(PLAKAR_CONFIG_DIR, "#{name}.yml") 162 return nil unless File.file?(path) 163 164 YAML.load_file(path)[name.to_s] || {} 165 rescue => e 166 fatal("Failed to parse #{path}: #{e.message}") 167 end 168 169 def self.plakar_stores 170 @plakar_stores ||= (load_plakar_yml("stores") || plakar_query("store")).freeze 171 end 172 173 def self.plakar_sources 174 @plakar_sources ||= (load_plakar_yml("sources") || plakar_query("source")).freeze 175 end 176 177 def self.plakar_stores_hash = plakar_stores 178 def self.plakar_stores_list = plakar_stores.keys 179 def self.plakar_sources_list = plakar_sources.keys 180 181 def self.plakar_ignore_file(store) 182 path = File.join(PLAKAR_CONFIG_DIR, "#{store}.ignore") 183 File.file?(path) ? path : nil 184 end 185 186 def self.plakar_source(verbose:, store:, source:) 187 ignore = plakar_ignore_file(store) 188 args = ["plakar", "at", "@#{store}", "backup"] 189 args.push("-ignore-file", ignore) if ignore 190 args.push("@#{source}") 191 log("Syncing plakar/#{store}/#{source}") 192 r = run_cmd(verbose, *args) 193 return unless check("plakar @#{store} failed for #{source}", r) 194 195 log("Done plakar/#{store}/#{source}") 196 end 197 198 def self.plakar_store(verbose:, store:) 199 fatal("Unknown plakar store: #{store}") unless plakar_stores_list.include?(store) 200 plakar_sources_list.each { |src| plakar_source(verbose: verbose, store: store, source: src) } 201 end 202 203 def self.backup_plakar(verbose:) 204 plakar_stores_list.each { |store| plakar_store(verbose: verbose, store: store) } 205 end 206 207 def self.list_flat(name, dirs) 208 puts(name) 209 dirs.keys.sort.each { |k| puts(" #{k}") } 210 end 211 212 def self.list_plakar 213 puts("plakar") 214 puts(" sources") 215 plakar_sources_list.sort.each { |s| puts(" #{s}") } 216 plakar_stores_list.sort.each do |store| 217 loc = plakar_stores_hash.dig(store, "location") || "?" 218 puts(" #{store} (#{loc})") 219 end 220 end 221 222 def self.git_run(verbose:, push:, fetch:, parts:) 223 if parts.empty? 224 GIT_DIRS.each { |n, d| sync_repo(verbose: verbose, push: push, fetch: fetch, name: n, dir: d) } 225 else 226 name = parts.first 227 sync_repo( 228 verbose: verbose, 229 push: push, 230 fetch: fetch, 231 name: name, 232 dir: GIT_DIRS.fetch(name) { fatal("Unknown git target: #{name}") } 233 ) 234 end 235 end 236 237 def self.tarsnap_run(verbose:, parts:) 238 if parts.empty? 239 TARSNAP_DIRS.each { |n, d| tarsnap_archive(verbose: verbose, name: n, dir: d) } 240 else 241 name = parts.first 242 tarsnap_archive( 243 verbose: verbose, 244 name: name, 245 dir: TARSNAP_DIRS.fetch(name) { fatal("Unknown tarsnap target: #{name}") } 246 ) 247 end 248 end 249 250 def self.plakar_run(verbose:, parts:) 251 if parts.empty? 252 backup_plakar(verbose: verbose) 253 elsif parts.size == 1 254 plakar_store(verbose: verbose, store: parts.first) 255 else 256 plakar_source(verbose: verbose, store: parts[0], source: parts[1]) 257 end 258 end 259 260 BACKENDS = { 261 "git" => Backend.new( 262 list: -> { list_flat("git", GIT_DIRS) }, 263 run: ->(verbose:, push:, fetch:, parts:) { git_run(verbose: verbose, push: push, fetch: fetch, parts: parts) } 264 ), 265 "tarsnap" => Backend.new( 266 list: -> { list_flat("tarsnap", TARSNAP_DIRS) }, 267 run: ->(verbose:, push:, fetch:, parts:) { tarsnap_run(verbose: verbose, parts: parts) } 268 ), 269 "plakar" => Backend.new( 270 list: -> { list_plakar }, 271 run: ->(verbose:, push:, fetch:, parts:) { plakar_run(verbose: verbose, parts: parts) } 272 ) 273 }.freeze 274 275 def self.list_targets 276 BACKENDS.each_value { |b| b.list.call } 277 end 278 279 def self.backup_target(verbose:, push:, fetch:, target:) 280 parts = target.split(":") 281 bname = parts.shift 282 backend = BACKENDS[bname] || fatal("Unknown target: #{target}") 283 backend.run.call(verbose: verbose, push: push, fetch: fetch, parts: parts) 284 end 285 286 def self.main(args) 287 Signal.trap("INT") do 288 warn("[#{Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")}]: Interrupted") 289 exit(130) 290 end 291 292 options = {verbose: true, push: false, fetch: false, list: false} 293 parser = OptionParser.new do |o| 294 o.banner = "Usage: backup [options] [target]\n" \ 295 " target: git, plakar, tarsnap, <backend>:<name>" 296 o.on("-p", "--push", "Fetch and push changes after commit") { options[:push] = true } 297 o.on("-f", "--fetch", "Run git pull for git targets before backup") { options[:fetch] = true } 298 o.on("-q", "--quiet", "Suppress command output") { options[:verbose] = false } 299 o.on("-l", "--list", "List all configured backup targets") { options[:list] = true } 300 o.on("-h", "--help") do 301 puts(o) 302 exit(0) 303 end 304 end 305 306 parser.parse!(args) 307 target = args.shift 308 unless options[:list] || target 309 list_targets 310 exit(0) 311 end 312 313 options[:fetch] = true if options[:push] 314 315 list_targets if options[:list] 316 if target 317 backup_target(verbose: options[:verbose], push: options[:push], fetch: options[:fetch], target: target) 318 end 319 320 fatal("#{@failures.size} backup target(s) failed") unless @failures.empty? 321 end 322 end 323 324 Backup.main(ARGV)