#!/usr/local/bin/ruby -w # Copyright (c) 2005 Brian Candler # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. ########################################################################## # rconftool is a reimplementation of Sam Varshavchik's sysconftool in Ruby. # See http://www.courier-mta.org/sysconftool/ for details of the original. # Its purpose is to keep configuration files "fresh" when upgrading an # application from one version to another, ensuring that all necessary # settings are present and obsolete ones removed. # # rconftool can be called as a library function or from the command line. It # can also install groups of files recursively from one directory tree into # another. ########################################################################## require 'fileutils' module Rconftool VERSION = "0.1" class NoVersionLine < RuntimeError; end # This module function installs a single source (.dist) file to a target # location, having first merged in any compatible settings from the # target file if it existed previously [if it does not exist, any settings # from 'oldfile' are used instead] # # If the distfile is not in sysconftool format (i.e. doesn't have a # ##VERSION: header within the first 20 lines), then for safety it is only # installed if the target file does not already exist. No attempt at data # merging is made in that case. def self.install(distfile, targetfile=nil, oldfile=nil, opt={}) debug = opt[:debug] || $stdout targetfile ||= distfile if opt[:strip_regexp] targetfile = targetfile.sub(opt[:strip_regexp], '') oldfile = oldfile.sub(opt[:strip_regexp], '') if oldfile end if opt[:add_suffix] targetfile = targetfile + opt[:add_suffix] oldfile = oldfile + opt[:add_suffix] if oldfile end raise Errno::EEXIST, "#{distfile}: dist and target filenames are the same" if distfile == targetfile # Read in the source (.dist) file begin src = ConfigFile.new(distfile) rescue NoVersionLine # Fallback behaviour when installing a file which is not in sysconftool # format: we install the file only if it doesn't already exist if File.exist?(targetfile) debug << "#{targetfile}: already exists, skipping\n" return end return if opt[:noclobber] copyfrom = (oldfile and File.exist?(oldfile)) ? oldfile : distfile if File.symlink?(copyfrom) File.symlink(File.readlink(copyfrom), targetfile) debug << "#{targetfile}: symlink copied from #{copyfrom}\n" else FileUtils.cp copyfrom, targetfile, :preserve=>true debug << "#{targetfile}: copied from #{copyfrom}\n" end return end # OK, so we have a sysconftool file to install. Read in the existing # target file, or if that does not exist, the oldfile begin old = ConfigFile.new old.read(targetfile) rescue NoVersionLine # That's OK; the old target will be renamed to .bak rescue Errno::ENOENT begin target_missing = true old.read(oldfile) if oldfile rescue Errno::ENOENT, NoVersionLine end end # Same VERSION? No merge is required if src.version == old.version and not opt[:force] if target_missing FileUtils.cp oldfile, targetfile, :preserve=>true debug << "#{targetfile}: same VERSION, copied from #{oldfile}\n" return end debug << "#{targetfile}: same VERSION, no change\n" return end # Merge in old settings (note: any settings which are in targetfile but # not in distfile will be silently dropped) debug << "#{targetfile}:\n" src.settings[1..-1].each do |src_setting| name = src_setting.name old_setting = old[name] unless old_setting debug << " #{name}: new\n" next end if old_setting.version == src_setting.version debug << " #{name}: unchanged\n" src_setting.add_comment("\n DEFAULT SETTING from #{distfile}:\n") src_setting.add_comment(src_setting.content) src_setting.content = old_setting.content next end # Otherwise, must install updated setting and comment out # the current setting for reference debug << " #{name}: UPDATED\n" src_setting.add_comment("\n Previous setting (inserted by rconftool):\n\n") src_setting.add_comment(old_setting.content) end return if opt[:noclobber] # Write out the new file and carry forward permissions begin tempfile = targetfile+".new#{$$}" src.write(tempfile) st = File.stat(distfile) begin File.chown(st.uid, st.gid, tempfile) rescue Errno::EPERM end File.chmod(st.mode, tempfile) File.rename(targetfile, targetfile+".bak") unless target_missing File.rename(tempfile, targetfile) rescue File.delete(tempfile) rescue nil raise end end HEADER_ID = '__header__' # Object to represent a single setting class Setting attr_reader :name, :version attr_accessor :content def initialize(name, version) @name = name.gsub(/\s+/,'') @version = version.gsub(/s+/,'') @comment = "" @content = "" @in_content = false end def <<(str) @in_content = true unless /\A#/ =~ str if @in_content @content << str else @comment << str end end # Add text to 'comment' portion of setting, prefixing each line with '#' def add_comment(str) @comment << str.gsub(/^/,'#') end def to_s return "#{@comment}#{@content}" if @name == HEADER_ID return "##NAME: #{@name}:#{@version}\n#{@comment}#{@content}" end end # class Setting # Object to represent an entire configuration file. It consists of # an array of Setting objects, with the first one having a special name # (__header__). We also keep a hash of setting name => setting object # to enable us to find a particular setting quickly. class ConfigFile attr_reader :version, :settings def initialize(filename=nil) read(filename) if filename end # fetch a setting by name def [](item) @settings_hash[item] end def read(filename) @version = nil curr_setting = Setting.new(HEADER_ID,'') @settings = [curr_setting] @settings_hash = {} File.open(filename) do |f| # VERSION header must occur within first 20 lines 20.times do line = f.gets break unless line curr_setting << line if line =~ /\A##VERSION:/ @version = line break end end raise NoVersionLine, "#{filename}: No VERSION line found" unless @version while line = f.gets unless line =~ /\A##NAME:(.*):(.*)/ curr_setting << line next end curr_setting = Setting.new($1,$2) @settings << curr_setting @settings_hash[curr_setting.name] = curr_setting end end end def write(filename) File.open(filename,"w") do |f| @settings.each do |s| f << s.to_s end end end end # class ConfigFile # Yield directory contents recursively, without doing chdir(). Note # that yielded pathnames are relative to the base directory given; # so that, for example, you can simulate 'cp -r /foo/bar/ /baz/' by # recurse_dir("/foo/bar") { |n| copy("/foo/bar/"+n,"/baz/"+n) unless # File.directory?("/foo/bar/"+n) } # Current behaviour is that if a directory is a symlink, we follow it. # (Perhaps the block we yield should return true/false?) def self.recurse_dir(base) base = base+File::SEPARATOR unless base[-1,1] == File::SEPARATOR dirs = [''] while dir = dirs.pop yield dir unless dir == '' Dir.foreach(base+dir) do |n| next if n == '.' || n == '..' target = dir + n if File.directory?(base+target) dirs << target+File::SEPARATOR next end yield target end end end class Processor attr_reader :o # Parse command-line options and set the @o options hash def initialize(argv=nil) require 'optparse' @o = { :strip_regexp => /\.dist\z/ } return unless argv opts = OptionParser.new do |opts| opts.banner = "rconftool version #{VERSION}" opts.separator "Usage: #{$0} [options]" opts.separator "" opts.separator "Specific options:" opts.on("-n", "--noclobber", "Dummy run") do @o[:noclobber] = true end opts.on("-f", "--force", "Update files even if VERSION is same") do @o[:force] = true end opts.on("-q", "--quiet", "No progress reporting") do @o[:debug] = "" end opts.on("--targetdir DIR", "Where to write merged config files") do |dir| @o[:targetdir] = dir end opts.on("--olddir DIR", "If file does not exist in targetdir,", "try to merge from here") do |dir| @o[:olddir] = dir end opts.on("--[no-]recursive", "Traverse directories recursively") do |v| @o[:recursive] = v end opts.on("--strip-suffix FOO", "Remove suffix FOO from target filenames", "(default .dist)") do |suffix| @o[:strip_regexp] = /#{Regexp.escape(suffix)}\z/ end opts.on("-a", "--add-suffix FOO", "Add suffix FOO to target filenames") do |suffix| @o[:add_suffix] = suffix end opts.on_tail("-?", "--help", "Show this message") do puts opts exit end end opts.parse!(argv) end # Process a list of files, [src1,src2,...]. If recursive mode has been # enabled, then subdirectories of destdir are created as necessary # when 'src' is a directory, and the mode/ownership of these newly # created directories is copied from the original. def run(files) done_work = false files.each do |f| if not File.directory?(f) dst = old = nil dst = @o[:targetdir] + File::SEPARATOR + File.basename(f) if @o[:targetdir] old = @o[:olddir] + File::SEPARATOR + File.basename(f) if @o[:olddir] Rconftool::install(f, dst, old, @o) elsif not @o[:recursive] raise Errno::EISDIR, "#{f} (not copied). Need --recursive?" else Rconftool::recurse_dir(f) do |nf| src = f + File::SEPARATOR + nf dst = old = nil dst = @o[:targetdir] + File::SEPARATOR + nf if @o[:targetdir] old = @o[:olddir] + File::SEPARATOR + nf if @o[:olddir] if File.directory?(src) if dst and not File.directory?(dst) orig = File.stat(src) Dir.mkdir(dst, orig.mode) begin File.chown(orig.uid, orig.gid, dst) rescue Errno::EPERM end end else Rconftool::install(src, dst, old, @o) end end end done_work = true end unless done_work $stderr.puts "Usage: #{$0} [options] src1 src2 ...\n"+ "Try #{$0} --help for more information\n" exit 1 end end end # class Processor end # module Rconftool # Run from command line? if __FILE__ == $0 begin s = Rconftool::Processor.new(ARGV) s.run(ARGV) rescue Exception => e $stderr.puts "#{$0}: #{e}" end end