nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

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)