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