diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e5e9e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Gemfile.lock +pkg/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e088013 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org/' + +gemspec diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..68c8829 --- /dev/null +++ b/Guardfile @@ -0,0 +1,13 @@ +guard 'spork' do + watch('Gemfile') { :rspec } + watch('Gemfile.lock') { :rspec } + watch('spec/spec_helper.rb') { :rspec } +end + +guard 'rspec', + :cmd => "rspec --drb", + :all_on_start => true do + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^lib/}) { "spec" } +end + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6a27b65 --- /dev/null +++ b/Rakefile @@ -0,0 +1,36 @@ +require 'rubygems' +require 'bundler' + +begin + Bundler.setup(:default, :development) +rescue Bundler::BundlerError => e + $stderr.puts e.message + $stderr.puts "Run `bundle install` to install missing gems" + exit e.status_code +end + +require 'git-version-bump/rake-tasks' + +Bundler::GemHelper.install_tasks + +require 'rdoc/task' + +Rake::RDocTask.new do |rd| + rd.main = "README.md" + rd.title = 'lvmsync' + rd.rdoc_files.include("README.md", "lib/**/*.rb") +end + +desc "Run guard" +task :guard do + require 'guard' + ::Guard.start(:clear => true) + while ::Guard.running do + sleep 0.5 + end +end + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new :test do |t| + t.pattern = "spec/**/*_spec.rb" +end diff --git a/lvmsync b/bin/lvmsync similarity index 50% rename from lvmsync rename to bin/lvmsync index 1d8241a..385ba72 100755 --- a/lvmsync +++ b/bin/lvmsync @@ -6,7 +6,7 @@ # Usage: Start with lvmsync --help, or read the README for all the gory # details. # -# Copyright (C) 2011-2013 Matt Palmer +# Copyright (C) 2011-2014 Matt Palmer # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License version 3, as published @@ -18,9 +18,12 @@ # `LICENCE` file for more details. # require 'optparse' +require 'lvm' PROTOCOL_VERSION = "lvmsync PROTO[2]" +include LVM::Helpers + def main() # Parse me some options options = {} @@ -146,51 +149,35 @@ def run_client(opts) destdev = opts[:destdev] outfd = nil - snapshotdm = canonicalise_dm(snapshot) - - # First, read the dm table so we can see what we're dealing with - dmtable = read_dm_table - dmlist = read_dm_list - - if dmlist[snapshotdm].nil? - $stderr.puts "Could not find dm device '#{snapshot}' (name mangled to '#{snapshotdm}')" - exit 1 - end + vg, lv = parse_snapshot_name(snapshot) - if dmtable[snapshotdm][0][:type] != 'snapshot' - $stderr.puts "#{snapshot} does not appear to be a snapshot" - exit 1 - end - - origindm = dm_from_devnum(dmtable[snapshotdm][0][:args][0], dmlist) + vgconfig = LVM::VGConfig.new(vg) - if origindm.nil? - $stderr.puts "CAN'T HAPPEN: No origin device for #{snapshot} found" + if vgconfig.logical_volumes[lv].nil? + $stderr.puts "#{snapshot}: Could not find logical volume" exit 1 end - - $stderr.puts "Found origin dm device: #{origindm}" if opts[:verbose] - exceptiondm = dm_from_devnum(dmtable[snapshotdm][0][:args][1], dmlist) - - if exceptiondm.nil? - $stderr.puts "CAN'T HAPPEN: No exception list device for #{snapshot} found!" + snap = if vgconfig.logical_volumes[lv].snapshot? + if vgconfig.logical_volumes[lv].thin? + LVM::ThinSnapshot.new(vg, lv) + else + LVM::Snapshot.new(vg, lv) + end + else + $stderr.puts "#{snapshot}: Not a snapshot device" exit 1 end - # Since, in principle, we're not supposed to be reading from the CoW - # device directly, the kernel makes no attempt to make the device's read + $stderr.puts "Origin device: #{vg}/#{snap.origin}" if opts[:verbose] + + # Since, in principle, we're not supposed to be reading from snapshot + # devices directly, the kernel makes no attempt to make the device's read # cache stay in sync with the actual state of the device. As a result, # we have to manually drop all caches before the data looks consistent. # PERFORMANCE WIN! File.open("/proc/sys/vm/drop_caches", 'w') { |fd| fd.print "3" } - $stderr.puts "Reading snapshot metadata from /dev/mapper/#{exceptiondm}" if opts[:verbose] - $stderr.puts "Reading changed chunks from /dev/mapper/#{origindm}" if opts[:verbose] - - xfer_count = 0 - total_size = 0 - chunksize = nil snapback = opts[:snapback] ? "--snapback #{opts[:snapback]}" : '' if opts[:stdout] @@ -208,150 +195,62 @@ def run_client(opts) outfd.puts PROTOCOL_VERSION start_time = Time.now - File.open("/dev/mapper/#{origindm}", 'r') do |origindev| - File.open("/dev/mapper/#{exceptiondm}", 'r') do |snapdev| - chunksize = read_header(snapdev) - origin_offset = nil + xfer_count = 0 + xfer_size = 0 + total_size = 0 + + originfile = "/dev/mapper/#{vg.gsub('-', '--')}-#{snap.origin.gsub('-', '--')}" + File.open(originfile, 'r') do |origindev| + snap.differences.each do |r| + xfer_count += 1 + chunk_size = r.last - r.first + 1 + xfer_size += chunk_size + + $stderr.puts "Sending chunk #{r.to_s}..." if opts[:verbose] + $stderr.puts "Seeking to #{r.first} in #{originfile}" if opts[:verbose] - snapdev.seek chunksize - in_progress = true - t = Time.now - while in_progress - (chunksize / 16).times do - origin_offset, snap_offset = snapdev.read(16).unpack("QQ") - origin_offset = ntohq(origin_offset) - snap_offset = ntohq(snap_offset) - if snap_offset == 0 - in_progress = false - break - end - xfer_count += 1 - $stderr.puts "Sending chunk #{origin_offset}" if opts[:verbose] - origindev.seek origin_offset * chunksize - outfd.print [htonq(origin_offset), chunksize].pack("QN") - outfd.print origindev.read(chunksize) - end - snapdev.seek chunksize * chunksize / 16, IO::SEEK_CUR if in_progress - $stderr.printf "\e[2K\rSending chunk %i (origin device offset %i), %.2fMB/s", - xfer_count, - origin_offset * chunksize, - chunksize * 16 / (Time.now - t) / 1048576 + origindev.seek(r.first, IO::SEEK_SET) + + outfd.print [htonq(r.first), chunk_size].pack("QN") + outfd.print origindev.read(chunk_size) + + # Progress bar! + if xfer_count % 100 == 50 + $stderr.printf "\e[2K\rSending chunk %i of %i, %.2fMB/s", + xfer_count, + snap.differences.length, + xfer_size / (Time.now - start_time) / 1048576 $stderr.flush - t = Time.now end end + origindev.seek(0, IO::SEEK_END) - total_size = origindev.tell / 4096 + total_size = origindev.tell end - - $stderr.printf "\rTransferred %i of %i chunks (%i bytes per chunk) in %.2f seconds\n", - xfer_count, total_size, chunksize, Time.now - start_time + + $stderr.printf "\rTransferred %i bytes in %.2f seconds\n", + xfer_size, Time.now - start_time $stderr.printf "You transferred your changes %.2fx faster than a full dd!\n", - total_size.to_f / xfer_count + total_size.to_f / xfer_size ensure outfd.close unless outfd.nil? or outfd == $stdout - -end - -# Call dmsetup ls and turn that into a hash of dm_name => [maj, min] data. -# Note that maj, min will be integers, and dm_name a string. -def read_dm_list - dmlist = {} - `dmsetup ls`.split("\n").each do |l| - next unless l =~ /^(\S+)\s+\((\d+)(, |:)(\d+)\)$/ - dmlist[$1] = [$2.to_i, $4.to_i] - end - dmlist -end - -# Call dmsetup table and turn that into a complicated hash of dm table data. -# -# Structure is: -# -# dm_name => [ -# { :offset => int, -# :length => int, -# :type => (linear|snapshot|...), -# :args => [str, str, str] -# }, -# { ... } -# ], -# dm_name => [ ... ] -# -# The arguments are kept as a list of strings (split on whitespace), and -# you'll need to interpret them yourself. Turning this whole shebang into a -# class hierarchy is a task for another time. -# -def read_dm_table - dmtable = {} - `dmsetup table`.split("\n").each do |l| - next unless l =~ /^(\S+): (\d+) (\d+) (\S+) (.*)$/ - dmtable[$1] ||= [] - dmtable[$1] << { :offset => $2, - :length => $3, - :type => $4, - :args => $5.split(/\s+/) - } - end - dmtable end -# Take a device name in any number of different formats and turn it into a -# "canonical" devicemapper name, equivalent to what you'll find in the likes -# of dmsetup ls -def canonicalise_dm(origname) +# Take a device name in any number of different formats and return a [VG, LV] pair. +# Raises ArgumentError if the name couldn't be parsed. +def parse_snapshot_name(origname) case origname - when %r{^/dev/mapper/(.+)$} then - $1 + when %r{^/dev/mapper/(.*[^-])-([^-].*)$} then + [$1, $2] when %r{^/dev/([^/]+)/(.+)$} then - vg = $1 - lv = $2 - vg.gsub('-', '--') + - '-' + - lv.gsub('-', '--') + [$1, $2] when %r{^([^/]+)/(.*)$} then - vg = $1 - lv = $2 - vg.gsub('-', '--') + - '-' + - lv.gsub('-', '--') + [$1, $2] else - # Let's *assume* that the user just gave us vg-lv... - origname + raise ArgumentError, + "Could not determine snapshot name and VG from #{origname.inspect}" end end -# Find the name of a dm device that corresponds to the given : -# string provided. -def dm_from_devnum(devnum, dmlist) - maj, min = devnum.split(':', 2) - dmlist.invert[[maj.to_i, min.to_i]] -end - -# Read the header off our snapshot device, validate all is well, and return -# the chunksize used by the snapshot, in bytes -def read_header(snapdev) - magic, valid, metadata_version, chunksize = snapdev.read(16).unpack("VVVV") - raise RuntimeError.new("Invalid snapshot magic number") unless magic == 0x70416e53 - raise RuntimeError.new("Snapshot marked as invalid") unless valid == 1 - raise RuntimeError.new("Incompatible metadata version") unless metadata_version == 1 - - # With all that out of the way, we can get down to business - chunksize * 512 -end - -# Are we on a big-endian system? Needed for our htonq/ntohq methods -def big_endian? - @bigendian ||= [1].pack("s") == [1].pack("n") -end - -def htonq val - big_endian? ? ([val].pack("Q").reverse.unpack("Q").first) : val -end - -def ntohq val - htonq val -end - -main if __FILE__ == $0 +main diff --git a/files/lvmsync b/files/lvmsync deleted file mode 120000 index 47672db..0000000 --- a/files/lvmsync +++ /dev/null @@ -1 +0,0 @@ -../lvmsync \ No newline at end of file diff --git a/lib/lvm.rb b/lib/lvm.rb new file mode 100644 index 0000000..ca24cad --- /dev/null +++ b/lib/lvm.rb @@ -0,0 +1,5 @@ +require 'lvm/helpers' +require 'lvm/thin_snapshot' +require 'lvm/snapshot' +require 'lvm/lv_config' +require 'lvm/vg_config' diff --git a/lib/lvm/helpers.rb b/lib/lvm/helpers.rb new file mode 100644 index 0000000..3e9194e --- /dev/null +++ b/lib/lvm/helpers.rb @@ -0,0 +1,18 @@ +module LVM; end + +module LVM::Helpers + # Are we on a big-endian system? Needed for our htonq/ntohq methods + def big_endian? + @bigendian ||= [1].pack("s") == [1].pack("n") + end + + def htonq val + # This won't work on a nUxi byte-order machine, but if you have one of + # those, I'm guessing you've got bigger problems + big_endian? ? ([val].pack("Q").reverse.unpack("Q").first) : val + end + + def ntohq val + htonq val + end +end diff --git a/lib/lvm/lv_config.rb b/lib/lvm/lv_config.rb new file mode 100644 index 0000000..6384eca --- /dev/null +++ b/lib/lvm/lv_config.rb @@ -0,0 +1,39 @@ +module LVM; end + +class LVM::LVConfig + attr_reader :name + + def initialize(tree, name, vgcfg) + @root = tree + @name = name + @vgcfg = vgcfg + end + + def thin? + @root.groups['segment1'].variable_value('type') == 'thin' + end + + def snapshot? + thin? ? !origin.nil? : !@vgcfg.logical_volumes.values.find { |lv| lv.cow_store == name }.nil? + end + + def thin_pool + @root.groups['segment1'].variable_value('thin_pool') + end + + def device_id + @root.groups['segment1'].variable_value('device_id') + end + + def origin + @root.groups['segment1'].variable_value('origin') + end + + def cow_store + @root.groups['segment1'].variable_value('cow_store') + end + + def chunk_size + @root.groups['segment1'].variable_value('chunk_size') * 512 + end +end diff --git a/lib/lvm/pv_config.rb b/lib/lvm/pv_config.rb new file mode 100644 index 0000000..8872dc2 --- /dev/null +++ b/lib/lvm/pv_config.rb @@ -0,0 +1,7 @@ +module LVM; end + +class LVM::PVConfig + def initialize(tree) + @root = tree + end +end diff --git a/lib/lvm/snapshot.rb b/lib/lvm/snapshot.rb new file mode 100644 index 0000000..ccffe9f --- /dev/null +++ b/lib/lvm/snapshot.rb @@ -0,0 +1,113 @@ +require 'rexml/document' +require 'lvm/helpers' + +module LVM; end + +class LVM::Snapshot + include LVM::Helpers + + def initialize(vg, lv) + @vg = vg + @lv = lv + end + + # Return an array of ranges which are the bytes which are different + # between the origin and the snapshot. + def differences + @differences ||= begin + # For a regular, old-skool snapshot, getting the differences is + # pretty trivial -- just read through the snapshot metadata, and + # the list of changed blocks is right there. + # + diff_block_list = [] + + File.open(metadata_device, 'r') do |metafd| + in_progress = true + + # The first chunk of the metadata LV is the header, which we + # don't care for at all + metafd.seek chunk_size, IO::SEEK_SET + + while in_progress + # The snapshot on-disk format is a stream of , + # sets; within each , it's network-byte-order 64-bit block + # IDs -- the first is the location (chunk_size * offset) in the origin + # LV that the data has been changed, the second is the location (again, + # chunk_size * offset) in the metadata LV where the changed data is + # being stored. + (chunk_size / 16).times do + origin_offset, snap_offset = metafd.read(16).unpack("QQ") + origin_offset = ntohq(origin_offset) + snap_offset = ntohq(snap_offset) + + # A snapshot offset of 0 would point back to the metadata + # device header, so that's clearly invalid -- hence it's the + # "no more blocks" indicator. + if snap_offset == 0 + in_progress = false + break + end + + diff_block_list << origin_offset + end + + # We've read through a set of origin => data mappings; now we need + # to take a giant leap over the data blocks that follow it. + metafd.seek chunk_size * chunk_size / 16, IO::SEEK_CUR + end + end + + # Block-to-byte-range is pretty trivial, and we're done! + diff_block_list.map do |b| + ((b*chunk_size)..(((b+1)*chunk_size)-1)) + end + + # There is one optimisation we could make here that we haven't -- + # coalescing adjacent byte ranges into single larger ranges. I haven't + # done it for two reasons: Firstly, I don't have any idea how much of a + # real-world benefit it would be, and secondly, I couldn't work out how + # to do it elegantly. So I punted. + end + end + + def origin + # Man old-skool snapshots are weird + vgcfg.logical_volumes.values.find { |lv| lv.cow_store == @lv }.origin + end + + private + def vgcfg + @vgcfg ||= LVM::VGConfig.new(@vg) + end + + def chunk_size + @chunk_size ||= metadata_header[:chunk_size] + end + + def metadata_header + @metadata_header ||= begin + magic, valid, version, chunk_size = File.read(metadata_device, 16).unpack("VVVV") + + unless magic == 0x70416e53 + raise RuntimeError, + "#{@vg}/#{@lv}: Invalid snapshot magic number" + end + + unless valid == 1 + raise RuntimeError, + "#{@vg}/#{@lv}: Snapshot is marked as invalid" + end + + unless version == 1 + raise RuntimeError, + "#{@vg}/#{@lv}: Incompatible snapshot metadata version" + end + + { :chunk_size => chunk_size * 512 } + end + end + + def metadata_device + "/dev/mapper/#{@vg}-#{@lv}-cow" + end +end diff --git a/lib/lvm/thin_snapshot.rb b/lib/lvm/thin_snapshot.rb new file mode 100644 index 0000000..99c1990 --- /dev/null +++ b/lib/lvm/thin_snapshot.rb @@ -0,0 +1,186 @@ +require 'rexml/document' + +module LVM; end + +class LVM::ThinSnapshot + def initialize(vg, lv) + @vg = vg + @lv = lv + end + + # Return an array of ranges which are the bytes which are different + # between the origin and the snapshot. + def differences + # This is a relatively complicated multi-step process. We have two + # piles of => mappings, one for the "origin" + # (the LV that's changing) and one for the "snapshot" (the LV that + # represents some past point-in-time). What we need to get out at the + # end is an array of (..) ranges which cover + # the parts of the volumes which are different (or that at least point + # to different blocks within the data pool). + # + # This is going to take a few steps to accomplish. + # + # First, we translate each of the hashes into a list of two-element + # arrays, expanding out ranges, because it means we don't have to + # handle ranges differently in later steps (a worthwhile optimisation, + # in my opinion -- if you think differently, I'd *really* welcome a + # patch that handles ranges in-place without turning into a complete + # mind-fuck, because I couldn't manage it). + # + # Next, we work out which mappings are "different" in all the possible + # ways. There's four cases we might come across: + # + # 1. Both origin and snapshot map the same LV block to the same data + # block. This is a mapping we can discard from the set of + # differences, because, well, it isn't a difference. + # + # 2. Both origin and snapshot map the same LV block, but they point + # to different data blocks. That's the easiest sort of difference + # to understand, and we *could* catch that just by comparing all + # of the mappings in the origin with the mappings in the snapshot, + # and listing those whose value differs. But that wouldn't catch + # these next two cases... + # + # 3. The origin maps a particular LV block to a data block, but the + # snapshot doesn't have any mapping for that LV block. This would + # occur quite commonly -- whenever a location in the origin LV was + # written to for the first time after the snapshot is taken. You + # would catch all these (as well as the previous case) by taking + # the origin block map and removing any mappings which were + # identical in the snapshot block map. However, that would fail to + # identify... + # + # 4. A block in the snapshot is mapped, when the corresponding origin + # block is *not* mapped. Given the assumption that the snapshot + # was never written to, how could this possibly happen? One word: + # "discard". Mappings in the origin block list are removed if + # the block to which they refer is discarded. Finding *these* (and also + # all mappings of type 2) by the reverse process to that in case + # 3 -- simply remove from the snapshot block list all mappings which + # appear identically in the origin block list. + # + # In order to get all of 2, 3, and 4 together, we can simply do the + # operations described in steps 3 & 4 and add the results together. Sure, + # we'll get two copies of all "type 2" block maps, but #uniq is good at + # fixing that. + # + @differences ||= begin + diff_maps = ((flat_origin_blocklist - flat_snapshot_blocklist) + + (flat_snapshot_blocklist - flat_origin_blocklist) + ).uniq + + # At this point, we're off to a good start -- we've got the mappings + # that are different. But we're not actually interested in the + # mappings themselves -- all we want is "the list of LV blocks which + # are different" (we'll translate LV blocks into byte ranges next). + # + changed_blocks = diff_maps.map { |m| m[0] }.uniq + + # Block-to-byte-range is pretty trivial, and we're done! + changed_blocks.map do |b| + ((b*chunk_size)..(((b+1)*chunk_size)-1)) + end + + # There is one optimisation we could make here that we haven't -- + # coalescing adjacent byte ranges into single larger ranges. I haven't + # done it for two reasons: Firstly, I don't have any idea how much of a + # real-world benefit it would be, and secondly, I couldn't work out how + # to do it elegantly. So I punted. + end + end + + def origin + @origin ||= vgcfg.logical_volumes[@lv].origin + end + + private + def vgcfg + @vgcfg ||= LVM::VGConfig.new(@vg) + end + + def flat_origin_blocklist + @flat_origin_blocklist ||= flatten_blocklist(origin_blocklist) + end + + def flat_snapshot_blocklist + @flat_snapshot_blocklist ||= flatten_blocklist(snapshot_blocklist) + end + + def origin_blocklist + @origin_blocklist ||= vg_block_dump[@vgcfg.logical_volumes[origin].device_id] + end + + def snapshot_blocklist + @snapshot_blocklist ||= vg_block_dump[@vgcfg.logical_volumes[@lv].device_id] + end + + def thin_pool_name + @thin_pool_name ||= vgcfg.logical_volumes[@lv].thin_pool + end + + def thin_pool + @thin_pool ||= vgcfg.logical_volumes[thin_pool_name] + end + + def chunk_size + @chunk_size ||= thin_pool.chunk_size + end + + # Take a hash of => elements and turn + # it into an array of [block, block] pairs -- any => + # elements get expanded out into their constituent => + # parts. + # + def flatten_blocklist(bl) + bl.to_a.map do |elem| + # Ranges are *hard*, let's go shopping + if elem[0].is_a? Range + lv_blocks = elem[0].to_a + data_blocks = elem[1].to_a + + # This will now produce an array of two-element arrays, which + # will itself be inside the top-level array that we're mapping. + # A flatten(1) at the end will take care of that problem, + # though. + lv_blocks.inject([]) { |a, v| a << [v, data_blocks[a.length]] } + elsif elem[0].is_a? Fixnum + # We wrap the [lv, data] pair that is `elem` into another array, + # so that the coming #flatten call doesn't de-array our matched + # pair + [elem] + else + raise ArgumentError, + "CAN'T HAPPEN: Unknown key type (#{elem.class}) found in blocklist" + end + end.flatten(1) + end + + def vg_block_dump + @vg_block_dump ||= begin + doc = REXML::Document.new(`thin_dump /dev/mapper/#{@vg.gsub('-', '--')}-#{thin_pool_name.gsub('-','--')}_tmeta`) + + doc.elements['superblock'].inject({}) do |h, dev| + next h unless dev.node_type == :element + + maps = dev.elements[''].inject({}) do |h2, r| + next h2 unless r.node_type == :element + + if r.name == 'single_mapping' + h2[r.attribute('origin_block').value.to_i] = r.attribute('data_block').value.to_i + else + len = r.attribute('length').value.to_i + ori = r.attribute('origin_begin').value.to_i + dat = r.attribute('data_begin').value.to_i + h2[(dat..dat+len-1)] = (ori..ori+len-1) + end + + h2 + end + + h[dev.attribute('dev_id').value.to_i] = maps + h + end + end + end +end diff --git a/lib/lvm/vg_config.rb b/lib/lvm/vg_config.rb new file mode 100644 index 0000000..42ccb26 --- /dev/null +++ b/lib/lvm/vg_config.rb @@ -0,0 +1,72 @@ +require 'tempfile' +require 'open3' +require 'treetop' +require File.expand_path('../../vgcfgbackup', __FILE__) + +Treetop.load(File.expand_path('../../vgcfgbackup.treetop', __FILE__)) + +require 'lvm/lv_config' +require 'lvm/pv_config' +require 'lvm/snapshot' +require 'lvm/thin_snapshot' + +module LVM; end + +class LVM::VGConfig + def initialize(vg_name, opts = {}) + @vgcfgbackup_cmd = opts[:vgcfgbackup_command] || 'vgcfgbackup' + @vg_name = vg_name + @parser = VgCfgBackupParser.new + @root = @parser.parse(vgcfgbackup_output) + if @root.nil? + raise RuntimeError, + "Cannot parse vgcfgbackup output: #{@parser.failure_reason}" + end + end + + def version + @version ||= @root.variable_value('version') + end + + def description + @description ||= @root.variable_value('description') + end + + def uuid + @uuid ||= volume_group.variable_value('id') + end + + def volume_group + @volume_group ||= @root.groups[@vg_name] + end + + def physical_volumes + @physical_volumes ||= volume_group.groups['physical_volumes'].groups.to_a.inject({}) { |h,v| h[v[0]] = LVM::PVConfig.new(v[1]); h } + end + + def logical_volumes + @logical_volumes ||= volume_group.groups['logical_volumes'].groups.to_a.inject({}) { |h,v| h[v[0]] = LVM::LVConfig.new(v[1], v[0], self); h } + end + + private + def vgcfgbackup_output + @vgcfgbackup_output ||= begin + Tempfile.open('vg_config') do |tmpf| + cmd = "#{@vgcfgbackup_cmd} -f #{tmpf.path} #{@vg_name}" + Open3.popen3(cmd) do |stdin_fd, stdout_fd, stderr_fd, thr| + stdin_fd.close + stdout = stdout_fd.read + stderr = stderr_fd.read + exit_status = thr.value + + if exit_status != 0 + raise RuntimeError, + "Failed to run vgcfgbackup: #{stdout}\n#{stderr}" + end + end + + File.read(tmpf.path) + end + end + end +end diff --git a/lib/vgcfgbackup.rb b/lib/vgcfgbackup.rb new file mode 100644 index 0000000..d2854ed --- /dev/null +++ b/lib/vgcfgbackup.rb @@ -0,0 +1,67 @@ +module VgCfgBackup + class Node < Treetop::Runtime::SyntaxNode + end + + class Group < Node + def name + self.elements[0].text_value + end + + def variables + self.elements[3].elements.select { |e| e.is_a? Variable } + end + + def variable_value(name) + self.variables.find { |v| v.name == name }.value rescue nil + end + + def groups + self.elements[3].elements.select { |e| e.is_a? Group }.inject({}) { |h,v| h[v.name] = v; h } + end + end + + class Config < Group + def name + nil + end + + def variables + self.elements.select { |e| e.is_a? Variable } + end + + def groups + self.elements.select { |e| e.is_a? Group }.inject({}) { |h,v| h[v.name] = v; h } + end + end + + class Variable < Node + def name + self.elements[0].text_value + end + + def value + self.elements[2].value + end + end + + class VariableName < Node + end + + class Integer < Node + def value + self.text_value.to_i + end + end + + class String < Node + def value + self.elements[1].text_value + end + end + + class List < Node + def value + self.elements.find { |e| e.is_a?(String) }.map { |e| e.value } + end + end +end diff --git a/lib/vgcfgbackup.treetop b/lib/vgcfgbackup.treetop new file mode 100644 index 0000000..0882afc --- /dev/null +++ b/lib/vgcfgbackup.treetop @@ -0,0 +1,37 @@ +grammar VgCfgBackup + rule config + (variable / group / space / comment)+ + end + + rule variable + variable_name " = " (integer / string / list) + end + + rule variable_name + [a-z0-9_]+ + end + + rule group + variable_name space "{" (variable / group / space / comment)+ "}" + end + + rule integer + [1-9] [0-9]* / "0" + end + + rule string + '"' [^"]* '"' + end + + rule list + "[" space? ((string / integer) ", " / (string / integer))* space? "]" + end + + rule space + [\s]+ + end + + rule comment + "#" [^\n]* + end +end diff --git a/lvmsync.gemspec b/lvmsync.gemspec new file mode 100644 index 0000000..1b6bc85 --- /dev/null +++ b/lvmsync.gemspec @@ -0,0 +1,45 @@ +require 'git-version-bump' + +Gem::Specification.new do |s| + s.name = "lvmsync" + + s.version = GVB.version + s.date = GVB.date + + s.platform = Gem::Platform::RUBY + + s.homepage = "http://theshed.hezmatt.org/lvmsync" + s.summary = "Efficiently transfer changes in LVM snapshots" + s.authors = ["Matt Palmer"] + + s.extra_rdoc_files = ["README.md"] + s.files = %w{ + README.md + LICENCE + bin/lvmsync + lib/lvm.rb + lib/lvm/lv_config.rb + lib/lvm/thin_snapshot.rb + lib/lvm/snapshot.rb + lib/lvm/vg_config.rb + lib/lvm/helpers.rb + lib/lvm/pv_config.rb + lib/vgcfgbackup.treetop + lib/vgcfgbackup.rb + } + s.executables = ["lvmsync"] + + s.add_runtime_dependency "git-version-bump" + s.add_runtime_dependency "treetop" + + s.add_development_dependency 'bundler' + s.add_development_dependency 'guard-spork' + s.add_development_dependency 'guard-rspec' + s.add_development_dependency 'plymouth' + s.add_development_dependency 'pry-debugger' + s.add_development_dependency 'rake' + # Needed for guard + s.add_development_dependency 'rb-inotify', '~> 0.9' + s.add_development_dependency 'rdoc' + s.add_development_dependency 'rspec' +end diff --git a/manifests/init.pp b/manifests/init.pp index 19ff15c..a370307 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -1,9 +1,6 @@ class lvmsync { - file { "/usr/local/sbin/lvmsync": - ensure => file, - source => "puppet:///modules/lvmsync/lvmsync", - mode => 0555, - owner => root, - group => root + package { "lvmsync": + provider => "gem", + ensure => "present" } } diff --git a/spec/fixtures/fullconfig b/spec/fixtures/fullconfig new file mode 100644 index 0000000..7296f02 --- /dev/null +++ b/spec/fixtures/fullconfig @@ -0,0 +1,237 @@ +contents = "Text Format Volume Group" +version = 1 + +description = "vgcfgbackup -f /tmp/faffen2" + +fullconfig { + id = "zAPMOi-5QlD-fp1M-FMgz-biha-TQIP-X9TfqX" + seqno = 436 + format = "lvm2" # informational + status = ["RESIZEABLE", "READ", "WRITE"] + flags = [] + extent_size = 8192 # 4 Megabytes + max_lv = 0 + max_pv = 0 + metadata_copies = 0 + + physical_volumes { + + pv0 { + id = "j7ZiWs-1fHB-aAwq-yL29-pBOk-H4hR-AOIe0P" + device = "/dev/dm-0" # Hint only + + status = ["ALLOCATABLE"] + flags = [] + dev_size = 1952545832 # 931.046 Gigabytes + pe_start = 384 + pe_count = 238347 # 931.043 Gigabytes + } + } + + logical_volumes { + + swap { + id = "BOfqkb-V4jw-7cBX-UR3D-SN3f-wWvd-NwysSj" + status = ["READ", "WRITE", "VISIBLE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 2048 # 8 Gigabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 0 + ] + } + } + + root { + id = "8sbW3s-qB1u-6IlS-ABSj-PAMU-UxRk-I18ECh" + status = ["READ", "WRITE", "VISIBLE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 3840 # 15 Gigabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 21504 + ] + } + } + + thinpool { + id = "YzvLKN-ly3F-gNgL-2YUQ-7J4v-nPw6-aEH0Q4" + status = ["READ", "WRITE", "VISIBLE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 25600 # 100 Gigabytes + + type = "thin-pool" + metadata = "thinpool_tmeta" + pool = "thinpool_tdata" + transaction_id = 13 + chunk_size = 128 # 64 Kilobytes + discards = "passdown" + zero_new_blocks = 1 + } + } + + thintest { + id = "KTu6v0-XGcp-dJnd-5IFv-t1rH-fD6t-gqijyN" + status = ["READ", "WRITE", "VISIBLE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 2560 # 10 Gigabytes + + type = "thin" + thin_pool = "thinpool" + transaction_id = 10 + device_id = 1 + } + } + + thinsnap { + id = "ltQMX8-SaQV-S5fP-zF2m-wb78-bveG-JiCMnv" + status = ["READ", "WRITE", "VISIBLE"] + flags = ["ACTIVATION_SKIP"] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 2560 # 10 Gigabytes + + type = "thin" + thin_pool = "thinpool" + transaction_id = 11 + device_id = 2 + origin = "thintest" + } + } + + thinsnap2 { + id = "E6Pa80-8PIh-GPAM-Qe6G-CRLc-rgeN-rno8pZ" + status = ["READ", "WRITE", "VISIBLE"] + flags = ["ACTIVATION_SKIP"] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 2560 # 10 Gigabytes + + type = "thin" + thin_pool = "thinpool" + transaction_id = 12 + device_id = 3 + origin = "thintest" + } + } + + rootsnap { + id = "nzBjUt-ydUU-zytj-Crkm-5sCk-16aJ-fufe1C" + status = ["READ", "WRITE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 2560 # 10 Gigabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 145433 + ] + } + } + + lvol0_pmspare { + id = "81KshE-Cjpm-Ec2w-bHg9-imDu-dAD7-SzCULf" + status = ["READ", "WRITE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 25 # 100 Megabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 2048 + ] + } + } + + thinpool_tmeta { + id = "B8z5ds-mF2h-JfFb-pg4w-dNKA-6DUr-3sG58d" + status = ["READ", "WRITE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 25 # 100 Megabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 145408 + ] + } + } + + thinpool_tdata { + id = "gN0VJh-1k4l-DvcD-o3b2-dbSW-YL2P-Yq4sF0" + status = ["READ", "WRITE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 25600 # 100 Gigabytes + + type = "striped" + stripe_count = 1 # linear + + stripes = [ + "pv0", 119808 + ] + } + } + + snapshot0 { + id = "c1b4sb-GxW1-8P1A-pRO5-WgnT-6JtO-dnXIr1" + status = ["READ", "WRITE", "VISIBLE"] + flags = [] + segment_count = 1 + + segment1 { + start_extent = 0 + extent_count = 3840 # 15 Gigabytes + + type = "snapshot" + chunk_size = 8 + origin = "root" + cow_store = "rootsnap" + } + } + } +} diff --git a/spec/fixtures/physicalvolume b/spec/fixtures/physicalvolume new file mode 100644 index 0000000..ca8e219 --- /dev/null +++ b/spec/fixtures/physicalvolume @@ -0,0 +1,32 @@ +# Generated by LVM2 version 2.02.104(2) (2013-11-13): Wed May 21 07:53:43 2014 + +contents = "Text Format Volume Group" +version = 1 + +description = "vgcfgbackup -f /tmp/faffen2" + +physicalvolume { + id = "zAPMOi-5QlD-fp1M-FMgz-biha-TQIP-X9TfqX" + seqno = 436 + format = "lvm2" # informational + status = ["RESIZEABLE", "READ", "WRITE"] + flags = [] + extent_size = 8192 # 4 Megabytes + max_lv = 0 + max_pv = 0 + metadata_copies = 0 + + physical_volumes { + + pv0 { + id = "j7ZiWs-1fHB-aAwq-yL29-pBOk-H4hR-AOIe0P" + device = "/dev/dm-0" # Hint only + + status = ["ALLOCATABLE"] + flags = [] + dev_size = 1952545832 # 931.046 Gigabytes + pe_start = 384 + pe_count = 238347 # 931.043 Gigabytes + } + } +} diff --git a/spec/fixtures/trivial b/spec/fixtures/trivial new file mode 100644 index 0000000..01bc223 --- /dev/null +++ b/spec/fixtures/trivial @@ -0,0 +1,7 @@ +# Generated by LVM2 version 2.02.104(2) (2013-11-13): Wed May 21 07:53:43 +# 2014 + +contents = "Text Format Volume Group" +version = 1 + +description = "vgcfgbackup -f /tmp/faffen2" diff --git a/spec/fixtures/vgcfgbackup b/spec/fixtures/vgcfgbackup new file mode 100755 index 0000000..ad40d09 --- /dev/null +++ b/spec/fixtures/vgcfgbackup @@ -0,0 +1,16 @@ +#!/bin/sh + +# Dumb test script to copy a dummy vgcfgbackup output file to where it +# should be. Must be called as "$0 -f ", and +# .../spec/fixtures/ will be copied to . + +set -e + +HERE="$(dirname $(readlink -f $0))" + +if [ "$#" != "3" ] || [ "$1" != "-f" ]; then + echo "Called incorrectly" + exit 1 +fi + +cp "$HERE/$3" "$2" diff --git a/spec/fixtures/vgmetadata b/spec/fixtures/vgmetadata new file mode 100644 index 0000000..eee05e3 --- /dev/null +++ b/spec/fixtures/vgmetadata @@ -0,0 +1,18 @@ +# Generated by LVM2 version 2.02.104(2) (2013-11-13): Wed May 21 07:53:43 2014 + +contents = "Text Format Volume Group" +version = 1 + +description = "vgcfgbackup -f /tmp/faffen2" + +vgmetadata { + id = "zAPMOi-5QlD-fp1M-FMgz-biha-TQIP-X9TfqX" + seqno = 436 + format = "lvm2" # informational + status = ["RESIZEABLE", "READ", "WRITE"] + flags = [] + extent_size = 8192 # 4 Megabytes + max_lv = 0 + max_pv = 0 + metadata_copies = 0 +} diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..9af51a6 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,25 @@ +require 'spork' + +Spork.prefork do + require 'bundler' + Bundler.setup(:default, :test) + require 'rspec/core' + + require 'rspec/mocks' + + require 'pry' + require 'plymouth' + + RSpec.configure do |config| + config.fail_fast = true +# config.full_backtrace = true + + config.expect_with :rspec do |c| + c.syntax = :expect + end + end +end + +Spork.each_run do + # Nothing to do here, specs will load the files they need +end diff --git a/spec/vg_cfg_spec.rb b/spec/vg_cfg_spec.rb new file mode 100644 index 0000000..8b9198b --- /dev/null +++ b/spec/vg_cfg_spec.rb @@ -0,0 +1,167 @@ +require 'lvm/vg_config' + +describe LVM::VGConfig do + let(:vgcfg) do + LVM::VGConfig.new( + vg_name, + :vgcfgbackup_command => File.expand_path( + '../fixtures/vgcfgbackup', + __FILE__ + ) + ) + end + + context "trivial config" do + let(:vg_name) { "trivial" } + + it "parses successfully" do + expect { vgcfg }.to_not raise_error + end + + it "Gives us back a VgCfg" do + expect(vgcfg).to be_an(LVM::VGConfig) + end + + it "has a version" do + expect(vgcfg.version).to eq(1) + end + + it "has a description" do + expect(vgcfg.description).to eq("vgcfgbackup -f /tmp/faffen2") + end + end + + context "volume group metadata" do + let(:vg_name) { "vgmetadata" } + + it "parses successfully" do + expect { vgcfg }.to_not raise_error + end + + it "has a UUID" do + expect(vgcfg.uuid).to match(/^[A-Za-z0-9-]+$/) + end + end + + context "physical volume" do + let(:vg_name) { "physicalvolume" } + + it "parses successfully" do + expect { vgcfg }.to_not raise_error + end + + it "is its own class" do + expect(vgcfg.physical_volumes["pv0"]).to be_an(LVM::PVConfig) + end + end + + context "complete config" do + let(:vg_name) { "fullconfig" } + + it "parses successfully" do + expect { vgcfg }.to_not raise_error + end + + it "contains logical volumes" do + expect(vgcfg.logical_volumes).to be_a(Hash) + + vgcfg.logical_volumes.values.each { |lv| expect(lv).to be_an(LVM::LVConfig) } + end + + it "has an LV named thintest" do + expect(vgcfg.logical_volumes['thintest']).to_not be(nil) + end + + context "thintest LV" do + let(:lv) { vgcfg.logical_volumes['thintest'] } + + it "is thin" do + expect(lv.thin?).to be(true) + end + + it "is not a snapshot" do + expect(lv.snapshot?).to be(false) + end + + it "belongs to 'thinpool'" do + expect(lv.thin_pool).to eq("thinpool") + end + + it "has device_id of 1" do + expect(lv.device_id).to eq(1) + end + end + + it "has an LV named thinsnap2" do + expect(vgcfg.logical_volumes['thinsnap2']).to_not be(nil) + end + + context "thinsnap2 LV" do + let(:lv) { vgcfg.logical_volumes['thinsnap2'] } + + it "is thin" do + expect(lv.thin?).to be(true) + end + + it "belongs to 'thinpool'" do + expect(lv.thin_pool).to eq("thinpool") + end + + it "has device_id of 3" do + expect(lv.device_id).to eq(3) + end + + it "is a snapshot" do + expect(lv.snapshot?).to be(true) + end + + it "is a snapshot of 'thintest'" do + expect(lv.origin).to eq('thintest') + end + end + + context "snapshot0 LV" do + let(:lv) { vgcfg.logical_volumes['snapshot0'] } + + it "has a CoW store" do + expect(lv.cow_store).to eq('rootsnap') + end + + it "is not thin" do + expect(lv.thin?).to be(false) + end + + it "is not a snapshot" do + expect(lv.snapshot?).to be(false) + end + end + + context "rootsnap LV" do + let(:lv) { vgcfg.logical_volumes['rootsnap'] } + + it "is not thin" do + expect(lv.thin?).to be(false) + end + + it "is a snapshot" do + expect(lv.snapshot?).to be(true) + end + end + + context "thinpool" do + let(:lv) { vgcfg.logical_volumes['thinpool'] } + + it "has a chunk size" do + expect(lv.chunk_size).to eq(65536) + end + + it "is not thin" do + expect(lv.thin?).to be(false) + end + + it "is not a snapshot" do + expect(lv.snapshot?).to be(false) + end + end + end +end