tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

reflink-snap.rb (9289B)


      1 #!/usr/bin/env ruby
      2 # frozen_string_literal: true
      3 
      4 #
      5 # reflink-snap.rb — reflink (CoW) snapshot manager for XFS.
      6 #
      7 # Ruby port of alexadriaanse/reflink-snap (create/prune/latest logic), plus
      8 # extent-sharing verification and dedup from
      9 # https://strugglers.net/posts/2017/xfs-reflinks-and-deduplication/
     10 #
     11 # A snapshot is a plain directory <snapshot_dir>/<prefix><timestamp> copied
     12 # with `cp -a --reflink=always -x`, so unmodified data shares extents with
     13 # the source and costs (almost) no disk space. `latest` symlinks the newest.
     14 
     15 require "find"
     16 require "open3"
     17 require "optparse"
     18 
     19 TS_FORMAT = "%Y-%m-%d_%H%M%S"
     20 TS_RE = /\d{4}-\d{2}-\d{2}_\d{6}/
     21 LATEST = "latest"
     22 
     23 BANNER = <<~EOS
     24   reflink-snap.rb — reflink (CoW) snapshots for XFS
     25 
     26   Usage:
     27     reflink-snap.rb create -s SRC -d SNAPDIR [-c KEEP] [-p PREFIX] [-n]
     28     reflink-snap.rb list   -d SNAPDIR [-p PREFIX]
     29     reflink-snap.rb prune  -d SNAPDIR -c KEEP [-p PREFIX] [-n]
     30     reflink-snap.rb verify PATH
     31     reflink-snap.rb dedupe [--hashfile FILE] PATH...
     32 
     33   Commands:
     34     create   copy SRC to SNAPDIR/[PREFIX]<timestamp> via cp -a --reflink=always,
     35              update the `latest` symlink, then prune if -c is given
     36     list     show snapshots (optionally filtered by -p), newest last
     37     prune    keep the newest KEEP snapshots, delete the rest
     38     verify   report how much of PATH's file data lives in shared (reflinked)
     39              extents, using filefrag -v
     40     dedupe   run duperemove to turn duplicate blocks under PATH... into reflinks
     41 
     42   Restoring is just a copy back out of a snapshot:
     43     cp -a --reflink=always SNAPDIR/latest/some/file SRC/some/file
     44 
     45   Space accounting: `du` counts reflinked data once per tree, so snapshots
     46   look expensive; `df` is the truth for what they actually cost.
     47 
     48   Example fcron entries:
     49     0 * * * *  ruby reflink-snap.rb create -s /home/miro -d /home/.snapshots -c 48 -p hourly_
     50     0 3 * * *  ruby reflink-snap.rb create -s /home/miro -d /home/.snapshots -c 14 -p daily_
     51 
     52   Options:
     53 EOS
     54 
     55 def die(msg)
     56   abort("reflink-snap: #{msg}")
     57 end
     58 
     59 def human(bytes)
     60   units = %w[B KiB MiB GiB TiB]
     61   size = bytes.to_f
     62   unit = units.first
     63   units.each do |u|
     64     unit = u
     65     break if size < 1024
     66     size /= 1024
     67   end
     68   format("%.1f %s", size, unit)
     69 end
     70 
     71 class ReflinkSnap
     72   def initialize(opts)
     73     @opts = opts
     74     @dry_run = opts.fetch(:dry_run, false)
     75   end
     76 
     77   def create
     78     src, dir, prefix = @opts[:src], @opts[:dir], @opts[:prefix]
     79     die "source #{src} is not a directory" unless File.directory?(src)
     80 
     81     run "mkdir", "-p", "--", dir
     82     if Dir.exist?(dir) && File.stat(dir).dev != File.stat(src).dev
     83       die "#{dir} is on a different filesystem than #{src}; reflinks cannot cross filesystems"
     84     end
     85 
     86     src_real = File.realpath(src)
     87     snap_real = Dir.exist?(dir) ? File.realpath(dir) : File.expand_path(dir)
     88     if snap_real.start_with?("#{src_real}/") && File.dirname(snap_real) != src_real
     89       warn "reflink-snap: warning: #{dir} is nested below #{src} but is not a" \
     90         " direct child, so snapshots will recursively include older snapshots"
     91     end
     92 
     93     src_dev = File.stat(src).dev
     94     entries = Dir.children(src).sort.filter_map do |name|
     95       path = File.join(src, name)
     96       next if File.lstat(path).dev != src_dev # skip mountpoints, like cp -x
     97       real = begin
     98         File.realpath(path)
     99       rescue Errno::ENOENT
    100         nil # broken symlink; cp -a copies the link itself, keep it
    101       end
    102       next if real == snap_real # never snapshot the snapshot dir
    103       path
    104     end
    105     die "nothing to snapshot in #{src}" if entries.empty?
    106 
    107     name = "#{prefix}#{Time.now.strftime(TS_FORMAT)}"
    108     final = File.join(dir, name)
    109     die "#{final} already exists (snapshots have 1s resolution)" if File.exist?(final)
    110 
    111     # Copy into a dot-prefixed temp dir and rename at the end, so an
    112     # interrupted copy never looks like a valid snapshot.
    113     tmp = File.join(dir, ".incomplete_#{name}")
    114     puts "Creating snapshot #{final}"
    115     run "mkdir", "--", tmp
    116     begin
    117       run "cp", "-a", "--reflink=always", "-x", "--", *entries, "#{tmp}/"
    118     rescue => e
    119       system("rm", "-rf", "--", tmp) unless @dry_run
    120       die "cp failed (#{e.message}); incomplete snapshot removed"
    121     end
    122     run "mv", "-T", "--", tmp, final
    123     run "ln", "-sfn", "--", name, File.join(dir, LATEST)
    124     puts "Done"
    125 
    126     prune if @opts[:count]
    127   end
    128 
    129   def list
    130     dir, prefix = @opts[:dir], @opts[:prefix]
    131     snaps = snapshots(dir, prefix)
    132     if snaps.empty?
    133       puts prefix ? "No #{prefix}* snapshots in #{dir}" : "No snapshots in #{dir}"
    134       return
    135     end
    136     latest = begin
    137       File.readlink(File.join(dir, LATEST))
    138     rescue
    139       nil
    140     end
    141     snaps.each do |name|
    142       mtime = File.stat(File.join(dir, name)).mtime.strftime("%Y-%m-%d %H:%M:%S")
    143       puts "#{name}  #{mtime}#{"  (latest)" if name == latest}"
    144     end
    145   end
    146 
    147   def prune
    148     dir, prefix, keep = @opts[:dir], @opts[:prefix], @opts[:count]
    149     die "prune needs -c KEEP" unless keep
    150     die "-c KEEP must be at least 1" if keep < 1
    151 
    152     snaps = snapshots(dir, prefix)
    153     doomed = (snaps.size > keep) ? snaps.first(snaps.size - keep) : []
    154     doomed.each do |name|
    155       run "rm", "-rf", "--", File.join(dir, name)
    156       puts "Removed snapshot #{File.join(dir, name)}" unless @dry_run
    157     end
    158 
    159     latest = File.join(dir, LATEST)
    160     if !@dry_run && File.symlink?(latest) && !File.exist?(latest)
    161       newest = snapshots(dir, prefix).last
    162       newest ? run("ln", "-sfn", "--", newest, latest) : run("rm", "--", latest)
    163     end
    164   end
    165 
    166   def verify
    167     path = @opts[:path]
    168     die "#{path} does not exist" unless File.exist?(path)
    169     path = File.realpath(path) # so verifying the `latest` symlink works
    170     unless system("filefrag", "-V", out: File::NULL, err: File::NULL)
    171       die "filefrag not found; install e2fsprogs"
    172     end
    173 
    174     files = []
    175     Find.find(path) do |p|
    176       next if File.symlink?(p)
    177       files << p if File.file?(p) && File.size(p).positive?
    178     end
    179     die "no regular files under #{path}" if files.empty?
    180 
    181     total_bytes = shared_bytes = 0
    182     files.each_slice(200) do |batch|
    183       # -b1 reports extent lengths in bytes, so no block-size math is needed.
    184       out, = Open3.capture3("filefrag", "-v", "-b1", *batch)
    185       out.each_line do |line|
    186         next unless line.start_with?(/\s*\d+:/)
    187         fields = line.split(":")
    188         bytes = fields[3].to_i
    189         total_bytes += bytes
    190         shared_bytes += bytes if fields.last.split(",").include?("shared")
    191       end
    192     end
    193 
    194     pct = total_bytes.zero? ? 0.0 : 100.0 * shared_bytes / total_bytes
    195     puts "#{files.size} files, #{human(total_bytes)} of file data"
    196     puts "  shared (reflinked) extents: #{human(shared_bytes)} (#{pct.round(1)}%)"
    197     puts "  unique to this tree:        #{human(total_bytes - shared_bytes)}"
    198   end
    199 
    200   def dedupe
    201     unless system("duperemove", "--help", out: File::NULL, err: File::NULL)
    202       die "duperemove not installed (xbps-install duperemove)"
    203     end
    204     argv = %w[duperemove -hdr]
    205     argv << "--hashfile=#{@opts[:hashfile]}" if @opts[:hashfile]
    206     argv += ["--", *@opts[:paths]]
    207     if @dry_run
    208       puts "+ #{argv.join(" ")}"
    209     else
    210       exec(*argv)
    211     end
    212   end
    213 
    214   private
    215 
    216   # Print instead of executing under -n; raise if a real command fails.
    217   def run(*argv)
    218     if @dry_run
    219       puts "+ #{argv.join(" ")}"
    220     else
    221       system(*argv, exception: true)
    222     end
    223   end
    224 
    225   def name_re(prefix)
    226     /\A#{Regexp.escape(prefix)}#{TS_RE}\z/
    227   end
    228 
    229   # Directories in `dir` that we created (prefix + timestamp), oldest first.
    230   # Only these are ever eligible for deletion. When prefix is nil, match any
    231   # name ending in a timestamp.
    232   def snapshots(dir, prefix)
    233     return [] unless Dir.exist?(dir)
    234     re = prefix ? name_re(prefix) : /\A.*#{TS_RE}\z/o
    235     Dir.children(dir)
    236       .grep(re)
    237       .select { |n| File.directory?(File.join(dir, n)) }
    238       .sort
    239   end
    240 end
    241 
    242 opts = {}
    243 parser = OptionParser.new do |o|
    244   o.banner = BANNER
    245   o.on("-s", "--source DIR", "tree to snapshot (create)") { opts[:src] = it }
    246   o.on("-d", "--snapshot-dir DIR", "where snapshots live") { opts[:dir] = it }
    247   o.on("-c", "--keep N", Integer, "snapshots to retain") { opts[:count] = it }
    248   o.on("-p", "--prefix PREFIX", "snapshot name prefix") { opts[:prefix] = it }
    249   o.on("--hashfile FILE", "duperemove hash cache (dedupe)") { opts[:hashfile] = it }
    250   o.on("-n", "--dry-run", "print commands instead of running them") { opts[:dry_run] = true }
    251   o.on("-h", "--help") do
    252     puts parser
    253     exit
    254   end
    255 end
    256 
    257 command = ARGV.shift
    258 begin
    259   parser.parse!(ARGV)
    260 rescue OptionParser::ParseError => e
    261   die "#{e.message}\n\n#{parser}"
    262 end
    263 die "prefix must not contain '/'" if opts[:prefix]&.include?("/")
    264 
    265 tool = ReflinkSnap.new(opts)
    266 
    267 case command
    268 when "create"
    269   die "create needs -s SRC and -d SNAPDIR" unless opts[:src] && opts[:dir]
    270   tool.create
    271 when "list"
    272   die "list needs -d SNAPDIR" unless opts[:dir]
    273   tool.list
    274 when "prune"
    275   die "prune needs -d SNAPDIR" unless opts[:dir]
    276   tool.prune
    277 when "verify"
    278   opts[:path] = ARGV.shift or die "verify needs a PATH"
    279   tool.verify
    280 when "dedupe"
    281   opts[:paths] = ARGV
    282   die "dedupe needs at least one PATH" if opts[:paths].empty?
    283   tool.dedupe
    284 when nil
    285   puts parser
    286   exit 1
    287 else
    288   die "unknown command #{command}\n\n#{parser}"
    289 end